Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4e9594f
feat(android): Add InternalSentrySdk.captureEnvelopeNonTerminating
buenaflor Aug 10, 2026
e298587
changelog
buenaflor Aug 10, 2026
9dd8a5b
ref: follow Session rename in InternalSentrySdk
buenaflor Aug 10, 2026
331275b
changelog
buenaflor Aug 10, 2026
4dfbdd9
ref: drop a redundant comment and stale pending wording
buenaflor Aug 11, 2026
fc2541c
ref(android): share one event scan between the two captureEnvelope me…
buenaflor Aug 11, 2026
5945267
ref(session): drop the inert ApiStatus.Internal from IWithSession
buenaflor Aug 11, 2026
f7e53c6
ref(android): rename scanEvents to eventStateOf
buenaflor Aug 11, 2026
9ff103b
ref(android): restore catch (Throwable) in captureEnvelope
buenaflor Aug 12, 2026
54e6672
ref(scope): mark IWithSession as internal
buenaflor Aug 13, 2026
6555830
ref(scope): Drop Internal from IWithSession
buenaflor Aug 24, 2026
c122da1
ref(scope): Keep IWithSession package-private
buenaflor Aug 24, 2026
9db7925
ref(scope): Mark IWithSession public internal like IWithTransaction
buenaflor Aug 24, 2026
d72de56
changelog
buenaflor Aug 24, 2026
1e05c47
ref(android): Persist the session snapshot outside the scope lock
buenaflor Aug 24, 2026
cc9f71c
Revert "ref(android): Persist the session snapshot outside the scope …
buenaflor Aug 24, 2026
7e0c57a
docs(android): Record why the session persist sits inside withSession
buenaflor Aug 24, 2026
011c31a
ref(android): Catch only what readEnvelope can throw
buenaflor Aug 25, 2026
ccc77e2
ref(android): Extract non-terminating session update helper
buenaflor Aug 25, 2026
6e78a04
ref(android): Route non-terminating session updates through withSession
buenaflor Aug 27, 2026
10e6545
docs(session): Say why a terminal status clears the unhandled marker
buenaflor Aug 27, 2026
40929e9
fix(android): Persist the non-terminating session synchronously
buenaflor Aug 28, 2026
ad18462
docs(android): Note that captureEnvelopeNonTerminating writes to disk
buenaflor Aug 28, 2026
b36d315
docs: Move captureEnvelopeNonTerminating changelog entry to Unreleased
buenaflor Aug 28, 2026
8131924
ref(android): restore catch (Throwable) in captureEnvelope
buenaflor Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
- Scopes that are explicitly made current, e.g. via `Sentry.setCurrentScopes` or the `SentryContext` coroutine integration, are now also honoured when `globalHubMode` is enabled
- `Sentry.pushScope`, `Sentry.pushIsolationScope` and `Sentry.popScope` remain no-ops when `globalHubMode` is enabled

### Internal

- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed` ([#5921](https://github.com/getsentry/sentry-java/pull/5921))

## 8.54.0

### Features
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ public abstract interface class io/sentry/android/core/IDebugImagesLoader {
public final class io/sentry/android/core/InternalSentrySdk {
public fun <init> ()V
public static fun captureEnvelope ([BZ)Lio/sentry/protocol/SentryId;
public static fun captureEnvelopeNonTerminating ([B)Lio/sentry/protocol/SentryId;
public static fun getAppStartMeasurement ()Ljava/util/Map;
public static fun getCurrentScope ()Lio/sentry/IScope;
public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import io.sentry.util.TracingUtils;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
Expand Down Expand Up @@ -153,7 +154,12 @@ public static Map<String, Object> serializeScope(
* - will not perform any sampling: it's up to the caller to take care of this<br>
* - will enrich the envelope with a Session update if applicable<br>
*
* <p>Unhandled events ({@code handled=false}) end the session as {@code crashed}. Prefer {@link
* #captureEnvelopeNonTerminating(byte[])} for hybrid runtimes where the process is expected to
* continue (e.g. Flutter).
*
* @param envelopeData the serialized envelope data
* @param maybeStartNewSession if true, starts a new session after a crashed session is cleared
* @return The Id (SentryId object) of the event, or null in case the envelope could not be
* captured
*/
Expand All @@ -163,35 +169,25 @@ public static SentryId captureEnvelope(
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
final @NotNull SentryOptions options = scopes.getOptions();

try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData);
Comment thread
buenaflor marked this conversation as resolved.
if (envelope == null) {
return null;
}

try {
final @NotNull ISerializer serializer = options.getSerializer();
final @Nullable SentryEnvelope envelope =
options.getEnvelopeReader().read(envelopeInputStream);
if (envelope == null) {
return null;
}
final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer);

final @NotNull List<SentryEnvelopeItem> envelopeItems = new ArrayList<>();

// determine session state based on events inside envelope
@Nullable Session.State status = null;
boolean crashedOrErrored = false;
for (SentryEnvelopeItem item : envelope.getItems()) {
envelopeItems.add(item);

final SentryEvent event = item.getEvent(serializer);
if (event != null) {
if (event.isCrashed()) {
status = Session.State.Crashed;
}
if (event.isCrashed() || event.isErrored()) {
crashedOrErrored = true;
}
}
}

// update session and add it to envelope if necessary
final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored);
final @Nullable Session.State status =
eventState == EnvelopeEventState.UNHANDLED ? Session.State.Crashed : null;
final @Nullable Session session =
updateSession(scopes, options, status, eventState != EnvelopeEventState.NONE);
if (session != null) {
final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session);
envelopeItems.add(sessionItem);
Expand All @@ -213,6 +209,139 @@ public static SentryId captureEnvelope(
return null;
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Captures the provided envelope for a non-terminating hybrid exception (e.g. Flutter).
*
* <p>Compared to {@link #captureEnvelope(byte[], boolean)} this method does <strong>not</strong>
* treat {@code handled=false} as a crash that ends the session. Instead it:
*
* <ul>
* <li>flags the current session with a non-terminating unhandled error and increments the error
* count
Comment thread
buenaflor marked this conversation as resolved.
* <li>keeps session status {@code Ok} and the same session id on the scope
* <li>does not attach a session update item to this envelope
* <li>does not start a new session
* <li>persists the current session before returning, so the flag survives process death
* </ul>
*
* <p>Persisting is a blocking disk write on the calling thread, so call this off the main thread
* as the hybrid SDKs do. It is synchronous on purpose: a deferred write would not be on disk yet
* if the process dies right after this returns.
*
* <p>The session is finalized later by normal lifecycle ({@code endSession} / background /
* previous-session recovery) as {@code unhandled}, unless a terminal status takes over first,
* such as {@code crashed} for a native crash or {@code abnormal} for an ANR.
*
* <p>Same as {@link #captureEnvelope(byte[], boolean)}, this method will not enrich events, run
* {@code beforeSend}, or sample — the caller is responsible for that.
*
* @param envelopeData the serialized envelope data
* @return the id of the captured envelope, or null if capture failed
*/
@Nullable
public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) {
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
final @NotNull SentryOptions options = scopes.getOptions();

final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData);
if (envelope == null) {
return null;
}

final @NotNull EnvelopeEventState eventState;
try {
eventState = eventStateOf(envelope, options.getSerializer());
} catch (Exception e) {
// getEvent reads through a Callable, whose call() declares Exception
options.getLogger().log(SentryLevel.ERROR, "Failed to inspect envelope events", e);
return null;
}

if (eventState != EnvelopeEventState.NONE) {
updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED);
}

return scopes.captureEnvelope(envelope);
}

/**
* Flags the current session for a non-terminating hybrid error and persists it before returning,
* so the marker survives an immediate process death.
*
* <p>The write stays inside {@code withSession}: the scope's session lock is what keeps the
* session from being ended or replaced mid-write.
*
* @param unhandled {@code true} if the error was unhandled ({@code mechanism.handled=false})
*/
private static void updateSessionNonTerminating(final boolean unhandled) {
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
final @NotNull SentryOptions options = scopes.getOptions();
scopes.configureScope(
scope ->
scope.withSession(
session -> {
if (session == null) {
options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating");
return;
}
if (session.isTerminated()) {
options
.getLogger()
.log(INFO, "Session already terminated, not recording the error.");
return;
}
final boolean recorded =
unhandled
? session.recordNonTerminatingUnhandledError()
: session.update(null, null, true, null);
Comment thread
buenaflor marked this conversation as resolved.
if (recorded && options.getEnvelopeDiskCache() instanceof EnvelopeCache) {
((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session);
}
}));
}

/** What the events inside an envelope amount to, from the session's point of view. */
private enum EnvelopeEventState {
/** No event carried an exception. */
NONE,
/** At least one event carried an exception, none of them unhandled. */
ERRORED,
/** At least one event carried an unhandled exception. */
UNHANDLED
}

private static @NotNull EnvelopeEventState eventStateOf(
final @NotNull SentryEnvelope envelope, final @NotNull ISerializer serializer)
throws Exception {
boolean unhandled = false;
boolean errored = false;
for (SentryEnvelopeItem item : envelope.getItems()) {
final SentryEvent event = item.getEvent(serializer);
if (event != null) {
if (event.isCrashed()) {
unhandled = true;
}
if (event.isCrashed() || event.isErrored()) {
errored = true;
}
}
}
if (unhandled) {
return EnvelopeEventState.UNHANDLED;
}
return errored ? EnvelopeEventState.ERRORED : EnvelopeEventState.NONE;
}

private static @Nullable SentryEnvelope readEnvelope(
final @NotNull SentryOptions options, final @NotNull byte[] envelopeData) {
try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
return options.getEnvelopeReader().read(envelopeInputStream);
} catch (IOException | IllegalArgumentException e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to read envelope", e);
return null;
}
}

public static Map<String, Object> getAppStartMeasurement() {
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
final @NotNull List<Map<String, Object>> spans = new ArrayList<>();
Expand Down Expand Up @@ -305,22 +434,26 @@ private static Session updateSession(
final @NotNull AtomicReference<Session> sessionRef = new AtomicReference<>();
scopes.configureScope(
scope -> {
final @Nullable Session session = scope.getSession();
if (session != null) {
final boolean updated = session.update(status, null, crashedOrErrored, null);
// if we have an uncaughtExceptionHint we can end the session.
if (updated) {
if (session.getStatus() == Session.State.Crashed) {
session.end();
// Session needs to be removed from the scope, otherwise it will be send twice
// standalone and with the crash event
scope.clearSession();
}
sessionRef.set(session);
}
} else {
options.getLogger().log(INFO, "Session is null on updateSession");
}
scope.withSession(
session -> {
if (session != null) {
final boolean updated = session.update(status, null, crashedOrErrored, null);
// if we have an uncaughtExceptionHint we can end the session.
if (updated) {
if (session.getStatus() == Session.State.Crashed) {
session.end();
// Session needs to be removed from the scope, otherwise it will be send twice
// standalone and with the crash event
scope.clearSession();
}
// fromSession serializes lazily, on the transport thread, so handing out the
// live session would race a later mutation
sessionRef.set(session.clone());
}
} else {
options.getLogger().log(INFO, "Session is null on updateSession");
}
});
});
return sessionRef.get();
}
Expand Down
Loading
Loading