Skip to content

Commit b3f8075

Browse files
feat(android): Add InternalSentrySdk.captureEnvelopeNonTerminating
Hybrid runtimes such as Flutter report unhandled exceptions that do not terminate the process. Routing those through captureEnvelope ends the session as crashed and starts a replacement one, which understates crash-free session rates. The new entry point keeps the session alive with the same id, increments its error count, and marks it pending-unhandled so it finalizes as unhandled at its natural end. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4c6184a commit b3f8075

6 files changed

Lines changed: 241 additions & 10 deletions

File tree

sentry-android-core/api/sentry-android-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ public abstract interface class io/sentry/android/core/IDebugImagesLoader {
315315
public final class io/sentry/android/core/InternalSentrySdk {
316316
public fun <init> ()V
317317
public static fun captureEnvelope ([BZ)Lio/sentry/protocol/SentryId;
318+
public static fun captureEnvelopeNonTerminating ([B)Lio/sentry/protocol/SentryId;
318319
public static fun getAppStartMeasurement ()Ljava/util/Map;
319320
public static fun getCurrentScope ()Lio/sentry/IScope;
320321
public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map;

sentry-android-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ dependencies {
115115
testImplementation(libs.androidx.test.ext.junit)
116116
testImplementation(libs.androidx.test.runner)
117117
testImplementation(libs.awaitility.kotlin)
118+
testImplementation(libs.google.truth)
118119
testImplementation(libs.mockito.kotlin)
119120
testImplementation(libs.mockito.inline)
120121
testImplementation(projects.sentryTestSupport)

sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import io.sentry.util.TracingUtils;
3535
import java.io.ByteArrayInputStream;
3636
import java.io.File;
37+
import java.io.IOException;
3738
import java.io.InputStream;
3839
import java.util.ArrayList;
3940
import java.util.HashMap;
@@ -153,7 +154,12 @@ public static Map<String, Object> serializeScope(
153154
* - will not perform any sampling: it's up to the caller to take care of this<br>
154155
* - will enrich the envelope with a Session update if applicable<br>
155156
*
157+
* <p>Unhandled events ({@code handled=false}) end the session as {@code crashed}. Prefer {@link
158+
* #captureEnvelopeNonTerminating(byte[])} for hybrid runtimes where the process is expected to
159+
* continue (e.g. Flutter).
160+
*
156161
* @param envelopeData the serialized envelope data
162+
* @param maybeStartNewSession if true, starts a new session after a crashed session is cleared
157163
* @return The Id (SentryId object) of the event, or null in case the envelope could not be
158164
* captured
159165
*/
@@ -163,14 +169,13 @@ public static SentryId captureEnvelope(
163169
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
164170
final @NotNull SentryOptions options = scopes.getOptions();
165171

166-
try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
167-
final @NotNull ISerializer serializer = options.getSerializer();
168-
final @Nullable SentryEnvelope envelope =
169-
options.getEnvelopeReader().read(envelopeInputStream);
170-
if (envelope == null) {
171-
return null;
172-
}
172+
final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData);
173+
if (envelope == null) {
174+
return null;
175+
}
173176

177+
try {
178+
final @NotNull ISerializer serializer = options.getSerializer();
174179
final @NotNull List<SentryEnvelopeItem> envelopeItems = new ArrayList<>();
175180

176181
// determine session state based on events inside envelope
@@ -207,12 +212,110 @@ public static SentryId captureEnvelope(
207212
final SentryEnvelope repackagedEnvelope =
208213
new SentryEnvelope(envelope.getHeader(), envelopeItems);
209214
return scopes.captureEnvelope(repackagedEnvelope);
210-
} catch (Throwable t) {
211-
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t);
215+
} catch (Exception e) {
216+
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e);
217+
}
218+
return null;
219+
}
220+
221+
/**
222+
* Captures the provided envelope for a non-terminating hybrid exception (e.g. Flutter).
223+
*
224+
* <p>Compared to {@link #captureEnvelope(byte[], boolean)} this method does <strong>not</strong>
225+
* treat {@code handled=false} as a crash that ends the session. Instead it:
226+
*
227+
* <ul>
228+
* <li>marks the current session as pending-unhandled and increments the error count
229+
* <li>keeps session status {@code Ok} and the same session id on the scope
230+
* <li>does not attach a session update item to this envelope
231+
* <li>does not start a new session
232+
* <li>persists the current session so pending-unhandled survives process death
233+
* </ul>
234+
*
235+
* <p>The session is finalized later by normal lifecycle ({@code endSession} / background /
236+
* previous-session recovery) as {@code unhandled}, unless a native crash escalates it to {@code
237+
* crashed}.
238+
*
239+
* <p>Same as {@link #captureEnvelope(byte[], boolean)}, this method will not enrich events, run
240+
* {@code beforeSend}, or sample — the caller is responsible for that.
241+
*
242+
* @param envelopeData the serialized envelope data
243+
* @return the id of the captured envelope, or null if capture failed
244+
*/
245+
@Nullable
246+
public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) {
247+
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
248+
final @NotNull SentryOptions options = scopes.getOptions();
249+
250+
final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData);
251+
if (envelope == null) {
252+
return null;
253+
}
254+
255+
try {
256+
final @NotNull ISerializer serializer = options.getSerializer();
257+
boolean markPendingUnhandled = false;
258+
boolean addErrorsCount = false;
259+
for (SentryEnvelopeItem item : envelope.getItems()) {
260+
final SentryEvent event = item.getEvent(serializer);
261+
if (event != null) {
262+
if (event.getUnhandledException() != null) {
263+
markPendingUnhandled = true;
264+
addErrorsCount = true;
265+
} else if (event.isErrored()) {
266+
addErrorsCount = true;
267+
}
268+
}
269+
}
270+
271+
if (markPendingUnhandled || addErrorsCount) {
272+
final boolean pending = markPendingUnhandled;
273+
final boolean addErrors = addErrorsCount;
274+
scopes.configureScope(
275+
scope -> {
276+
scope.withSession(
277+
session -> {
278+
if (session != null) {
279+
final boolean updated =
280+
pending
281+
? session.markPendingUnhandled()
282+
: session.update(null, null, addErrors, null);
283+
if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) {
284+
((EnvelopeCache) options.getEnvelopeDiskCache())
285+
.persistCurrentSession(session);
286+
}
287+
} else {
288+
options
289+
.getLogger()
290+
.log(INFO, "Session is null on captureEnvelopeNonTerminating");
291+
}
292+
});
293+
});
294+
}
295+
296+
// Capture the original envelope as-is (no session item attached).
297+
return scopes.captureEnvelope(envelope);
298+
} catch (Exception e) {
299+
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e);
212300
}
213301
return null;
214302
}
215303

304+
/**
305+
* Reads an envelope from the given bytes. Besides the declared {@link IOException}, {@link
306+
* io.sentry.IEnvelopeReader#read(InputStream)} also rejects malformed payloads with an unchecked
307+
* {@link IllegalArgumentException}, hence the broader catch.
308+
*/
309+
private static @Nullable SentryEnvelope readEnvelope(
310+
final @NotNull SentryOptions options, final @NotNull byte[] envelopeData) {
311+
try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
312+
return options.getEnvelopeReader().read(envelopeInputStream);
313+
} catch (Exception e) {
314+
options.getLogger().log(SentryLevel.ERROR, "Failed to read envelope", e);
315+
return null;
316+
}
317+
}
318+
216319
public static Map<String, Object> getAppStartMeasurement() {
217320
final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance();
218321
final @NotNull List<Map<String, Object>> spans = new ArrayList<>();

sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.ContentProvider
55
import android.content.Context
66
import androidx.test.core.app.ApplicationProvider
77
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import com.google.common.truth.Truth.assertThat
89
import io.sentry.Breadcrumb
910
import io.sentry.Hint
1011
import io.sentry.IScope
@@ -22,6 +23,7 @@ import io.sentry.Session
2223
import io.sentry.SpanId
2324
import io.sentry.android.core.performance.ActivityLifecycleTimeSpan
2425
import io.sentry.android.core.performance.AppStartMetrics
26+
import io.sentry.cache.EnvelopeCache
2527
import io.sentry.exception.ExceptionMechanismException
2628
import io.sentry.protocol.App
2729
import io.sentry.protocol.Contexts
@@ -107,6 +109,21 @@ class InternalSentrySdkTest {
107109
InternalSentrySdk.captureEnvelope(data, maybeStartNewSession)
108110
}
109111

112+
fun captureEnvelopeNonTerminatingWithEvent(event: SentryEvent = SentryEvent()) {
113+
val options = Sentry.getCurrentScopes().options
114+
val eventId = SentryId()
115+
val header = SentryEnvelopeHeader(eventId)
116+
val eventItem = SentryEnvelopeItem.fromEvent(options.serializer, event)
117+
118+
val envelope = SentryEnvelope(header, listOf(eventItem))
119+
120+
val outputStream = ByteArrayOutputStream()
121+
options.serializer.serialize(envelope, outputStream)
122+
val data = outputStream.toByteArray()
123+
124+
InternalSentrySdk.captureEnvelopeNonTerminating(data)
125+
}
126+
110127
fun createSentryEventWithUnhandledException(): SentryEvent {
111128
return SentryEvent(RuntimeException()).apply {
112129
val mechanism = Mechanism()
@@ -452,6 +469,110 @@ class InternalSentrySdkTest {
452469
assertNotEquals(capturedSession.sessionId, scopeRef.get().session!!.sessionId)
453470
}
454471

472+
@Test
473+
fun `captureEnvelopeNonTerminating keeps the session Ok and marks it pending unhandled`() {
474+
val fixture = Fixture()
475+
fixture.init(context)
476+
477+
val originalSid = AtomicReference<String>()
478+
Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) }
479+
480+
// when capture envelope is called with an unhandled event through the non-terminating API
481+
fixture.captureEnvelopeNonTerminatingWithEvent(
482+
fixture.createSentryEventWithUnhandledException()
483+
)
484+
485+
// then only the original event envelope is captured, without a session item
486+
assertThat(fixture.capturedEnvelopes).hasSize(1)
487+
val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList()
488+
assertThat(capturedEnvelopeItems).hasSize(1)
489+
assertThat(capturedEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event)
490+
491+
// and the session stays alive on the scope, same id, marked pending unhandled
492+
val scopeSession = AtomicReference<Session>()
493+
Sentry.configureScope { scope -> scopeSession.set(scope.session) }
494+
assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok)
495+
assertThat(scopeSession.get().isPendingUnhandled).isTrue()
496+
assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get())
497+
498+
// and it is persisted so pending survives process death
499+
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
500+
val persistedSession =
501+
fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!!
502+
assertThat(persistedSession.status).isEqualTo(Session.State.Ok)
503+
assertThat(persistedSession.isPendingUnhandled).isTrue()
504+
assertThat(persistedSession.sessionId).isEqualTo(originalSid.get())
505+
}
506+
507+
@Test
508+
fun `captureEnvelopeNonTerminating then endSession finalizes the session as unhandled`() {
509+
val fixture = Fixture()
510+
fixture.init(context)
511+
512+
fixture.captureEnvelopeNonTerminatingWithEvent(
513+
fixture.createSentryEventWithUnhandledException()
514+
)
515+
fixture.capturedEnvelopes.clear()
516+
517+
// when the session is ended by normal lifecycle
518+
Sentry.endSession()
519+
520+
// then the ended session is captured as unhandled
521+
val sessionItems =
522+
fixture.capturedEnvelopes
523+
.flatMap { it.items.toList() }
524+
.filter {
525+
it.header.type == SentryItemType.Session
526+
}
527+
assertThat(sessionItems).hasSize(1)
528+
val endedSession =
529+
fixture.options.serializer.deserialize(
530+
InputStreamReader(ByteArrayInputStream(sessionItems[0].data)),
531+
Session::class.java,
532+
)!!
533+
assertThat(endedSession.status).isEqualTo(Session.State.Unhandled)
534+
}
535+
536+
@Test
537+
fun `captureEnvelopeNonTerminating then a crash finalizes old session and starts a new one`() {
538+
val fixture = Fixture()
539+
fixture.init(context)
540+
541+
fixture.captureEnvelopeNonTerminatingWithEvent(
542+
fixture.createSentryEventWithUnhandledException()
543+
)
544+
val pendingSession = AtomicReference<Session>()
545+
Sentry.configureScope { scope -> pendingSession.set(scope.session) }
546+
val oldSid = pendingSession.get().sessionId
547+
assertThat(pendingSession.get().isPendingUnhandled).isTrue()
548+
fixture.capturedEnvelopes.clear()
549+
550+
// when a subsequent hard crash is captured through the existing terminating API
551+
fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true)
552+
553+
// then the crash envelope contains the finalized old session
554+
assertThat(fixture.capturedEnvelopes).hasSize(2)
555+
val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList()
556+
assertThat(crashEnvelopeItems).hasSize(2)
557+
assertThat(crashEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event)
558+
assertThat(crashEnvelopeItems[1].header.type).isEqualTo(SentryItemType.Session)
559+
val crashedSession =
560+
fixture.options.serializer.deserialize(
561+
InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)),
562+
Session::class.java,
563+
)!!
564+
assertThat(crashedSession.status).isEqualTo(Session.State.Crashed)
565+
assertThat(crashedSession.isPendingUnhandled).isFalse()
566+
assertThat(crashedSession.sessionId).isEqualTo(oldSid)
567+
568+
// and a new Ok session with a different id is active
569+
val activeSession = AtomicReference<Session>()
570+
Sentry.configureScope { scope -> activeSession.set(scope.session) }
571+
assertThat(activeSession.get().status).isEqualTo(Session.State.Ok)
572+
assertThat(activeSession.get().isPendingUnhandled).isFalse()
573+
assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid)
574+
}
575+
455576
@Test
456577
fun `getAppStartMeasurement returns correct serialized data from the app start instance`() {
457578
Fixture().mockFinishedAppStart()

sentry/api/sentry.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,10 @@ public abstract interface class io/sentry/Scope$IWithPropagationContext {
24972497
public abstract fun accept (Lio/sentry/PropagationContext;)V
24982498
}
24992499

2500+
public abstract interface class io/sentry/Scope$IWithSession {
2501+
public abstract fun accept (Lio/sentry/Session;)V
2502+
}
2503+
25002504
public abstract interface class io/sentry/Scope$IWithTransaction {
25012505
public abstract fun accept (Lio/sentry/ITransaction;)V
25022506
}

sentry/src/main/java/io/sentry/Scope.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,8 @@ public Session withSession(final @NotNull IWithSession sessionCallback) {
10181018
}
10191019

10201020
/** The IWithSession callback */
1021-
interface IWithSession {
1021+
@ApiStatus.Internal
1022+
public interface IWithSession {
10221023

10231024
/**
10241025
* The accept method of the callback

0 commit comments

Comments
 (0)