Skip to content

fix(session): When capturing unhandled hybrid exception session should be ended #3480

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jun 28, 2024
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Use SecureRandom in favor of Random for Metrics ([#3495](https://github.com/getsentry/sentry-java/pull/3495))
- Fix UncaughtExceptionHandlerIntegration Memory Leak ([#3398](https://github.com/getsentry/sentry-java/pull/3398))
- Fix duplicated http spans ([#3526](https://github.com/getsentry/sentry-java/pull/3526))
- When capturing unhandled hybrid exception session should be ended and new start if need ([#3480](https://github.com/getsentry/sentry-java/pull/3480))

### Dependencies

Expand Down
2 changes: 1 addition & 1 deletion sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,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 ([B)Lio/sentry/protocol/SentryId;
public static fun captureEnvelope ([BZ)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
@@ -1,5 +1,9 @@
package io.sentry.android.core;

import static io.sentry.SentryLevel.DEBUG;
import static io.sentry.SentryLevel.INFO;
import static io.sentry.SentryLevel.WARNING;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
Expand All @@ -19,12 +23,14 @@
import io.sentry.android.core.performance.ActivityLifecycleTimeSpan;
import io.sentry.android.core.performance.AppStartMetrics;
import io.sentry.android.core.performance.TimeSpan;
import io.sentry.cache.EnvelopeCache;
import io.sentry.protocol.App;
import io.sentry.protocol.Device;
import io.sentry.protocol.SentryId;
import io.sentry.protocol.User;
import io.sentry.util.MapObjectWriter;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
Expand Down Expand Up @@ -148,7 +154,8 @@ public static Map<String, Object> serializeScope(
* captured
*/
@Nullable
public static SentryId captureEnvelope(final @NotNull byte[] envelopeData) {
public static SentryId captureEnvelope(
final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) {
Copy link
Contributor

Choose a reason for hiding this comment

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

So if I get it right on the Flutter Android side I just need to check if the error is unhandled and set maybeStartNewSession to true in those cases

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, exactly.

Copy link
Member

Choose a reason for hiding this comment

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

Does the envelope contain any extra information we could use instead of maybeStartNewSession?
I see that we have event.isCrashed() maybe, having a event.isUnhandled() would make sense as well.

final @NotNull IHub hub = HubAdapter.getInstance();
final @NotNull SentryOptions options = hub.getOptions();

Expand Down Expand Up @@ -184,6 +191,13 @@ public static SentryId captureEnvelope(final @NotNull byte[] envelopeData) {
if (session != null) {
final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session);
envelopeItems.add(sessionItem);
deleteCurrentSessionFile(
options,
// should be sync if going to crash or already not a main thread
!maybeStartNewSession || !hub.getOptions().getMainThreadChecker().isMainThread());
if (maybeStartNewSession) {
hub.startSession();
}
}

final SentryEnvelope repackagedEnvelope =
Expand Down Expand Up @@ -233,15 +247,15 @@ private static void addTimeSpanToSerializedSpans(TimeSpan span, List<Map<String,
HubAdapter.getInstance()
.getOptions()
.getLogger()
.log(SentryLevel.WARNING, "Can not convert not-started TimeSpan to Map for Hybrid SDKs.");
.log(WARNING, "Can not convert not-started TimeSpan to Map for Hybrid SDKs.");
return;
}

if (span.hasNotStopped()) {
HubAdapter.getInstance()
.getOptions()
.getLogger()
.log(SentryLevel.WARNING, "Can not convert not-stopped TimeSpan to Map for Hybrid SDKs.");
.log(WARNING, "Can not convert not-stopped TimeSpan to Map for Hybrid SDKs.");
return;
}

Expand All @@ -252,6 +266,46 @@ private static void addTimeSpanToSerializedSpans(TimeSpan span, List<Map<String,
spans.add(spanMap);
}

private static void deleteCurrentSessionFile(
final @NotNull SentryOptions options, boolean isSync) {
if (!isSync) {
try {
options
.getExecutorService()
.submit(
() -> {
deleteCurrentSessionFile(options);
});
} catch (Throwable e) {
options
.getLogger()
.log(WARNING, "Submission of deletion of the current session file rejected.", e);
}
} else {
deleteCurrentSessionFile(options);
}
}

private static void deleteCurrentSessionFile(final @NotNull SentryOptions options) {
Copy link
Member

Choose a reason for hiding this comment

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

Hmm why do you need to delete the file manually here? I see we anyway delete the session file on session end here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, when I don't explicitly delete it, it's not removed and it's read on the next app start. But I didn't know it should be deleted automatically.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I'm seeing this right, the envelope is only deleted when the session end is send as standalone envelope, but in the case of hybrids the session end will be send with the crash event in the same envelope.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just for this is the warning I get if not cleaning the session file

options.getLogger().log(WARNING, "Current session is not ended, we'd need to end it.");

final String cacheDirPath = options.getCacheDirPath();
if (cacheDirPath == null) {
options.getLogger().log(INFO, "Cache dir is not set, not deleting the current session.");
return;
}

if (!options.isEnableAutoSessionTracking()) {
options
.getLogger()
.log(DEBUG, "Session tracking is disabled, bailing from deleting current session file.");
return;
}

final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath);
if (!sessionFile.delete()) {
options.getLogger().log(WARNING, "Failed to delete the current session file.");
}
}

@Nullable
private static Session updateSession(
final @NotNull IHub hub,
Expand All @@ -268,11 +322,14 @@ private static Session updateSession(
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(SentryLevel.INFO, "Session is null on updateSession");
options.getLogger().log(INFO, "Session is null on updateSession");
}
});
return sessionRef.get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
Expand Down Expand Up @@ -84,7 +85,7 @@ class InternalSentrySdkTest {
capturedEnvelopes.clear()
}

fun captureEnvelopeWithEvent(event: SentryEvent = SentryEvent()) {
fun captureEnvelopeWithEvent(event: SentryEvent = SentryEvent(), maybeStartNewSession: Boolean = false) {
// create an envelope with session data
val options = Sentry.getCurrentHub().options
val eventId = SentryId()
Expand All @@ -103,7 +104,24 @@ class InternalSentrySdkTest {
options.serializer.serialize(envelope, outputStream)
val data = outputStream.toByteArray()

InternalSentrySdk.captureEnvelope(data)
InternalSentrySdk.captureEnvelope(data, maybeStartNewSession)
}

fun createSentryEventWithUnhandledException(): SentryEvent {
return SentryEvent(RuntimeException()).apply {
val mechanism = Mechanism()
mechanism.isHandled = false

val factory = SentryExceptionFactory(mock())
val sentryExceptions = factory.getSentryExceptions(
ExceptionMechanismException(
mechanism,
Throwable(),
Thread()
)
)
exceptions = sentryExceptions
}
}

fun mockFinishedAppStart() {
Expand Down Expand Up @@ -313,7 +331,7 @@ class InternalSentrySdkTest {

@Test
fun `captureEnvelope fails if payload is invalid`() {
assertNull(InternalSentrySdk.captureEnvelope(ByteArray(8)))
assertNull(InternalSentrySdk.captureEnvelope(ByteArray(8), false))
}

@Test
Expand All @@ -337,27 +355,19 @@ class InternalSentrySdkTest {
}

@Test
fun `captureEnvelope correctly enriches the envelope with session data`() {
fun `captureEnvelope correctly enriches the envelope with session data and does not start new session`() {
val fixture = Fixture()
fixture.init(context)

// when capture envelope is called with an crashed event
fixture.captureEnvelopeWithEvent(
SentryEvent(RuntimeException()).apply {
val mechanism = Mechanism()
mechanism.isHandled = false
// keep reference for current session for later assertions
// we need to get the reference now as it will be removed from the scope
val sessionRef = AtomicReference<Session>()
Sentry.configureScope { scope ->
sessionRef.set(scope.session)
}

val factory = SentryExceptionFactory(mock())
val sentryExceptions = factory.getSentryExceptions(
ExceptionMechanismException(
mechanism,
Throwable(),
Thread()
)
)
exceptions = sentryExceptions
}
)
// when capture envelope is called with an crashed event
fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException())

val capturedEnvelope = fixture.capturedEnvelopes.first()
val capturedEnvelopeItems = capturedEnvelope.items.toList()
Expand All @@ -374,12 +384,52 @@ class InternalSentrySdkTest {
)!!
assertEquals(Session.State.Crashed, capturedSession.status)

// and the local session should be marked as crashed too
assertEquals(Session.State.Crashed, sessionRef.get().status)
assertEquals(capturedSession.sessionId, sessionRef.get().sessionId)
}

@Test
fun `captureEnvelope starts new session when enabled`() {
val fixture = Fixture()
fixture.init(context)

// when capture envelope is called with an crashed event
fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true)

val scopeRef = AtomicReference<IScope>()
Sentry.configureScope { scope ->
scopeRef.set(scope)
}
assertEquals(Session.State.Crashed, scopeRef.get().session!!.status)

// first envelope is the new session start
val capturedStartSessionEnvelope = fixture.capturedEnvelopes.first()
val capturedNewSessionStart = fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(capturedStartSessionEnvelope.items.toList()[0].data)),
Session::class.java
)!!
assertEquals(capturedNewSessionStart.sessionId, scopeRef.get().session!!.sessionId)
assertEquals(Session.State.Ok, capturedNewSessionStart.status)

val capturedEnvelope = fixture.capturedEnvelopes.last()
val capturedEnvelopeItems = capturedEnvelope.items.toList()

// there should be two envelopes session start and captured crash
assertEquals(2, fixture.capturedEnvelopes.size)

// then it should contain the original event + session
assertEquals(2, capturedEnvelopeItems.size)
assertEquals(SentryItemType.Event, capturedEnvelopeItems[0].header.type)
assertEquals(SentryItemType.Session, capturedEnvelopeItems[1].header.type)

// and then the sent session should be marked as crashed
val capturedSession = fixture.options.serializer.deserialize(
InputStreamReader(ByteArrayInputStream(capturedEnvelopeItems[1].data)),
Session::class.java
)!!
assertEquals(Session.State.Crashed, capturedSession.status)

// and the local session should be a new session
assertNotEquals(capturedSession.sessionId, scopeRef.get().session!!.sessionId)
}

@Test
Expand Down
3 changes: 3 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ public abstract interface class io/sentry/IScope {
public abstract fun clear ()V
public abstract fun clearAttachments ()V
public abstract fun clearBreadcrumbs ()V
public abstract fun clearSession ()V
public abstract fun clearTransaction ()V
public abstract fun clone ()Lio/sentry/IScope;
public abstract fun endSession ()Lio/sentry/Session;
Expand Down Expand Up @@ -1221,6 +1222,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope {
public fun clear ()V
public fun clearAttachments ()V
public fun clearBreadcrumbs ()V
public fun clearSession ()V
public fun clearTransaction ()V
public fun clone ()Lio/sentry/IScope;
public synthetic fun clone ()Ljava/lang/Object;
Expand Down Expand Up @@ -1592,6 +1594,7 @@ public final class io/sentry/Scope : io/sentry/IScope {
public fun clear ()V
public fun clearAttachments ()V
public fun clearBreadcrumbs ()V
public fun clearSession ()V
public fun clearTransaction ()V
public fun clone ()Lio/sentry/IScope;
public synthetic fun clone ()Ljava/lang/Object;
Expand Down
3 changes: 3 additions & 0 deletions sentry/src/main/java/io/sentry/IScope.java
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ public interface IScope {
@Nullable
Session getSession();

@ApiStatus.Internal
void clearSession();

@ApiStatus.Internal
void setPropagationContext(final @NotNull PropagationContext propagationContext);

Expand Down
4 changes: 4 additions & 0 deletions sentry/src/main/java/io/sentry/NoOpScope.java
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ public void withTransaction(Scope.@NotNull IWithTransaction callback) {}
return null;
}

@ApiStatus.Internal
@Override
public void clearSession() {}

@ApiStatus.Internal
@Override
public void setPropagationContext(@NotNull PropagationContext propagationContext) {}
Expand Down
6 changes: 6 additions & 0 deletions sentry/src/main/java/io/sentry/Scope.java
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,12 @@ public SentryOptions getOptions() {
return session;
}

@ApiStatus.Internal
@Override
public void clearSession() {
session = null;
}

@ApiStatus.Internal
@Override
public void setPropagationContext(final @NotNull PropagationContext propagationContext) {
Expand Down
Loading