Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e84200f
feat(android): Add InternalSentrySdk.captureEnvelopeNonTerminating
buenaflor Aug 10, 2026
790c521
changelog
buenaflor Aug 10, 2026
8caa61d
ref: follow Session rename in InternalSentrySdk
buenaflor Aug 10, 2026
71f94e3
changelog
buenaflor Aug 10, 2026
a7bb89e
ref: drop a redundant comment and stale pending wording
buenaflor Aug 11, 2026
aa1f531
ref(android): share one event scan between the two captureEnvelope me…
buenaflor Aug 11, 2026
97adc1b
ref(session): drop the inert ApiStatus.Internal from IWithSession
buenaflor Aug 11, 2026
c840fa3
ref(android): rename scanEvents to eventStateOf
buenaflor Aug 11, 2026
b01be8a
ref(android): restore catch (Throwable) in captureEnvelope
buenaflor Aug 12, 2026
43d6344
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 13, 2026
841df11
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 13, 2026
86a48ac
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 13, 2026
6e01479
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 13, 2026
6a2a891
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 13, 2026
24da0ef
ref(scope): mark IWithSession as internal
buenaflor Aug 13, 2026
f3f34df
ref(scope): Drop Internal from IWithSession
buenaflor Aug 24, 2026
5452526
ref(scope): Keep IWithSession package-private
buenaflor Aug 24, 2026
25922d4
ref(scope): Mark IWithSession public internal like IWithTransaction
buenaflor Aug 24, 2026
cbe9cb0
Merge remote-tracking branch 'origin/feat/unhandled-sessions-cache' i…
buenaflor Aug 24, 2026
84ba05a
changelog
buenaflor Aug 24, 2026
350fe77
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 24, 2026
c7c5a61
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 24, 2026
668122e
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 24, 2026
da288f8
ref(android): Persist the session snapshot outside the scope lock
buenaflor Aug 24, 2026
e953d29
Revert "ref(android): Persist the session snapshot outside the scope …
buenaflor Aug 24, 2026
c1cbf99
Merge branch 'feat/unhandled-sessions-cache' into feat/unhandled-sess…
buenaflor Aug 24, 2026
960c618
docs(android): Record why the session persist sits inside withSession
buenaflor Aug 24, 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 @@ -12,6 +12,10 @@
- [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0163)
- [diff](https://github.com/getsentry/sentry-native/compare/0.16.2...0.16.3)

### 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.53.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);
Comment thread
buenaflor marked this conversation as resolved.
if (session != null) {
final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session);
envelopeItems.add(sessionItem);
Expand All @@ -213,6 +209,125 @@ public static SentryId captureEnvelope(
return null;
}

/**
* 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 so the flag survives process death
* </ul>
*
* <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;
}

try {
final @NotNull ISerializer serializer = options.getSerializer();
final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer);

if (eventState != EnvelopeEventState.NONE) {
scopes.configureScope(
scope -> {
// the write stays inside the callback so the mutation and the persist are one
// critical section. Persisting outside it lets a concurrent caller's older snapshot
// land last and drop the unhandled marker.
scope.withSession(
session -> {
if (session != null) {
final boolean updated =
eventState == EnvelopeEventState.UNHANDLED
? session.recordNonTerminatingUnhandledError()
: session.update(null, null, true, null);
if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) {
((EnvelopeCache) options.getEnvelopeDiskCache())
.persistCurrentSession(session);
}
Comment thread
buenaflor marked this conversation as resolved.
} else {
options
.getLogger()
.log(INFO, "Session is null on captureEnvelopeNonTerminating");
}
});
});
}

return scopes.captureEnvelope(envelope);
} catch (Exception e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e);
}
return null;
}

/** 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;
}

/**
* Reads an envelope from the given bytes. Besides the declared {@link IOException}, {@link
* io.sentry.IEnvelopeReader#read(InputStream)} also rejects malformed payloads with an unchecked
* {@link IllegalArgumentException}, hence the broader catch.
*/
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 (Exception e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only catch the exceptions expect to throw here. Looks like we expect IOException and IllegalArgumentException ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, thx, will update it

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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.ContentProvider
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.sentry.Breadcrumb
import io.sentry.Hint
import io.sentry.IScope
Expand All @@ -22,6 +23,7 @@ import io.sentry.Session
import io.sentry.SpanId
import io.sentry.android.core.performance.ActivityLifecycleTimeSpan
import io.sentry.android.core.performance.AppStartMetrics
import io.sentry.cache.EnvelopeCache
import io.sentry.exception.ExceptionMechanismException
import io.sentry.protocol.App
import io.sentry.protocol.Contexts
Expand Down Expand Up @@ -107,6 +109,21 @@ class InternalSentrySdkTest {
InternalSentrySdk.captureEnvelope(data, maybeStartNewSession)
}

fun captureEnvelopeNonTerminatingWithEvent(event: SentryEvent = SentryEvent()) {
val options = Sentry.getCurrentScopes().options
val eventId = SentryId()
val header = SentryEnvelopeHeader(eventId)
val eventItem = SentryEnvelopeItem.fromEvent(options.serializer, event)

val envelope = SentryEnvelope(header, listOf(eventItem))

val outputStream = ByteArrayOutputStream()
options.serializer.serialize(envelope, outputStream)
val data = outputStream.toByteArray()

InternalSentrySdk.captureEnvelopeNonTerminating(data)
}

fun createSentryEventWithUnhandledException(): SentryEvent {
return SentryEvent(RuntimeException()).apply {
val mechanism = Mechanism()
Expand Down Expand Up @@ -452,6 +469,110 @@ class InternalSentrySdkTest {
assertNotEquals(capturedSession.sessionId, scopeRef.get().session!!.sessionId)
}

@Test
fun `captureEnvelopeNonTerminating keeps the session Ok and flags the unhandled error`() {
val fixture = Fixture()
fixture.init(context)

val originalSid = AtomicReference<String>()
Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) }

// when capture envelope is called with an unhandled event through the non-terminating API
fixture.captureEnvelopeNonTerminatingWithEvent(
fixture.createSentryEventWithUnhandledException()
)

// then only the original event envelope is captured, without a session item
assertThat(fixture.capturedEnvelopes).hasSize(1)
val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList()
assertThat(capturedEnvelopeItems).hasSize(1)
assertThat(capturedEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event)

// and the session stays alive on the scope, same id, flagged with the unhandled error
val scopeSession = AtomicReference<Session>()
Sentry.configureScope { scope -> scopeSession.set(scope.session) }
assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok)
assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue()
assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get())

// and it is persisted so the flag survives process death
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val persistedSession =
fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!!
Comment thread
buenaflor marked this conversation as resolved.
assertThat(persistedSession.status).isEqualTo(Session.State.Ok)
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue()
assertThat(persistedSession.sessionId).isEqualTo(originalSid.get())
}

