Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down Expand Up @@ -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"}
Copy link
Member

Choose a reason for hiding this comment

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

@romtsn Not sure if you followed the conversations, but as you can see here, protobuf requires a runtime dependency. It will have an impact of around 10kb. IMHO fine for now, we should still check how stable this library is to avoid and consumer version mismatch issues.

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" }
Expand Down
13 changes: 13 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ 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 isTombstoneEnabled ()Z
public fun setAnrEnabled (Z)V
public fun setAnrReportInDebug (Z)V
public fun setAnrTimeoutIntervalMillis (J)V
Expand Down Expand Up @@ -367,6 +368,7 @@ 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 setTombstoneEnabled (Z)V
}

public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback {
Expand Down Expand Up @@ -455,6 +457,17 @@ 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 <init> (Landroid/content/Context;)V
public fun close ()V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public class io/sentry/android/core/TombstoneIntegration$TombstoneProcessor : java/lang/Runnable {
public fun <init> (Landroid/content/Context;Lio/sentry/IScopes;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/transport/ICurrentDateProvider;)V
public fun run ()V
}

public final class io/sentry/android/core/UserInteractionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable {
public fun <init> (Landroid/app/Application;Lio/sentry/util/LoadClass;)V
public fun close ()V
Expand Down
9 changes: 9 additions & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
alias(libs.plugins.jacoco.android)
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
alias(libs.plugins.protobuf)
}

android {
Expand Down Expand Up @@ -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)
Expand All @@ -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") } } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -372,6 +373,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.R) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not entirely sure about this one:

  • not sure if we want to add the integration conditionally here, or instead only run it conditionally (with a log that explains that the integration isn't really running)
  • independent of where we check this: while it is true that REASON_CRASH_NATIVE is available with R, the tombstone will not be available before S.
  • We can ignore the latter, since the API is available with R and we have to handle a null InputStream anyway.

Copy link
Member

Choose a reason for hiding this comment

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

As without tombstones the reported events won't be any useful I suggest to check against S instead, >= Build.VERSION_CODES.S

Copy link

Choose a reason for hiding this comment

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

Bug: Wrong Android API level check for tombstone support

The integration checks for Build.VERSION_CODES.R (Android 11, API 30), but according to the author's own PR comment and Android documentation, tombstones via getTraceInputStream() are only available from Build.VERSION_CODES.S (Android 12, API 31). On Android R, getTraceInputStream() returns null for native crashes, which combined with the missing null check in the parser, causes the integration to fail on devices running Android 11.

Fix in Cursor Fix in Web

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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public final class SentryAndroidOptions extends SentryOptions {
* <li>The transaction status will be {@link SpanStatus#OK} if none is set.
* </ul>
*
* The transaction is automatically bound to the {@link IScope}, but only if there's no
* <p>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;
Expand Down Expand Up @@ -227,6 +227,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());
Expand Down Expand Up @@ -300,6 +302,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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package io.sentry.android.core;

import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;

import android.app.ActivityManager;
import android.app.ApplicationExitInfo;
import android.content.Context;
import android.os.Build;
import androidx.annotation.RequiresApi;
import io.sentry.DateUtils;
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.internal.tombstone.TombstoneParser;
import io.sentry.cache.EnvelopeCache;
import io.sentry.cache.IEnvelopeCache;
import io.sentry.transport.CurrentDateProvider;
import io.sentry.transport.ICurrentDateProvider;
import io.sentry.util.Objects;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
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
public class TombstoneIntegration implements Integration, Closeable {
static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91);

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 TombstoneProcessor(context, scopes, this.options, dateProvider));
} catch (Throwable e) {
options.getLogger().log(SentryLevel.DEBUG, "Failed to start TombstoneProcessor.", 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 TombstoneProcessor implements Runnable {

@NotNull private final Context context;
@NotNull private final IScopes scopes;
@NotNull private final SentryAndroidOptions options;
private final long threshold;

public TombstoneProcessor(
@NotNull Context context,
@NotNull IScopes scopes,
@NotNull SentryAndroidOptions options,
@NotNull ICurrentDateProvider dateProvider) {
this.context = context;
this.scopes = scopes;
this.options = options;

this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD;
}

@Override
@RequiresApi(api = Build.VERSION_CODES.R)
public void run() {
final ActivityManager activityManager =
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);

final List<ApplicationExitInfo> applicationExitInfoList;
applicationExitInfoList = activityManager.getHistoricalProcessExitReasons(null, 0, 0);

if (applicationExitInfoList.isEmpty()) {
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<ApplicationExitInfo> exitInfos = new ArrayList<>(applicationExitInfoList);

// search for the latest Tombstone to report it separately as we're gonna enrich it. The
// latest
// Tombstone will be first in the list, as it's filled last-to-first in order of appearance
ApplicationExitInfo latestTombstone = null;
for (ApplicationExitInfo applicationExitInfo : exitInfos) {
if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_CRASH_NATIVE) {
latestTombstone = applicationExitInfo;
// remove it, so it's not reported twice
// TODO: if we fail after this, we effectively lost the ApplicationExitInfo (maybe only
// remove after we reported it)
exitInfos.remove(applicationExitInfo);

This comment was marked as outdated.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is intentionally marked as TODO, so we can discuss handling history in this PR.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah good point, for ANRs we write a timestamp to disk (impl) to ensure we don't double report on the next app launch. @romtsn what do you think about this? I guess what's to discuss here:

  • How many days back should we consider crashes?
    the 90 days threshold sounds good to me

  • Should we report them all?
    In my opinion yes. TBD: How to deal with persisted scope data for enrichment?

  • How to deal with unprocessable tombstones?
    In my opinion we should skip them, otherwise the processing will get stuck until the crash is out of the 90 day window. TBD: should we report those as dropped events?

break;
}
}

if (latestTombstone == null) {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"No Tombstones have been found in the historical exit reasons list.");
return;
}

if (latestTombstone.getTimestamp() < threshold) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Latest Tombstones happened too long ago, returning early.");
return;
}

reportAsSentryEvent(latestTombstone);
}

@RequiresApi(api = Build.VERSION_CODES.R)
private void reportAsSentryEvent(ApplicationExitInfo exitInfo) {
SentryEvent event;
try {
TombstoneParser parser = new TombstoneParser(exitInfo.getTraceInputStream());

This comment was marked as outdated.

Copy link
Collaborator Author

@supervacuus supervacuus Nov 25, 2025

Choose a reason for hiding this comment

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

Agreed, but also left it open, since I don't know yet how the general propagation of errors is handled in integration runs (top-level handler? silent/logging discard? ...?).

Copy link
Member

Choose a reason for hiding this comment

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

We usually use a wide try/catch and silently swallow those errors in combination with logging the error using options.getLogger()

Copy link

Choose a reason for hiding this comment

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

Bug: Missing null check for trace input stream

ApplicationExitInfo.getTraceInputStream() can return null according to Android documentation (and as handled correctly in AnrV2Integration.parseThreadDump()). When null is passed to TombstoneParser, the subsequent TombstoneProtos.Tombstone.parseFrom(null) call in parse() will throw a NullPointerException. This exception isn't caught by the IOException catch block, causing the background TombstoneProcessor thread to crash silently without reporting the tombstone.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

event = parser.parse();
Copy link

Choose a reason for hiding this comment

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

Bug: InputStream from getTraceInputStream is never closed

The InputStream returned by exitInfo.getTraceInputStream() is passed to TombstoneParser but never closed. The existing AnrV2Integration properly uses try-with-resources to close this stream (line 306). The tombstone stream is stored in TombstoneParser.tombstoneStream and used by parseFrom(), but neither the parser nor the caller closes it afterward. This resource leak could exhaust file descriptors if the integration processes tombstones repeatedly.

Fix in Cursor Fix in Web

event.setTimestamp(DateUtils.getDateTime(exitInfo.getTimestamp()));
} catch (IOException e) {
throw new RuntimeException(e);
Copy link

Choose a reason for hiding this comment

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

Bug: Uncaught exception crashes background thread silently

When IOException occurs during tombstone parsing, wrapping it in RuntimeException and throwing terminates the background thread without logging. Since this runs in an ExecutorService submitted task, the exception is swallowed and never reported. The try-catch at line 77 only catches submission failures, not execution failures. This makes tombstone parsing failures invisible, preventing debugging and potentially losing crash reports.

Fix in Cursor Fix in Web

}

scopes.captureEvent(event);
}
}
}
Loading
Loading