diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4563d15564b..404367a8782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ spotless = "7.0.4" gummyBears = "0.12.0" camerax = "1.3.0" openfeature = "1.18.2" +protobuf = "4.33.1" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -60,6 +61,7 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" } kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } +protobuf = { id = "com.google.protobuf", version = "0.9.5" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } springboot2 = { id = "org.springframework.boot", version.ref = "springboot2" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } @@ -138,6 +140,8 @@ otel-javaagent-extension-api = { module = "io.opentelemetry.javaagent:openteleme otel-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version.ref = "otelSemanticConventions" } otel-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-semconv-incubating", version.ref = "otelSemanticConventionsAlpha" } p6spy = { module = "p6spy:p6spy", version = "3.9.1" } +protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf"} +protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 08895e6713f..569e46fee02 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -141,13 +141,6 @@ public final class io/sentry/android/core/AnrIntegrationFactory { public static fun create (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)Lio/sentry/Integration; } -public final class io/sentry/android/core/AnrV2EventProcessor : io/sentry/BackfillingEventProcessor { - public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V - public fun getOrder ()Ljava/lang/Long; - public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; - public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; -} - public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V @@ -203,6 +196,13 @@ public final class io/sentry/android/core/AppState$LifecycleObserver : androidx/ public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V } +public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io/sentry/BackfillingEventProcessor { + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun getOrder ()Ljava/lang/Long; + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -351,6 +351,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z + public fun isReportHistoricalTombstones ()Z + public fun isTombstoneEnabled ()Z public fun setAnrEnabled (Z)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V @@ -379,6 +381,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V public fun setReportHistoricalAnrs (Z)V + public fun setReportHistoricalTombstones (Z)V + public fun setTombstoneEnabled (Z)V } public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback { @@ -467,6 +471,29 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/TombstoneIntegration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;)V + public fun close ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + +public final class io/sentry/android/core/TombstoneIntegration$TombstoneHint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/Backfillable, io/sentry/hints/NativeCrashExit { + public fun (JLio/sentry/ILogger;JZ)V + public fun isFlushable (Lio/sentry/protocol/SentryId;)Z + public fun setFlushable (Lio/sentry/protocol/SentryId;)V + public fun shouldEnrich ()Z + public fun timestamp ()Ljava/lang/Long; +} + +public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/sentry/android/core/ApplicationExitInfoHistoryDispatcher$ApplicationExitInfoPolicy { + public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun buildReport (Landroid/app/ApplicationExitInfo;Z)Lio/sentry/android/core/ApplicationExitInfoHistoryDispatcher$Report; + public fun getLabel ()Ljava/lang/String; + public fun getLastReportedTimestamp ()Ljava/lang/Long; + public fun getTargetReason ()I + public fun shouldReportHistorical ()Z +} + public final class io/sentry/android/core/UserInteractionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { public fun (Landroid/app/Application;Lio/sentry/util/LoadClass;)V public fun close ()V @@ -493,11 +520,15 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr } public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { + public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; + public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 99d6b5115c8..e293fd76ee9 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.jacoco.android) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + alias(libs.plugins.protobuf) } android { @@ -83,6 +84,7 @@ dependencies { implementation(libs.androidx.lifecycle.common.java8) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.core) + implementation(libs.protobuf.javalite) errorprone(libs.errorprone.core) errorprone(libs.nopen.checker) @@ -109,3 +111,10 @@ dependencies { testRuntimeOnly(libs.androidx.fragment.ktx) testRuntimeOnly(libs.timber) } + +protobuf { + protoc { artifact = libs.protoc.get().toString() } + generateProtoTasks { + all().forEach { task -> task.builtins { create("java") { option("lite") } } } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index c284d2256e2..1243c3acb50 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -5,6 +5,7 @@ import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; +import android.os.Build; import io.sentry.CompositePerformanceCollector; import io.sentry.DeduplicateMultithreadedEventProcessor; import io.sentry.DefaultCompositePerformanceCollector; @@ -188,7 +189,8 @@ static void initializeIntegrationsAndProcessors( options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider)); options.addEventProcessor(new ViewHierarchyEventProcessor(options)); - options.addEventProcessor(new AnrV2EventProcessor(context, options, buildInfoProvider)); + options.addEventProcessor( + new ApplicationExitInfoEventProcessor(context, options, buildInfoProvider)); if (options.getTransportGate() instanceof NoOpTransportGate) { options.setTransportGate(new AndroidTransportGate(options)); } @@ -373,6 +375,10 @@ static void installDefaultIntegrations( final Class sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()); options.addIntegration(new NdkIntegration(sentryNdkClass)); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { + options.addIntegration(new TombstoneIntegration(context)); + } + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index c6d47cadcb4..af3a942c8cc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -18,8 +18,6 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; -import io.sentry.cache.EnvelopeCache; -import io.sentry.cache.IEnvelopeCache; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; @@ -39,10 +37,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -50,9 +45,6 @@ @SuppressLint("NewApi") // we check this in AnrIntegrationFactory public class AnrV2Integration implements Integration, Closeable { - // using 91 to avoid timezone change hassle, 90 days is how long Sentry keeps the events - static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91); - private final @NotNull Context context; private final @NotNull ICurrentDateProvider dateProvider; private @Nullable SentryAndroidOptions options; @@ -92,9 +84,11 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { try { options .getExecutorService() - .submit(new AnrProcessor(context, scopes, this.options, dateProvider)); + .submit( + new ApplicationExitInfoHistoryDispatcher( + context, scopes, this.options, dateProvider, new AnrV2Policy(this.options))); } catch (Throwable e) { - options.getLogger().log(SentryLevel.DEBUG, "Failed to start AnrProcessor.", e); + options.getLogger().log(SentryLevel.DEBUG, "Failed to start ANR processor.", e); } options.getLogger().log(SentryLevel.DEBUG, "AnrV2Integration installed."); addIntegrationToSdkVersion("AnrV2"); @@ -108,132 +102,37 @@ public void close() throws IOException { } } - static class AnrProcessor implements Runnable { + private static final class AnrV2Policy + implements ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy { - private final @NotNull Context context; - private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; - private final long threshold; - - AnrProcessor( - final @NotNull Context context, - final @NotNull IScopes scopes, - final @NotNull SentryAndroidOptions options, - final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; - this.scopes = scopes; + + AnrV2Policy(final @NotNull SentryAndroidOptions options) { this.options = options; - this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; } - @SuppressLint("NewApi") // we check this in AnrIntegrationFactory @Override - public void run() { - final ActivityManager activityManager = - (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - - final List applicationExitInfoList = - activityManager.getHistoricalProcessExitReasons(null, 0, 0); - if (applicationExitInfoList.size() == 0) { - options.getLogger().log(SentryLevel.DEBUG, "No records in historical exit reasons."); - return; - } - - final IEnvelopeCache cache = options.getEnvelopeDiskCache(); - if (cache instanceof EnvelopeCache) { - if (options.isEnableAutoSessionTracking() - && !((EnvelopeCache) cache).waitPreviousSessionFlush()) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Timed out waiting to flush previous session to its own file."); - - // if we timed out waiting here, we can already flush the latch, because the timeout is - // big - // enough to wait for it only once and we don't have to wait again in - // PreviousSessionFinalizer - ((EnvelopeCache) cache).flushPreviousSession(); - } - } - - // making a deep copy as we're modifying the list - final List exitInfos = new ArrayList<>(applicationExitInfoList); - final @Nullable Long lastReportedAnrTimestamp = AndroidEnvelopeCache.lastReportedAnr(options); - - // search for the latest ANR to report it separately as we're gonna enrich it. The latest - // ANR will be first in the list, as it's filled last-to-first in order of appearance - ApplicationExitInfo latestAnr = null; - for (ApplicationExitInfo applicationExitInfo : exitInfos) { - if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { - latestAnr = applicationExitInfo; - // remove it, so it's not reported twice - exitInfos.remove(applicationExitInfo); - break; - } - } - - if (latestAnr == null) { - options - .getLogger() - .log(SentryLevel.DEBUG, "No ANRs have been found in the historical exit reasons list."); - return; - } - - if (latestAnr.getTimestamp() < threshold) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Latest ANR happened too long ago, returning early."); - return; - } - - if (lastReportedAnrTimestamp != null - && latestAnr.getTimestamp() <= lastReportedAnrTimestamp) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Latest ANR has already been reported, returning early."); - return; - } + public @NotNull String getLabel() { + return "ANR"; + } - if (options.isReportHistoricalAnrs()) { - // report the remainder without enriching - reportNonEnrichedHistoricalAnrs(exitInfos, lastReportedAnrTimestamp); - } + @Override + public int getTargetReason() { + return ApplicationExitInfo.REASON_ANR; + } - // report the latest ANR with enriching, if contexts are available, otherwise report it - // non-enriched - reportAsSentryEvent(latestAnr, true); + @Override + public boolean shouldReportHistorical() { + return options.isReportHistoricalAnrs(); } - private void reportNonEnrichedHistoricalAnrs( - final @NotNull List exitInfos, final @Nullable Long lastReportedAnr) { - // we reverse the list, because the OS puts errors in order of appearance, last-to-first - // and we want to write a marker file after each ANR has been processed, so in case the app - // gets killed meanwhile, we can proceed from the last reported ANR and not process the entire - // list again - Collections.reverse(exitInfos); - for (ApplicationExitInfo applicationExitInfo : exitInfos) { - if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { - if (applicationExitInfo.getTimestamp() < threshold) { - options - .getLogger() - .log(SentryLevel.DEBUG, "ANR happened too long ago %s.", applicationExitInfo); - continue; - } - - if (lastReportedAnr != null && applicationExitInfo.getTimestamp() <= lastReportedAnr) { - options - .getLogger() - .log(SentryLevel.DEBUG, "ANR has already been reported %s.", applicationExitInfo); - continue; - } - - reportAsSentryEvent(applicationExitInfo, false); // do not enrich past events - } - } + @Override + public @Nullable Long getLastReportedTimestamp() { + return AndroidEnvelopeCache.lastReportedAnr(options); } - private void reportAsSentryEvent( + @Override + public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { final long anrTimestamp = exitInfo.getTimestamp(); final boolean isBackground = @@ -247,7 +146,7 @@ private void reportAsSentryEvent( SentryLevel.WARNING, "Not reporting ANR event as there was no thread dump for the ANR %s", exitInfo.toString()); - return; + return null; } final AnrV2Hint anrHint = new AnrV2Hint( @@ -284,19 +183,7 @@ private void reportAsSentryEvent( } } - final @NotNull SentryId sentryId = scopes.captureEvent(event, hint); - final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); - if (!isEventDropped) { - // Block until the event is flushed to disk and the last_reported_anr marker is updated - if (!anrHint.waitFlush()) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Timed out waiting to flush ANR event to disk. Event: %s", - event.getEventId()); - } - } + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, anrHint); } private @NotNull ParseResult parseThreadDump( @@ -379,6 +266,7 @@ public boolean ignoreCurrentThread() { return false; } + @NotNull @Override public Long timestamp() { return timestamp; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java similarity index 76% rename from sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java rename to sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 91bae9d7fe7..0f3b8c3a113 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -69,12 +69,12 @@ import org.jetbrains.annotations.Nullable; /** - * AnrV2Integration processes events on a background thread, hence the event processors will also be - * invoked on the same background thread, so we can safely read data from disk synchronously. + * Processes cached ApplicationExitInfo events (ANRs, tombstones) on a background thread, so we can + * safely read data from disk synchronously. */ @ApiStatus.Internal @WorkerThread -public final class AnrV2EventProcessor implements BackfillingEventProcessor { +public final class ApplicationExitInfoEventProcessor implements BackfillingEventProcessor { private final @NotNull Context context; @@ -86,7 +86,12 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @Nullable PersistingScopeObserver persistingScopeObserver; - public AnrV2EventProcessor( + // Only ANRv2 events are currently enriched with hint-specific content. + // This can be extended to other hints like NativeCrashExit. + private final @NotNull List hintEnrichers = + Collections.singletonList(new AnrHintEnricher()); + + public ApplicationExitInfoEventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { @@ -101,6 +106,15 @@ public AnrV2EventProcessor( sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); } + private @Nullable HintEnricher findEnricher(final @NotNull Object hint) { + for (HintEnricher enricher : hintEnrichers) { + if (enricher.supports(hint)) { + return enricher; + } + } + return null; + } + @Override public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { @@ -121,16 +135,19 @@ public AnrV2EventProcessor( "The event is not Backfillable, but has been passed to BackfillingEventProcessor, skipping."); return event; } + final @NotNull Backfillable backfillable = (Backfillable) unwrappedHint; + final @Nullable HintEnricher hintEnricher = findEnricher(unwrappedHint); + + if (hintEnricher != null) { + hintEnricher.applyPreEnrichment(event, backfillable, unwrappedHint); + } - // we always set exception values, platform, os and device even if the ANR is not enrich-able - // even though the OS context may change in the meantime (OS update), we consider this an - // edge-case - setExceptions(event, unwrappedHint); - setPlatform(event); + // We always set os and device even if the ApplicationExitInfo event is not enrich-able. + // The OS context may change in the meantime (OS update); we consider this an edge-case. mergeOS(event); setDevice(event); - if (!((Backfillable) unwrappedHint).shouldEnrich()) { + if (!backfillable.shouldEnrich()) { options .getLogger() .log( @@ -139,17 +156,21 @@ public AnrV2EventProcessor( return event; } - backfillScope(event, unwrappedHint); + backfillScope(event); - backfillOptions(event, unwrappedHint); + backfillOptions(event); setStaticValues(event); + if (hintEnricher != null) { + hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint); + } + return event; } // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event, final @NotNull Object hint) { + private void backfillScope(final @NotNull SentryEvent event) { setRequest(event); setUser(event); setScopeTags(event); @@ -157,7 +178,7 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setExtras(event); setContexts(event); setTransaction(event); - setFingerprints(event, hint); + setFingerprints(event); setLevel(event); setTrace(event); setReplayId(event); @@ -193,8 +214,11 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { private void setReplayId(final @NotNull SentryEvent event) { @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); - final @NotNull File replayFolder = - new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + @Nullable String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); if (!replayFolder.exists()) { if (!sampleReplay(event)) { return; @@ -203,7 +227,7 @@ private void setReplayId(final @NotNull SentryEvent event) { // latest replay folder that was modified before the ANR event. persistedReplayId = null; long lastModified = Long.MIN_VALUE; - final File[] dirs = new File(options.getCacheDirPath()).listFiles(); + final File[] dirs = new File(cacheDirPath).listFiles(); if (dirs != null) { for (File dir : dirs) { if (dir.isDirectory() && dir.getName().startsWith("replay_")) { @@ -229,9 +253,7 @@ private void setReplayId(final @NotNull SentryEvent event) { private void setTrace(final @NotNull SentryEvent event) { final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); if (event.getContexts().getTrace() == null) { - if (spanContext != null - && spanContext.getSpanId() != null - && spanContext.getTraceId() != null) { + if (spanContext != null) { event.getContexts().setTrace(spanContext); } } @@ -245,21 +267,12 @@ private void setLevel(final @NotNull SentryEvent event) { } @SuppressWarnings("unchecked") - private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { + private void setFingerprints(final @NotNull SentryEvent event) { final List fingerprint = (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); if (event.getFingerprints() == null) { event.setFingerprints(fingerprint); } - - // sentry does not yet have a capability to provide default server-side fingerprint rules, - // so we're doing this on the SDK side to group background and foreground ANRs separately - // even if they have similar stacktraces - final boolean isBackgroundAnr = isBackgroundAnr(hint); - if (event.getFingerprints() == null) { - event.setFingerprints( - Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); - } } private void setTransaction(final @NotNull SentryEvent event) { @@ -366,26 +379,22 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // endregion // region options persisted values - private void backfillOptions(final @NotNull SentryEvent event, final @NotNull Object hint) { + private void backfillOptions(final @NotNull SentryEvent event) { setRelease(event); setEnvironment(event); setDist(event); setDebugMeta(event); setSdk(event); - setApp(event, hint); + setApp(event); setOptionsTags(event); } - private void setApp(final @NotNull SentryBaseEvent event, final @NotNull Object hint) { + private void setApp(final @NotNull SentryBaseEvent event) { App app = event.getContexts().getApp(); if (app == null) { app = new App(); } app.setAppName(ContextUtils.getApplicationName(context)); - // TODO: not entirely correct, because we define background ANRs as not the ones of - // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR happened - // but it's our best effort for now. We could serialize AppState in theory. - app.setInForeground(!isBackgroundAnr(hint)); final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { @@ -530,26 +539,13 @@ private void setStaticValues(final @NotNull SentryEvent event) { setSideLoadedInfo(event); } - private void setPlatform(final @NotNull SentryBaseEvent event) { + private void setDefaultPlatform(final @NotNull SentryBaseEvent event) { if (event.getPlatform() == null) { // this actually means JVM related. event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); } } - @Nullable - private SentryThread findMainThread(final @Nullable List threads) { - if (threads != null) { - for (SentryThread thread : threads) { - final String name = thread.getName(); - if (name != null && name.equals("main")) { - return thread; - } - } - } - return null; - } - // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" private boolean isBackgroundAnr(final @NotNull Object hint) { if (hint instanceof AbnormalExit) { @@ -559,36 +555,6 @@ private boolean isBackgroundAnr(final @NotNull Object hint) { return false; } - private void setExceptions(final @NotNull SentryEvent event, final @NotNull Object hint) { - // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump - // and make an exception out of its stacktrace - final Mechanism mechanism = new Mechanism(); - if (!((Backfillable) hint).shouldEnrich()) { - // we only enrich the latest ANR in the list, so this is historical - mechanism.setType("HistoricalAppExitInfo"); - } else { - mechanism.setType("AppExitInfo"); - } - - final boolean isBackgroundAnr = isBackgroundAnr(hint); - String message = "ANR"; - if (isBackgroundAnr) { - message = "Background " + message; - } - final ApplicationNotResponding anr = - new ApplicationNotResponding(message, Thread.currentThread()); - - SentryThread mainThread = findMainThread(event.getThreads()); - if (mainThread == null) { - // if there's no main thread in the event threads, we just create a dummy thread so the - // exception is properly created as well, but without stacktrace - mainThread = new SentryThread(); - mainThread.setStacktrace(new SentryStackTrace()); - } - event.setExceptions( - sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); - } - private void mergeUser(final @NotNull SentryBaseEvent event) { @Nullable User user = event.getUser(); if (user == null) { @@ -699,6 +665,118 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { } event.getContexts().put(osNameKey, currentOS); } + // endregion + } + + private interface HintEnricher { + boolean supports(@NotNull Object hint); + + void applyPreEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + + void applyPostEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + } + + private final class AnrHintEnricher implements HintEnricher { + + @Override + public boolean supports(@NotNull Object hint) { + // TODO: not sure about this. all tests are written with AbnormalExit, but enrichment changes + // are ANR-specific. I called it AnrHintEnricher because that is what it does, but it + // actually triggers on AbnormalExit. Let me know what makes most sense. + return hint instanceof AbnormalExit; + } + + @Override + public void applyPreEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + // we always set exception values and default platform even if the ANR is not enrich-able + setDefaultPlatform(event); + setAnrExceptions(event, hint, isBackgroundAnr); + } + + @Override + public void applyPostEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + setAppForeground(event, !isBackgroundAnr); + setDefaultAnrFingerprint(event, isBackgroundAnr); + } + + private void setDefaultAnrFingerprint( + final @NotNull SentryEvent event, final boolean isBackgroundAnr) { + // sentry does not yet have a capability to provide default server-side fingerprint rules, + // so we're doing this on the SDK side to group background and foreground ANRs separately + // even if they have similar stacktraces. + if (event.getFingerprints() == null) { + event.setFingerprints( + Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); + } + } + + private void setAppForeground( + final @NotNull SentryBaseEvent event, final boolean inForeground) { + App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + event.getContexts().setApp(app); + } + // TODO: not entirely correct, because we define background ANRs as not the ones of + // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR + // happened but it's our best effort for now. We could serialize AppState in theory. + if (app.getInForeground() == null) { + app.setInForeground(inForeground); + } + } + + @Nullable + private SentryThread findMainThread(final @Nullable List threads) { + if (threads != null) { + for (SentryThread thread : threads) { + final String name = thread.getName(); + if (name != null && name.equals("main")) { + return thread; + } + } + } + return null; + } + + private void setAnrExceptions( + final @NotNull SentryEvent event, + final @NotNull Backfillable hint, + final boolean isBackgroundAnr) { + if (event.getExceptions() != null) { + return; + } + // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump + // and make an exception out of its stacktrace + final Mechanism mechanism = new Mechanism(); + if (!hint.shouldEnrich()) { + // we only enrich the latest ANR in the list, so this is historical + mechanism.setType("HistoricalAppExitInfo"); + } else { + mechanism.setType("AppExitInfo"); + } + + String message = "ANR"; + if (isBackgroundAnr) { + message = "Background " + message; + } + final ApplicationNotResponding anr = + new ApplicationNotResponding(message, Thread.currentThread()); + + SentryThread mainThread = findMainThread(event.getThreads()); + if (mainThread == null) { + // if there's no main thread in the event threads, we just create a dummy thread so the + // exception is properly created as well, but without stacktrace + mainThread = new SentryThread(); + mainThread.setStacktrace(new SentryStackTrace()); + } + event.setExceptions( + sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); + } } - // endregion } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java new file mode 100644 index 00000000000..79c3f19c6e5 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -0,0 +1,247 @@ +package io.sentry.android.core; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.SentryId; +import io.sentry.transport.ICurrentDateProvider; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +final class ApplicationExitInfoHistoryDispatcher implements Runnable { + + // using 91 to avoid timezone change hassle, 90 days is how long Sentry keeps the events + static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91); + + private final @NotNull Context context; + private final @NotNull IScopes scopes; + private final @NotNull SentryAndroidOptions options; + private final @NotNull ApplicationExitInfoPolicy policy; + private final long threshold; + + ApplicationExitInfoHistoryDispatcher( + final @NotNull Context context, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options, + final @NotNull ICurrentDateProvider dateProvider, + final @NotNull ApplicationExitInfoPolicy policy) { + this.context = ContextUtils.getApplicationContext(context); + this.scopes = scopes; + this.options = options; + this.policy = policy; + this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public void run() { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + final List applicationExitInfoList = + activityManager.getHistoricalProcessExitReasons(null, 0, 0); + + if (applicationExitInfoList.isEmpty()) { + options.getLogger().log(SentryLevel.DEBUG, "No records in historical exit reasons."); + return; + } + + waitPreviousSessionFlush(); + + final List exitInfos = new ArrayList<>(applicationExitInfoList); + final @Nullable Long lastReportedTimestamp = policy.getLastReportedTimestamp(); + + final ApplicationExitInfo latest = removeLatest(exitInfos); + if (latest == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "No %ss have been found in the historical exit reasons list.", + policy.getLabel()); + return; + } + + if (latest.getTimestamp() < threshold) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Latest %s happened too long ago, returning early.", + policy.getLabel()); + return; + } + + if (lastReportedTimestamp != null && latest.getTimestamp() <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Latest %s has already been reported, returning early.", + policy.getLabel()); + return; + } + + if (policy.shouldReportHistorical()) { + reportHistorical(exitInfos, lastReportedTimestamp); + } + + report(latest, true); + } + + private void waitPreviousSessionFlush() { + final IEnvelopeCache cache = options.getEnvelopeDiskCache(); + if (cache instanceof EnvelopeCache) { + if (options.isEnableAutoSessionTracking() + && !((EnvelopeCache) cache).waitPreviousSessionFlush()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Timed out waiting to flush previous session to its own file."); + + // if we timed out waiting here, we can already flush the latch, because the timeout is + // big enough to wait for it only once and we don't have to wait again in + // PreviousSessionFinalizer + ((EnvelopeCache) cache).flushPreviousSession(); + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private @Nullable ApplicationExitInfo removeLatest( + final @NotNull List exitInfos) { + for (Iterator it = exitInfos.iterator(); it.hasNext(); ) { + ApplicationExitInfo applicationExitInfo = it.next(); + if (applicationExitInfo.getReason() == policy.getTargetReason()) { + it.remove(); + return applicationExitInfo; + } + } + return null; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private void reportHistorical( + final @NotNull List exitInfos, + final @Nullable Long lastReportedTimestamp) { + Collections.reverse(exitInfos); + for (ApplicationExitInfo applicationExitInfo : exitInfos) { + if (applicationExitInfo.getReason() == policy.getTargetReason()) { + if (applicationExitInfo.getTimestamp() < threshold) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s happened too long ago %s.", + policy.getLabel(), + applicationExitInfo); + continue; + } + + if (lastReportedTimestamp != null + && applicationExitInfo.getTimestamp() <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s has already been reported %s.", + policy.getLabel(), + applicationExitInfo); + continue; + } + + report(applicationExitInfo, false); // do not enrich past events + } + } + } + + private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { + final @Nullable Report report = policy.buildReport(exitInfo, enrich); + + if (report == null) { + return; + } + + final @NotNull SentryId sentryId = scopes.captureEvent(report.getEvent(), report.getHint()); + final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); + if (!isEventDropped) { + final @Nullable BlockingFlushHint flushHint = report.getFlushHint(); + if (flushHint != null && !flushHint.waitFlush()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Timed out waiting to flush %s event to disk. Event: %s", + policy.getLabel(), + report.getEvent().getEventId()); + } + } + } + + interface ApplicationExitInfoPolicy { + @NotNull + String getLabel(); + + int getTargetReason(); + + boolean shouldReportHistorical(); + + @Nullable + Long getLastReportedTimestamp(); + + @Nullable + Report buildReport(@NotNull ApplicationExitInfo exitInfo, boolean enrich); + } + + public static final class Report { + private final @NotNull SentryEvent event; + private final @NotNull Hint hint; + private final @Nullable BlockingFlushHint flushHint; + + Report( + final @NotNull SentryEvent event, + final @NotNull Hint hint, + final @Nullable BlockingFlushHint flushHint) { + this.event = event; + this.hint = hint; + this.flushHint = flushHint; + } + + @NotNull + public SentryEvent getEvent() { + return event; + } + + @NotNull + public Hint getHint() { + return hint; + } + + @Nullable + public BlockingFlushHint getFlushHint() { + return flushHint; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 221495172eb..6b315fef3ca 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -82,7 +82,7 @@ public final class SentryAndroidOptions extends SentryOptions { *
  • The transaction status will be {@link SpanStatus#OK} if none is set. * * - * The transaction is automatically bound to the {@link IScope}, but only if there's no + *

    The transaction is automatically bound to the {@link IScope}, but only if there's no * transaction already bound to the Scope. */ private boolean enableAutoActivityLifecycleTracing = true; @@ -217,6 +217,17 @@ public interface BeforeCaptureCallback { */ private boolean reportHistoricalAnrs = false; + /** + * Controls whether to report historical Tombstones from the {@link ApplicationExitInfo} system + * API. When enabled, reports all of the Tombstones available in the {@link + * ActivityManager#getHistoricalProcessExitReasons(String, int, int)} list, as opposed to + * reporting only the latest one. + * + *

    These events do not affect crash rate nor are they enriched with additional information from + * {@link IScope} like breadcrumbs. + */ + private boolean reportHistoricalTombstones = false; + /** * Controls whether to send ANR (v2) thread dump as an attachment with plain text. The thread dump * is being attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. @@ -227,6 +238,8 @@ public interface BeforeCaptureCallback { private @Nullable SentryFrameMetricsCollector frameMetricsCollector; + private boolean enableTombstone = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -300,6 +313,27 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + /** + * Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled. + * + * @param enableTombstone true for enabled and false for disabled + */ + @ApiStatus.Internal + public void setTombstoneEnabled(boolean enableTombstone) { + this.enableTombstone = enableTombstone; + } + + /** + * Checks if Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) is enabled or disabled + * Default is disabled + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Internal + public boolean isTombstoneEnabled() { + return enableTombstone; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } @@ -570,6 +604,16 @@ public void setReportHistoricalAnrs(final boolean reportHistoricalAnrs) { this.reportHistoricalAnrs = reportHistoricalAnrs; } + @ApiStatus.Internal + public boolean isReportHistoricalTombstones() { + return reportHistoricalTombstones; + } + + @ApiStatus.Internal + public void setReportHistoricalTombstones(final boolean reportHistoricalTombstones) { + this.reportHistoricalTombstones = reportHistoricalTombstones; + } + public boolean isAttachAnrThreadDump() { return attachAnrThreadDump; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java new file mode 100644 index 00000000000..6d1c56db5ee --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -0,0 +1,212 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.DateUtils; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.tombstone.TombstoneParser; +import io.sentry.hints.Backfillable; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.hints.NativeCrashExit; +import io.sentry.protocol.SentryId; +import io.sentry.transport.CurrentDateProvider; +import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class TombstoneIntegration implements Integration, Closeable { + private final @NotNull Context context; + private final @NotNull ICurrentDateProvider dateProvider; + private @Nullable SentryAndroidOptions options; + + public TombstoneIntegration(final @NotNull Context context) { + // using CurrentDateProvider instead of AndroidCurrentDateProvider as AppExitInfo uses + // System.currentTimeMillis + this(context, CurrentDateProvider.getInstance()); + } + + TombstoneIntegration( + final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.dateProvider = dateProvider; + } + + @Override + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + this.options + .getLogger() + .log( + SentryLevel.DEBUG, + "TombstoneIntegration enabled: %s", + this.options.isTombstoneEnabled()); + + if (this.options.isTombstoneEnabled()) { + if (this.options.getCacheDirPath() == null) { + this.options + .getLogger() + .log(SentryLevel.INFO, "Cache dir is not set, unable to process Tombstones"); + return; + } + + try { + options + .getExecutorService() + .submit( + new ApplicationExitInfoHistoryDispatcher( + context, + scopes, + this.options, + dateProvider, + new TombstonePolicy(this.options))); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to start tombstone processor.", e); + } + options.getLogger().log(SentryLevel.DEBUG, "TombstoneIntegration installed."); + addIntegrationToSdkVersion("Tombstone"); + } + } + + @Override + public void close() throws IOException { + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "TombstoneIntegration removed."); + } + } + + @ApiStatus.Internal + public static class TombstonePolicy implements ApplicationExitInfoPolicy { + + private final @NotNull SentryAndroidOptions options; + + public TombstonePolicy(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public @NotNull String getLabel() { + return "Tombstone"; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public int getTargetReason() { + return ApplicationExitInfo.REASON_CRASH_NATIVE; + } + + @Override + public boolean shouldReportHistorical() { + return options.isReportHistoricalTombstones(); + } + + @Override + public @Nullable Long getLastReportedTimestamp() { + return AndroidEnvelopeCache.lastReportedTombstone(options); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( + final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { + final SentryEvent event; + try { + final InputStream tombstoneInputStream = exitInfo.getTraceInputStream(); + if (tombstoneInputStream == null) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No tombstone InputStream available for ApplicationExitInfo from %s", + DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochMilli(exitInfo.getTimestamp()))); + return null; + } + + try (final TombstoneParser parser = new TombstoneParser(tombstoneInputStream)) { + event = parser.parse(); + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to parse tombstone from %s: %s", + DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(exitInfo.getTimestamp())), + e.getMessage()); + return null; + } + + final long tombstoneTimestamp = exitInfo.getTimestamp(); + event.setTimestamp(DateUtils.getDateTime(tombstoneTimestamp)); + + final TombstoneHint tombstoneHint = + new TombstoneHint( + options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); + final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); + } + } + + @ApiStatus.Internal + public static final class TombstoneHint extends BlockingFlushHint + implements Backfillable, NativeCrashExit { + + private final long tombstoneTimestamp; + private final boolean shouldEnrich; + + public TombstoneHint( + long flushTimeoutMillis, + @NotNull ILogger logger, + long tombstoneTimestamp, + boolean shouldEnrich) { + super(flushTimeoutMillis, logger); + this.tombstoneTimestamp = tombstoneTimestamp; + this.shouldEnrich = shouldEnrich; + } + + @NotNull + @Override + public Long timestamp() { + return tombstoneTimestamp; + } + + @Override + public boolean shouldEnrich() { + return shouldEnrich; + } + + @Override + public boolean isFlushable(@Nullable SentryId eventId) { + return true; + } + + @Override + public void setFlushable(@NotNull SentryId eventId) {} + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 6481be70b0e..ddbfa0cc2dc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -10,6 +10,7 @@ import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrV2Integration; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.TombstoneIntegration; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -22,6 +23,8 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,6 +34,7 @@ public final class AndroidEnvelopeCache extends EnvelopeCache { public static final String LAST_ANR_REPORT = "last_anr_report"; + public static final String LAST_TOMBSTONE_REPORT = "last_tombstone_report"; private final @NotNull ICurrentDateProvider currentDateProvider; @@ -80,20 +84,10 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull } } - HintUtils.runIfHasType( - hint, - AnrV2Integration.AnrV2Hint.class, - (anrHint) -> { - final @Nullable Long timestamp = anrHint.timestamp(); - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Writing last reported ANR marker with timestamp %d", - timestamp); + for (TimestampMarkerHandler handler : TIMESTAMP_MARKER_HANDLERS) { + handler.handle(this, hint, options); + } - writeLastReportedAnrMarker(timestamp); - }); return didStore; } @@ -132,9 +126,9 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); try { final boolean exists = - options.getRuntimeManager().runWithRelaxedPolicy(() -> crashMarkerFile.exists()); + options.getRuntimeManager().runWithRelaxedPolicy(crashMarkerFile::exists); if (exists) { - if (!options.getRuntimeManager().runWithRelaxedPolicy(() -> crashMarkerFile.delete())) { + if (!options.getRuntimeManager().runWithRelaxedPolicy(crashMarkerFile::delete)) { options .getLogger() .log( @@ -152,14 +146,18 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options return false; } - public static @Nullable Long lastReportedAnr(final @NotNull SentryOptions options) { + private static @Nullable Long lastReportedMarker( + final @NotNull SentryOptions options, + @NotNull String reportFilename, + @NotNull String markerLabel) { final String cacheDirPath = Objects.requireNonNull( - options.getCacheDirPath(), "Cache dir path should be set for getting ANRs reported"); + options.getCacheDirPath(), + "Cache dir path should be set for getting " + markerLabel + "s reported"); - final File lastAnrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + final File lastMarker = new File(cacheDirPath, reportFilename); try { - final String content = FileUtils.readText(lastAnrMarker); + final String content = FileUtils.readText(lastMarker); // we wrapped into try-catch already //noinspection ConstantConditions return content.equals("null") ? null : Long.parseLong(content.trim()); @@ -167,27 +165,105 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options if (e instanceof FileNotFoundException) { options .getLogger() - .log(DEBUG, "Last ANR marker does not exist. %s.", lastAnrMarker.getAbsolutePath()); + .log( + DEBUG, + "Last " + markerLabel + " marker does not exist. %s.", + lastMarker.getAbsolutePath()); } else { - options.getLogger().log(ERROR, "Error reading last ANR marker", e); + options.getLogger().log(ERROR, "Error reading last " + markerLabel + " marker", e); } } return null; } - private void writeLastReportedAnrMarker(final @Nullable Long timestamp) { + private void writeLastReportedMarker( + final @Nullable Long timestamp, + @NotNull String reportFilename, + @NotNull String markerCategory) { final String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath == null) { - options.getLogger().log(DEBUG, "Cache dir path is null, the ANR marker will not be written"); + options + .getLogger() + .log( + DEBUG, + "Cache dir path is null, the " + markerCategory + " marker will not be written"); return; } - final File anrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + final File anrMarker = new File(cacheDirPath, reportFilename); try (final OutputStream outputStream = new FileOutputStream(anrMarker)) { outputStream.write(String.valueOf(timestamp).getBytes(UTF_8)); outputStream.flush(); } catch (Throwable e) { - options.getLogger().log(ERROR, "Error writing the ANR marker to the disk", e); + options + .getLogger() + .log(ERROR, "Error writing the " + markerCategory + " marker to the disk", e); } } + + public static @Nullable Long lastReportedAnr(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_ANR_REPORT, LAST_ANR_MARKER_LABEL); + } + + public static @Nullable Long lastReportedTombstone(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); + } + + private static final class TimestampMarkerHandler { + interface TimestampExtractor { + @NotNull + Long extract(T value); + } + + private final @NotNull Class type; + private final @NotNull String label; + private final @NotNull String reportFilename; + private final @NotNull TimestampExtractor timestampProvider; + + TimestampMarkerHandler( + final @NotNull Class type, + final @NotNull String label, + final @NotNull String reportFilename, + final @NotNull TimestampExtractor timestampProvider) { + this.type = type; + this.label = label; + this.reportFilename = reportFilename; + this.timestampProvider = timestampProvider; + } + + void handle( + final @NotNull AndroidEnvelopeCache cache, + final @NotNull Hint hint, + final @NotNull SentryAndroidOptions options) { + HintUtils.runIfHasType( + hint, + type, + (typedHint) -> { + final @NotNull Long timestamp = timestampProvider.extract(typedHint); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Writing last reported %s marker with timestamp %d", + label, + timestamp); + cache.writeLastReportedMarker(timestamp, reportFilename, label); + }); + } + } + + public static final String LAST_TOMBSTONE_MARKER_LABEL = "Tombstone"; + public static final String LAST_ANR_MARKER_LABEL = "ANR"; + private static final List> TIMESTAMP_MARKER_HANDLERS = + Arrays.asList( + new TimestampMarkerHandler<>( + AnrV2Integration.AnrV2Hint.class, + LAST_ANR_MARKER_LABEL, + LAST_ANR_REPORT, + AnrV2Integration.AnrV2Hint::timestamp), + new TimestampMarkerHandler<>( + TombstoneIntegration.TombstoneHint.class, + LAST_TOMBSTONE_MARKER_LABEL, + LAST_TOMBSTONE_REPORT, + TombstoneIntegration.TombstoneHint::timestamp)); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java new file mode 100644 index 00000000000..60f9376dfa6 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -0,0 +1,222 @@ +package io.sentry.android.core.internal.tombstone; + +import androidx.annotation.NonNull; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.Message; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.SentryStackTrace; +import io.sentry.protocol.SentryThread; +import java.io.Closeable; +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; + +public class TombstoneParser implements Closeable { + + private final InputStream tombstoneStream; + private final Map excTypeValueMap = new HashMap<>(); + + public TombstoneParser(@NonNull final InputStream tombstoneStream) { + this.tombstoneStream = tombstoneStream; + + // keep the current signal type -> value mapping for compatibility + excTypeValueMap.put("SIGILL", "IllegalInstruction"); + excTypeValueMap.put("SIGTRAP", "Trap"); + excTypeValueMap.put("SIGABRT", "Abort"); + excTypeValueMap.put("SIGBUS", "BusError"); + excTypeValueMap.put("SIGFPE", "FloatingPointException"); + excTypeValueMap.put("SIGSEGV", "Segfault"); + } + + @NonNull + public SentryEvent parse() throws IOException { + @NonNull + final TombstoneProtos.Tombstone tombstone = + TombstoneProtos.Tombstone.parseFrom(tombstoneStream); + + final SentryEvent event = new SentryEvent(); + event.setLevel(SentryLevel.FATAL); + + // must use the "native" platform because otherwise the stack-trace wouldn't be correctly parsed + event.setPlatform("native"); + + event.setMessage(constructMessage(tombstone)); + event.setDebugMeta(createDebugMeta(tombstone)); + event.setExceptions(createException(tombstone)); + if (event.getExceptions() == null || event.getExceptions().isEmpty()) { + throw new RuntimeException("Failed to decode exception information from tombstone"); + } + event.setThreads(createThreads(tombstone, event.getExceptions().get(0))); + + return event; + } + + @NonNull + private List createThreads( + @NonNull final TombstoneProtos.Tombstone tombstone, @NonNull final SentryException exc) { + final List threads = new ArrayList<>(); + for (Map.Entry threadEntry : + tombstone.getThreadsMap().entrySet()) { + final TombstoneProtos.Thread threadEntryValue = threadEntry.getValue(); + + final SentryThread thread = new SentryThread(); + thread.setId(Long.valueOf(threadEntry.getKey())); + thread.setName(threadEntryValue.getName()); + + final SentryStackTrace stacktrace = createStackTrace(threadEntryValue); + thread.setStacktrace(stacktrace); + if (tombstone.getTid() == threadEntryValue.getId()) { + thread.setCrashed(true); + // even though we refer to the thread_id from the exception, + // the backend currently requires a stack-trace in exception + exc.setStacktrace(stacktrace); + } + threads.add(thread); + } + + return threads; + } + + @NonNull + private static SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread thread) { + final List frames = new ArrayList<>(); + + for (TombstoneProtos.BacktraceFrame frame : thread.getCurrentBacktraceList()) { + final SentryStackFrame stackFrame = new SentryStackFrame(); + stackFrame.setPackage(frame.getFileName()); + stackFrame.setFunction(frame.getFunctionName()); + stackFrame.setInstructionAddr(String.format("0x%x", frame.getPc())); + frames.add(0, stackFrame); + } + + final SentryStackTrace stacktrace = new SentryStackTrace(); + stacktrace.setFrames(frames); + + // `libunwindstack` used for tombstones already applies instruction address adjustment: + // https://android.googlesource.com/platform/system/unwinding/+/refs/heads/main/libunwindstack/Regs.cpp#175 + // prevent "processing" from doing it again. + stacktrace.setInstructionAddressAdjustment("none"); + + final Map registers = new HashMap<>(); + for (TombstoneProtos.Register register : thread.getRegistersList()) { + registers.put(register.getName(), String.format("0x%x", register.getU64())); + } + stacktrace.setRegisters(registers); + + return stacktrace; + } + + @NonNull + private List createException(@NonNull TombstoneProtos.Tombstone tombstone) { + final SentryException exception = new SentryException(); + + if (tombstone.hasSignalInfo()) { + final TombstoneProtos.Signal signalInfo = tombstone.getSignalInfo(); + exception.setType(signalInfo.getName()); + exception.setValue(excTypeValueMap.get(signalInfo.getName())); + exception.setMechanism(createMechanismFromSignalInfo(signalInfo)); + } + + exception.setThreadId((long) tombstone.getTid()); + final List exceptions = new ArrayList<>(1); + exceptions.add(exception); + + return exceptions; + } + + @NonNull + private static Mechanism createMechanismFromSignalInfo( + @NonNull final TombstoneProtos.Signal signalInfo) { + + final Mechanism mechanism = new Mechanism(); + // this follows the current processing triggers strictly, changing any of these + // alters grouping and name (long-term we might want to have a tombstone mechanism) + // TODO: if we align this with ANRv2 this would be overwritten in a BackfillingEventProcessor as + // `ApplicationExitInfo` not sure what the right call is. `ApplicationExitInfo` is + // certainly correct. But `signalhandler` isn't wrong either, since all native crashes + // retrieved via `REASON_CRASH_NATIVE` will be signals. I am not sure what the side-effect + // in ingestion/processing will be if we change the mechanism, but initially i wanted to + // stay close to the Native SDK. + mechanism.setType("signalhandler"); + mechanism.setHandled(false); + mechanism.setSynthetic(true); + + final Map meta = new HashMap<>(); + meta.put("number", signalInfo.getNumber()); + meta.put("name", signalInfo.getName()); + meta.put("code", signalInfo.getCode()); + meta.put("code_name", signalInfo.getCodeName()); + mechanism.setMeta(meta); + + return mechanism; + } + + @NonNull + private Message constructMessage(@NonNull final TombstoneProtos.Tombstone tombstone) { + final Message message = new Message(); + final TombstoneProtos.Signal signalInfo = tombstone.getSignalInfo(); + + // reproduce the message `debuggerd` would use to dump the stack trace in logcat + String command = String.join(" ", tombstone.getCommandLineList()); + if (tombstone.hasSignalInfo()) { + String abortMessage = tombstone.getAbortMessage(); + message.setFormatted( + String.format( + Locale.ROOT, + "%sFatal signal %s (%d), %s (%d), pid = %d (%s)", + !abortMessage.isEmpty() ? abortMessage + ": " : "", + signalInfo.getName(), + signalInfo.getNumber(), + signalInfo.getCodeName(), + signalInfo.getCode(), + tombstone.getPid(), + command)); + } else { + message.setFormatted( + String.format(Locale.ROOT, "Fatal exit pid = %d (%s)", tombstone.getPid(), command)); + } + + return message; + } + + private DebugMeta createDebugMeta(@NonNull final TombstoneProtos.Tombstone tombstone) { + final List images = new ArrayList<>(); + + for (TombstoneProtos.MemoryMapping module : tombstone.getMemoryMappingsList()) { + // exclude anonymous and non-executable maps + if (module.getBuildId().isEmpty() + || module.getMappingName().isEmpty() + || !module.getExecute()) { + continue; + } + final DebugImage image = new DebugImage(); + image.setCodeId(module.getBuildId()); + image.setCodeFile(module.getMappingName()); + image.setDebugId(module.getBuildId()); + image.setImageAddr(String.format("0x%x", module.getBeginAddress())); + image.setImageSize(module.getEndAddress() - module.getBeginAddress()); + image.setType("elf"); + + images.add(image); + } + + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(images); + + return debugMeta; + } + + @Override + public void close() throws IOException { + tombstoneStream.close(); + } +} diff --git a/sentry-android-core/src/main/proto/io/sentry/android/core/internal/tombstone/tombstone.proto b/sentry-android-core/src/main/proto/io/sentry/android/core/internal/tombstone/tombstone.proto new file mode 100644 index 00000000000..2f9cbe52850 --- /dev/null +++ b/sentry-android-core/src/main/proto/io/sentry/android/core/internal/tombstone/tombstone.proto @@ -0,0 +1,218 @@ +// Added and adapted from: https://android.googlesource.com/platform/system/core/+/refs/heads/main/debuggerd/proto/tombstone.proto +// Sentry changes: +// * change the java_package +// +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Protobuf definition for Android tombstones. +// +// An app can get hold of these for any `REASON_CRASH_NATIVE` instance of +// `android.app.ApplicationExitInfo`. +// +// https://developer.android.com/reference/android/app/ApplicationExitInfo#getTraceInputStream() +// +syntax = "proto3"; +option java_package = "io.sentry.android.core.internal.tombstone"; +option java_outer_classname = "TombstoneProtos"; +// NOTE TO OEMS: +// If you add custom fields to this proto, do not use numbers in the reserved range. +// NOTE TO CONSUMERS: +// With proto3 -- unlike proto2 -- HasValue is unreliable for any field +// where the default value for that type is also a valid value for the field. +// This means, for example, that a boolean that is false or an integer that +// is zero will appear to be missing --- but because they're not actually +// marked as `optional` in this schema, consumers should just use values +// without first checking whether or not they're "present". +// https://protobuf.dev/programming-guides/proto3/#default +message CrashDetail { + bytes name = 1; + bytes data = 2; + reserved 3 to 999; +} +message StackHistoryBufferEntry { + BacktraceFrame addr = 1; + uint64 fp = 2; + uint64 tag = 3; + reserved 4 to 999; +} +message StackHistoryBuffer { + uint64 tid = 1; + repeated StackHistoryBufferEntry entries = 2; + reserved 3 to 999; +} +message Tombstone { + Architecture arch = 1; + Architecture guest_arch = 24; + string build_fingerprint = 2; + string revision = 3; + string timestamp = 4; + uint32 pid = 5; + uint32 tid = 6; + uint32 uid = 7; + string selinux_label = 8; + repeated string command_line = 9; + // Process uptime in seconds. + uint32 process_uptime = 20; + Signal signal_info = 10; + string abort_message = 14; + repeated CrashDetail crash_details = 21; + repeated Cause causes = 15; + map threads = 16; + map guest_threads = 25; + repeated MemoryMapping memory_mappings = 17; + repeated LogBuffer log_buffers = 18; + repeated FD open_fds = 19; + uint32 page_size = 22; + bool has_been_16kb_mode = 23; + StackHistoryBuffer stack_history_buffer = 26; + reserved 27 to 999; +} +enum Architecture { + ARM32 = 0; + ARM64 = 1; + X86 = 2; + X86_64 = 3; + RISCV64 = 4; + NONE = 5; + reserved 6 to 999; +} +message Signal { + int32 number = 1; + string name = 2; + int32 code = 3; + string code_name = 4; + bool has_sender = 5; + int32 sender_uid = 6; + int32 sender_pid = 7; + bool has_fault_address = 8; + uint64 fault_address = 9; + // Note, may or may not contain the dump of the actual memory contents. Currently, on arm64, we + // only include metadata, and not the contents. + MemoryDump fault_adjacent_metadata = 10; + reserved 11 to 999; +} +message HeapObject { + uint64 address = 1; + uint64 size = 2; + uint64 allocation_tid = 3; + repeated BacktraceFrame allocation_backtrace = 4; + uint64 deallocation_tid = 5; + repeated BacktraceFrame deallocation_backtrace = 6; +} +message MemoryError { + enum Tool { + GWP_ASAN = 0; + SCUDO = 1; + reserved 2 to 999; + } + Tool tool = 1; + enum Type { + UNKNOWN = 0; + USE_AFTER_FREE = 1; + DOUBLE_FREE = 2; + INVALID_FREE = 3; + BUFFER_OVERFLOW = 4; + BUFFER_UNDERFLOW = 5; + reserved 6 to 999; + } + Type type = 2; + oneof location { + HeapObject heap = 3; + } + reserved 4 to 999; +} +message Cause { + string human_readable = 1; + oneof details { + MemoryError memory_error = 2; + } + reserved 3 to 999; +} +message Register { + string name = 1; + uint64 u64 = 2; + reserved 3 to 999; +} +message Thread { + int32 id = 1; + string name = 2; + repeated Register registers = 3; + repeated string backtrace_note = 7; + repeated string unreadable_elf_files = 9; + repeated BacktraceFrame current_backtrace = 4; + repeated MemoryDump memory_dump = 5; + int64 tagged_addr_ctrl = 6; + int64 pac_enabled_keys = 8; + reserved 10 to 999; +} +message BacktraceFrame { + uint64 rel_pc = 1; + uint64 pc = 2; + uint64 sp = 3; + string function_name = 4; + uint64 function_offset = 5; + string file_name = 6; + uint64 file_map_offset = 7; + string build_id = 8; + reserved 9 to 999; +} +message ArmMTEMetadata { + // One memory tag per granule (e.g. every 16 bytes) of regular memory. + bytes memory_tags = 1; + reserved 2 to 999; +} +message MemoryDump { + string register_name = 1; + string mapping_name = 2; + uint64 begin_address = 3; + bytes memory = 4; + oneof metadata { + ArmMTEMetadata arm_mte_metadata = 6; + } + reserved 5, 7 to 999; +} +message MemoryMapping { + uint64 begin_address = 1; + uint64 end_address = 2; + uint64 offset = 3; + bool read = 4; + bool write = 5; + bool execute = 6; + string mapping_name = 7; + string build_id = 8; + uint64 load_bias = 9; + reserved 10 to 999; +} +message FD { + int32 fd = 1; + string path = 2; + string owner = 3; + uint64 tag = 4; + reserved 5 to 999; +} +message LogBuffer { + string name = 1; + repeated LogMessage logs = 2; + reserved 3 to 999; +} +message LogMessage { + string timestamp = 1; + uint32 pid = 2; + uint32 tid = 3; + uint32 priority = 4; + string tag = 5; + string message = 6; + reserved 7 to 999; +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 47825cd1e7b..348075ff900 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -259,9 +259,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `AnrV2EventProcessor added to processors list`() { + fun `ApplicationExitInfoProcessor added to processors list`() { fixture.initSut() - val actual = fixture.sentryOptions.eventProcessors.firstOrNull { it is AnrV2EventProcessor } + val actual = + fixture.sentryOptions.eventProcessors.firstOrNull { it is ApplicationExitInfoEventProcessor } assertNotNull(actual) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index af2c208440d..a1a35facf56 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -2,124 +2,56 @@ package io.sentry.android.core import android.app.ActivityManager import android.app.ApplicationExitInfo -import android.content.Context -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint -import io.sentry.ILogger -import io.sentry.IScopes -import io.sentry.SentryEnvelope -import io.sentry.SentryLevel +import io.sentry.SentryEvent import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.cache.AndroidEnvelopeCache -import io.sentry.cache.EnvelopeCache -import io.sentry.hints.DiskFlushNotification -import io.sentry.hints.SessionStartHint -import io.sentry.protocol.SentryId -import io.sentry.test.ImmediateExecutorService import io.sentry.util.HintUtils -import java.io.File -import java.util.concurrent.TimeUnit -import kotlin.concurrent.thread -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.junit.Rule -import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.atMost import org.mockito.kotlin.check -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.spy -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import org.robolectric.shadow.api.Shadow -import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) -class AnrV2IntegrationTest { - @get:Rule val tmpDir = TemporaryFolder() - - class Fixture { - lateinit var context: Context - lateinit var shadowActivityManager: ShadowActivityManager - lateinit var lastReportedAnrFile: File - - val options = SentryAndroidOptions() - val scopes = mock() - val logger = mock() - - fun getSut( - dir: TemporaryFolder?, - useImmediateExecutorService: Boolean = true, - isAnrEnabled: Boolean = true, - flushTimeoutMillis: Long = 0L, - sessionFlushTimeoutMillis: Long = 0L, - lastReportedAnrTimestamp: Long? = null, - lastEventId: SentryId = SentryId(), - sessionTrackingEnabled: Boolean = true, - reportHistoricalAnrs: Boolean = true, - attachAnrThreadDump: Boolean = false, - ): AnrV2Integration { - options.run { - setLogger(this@Fixture.logger) - isDebug = true - cacheDirPath = dir?.newFolder()?.absolutePath - executorService = if (useImmediateExecutorService) ImmediateExecutorService() else mock() - this.isAnrEnabled = isAnrEnabled - this.flushTimeoutMillis = flushTimeoutMillis - this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis - this.isEnableAutoSessionTracking = sessionTrackingEnabled - this.isReportHistoricalAnrs = reportHistoricalAnrs - this.isAttachAnrThreadDump = attachAnrThreadDump - addInAppInclude("io.sentry.samples") - setEnvelopeDiskCache(EnvelopeCache.create(this)) - } - options.cacheDirPath?.let { cacheDir -> - lastReportedAnrFile = File(cacheDir, AndroidEnvelopeCache.LAST_ANR_REPORT) - lastReportedAnrFile.writeText(lastReportedAnrTimestamp.toString()) - } - whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) - return AnrV2Integration(context) - } - - fun addAppExitInfo( - reason: Int? = ApplicationExitInfo.REASON_ANR, - timestamp: Long? = null, - importance: Int? = null, - addTrace: Boolean = true, - addBadTrace: Boolean = false, - ) { - val builder = ApplicationExitInfoBuilder.newBuilder() - if (reason != null) { - builder.setReason(reason) - } - if (timestamp != null) { - builder.setTimestamp(timestamp) - } - if (importance != null) { - builder.setImportance(importance) - } - val exitInfo = - spy(builder.build()) { - if (!addTrace) { - return - } - if (addBadTrace) { - whenever(mock.traceInputStream) - .thenReturn( - """ +class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { + + override val config = + IntegrationTestConfig( + setEnabledFlag = { isAnrEnabled = it }, + setReportHistoricalFlag = { isReportHistoricalAnrs = it }, + createIntegration = { context -> AnrV2Integration(context) }, + lastReportedFileName = AndroidEnvelopeCache.LAST_ANR_REPORT, + defaultExitReason = ApplicationExitInfo.REASON_ANR, + hintAccessors = + HintAccessors( + cast = { it as AnrV2Hint }, + shouldEnrich = { it.shouldEnrich() }, + timestamp = { it.timestamp() }, + ), + addExitInfo = { reason, timestamp, importance, addTrace, addBadTrace -> + val builder = ApplicationExitInfoBuilder.newBuilder() + reason?.let { builder.setReason(it) } + timestamp?.let { builder.setTimestamp(it) } + importance?.let { builder.setImportance(it) } + val exitInfo = + spy(builder.build()) { + if (!addTrace) { + return@spy + } + if (addBadTrace) { + whenever(mock.traceInputStream) + .thenReturn( + """ Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) Here are no Binder-related exception messages available. Pid(12233) have D state thread(tid:12236 name:Signal Catcher) @@ -143,13 +75,13 @@ class AnrV2IntegrationTest { ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- Cmd line: com.example.app:mainProcess """ - .trimIndent() - .byteInputStream() - ) - } else { - whenever(mock.traceInputStream) - .thenReturn( - """ + .trimIndent() + .byteInputStream() + ) + } else { + whenever(mock.traceInputStream) + .thenReturn( + """ "main" prio=5 tid=1 Blocked | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 @@ -179,154 +111,58 @@ class AnrV2IntegrationTest { native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) (no managed stack frames) """ - .trimIndent() - .byteInputStream() - ) + .trimIndent() + .byteInputStream() + ) + } } - } - shadowActivityManager.addApplicationExitInfo(exitInfo) - } - } - - private val fixture = Fixture() - private val oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) - private val newTimestamp = oldTimestamp + TimeUnit.DAYS.toMillis(5) - - @BeforeTest - fun `set up`() { - fixture.context = ApplicationProvider.getApplicationContext() - val activityManager = - fixture.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? - fixture.shadowActivityManager = Shadow.extract(activityManager) - } - - @Test - fun `when cacheDir is not set, does not process historical exits`() { - val integration = fixture.getSut(null, useImmediateExecutorService = false) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.options.executorService, never()).submit(any()) - } - - @Test - fun `when anr tracking is not enabled, does not process historical exits`() { - val integration = - fixture.getSut(tmpDir, isAnrEnabled = false, useImmediateExecutorService = false) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.options.executorService, never()).submit(any()) - } - - @Test - fun `when historical exit list is empty, does not process historical exits`() { - val integration = fixture.getSut(tmpDir) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when there are no ANRs in historical exits, does not capture events`() { - val integration = fixture.getSut(tmpDir) - fixture.addAppExitInfo(reason = null) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR is older than 90 days, does not capture events`() { - val oldTimestamp = - System.currentTimeMillis() - - AnrV2Integration.NINETY_DAYS_THRESHOLD - - TimeUnit.DAYS.toMillis(2) - val integration = fixture.getSut(tmpDir) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR has already been reported, does not capture events`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when no ANRs have ever been reported, captures events`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = null) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR has not been reported, captures event with enriching`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent( - check { - assertEquals(newTimestamp, it.timestamp.time) - assertEquals(SentryLevel.FATAL, it.level) - val mainThread = it.threads!!.first() - assertEquals("main", mainThread.name) - assertEquals(1, mainThread.id) - assertEquals("Blocked", mainThread.state) - assertEquals(true, mainThread.isCrashed) - assertEquals(true, mainThread.isMain) - assertEquals("0x0d3a2f0a", mainThread.heldLocks!!.values.first().address) - assertEquals(5, mainThread.heldLocks!!.values.first().threadId) - val lastFrame = mainThread.stacktrace!!.frames!!.last() - assertEquals("io.sentry.samples.android.MainActivity$2", lastFrame.module) - assertEquals("MainActivity.java", lastFrame.filename) - assertEquals("run", lastFrame.function) - assertEquals(177, lastFrame.lineno) - assertEquals(true, lastFrame.isInApp) - val otherThread = it.threads!![1] - assertEquals("perfetto_hprof_listener", otherThread.name) - assertEquals(7, otherThread.id) - assertEquals("Native", otherThread.state) - assertEquals(false, otherThread.isCrashed) - assertEquals(false, otherThread.isMain) - val firstFrame = otherThread.stacktrace!!.frames!!.first() - assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", firstFrame.`package`) - assertEquals("__start_thread", firstFrame.function) - assertEquals(64, firstFrame.lineno) - assertEquals("0x00000000000530b8", firstFrame.instructionAddr) - assertEquals("native", firstFrame.platform) - assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) + shadowActivityManager.addApplicationExitInfo(exitInfo) + }, + flushLogPrefix = "Timed out waiting to flush ANR event to disk.", + ) - val image = - it.debugMeta?.images?.find { it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" } - assertNotNull(image) - assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) - }, - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).shouldEnrich() - }, - ) + override fun assertEnrichedEvent(event: SentryEvent) { + val mainThread = event.threads!!.first() + assertEquals("main", mainThread.name) + assertEquals(1, mainThread.id) + assertEquals("Blocked", mainThread.state) + assertEquals(true, mainThread.isCrashed) + assertEquals(true, mainThread.isMain) + assertEquals("0x0d3a2f0a", mainThread.heldLocks!!.values.first().address) + assertEquals(5, mainThread.heldLocks!!.values.first().threadId) + + val lastFrame = mainThread.stacktrace!!.frames!!.last() + assertEquals("io.sentry.samples.android.MainActivity$2", lastFrame.module) + assertEquals("MainActivity.java", lastFrame.filename) + assertEquals("run", lastFrame.function) + assertEquals(177, lastFrame.lineno) + assertEquals(true, lastFrame.isInApp) + + val otherThread = event.threads!![1] + assertEquals("perfetto_hprof_listener", otherThread.name) + assertEquals(7, otherThread.id) + assertEquals("Native", otherThread.state) + assertEquals(false, otherThread.isCrashed) + assertEquals(false, otherThread.isMain) + + val firstFrame = otherThread.stacktrace!!.frames!!.first() + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", firstFrame.`package`) + assertEquals("__start_thread", firstFrame.function) + assertEquals(64, firstFrame.lineno) + assertEquals("0x00000000000530b8", firstFrame.instructionAddr) + assertEquals("native", firstFrame.platform) + assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) + + val image = + event.debugMeta?.images?.find { it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" } + assertNotNull(image) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) } @Test fun `when latest ANR has foreground importance, sets abnormal mechanism to anr_foreground`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionTrackingEnabled = true) fixture.addAppExitInfo( timestamp = newTimestamp, importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, @@ -344,145 +180,9 @@ class AnrV2IntegrationTest { ) } - @Test - fun `waits for ANR events to be flushed on disk`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, flushTimeoutMillis = 500L) - fixture.addAppExitInfo(timestamp = newTimestamp) - - whenever(fixture.scopes.captureEvent(any(), any())).thenAnswer { invocation -> - val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) as DiskFlushNotification - thread { - Thread.sleep(200L) - hint.markFlushed() - } - SentryId() - } - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - // shouldn't fall into timed out state, because we marked event as flushed on another thread - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush ANR event to disk.") }, - any(), - ) - } - - @Test - fun `when latest ANR event was dropped, does not block flushing`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - lastEventId = SentryId.EMPTY_ID, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - // we do not call markFlushed, hence it should time out waiting for flush, but because - // we drop the event, it should not even come to this if-check - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush ANR event to disk.") }, - any(), - ) - } - - @Test - fun `historical ANRs are reported non-enriched`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, times(2)) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - !(hint as AnrV2Hint).shouldEnrich() - }, - ) - } - - @Test - fun `when historical ANRs flag is disabled, does not report`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, reportHistoricalAnrs = false) - fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - // only the latest anr is reported which should be enrichable - verify(fixture.scopes, atMost(1)) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).shouldEnrich() - }, - ) - } - - @Test - fun `historical ANRs are reported in reverse order to keep track of last reported ANR in a marker file`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - // robolectric uses addFirst when adding exit infos, so the last one here will be the first on - // the list - fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(2)) - fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - // the order is reverse here, so the oldest ANR will be reported first to keep track of - // last reported ANR in a marker file - inOrder(fixture.scopes) { - verify(fixture.scopes) - .captureEvent( - argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, - anyOrNull(), - ) - verify(fixture.scopes) - .captureEvent( - argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, - anyOrNull(), - ) - verify(fixture.scopes) - .captureEvent(argThat { timestamp.time == newTimestamp }, anyOrNull()) - } - } - - @Test - fun `ANR timestamp is passed with the hint`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).timestamp() == newTimestamp - }, - ) - } - @Test fun `abnormal mechanism is passed with the hint`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) @@ -498,108 +198,17 @@ class AnrV2IntegrationTest { } @Test - fun `awaits for previous session flush if cache is EnvelopeCache`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - thread { - Thread.sleep(200L) - val sessionHint = HintUtils.createWithTypeCheckHint(SessionStartHint()) - fixture.options.envelopeDiskCache.store( - SentryEnvelope(SentryId.EMPTY_ID, null, emptyList()), - sessionHint, - ) - } - - integration.register(fixture.scopes, fixture.options) - - // we store envelope with StartSessionHint on different thread after some delay, which - // triggers the previous session flush, so no timeout - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - } - - @Test - fun `does not await for previous session flush, if session tracking is disabled`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, - sessionTrackingEnabled = false, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - verify(fixture.scopes).captureEvent(any(), any()) - } - - @Test - fun `flushes previous session latch, if timed out waiting`() { + fun `attaches plain thread dump, if enabled`() { val integration = fixture.getSut( tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, + lastReportedTimestamp = oldTimestamp, + extraOptions = { opts -> opts.isAttachAnrThreadDump = true }, ) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) - verify(fixture.logger) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - // should return true, because latch is 0 now - assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) - } - - @Test - fun `attaches plain thread dump, if enabled`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, attachAnrThreadDump = true) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - verify(fixture.scopes).captureEvent(any(), check { assertNotNull(it.threadDump) }) } - - @Test - fun `when traceInputStream is null, does not report ANR`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when traceInputStream has bad data, does not report ANR`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt similarity index 94% rename from sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index b80d2838c4e..66090fc815e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -47,6 +47,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace @@ -74,7 +75,7 @@ import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild @RunWith(AndroidJUnit4::class) -class AnrV2EventProcessorTest { +class ApplicationExitInfoEventProcessorTest { @get:Rule val tmpDir = TemporaryFolder() class Fixture { @@ -93,7 +94,7 @@ class AnrV2EventProcessorTest { populateOptionsCache: Boolean = false, replayErrorSampleRate: Double? = null, isSendDefaultPii: Boolean = true, - ): AnrV2EventProcessor { + ): ApplicationExitInfoEventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" options.isSendDefaultPii = isSendDefaultPii @@ -150,7 +151,7 @@ class AnrV2EventProcessorTest { } } - return AnrV2EventProcessor(context, options, buildInfo) + return ApplicationExitInfoEventProcessor(context, options, buildInfo) } fun persistScope(filename: String, entity: T) { @@ -204,7 +205,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is not enrichable, sets different mechanism`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) val processed = processEvent(hint) @@ -213,7 +214,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is not enrichable, sets platform`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) val processed = processEvent(hint) @@ -277,7 +278,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is enrichable, still sets static data`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) val processed = processEvent(hint) @@ -352,11 +353,20 @@ class AnrV2EventProcessorTest { assertEquals("io.sentry.android.core.test", processed.contexts.app!!.appName) assertEquals("1.2.0", processed.contexts.app!!.appVersion) assertEquals("232", processed.contexts.app!!.appBuild) - assertEquals(true, processed.contexts.app!!.inForeground) + assertNull(processed.contexts.app!!.inForeground) // tags assertEquals("tag", processed.tags!!["option"]) } + @Test + fun `when ANR event is enrichable, sets foreground flag`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint, populateOptionsCache = true) + + assertEquals(true, processed.contexts.app!!.inForeground) + } + @Test fun `if release is in wrong format, does not crash and leaves app version and build empty`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -595,6 +605,29 @@ class AnrV2EventProcessorTest { assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `tombstone hint does not override platform or exceptions`() { + val hint = + HintUtils.createWithTypeCheckHint( + TombstoneIntegration.TombstoneHint( + fixture.options.flushTimeoutMillis, + NoOpLogger.getInstance(), + 0, + true, + ) + ) + + val processed = + processEvent(hint, populateScopeCache = false, populateOptionsCache = false) { + platform = "native" + exceptions = listOf(SentryException().apply { type = "NativeCrash" }) + } + + assertEquals("native", processed.platform) + assertEquals("NativeCrash", processed.exceptions!!.first().type) + assertNull(processed.fingerprints) + } + @Test fun `sets replayId when replay folder exists`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -691,14 +724,17 @@ class AnrV2EventProcessorTest { return processor.process(original, hint)!! } - internal class AbnormalExitHint(val mechanism: String? = null) : AbnormalExit, Backfillable { + internal class AbnormalExitHint( + val mechanism: String? = null, + private val shouldEnrich: Boolean = true, + ) : AbnormalExit, Backfillable { override fun mechanism(): String? = mechanism override fun ignoreCurrentThread(): Boolean = false override fun timestamp(): Long? = null - override fun shouldEnrich(): Boolean = true + override fun shouldEnrich(): Boolean = shouldEnrich } internal class BackfillableHint(private val shouldEnrich: Boolean = true) : Backfillable { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt new file mode 100644 index 00000000000..4f2533d4268 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -0,0 +1,435 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.Integration +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.cache.EnvelopeCache +import io.sentry.hints.DiskFlushNotification +import io.sentry.hints.SessionStartHint +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import io.sentry.util.HintUtils +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.atMost +import org.mockito.kotlin.check +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager + +abstract class ApplicationExitIntegrationTestBase { + + protected abstract val config: IntegrationTestConfig + + @get:Rule val tmpDir = TemporaryFolder() + + protected val fixture: ApplicationExitTestFixture by lazy { + ApplicationExitTestFixture(config) + } + protected val oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) + protected val newTimestamp = oldTimestamp + TimeUnit.DAYS.toMillis(5) + + @BeforeTest + fun `set up`() { + val context = ApplicationProvider.getApplicationContext() + fixture.init(context) + } + + @Test + fun `when cacheDir is not set, does not process historical exits`() { + val integration = fixture.getSut(null, useImmediateExecutorService = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when integration is not enabled, does not process historical exits`() { + val integration = fixture.getSut(tmpDir, enabled = false, useImmediateExecutorService = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when historical exit list is empty, does not process historical exits`() { + val integration = fixture.getSut(tmpDir) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when there are no matching exits, does not capture events`() { + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(reason = null) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit is older than 90 days, does not capture events`() { + val oldTimestamp = + System.currentTimeMillis() - + ApplicationExitInfoHistoryDispatcher.NINETY_DAYS_THRESHOLD - + TimeUnit.DAYS.toMillis(2) + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit has already been reported, does not capture events`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when no exits have ever been reported, captures events`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = null) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit has not been reported, captures event with enriching`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals(event.timestamp!!.time, newTimestamp) + assertEquals(event.level, SentryLevel.FATAL) + assertEnrichedEvent(event) + }, + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `waits for events to be flushed on disk`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, flushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + whenever(fixture.scopes.captureEvent(any(), any())).thenAnswer { invocation -> + val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) as DiskFlushNotification + thread { + Thread.sleep(200L) + hint.markFlushed() + } + SentryId() + } + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + verify(fixture.logger, never()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + } + + @Test + fun `when latest event was dropped, does not block flushing`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + verify(fixture.logger, never()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + } + + @Test + fun `historical exits are reported non-enriched`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, times(2)) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + !config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `when historical flag is disabled, does not report`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, reportHistorical = false) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, atMost(1)) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `historical exits are reported in reverse order to keep track of last reported exit in a marker file`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(2)) + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, + anyOrNull(), + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, + anyOrNull(), + ) + verify(fixture.scopes) + .captureEvent(argThat { timestamp.time == newTimestamp }, anyOrNull()) + } + } + + @Test + fun `timestamp is passed with the hint`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.timestamp(hint) == newTimestamp + }, + ) + } + + @Test + fun `awaits for previous session flush if cache is EnvelopeCache`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionFlushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + thread { + Thread.sleep(200L) + val sessionHint = HintUtils.createWithTypeCheckHint(SessionStartHint()) + fixture.options.envelopeDiskCache.storeEnvelope( + SentryEnvelope(SentryId.EMPTY_ID, null, emptyList()), + sessionHint, + ) + } + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger, never()) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + } + + @Test + fun `does not await for previous session flush, if session tracking is disabled`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + sessionFlushTimeoutMillis = 500L, + sessionTrackingEnabled = false, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger, never()) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `flushes previous session latch, if timed out waiting`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionFlushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) + } + + @Test + fun `when traceInputStream is null, does not report`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when traceInputStream has bad data, does not report`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + protected open fun assertEnrichedEvent(event: SentryEvent) {} + + protected data class HintAccessors( + val cast: (Any?) -> THint, + val shouldEnrich: (THint) -> Boolean, + val timestamp: (THint) -> Long, + ) + + protected data class IntegrationTestConfig( + val setEnabledFlag: SentryAndroidOptions.(Boolean) -> Unit, + val setReportHistoricalFlag: SentryAndroidOptions.(Boolean) -> Unit, + val createIntegration: (Context) -> Integration, + val lastReportedFileName: String, + val defaultExitReason: Int, + val hintAccessors: HintAccessors, + val addExitInfo: + ApplicationExitTestFixture.( + reason: Int?, timestamp: Long?, importance: Int?, addTrace: Boolean, addBadTrace: Boolean, + ) -> Unit, + val flushLogPrefix: String, + ) + + protected class ApplicationExitTestFixture( + private val config: IntegrationTestConfig + ) { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + lateinit var lastReportedFile: File + + val options = SentryAndroidOptions() + val scopes = mock() + val logger = mock() + + fun init(appContext: Context) { + context = appContext + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + shadowActivityManager = Shadow.extract(activityManager) + } + + fun getSut( + dir: TemporaryFolder?, + useImmediateExecutorService: Boolean = true, + enabled: Boolean = true, + flushTimeoutMillis: Long = 0L, + sessionFlushTimeoutMillis: Long = 0L, + lastReportedTimestamp: Long? = null, + lastEventId: SentryId = SentryId(), + sessionTrackingEnabled: Boolean = true, + reportHistorical: Boolean = true, + extraOptions: (SentryAndroidOptions) -> Unit = {}, + ): Integration { + options.run { + setLogger(this@ApplicationExitTestFixture.logger) + isDebug = true + cacheDirPath = dir?.newFolder()?.absolutePath + executorService = if (useImmediateExecutorService) ImmediateExecutorService() else mock() + config.setEnabledFlag(this, enabled) + this.flushTimeoutMillis = flushTimeoutMillis + this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis + this.isEnableAutoSessionTracking = sessionTrackingEnabled + config.setReportHistoricalFlag(this, reportHistorical) + addInAppInclude("io.sentry.samples") + setEnvelopeDiskCache(EnvelopeCache.create(this)) + extraOptions(this) + } + options.cacheDirPath?.let { cacheDir -> + lastReportedFile = File(cacheDir, config.lastReportedFileName) + lastReportedFile.writeText(lastReportedTimestamp.toString()) + } + whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) + return config.createIntegration(context) + } + + fun addAppExitInfo( + reason: Int? = config.defaultExitReason, + timestamp: Long? = null, + importance: Int? = null, + addTrace: Boolean = true, + addBadTrace: Boolean = false, + ) { + config.addExitInfo(this, reason, timestamp, importance, addTrace, addBadTrace) + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index e2d92239f19..a9ab18098f3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -47,7 +47,6 @@ import io.sentry.cache.tape.QueueFile import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId import io.sentry.test.applyTestOptions -import io.sentry.test.initForTest import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils import java.io.ByteArrayOutputStream @@ -539,7 +538,7 @@ class SentryAndroidTest { } assertTrue(optionsRef.eventProcessors.any { it is DefaultAndroidEventProcessor }) - assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) + assertTrue(optionsRef.eventProcessors.any { it is ApplicationExitInfoEventProcessor }) } private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt new file mode 100644 index 00000000000..cb3988a6297 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -0,0 +1,76 @@ +package io.sentry.android.core + +import android.app.ApplicationExitInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.android.core.TombstoneIntegration.TombstoneHint +import io.sentry.android.core.cache.AndroidEnvelopeCache +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase() { + + override val config = + IntegrationTestConfig( + setEnabledFlag = { isTombstoneEnabled = it }, + setReportHistoricalFlag = { isReportHistoricalTombstones = it }, + createIntegration = { context -> TombstoneIntegration(context) }, + lastReportedFileName = AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT, + defaultExitReason = ApplicationExitInfo.REASON_CRASH_NATIVE, + hintAccessors = + HintAccessors( + cast = { it as TombstoneHint }, + shouldEnrich = { it.shouldEnrich() }, + timestamp = { it.timestamp() }, + ), + addExitInfo = { reason, timestamp, importance, addTrace, addBadTrace -> + val builder = ApplicationExitInfoBuilder.newBuilder() + reason?.let { builder.setReason(it) } + timestamp?.let { builder.setTimestamp(it) } + importance?.let { builder.setImportance(it) } + val exitInfo = + spy(builder.build()) { + if (!addTrace) { + return@spy + } + if (addBadTrace) { + whenever(mock.traceInputStream).thenReturn("XXXXX".byteInputStream()) + } else { + whenever(mock.traceInputStream) + .thenReturn(File("src/test/resources/tombstone.pb").inputStream()) + } + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + }, + flushLogPrefix = "Timed out waiting to flush Tombstone event to disk.", + ) + + override fun assertEnrichedEvent(event: SentryEvent) { + assertEquals(SentryLevel.FATAL, event.level) + assertEquals(newTimestamp, event.timestamp!!.time) + assertEquals("native", event.platform) + + val crashedThreadId = 21891L + assertEquals(crashedThreadId, event.exceptions!![0].threadId) + val crashedThread = event.threads!!.find { thread -> thread.id == crashedThreadId } + assertEquals("samples.android", crashedThread!!.name) + assertTrue(crashedThread.isCrashed!!) + + val image = + event.debugMeta?.images?.find { image -> image.debugId == "f60b4b74005f33fb3ef3b98aa4546008" } + assertNotNull(image) + assertEquals("/system/lib64/libcompiler_rt.so", image.codeFile) + assertEquals("0x764c32a000", image.imageAddr) + assertEquals(32768, image.imageSize) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt new file mode 100644 index 00000000000..f4e17f04565 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -0,0 +1,112 @@ +package io.sentry.android.core.internal.tombstone + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class TombstoneParserTest { + val expectedRegisters = + setOf( + "x8", + "x9", + "esr", + "lr", + "pst", + "x10", + "x12", + "x11", + "x14", + "x13", + "x16", + "x15", + "sp", + "x18", + "x17", + "x19", + "pc", + "x21", + "x20", + "x0", + "x23", + "x1", + "x22", + "x2", + "x25", + "x3", + "x24", + "x4", + "x27", + "x5", + "x26", + "x6", + "x29", + "x7", + "x28", + ) + + @Test + fun `parses a snapshot tombstone into Event`() { + val tombstone = File("src/test/resources/tombstone.pb") + val parser = TombstoneParser(tombstone.inputStream()) + val event = parser.parse() + + // top-level data + assertNotNull(event.eventId) + assertEquals( + "Fatal signal SIGSEGV (11), SEGV_MAPERR (1), pid = 21891 (io.sentry.samples.android)", + event.message!!.formatted, + ) + assertEquals("native", event.platform) + assertEquals("FATAL", event.level!!.name) + + // exception + // we only track one native exception (no nesting, one crashed thread) + assertEquals(1, event.exceptions!!.size) + val exception = event.exceptions!![0] + assertEquals("SIGSEGV", exception.type) + assertEquals("Segfault", exception.value) + val crashedThreadId = exception.threadId + assertNotNull(crashedThreadId) + + val mechanism = exception.mechanism + assertEquals("signalhandler", mechanism!!.type) + assertEquals(false, mechanism.isHandled) + assertEquals(true, mechanism.synthetic) + assertEquals("SIGSEGV", mechanism.meta!!["name"]) + assertEquals(11, mechanism.meta!!["number"]) + assertEquals("SEGV_MAPERR", mechanism.meta!!["code_name"]) + assertEquals(1, mechanism.meta!!["code"]) + + // threads + assertEquals(62, event.threads!!.size) + for (thread in event.threads!!) { + assertNotNull(thread.id) + if (thread.id == crashedThreadId) { + assert(thread.isCrashed == true) + } + assert(thread.stacktrace!!.frames!!.isNotEmpty()) + + for (frame in thread.stacktrace!!.frames!!) { + assertNotNull(frame.function) + assertNotNull(frame.`package`) + assertNotNull(frame.instructionAddr) + } + + assert(thread.stacktrace!!.registers!!.keys.containsAll(expectedRegisters)) + } + + // debug-meta + assertEquals(357, event.debugMeta!!.images!!.size) + for (image in event.debugMeta!!.images!!) { + assertEquals("elf", image.type) + assertNotNull(image.debugId) + assertNotNull(image.codeId) + assertEquals(image.codeId, image.debugId) + assertNotNull(image.codeFile) + val imageAddress = image.imageAddr!!.removePrefix("0x").toLong(16) + assert(imageAddress > 0) + assert(image.imageSize!! > 0) + } + } +} diff --git a/sentry-android-core/src/test/resources/tombstone.pb b/sentry-android-core/src/test/resources/tombstone.pb new file mode 100644 index 00000000000..051356a8fef Binary files /dev/null and b/sentry-android-core/src/test/resources/tombstone.pb differ diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0bc833222aa..4f35abde3e7 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4849,6 +4849,10 @@ public abstract interface class io/sentry/hints/Flushable { public abstract fun waitFlush ()Z } +public abstract interface class io/sentry/hints/NativeCrashExit { + public abstract fun timestamp ()Ljava/lang/Long; +} + public abstract interface class io/sentry/hints/Resettable { public abstract fun reset ()V } @@ -6229,11 +6233,13 @@ public final class io/sentry/protocol/SentryStackTrace : io/sentry/JsonSerializa public fun ()V public fun (Ljava/util/List;)V public fun getFrames ()Ljava/util/List; + public fun getInstructionAddressAdjustment ()Ljava/lang/String; public fun getRegisters ()Ljava/util/Map; public fun getSnapshot ()Ljava/lang/Boolean; public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setFrames (Ljava/util/List;)V + public fun setInstructionAddressAdjustment (Ljava/lang/String;)V public fun setRegisters (Ljava/util/Map;)V public fun setSnapshot (Ljava/lang/Boolean;)V public fun setUnknown (Ljava/util/Map;)V @@ -6247,6 +6253,7 @@ public final class io/sentry/protocol/SentryStackTrace$Deserializer : io/sentry/ public final class io/sentry/protocol/SentryStackTrace$JsonKeys { public static final field FRAMES Ljava/lang/String; + public static final field INSTRUCTION_ADDRESS_ADJUSTMENT Ljava/lang/String; public static final field REGISTERS Ljava/lang/String; public static final field SNAPSHOT Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 893bef4ab90..9cc1ca9cf63 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -122,6 +122,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (HintUtils.hasType(hint, AbnormalExit.class)) { tryEndPreviousSession(hint); } + // TODO: adapt tryEndPreviousSession(hint); to CrashExit.class if (HintUtils.hasType(hint, SessionStart.class)) { movePreviousSession(currentSessionFile, previousSessionFile); @@ -129,6 +130,9 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not boolean crashedLastRun = false; final File crashMarkerFile = new File(options.getCacheDirPath(), NATIVE_CRASH_MARKER_FILE); + // TODO: this should probably check whether the Native SDK integration is currently enabled or + // remove the marker file if it isn't. Otherwise, application that disable the Native SDK, + // will report a crash for the last run forever. if (crashMarkerFile.exists()) { crashedLastRun = true; } @@ -153,6 +157,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not } } + // TODO: maybe set crashLastRun for tombstone too? SentryCrashLastRunState.getInstance().setCrashedLastRun(crashedLastRun); flushPreviousSession(); diff --git a/sentry/src/main/java/io/sentry/hints/NativeCrashExit.java b/sentry/src/main/java/io/sentry/hints/NativeCrashExit.java new file mode 100644 index 00000000000..097d7ab553b --- /dev/null +++ b/sentry/src/main/java/io/sentry/hints/NativeCrashExit.java @@ -0,0 +1,16 @@ +package io.sentry.hints; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * This is not sensationally useful right now. It only exists as marker interface to distinguish + * Tombstone events from AbnormalExits, which they are not. The timestamp is used to record the + * timestamp of the last reported native crash we retrieved from the ApplicationExitInfo. + */ +@ApiStatus.Internal +public interface NativeCrashExit { + /** When exactly the crash exit happened */ + @NotNull + Long timestamp(); +} diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java index c635f78673b..f04982a4f69 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java @@ -66,6 +66,16 @@ public final class SentryStackTrace implements JsonUnknown, JsonSerializable { */ private @Nullable Boolean snapshot; + /** + * This value indicates if, and how, `instruction_addr` values in the stack frames need to be + * adjusted before they are symbolicated. TODO: should we make this an enum or is a string value + * fine? + * + * @see SentryStackFrame#getInstructionAddr() + * @see SentryStackFrame#setInstructionAddr(String) + */ + private @Nullable String instructionAddressAdjustment; + @SuppressWarnings("unused") private @Nullable Map unknown; @@ -122,10 +132,19 @@ public void setUnknown(@Nullable Map unknown) { this.unknown = unknown; } + public @Nullable String getInstructionAddressAdjustment() { + return instructionAddressAdjustment; + } + + public void setInstructionAddressAdjustment(@Nullable String instructionAddressAdjustment) { + this.instructionAddressAdjustment = instructionAddressAdjustment; + } + public static final class JsonKeys { public static final String FRAMES = "frames"; public static final String REGISTERS = "registers"; public static final String SNAPSHOT = "snapshot"; + public static final String INSTRUCTION_ADDRESS_ADJUSTMENT = "instruction_addr_adjustment"; } @Override @@ -141,6 +160,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (snapshot != null) { writer.name(JsonKeys.SNAPSHOT).value(snapshot); } + if (instructionAddressAdjustment != null) { + writer.name(JsonKeys.INSTRUCTION_ADDRESS_ADJUSTMENT).value(instructionAddressAdjustment); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -174,6 +196,9 @@ public static final class Deserializer implements JsonDeserializer();