@Test
fun `captureEnvelopeNonTerminating then endSession finalizes the session as unhandled`() {
val fixture = Fixture()
fixture.init(context)

fixture.captureEnvelopeNonTerminatingWithEvent(
fixture.createSentryEventWithUnhandledException()
)
fixture.capturedEnvelopes.clear()

// when the session is ended by normal lifecycle
Sentry.endSession()

// then the ended session is captured as unhandled
val sessionItems =
fixture.capturedEnvelopes
.flatMap { it.items.toList() }
.filter {
it.header.type == SentryItemType.Session
}
assertThat(sessionItems).hasSize(1)
val endedSession =
fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(sessionItems[0].data)),
Session::class.java,
)!!
assertThat(endedSession.status).isEqualTo(Session.State.Unhandled)
}

@Test
fun `captureEnvelopeNonTerminating then a crash finalizes old session and starts a new one`() {
val fixture = Fixture()
fixture.init(context)

fixture.captureEnvelopeNonTerminatingWithEvent(
fixture.createSentryEventWithUnhandledException()
)
val unhandledSession = AtomicReference<Session>()
Sentry.configureScope { scope -> unhandledSession.set(scope.session) }
val oldSid = unhandledSession.get().sessionId
assertThat(unhandledSession.get().hasNonTerminatingUnhandledError()).isTrue()
fixture.capturedEnvelopes.clear()

// when a subsequent hard crash is captured through the existing terminating API
fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true)

// then the crash envelope contains the finalized old session
assertThat(fixture.capturedEnvelopes).hasSize(2)
val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList()
assertThat(crashEnvelopeItems).hasSize(2)
assertThat(crashEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event)
assertThat(crashEnvelopeItems[1].header.type).isEqualTo(SentryItemType.Session)
val crashedSession =
fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)),
Session::class.java,
)!!
assertThat(crashedSession.status).isEqualTo(Session.State.Crashed)
assertThat(crashedSession.hasNonTerminatingUnhandledError()).isFalse()
assertThat(crashedSession.sessionId).isEqualTo(oldSid)

// and a new Ok session with a different id is active
val activeSession = AtomicReference<Session>()
Sentry.configureScope { scope -> activeSession.set(scope.session) }
assertThat(activeSession.get().status).isEqualTo(Session.State.Ok)
assertThat(activeSession.get().hasNonTerminatingUnhandledError()).isFalse()
assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid)
}

@Test
fun `getAppStartMeasurement returns correct serialized data from the app start instance`() {
Fixture().mockFinishedAppStart()
Expand Down
4 changes: 4 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,10 @@ public abstract interface class io/sentry/Scope$IWithPropagationContext {
public abstract fun accept (Lio/sentry/PropagationContext;)V
}

public abstract interface class io/sentry/Scope$IWithSession {
public abstract fun accept (Lio/sentry/Session;)V
}

public abstract interface class io/sentry/Scope$IWithTransaction {
public abstract fun accept (Lio/sentry/ITransaction;)V
}
Expand Down
3 changes: 2 additions & 1 deletion sentry/src/main/java/io/sentry/Scope.java
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,8 @@ public Session withSession(final @NotNull IWithSession sessionCallback) {
}

/** The IWithSession callback */
interface IWithSession {
@ApiStatus.Internal
public interface IWithSession {

/**
* The accept method of the callback
Expand Down
Loading