-
-
Notifications
You must be signed in to change notification settings - Fork 474
feat(android): [Unhandled Sessions 3] Add internal non-terminating envelope capture #5921
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
Changes from all commits
e84200f
790c521
8caa61d
71f94e3
a7bb89e
aa1f531
97adc1b
c840fa3
b01be8a
43d6344
841df11
86a48ac
6e01479
6a2a891
24da0ef
f3f34df
5452526
25922d4
cbe9cb0
84ba05a
350fe77
c7c5a61
668122e
da288f8
e953d29
c1cbf99
960c618
7fa2d10
b91d6fe
32b70a7
ed4534d
f356f78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,12 +34,14 @@ | |
| 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; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.concurrent.RejectedExecutionException; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
| import org.jetbrains.annotations.ApiStatus; | ||
| import org.jetbrains.annotations.NotNull; | ||
|
|
@@ -153,7 +155,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 | ||
| */ | ||
|
|
@@ -163,35 +170,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); | ||
| 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); | ||
|
buenaflor marked this conversation as resolved.
|
||
| if (session != null) { | ||
| final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); | ||
| envelopeItems.add(sessionItem); | ||
|
|
@@ -213,6 +210,151 @@ 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 | ||
|
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; | ||
| } | ||
|
|
||
| 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 and persists the current session for a non-terminating hybrid error. | ||
| * | ||
| * @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); | ||
|
buenaflor marked this conversation as resolved.
|
||
| if (recorded) { | ||
| schedulePersistSession(options, session.clone()); | ||
| } | ||
| })); | ||
| } | ||
|
|
||
| /** | ||
| * This function is mostly called from Flutter where capturing envelope is run in a background | ||
| * thread. | ||
| * | ||
| * <p>Nonetheless we should run this in the same executor service as the deleteCurrentSessionFile | ||
| * function for consistency. | ||
| */ | ||
| private static void schedulePersistSession( | ||
| final @NotNull SentryOptions options, final @NotNull Session session) { | ||
| if (!(options.getEnvelopeDiskCache() instanceof EnvelopeCache)) { | ||
| return; | ||
| } | ||
| final @NotNull EnvelopeCache cache = (EnvelopeCache) options.getEnvelopeDiskCache(); | ||
| try { | ||
| options.getExecutorService().submit(() -> cache.persistCurrentSession(session)); | ||
| } catch (RejectedExecutionException e) { | ||
| options.getLogger().log(WARNING, "Submission of session persisting rejected.", e); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Async persist overwrites rotated sessionsHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit f356f78. Configure here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @runningcode afaict this is real. I think we should just run the persist as sync. This is only called by Flutter where we run it on a background thread so maybe we should just document it clearly here |
||
| } | ||
|
|
||
| /** 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<>(); | ||
|
|
@@ -305,22 +447,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(); | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.