Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
- Bump Native SDK from v0.15.3 to v0.15.4 ([#5793](https://github.com/getsentry/sentry-java/pull/5793))
- [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154)
- [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4)
- The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779))

### Dependencies
### Internal

- The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779))
- 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`. ([#5795](https://github.com/getsentry/sentry-java/pull/5795))

## 8.49.0

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 @@ -315,6 +315,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 @@ -153,7 +153,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 Down Expand Up @@ -213,6 +218,90 @@ 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>marks the current session as pending-unhandled and increments the error count
* <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 pending-unhandled survives process death
* </ul>
*
* <p>The session is finalized later by normal lifecycle ({@code endSession} / background /
* previous-session recovery) as {@code unhandled}, unless a native crash escalates it to {@code
* crashed}.
*
* <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();

try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
final @NotNull ISerializer serializer = options.getSerializer();
final @Nullable SentryEnvelope envelope =
options.getEnvelopeReader().read(envelopeInputStream);
if (envelope == null) {
return null;
}

boolean markPendingUnhandled = false;
boolean addErrorsCount = false;
for (SentryEnvelopeItem item : envelope.getItems()) {
final SentryEvent event = item.getEvent(serializer);
if (event != null) {
if (event.getUnhandledException() != null) {
markPendingUnhandled = true;
addErrorsCount = true;
} else if (event.isErrored()) {
addErrorsCount = true;
}
}
}

if (markPendingUnhandled || addErrorsCount) {
final boolean pending = markPendingUnhandled;
final boolean addErrors = addErrorsCount;
scopes.configureScope(
scope -> {
scope.withSession(
session -> {
if (session != null) {
final boolean updated =
pending
? session.markPendingUnhandled()
: session.update(null, null, addErrors, null);
if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) {
((EnvelopeCache) options.getEnvelopeDiskCache())
.persistCurrentSession(session);
}
} else {
options
.getLogger()
.log(INFO, "Session is null on captureEnvelopeNonTerminating");
}
});
});
}

// Capture the original envelope as-is (no session item attached).
return scopes.captureEnvelope(envelope);
} catch (Throwable t) {
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t);
}
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 @@ -22,6 +22,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 All @@ -38,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
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 marks it pending unhandled`() {
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
assertEquals(1, fixture.capturedEnvelopes.size)
val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList()
assertEquals(1, capturedEnvelopeItems.size)
assertEquals(SentryItemType.Event, capturedEnvelopeItems[0].header.type)

// and the session stays alive on the scope, same id, marked pending unhandled
val scopeSession = AtomicReference<Session>()
Sentry.configureScope { scope -> scopeSession.set(scope.session) }
assertEquals(Session.State.Ok, scopeSession.get().status)
assertTrue(scopeSession.get().isPendingUnhandled)
assertEquals(originalSid.get(), scopeSession.get().sessionId)

// and it is persisted so pending survives process death
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val persistedSession =
fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!!
assertEquals(Session.State.Ok, persistedSession.status)
assertTrue(persistedSession.isPendingUnhandled)
assertEquals(originalSid.get(), persistedSession.sessionId)
}

@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
}
assertEquals(1, sessionItems.size)
val endedSession =
fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(sessionItems[0].data)),
Session::class.java,
)!!
assertEquals(Session.State.Unhandled, endedSession.status)
}

@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 pendingSession = AtomicReference<Session>()
Sentry.configureScope { scope -> pendingSession.set(scope.session) }
val oldSid = pendingSession.get().sessionId
assertTrue(pendingSession.get().isPendingUnhandled)
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
assertEquals(2, fixture.capturedEnvelopes.size)
val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList()
assertEquals(2, crashEnvelopeItems.size)
assertEquals(SentryItemType.Event, crashEnvelopeItems[0].header.type)
assertEquals(SentryItemType.Session, crashEnvelopeItems[1].header.type)
val crashedSession =
fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)),
Session::class.java,
)!!
assertEquals(Session.State.Crashed, crashedSession.status)
assertFalse(crashedSession.isPendingUnhandled)
assertEquals(oldSid, crashedSession.sessionId)

// and a new Ok session with a different id is active
val activeSession = AtomicReference<Session>()
Sentry.configureScope { scope -> activeSession.set(scope.session) }
assertEquals(Session.State.Ok, activeSession.get().status)
assertFalse(activeSession.get().isPendingUnhandled)
assertNotEquals(oldSid, activeSession.get().sessionId)
}

@Test
fun `getAppStartMeasurement returns correct serialized data from the app start instance`() {
Fixture().mockFinishedAppStart()
Expand Down
10 changes: 10 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -2492,6 +2492,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 Expand Up @@ -4273,9 +4277,12 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso
public fun getTimestamp ()Ljava/util/Date;
public fun getUnknown ()Ljava/util/Map;
public fun getUserAgent ()Ljava/lang/String;
public fun isPendingUnhandled ()Z
public fun isTerminated ()Z
public fun markPendingUnhandled ()Z
public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
public fun setInitAsTrue ()V
public fun setPendingUnhandled (Z)V
public fun setUnknown (Ljava/util/Map;)V
public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z
public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z
Expand All @@ -4296,6 +4303,7 @@ public final class io/sentry/Session$JsonKeys {
public static final field ERRORS Ljava/lang/String;
public static final field INIT Ljava/lang/String;
public static final field IP_ADDRESS Ljava/lang/String;
public static final field PENDING_UNHANDLED Ljava/lang/String;
public static final field RELEASE Ljava/lang/String;
public static final field SEQ Ljava/lang/String;
public static final field SID Ljava/lang/String;
Expand All @@ -4311,6 +4319,7 @@ public final class io/sentry/Session$State : java/lang/Enum {
public static final field Crashed Lio/sentry/Session$State;
public static final field Exited Lio/sentry/Session$State;
public static final field Ok Lio/sentry/Session$State;
public static final field Unhandled Lio/sentry/Session$State;
public static fun valueOf (Ljava/lang/String;)Lio/sentry/Session$State;
public static fun values ()[Lio/sentry/Session$State;
}
Expand Down Expand Up @@ -4844,6 +4853,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache {
public static fun getPreviousSessionFile (Ljava/lang/String;)Ljava/io/File;
public fun iterator ()Ljava/util/Iterator;
public fun movePreviousSession (Ljava/io/File;Ljava/io/File;)V
public fun persistCurrentSession (Lio/sentry/Session;)V
public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V
public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z
public fun waitPreviousSessionFlush ()Z
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 @@ -1013,7 +1013,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
Loading