Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
507da9a
feat(android): Add service-managed NDK crash daemon
jpnurmi May 20, 2026
54dea34
Update CHANGELOG.md
jpnurmi Oct 21, 2026
d34d408
fix(android): Narrow exception handling in the NDK crash service
jpnurmi Oct 22, 2026
c47c86c
fix(android): Stop the NDK crash service when its daemon exits
jpnurmi Oct 22, 2026
a60a8e9
fix(android): Match the NDK crash service stop to its own start
jpnurmi Oct 22, 2026
f5dc791
fix(android): Write the NDK tombstone marker after a successful send
jpnurmi Oct 22, 2026
919eb5a
fix(android): Bind the NDK crash service for the app's lifetime
jpnurmi Oct 22, 2026
ae47d7b
fix(android): Match NDK tombstones by the app's pid
jpnurmi Oct 22, 2026
05e75eb
fix(android): Start an NDK crash daemon for each relaunch
jpnurmi Oct 22, 2026
e84e3c6
ci(temp): Publish sentry-native-ndk-outproc to Maven Local
jpnurmi Oct 22, 2026
0fe3673
fix(android): Wait for the NDK tombstone outside the send budget
jpnurmi Oct 22, 2026
4a7c12d
fix(android): Remove stale NDK crash IPC files
jpnurmi Oct 22, 2026
094728d
fix(android): Fall back to the 1-argument NDK init
jpnurmi Oct 22, 2026
20d4547
ref(android): Stop passing the tombstone flag to NdkOptions
jpnurmi Oct 22, 2026
71a3cda
fix(android): Skip SDK init in the NDK crash service process
jpnurmi Oct 22, 2026
c779feb
ref(android): Remove the NDK crash service process check
jpnurmi Oct 22, 2026
1d66db7
fix(android): Keep a newer NDK tombstone marker
jpnurmi Oct 22, 2026
390f348
ci: Stop the sentry-native Gradle daemon after publishing
jpnurmi Oct 22, 2026
a36c1e5
fix(android): Bind the NDK crash service as important
jpnurmi Oct 23, 2026
7d92170
Apply batched suggestions from code review
jpnurmi Nov 3, 2026
5324909
chore(android): Update the outproc API dump
jpnurmi Nov 3, 2026
df1000d
fix(android): Reattach the NDK crash daemon after a service restart
jpnurmi Nov 3, 2026
150ab4c
ref(android): Build the NDK outproc package on top of sentry-android-ndk
jpnurmi Nov 3, 2026
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
14 changes: 14 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1

# TODO: remove once sentry-native publishes sentry-native-ndk-outproc
- name: Checkout sentry-native
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: getsentry/sentry-native
ref: jpnurmi/feat/native-android
path: sentry-native
submodules: 'recursive'

# TODO: remove once sentry-native publishes sentry-native-ndk-outproc
- name: Publish sentry-native-ndk-outproc to Maven Local
working-directory: sentry-native/ndk
run: ./gradlew --no-daemon :sentry-native-ndk-outproc:publishToMavenLocal

- name: Run Tests and Lint
run: make preMerge

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment
- Add an `attachTracingHeaders` flag to `SentryApolloInterceptor` to stop attaching `sentry-trace` and `baggage` headers to Apollo v2 requests ([#6414](https://github.com/getsentry/sentry-java/pull/6414))
- Apollo v2 has no request URL in the interceptor, so these headers are attached without checking `tracePropagationTargets`. Pass `attachTracingHeaders = false` to opt out.
- Add experimental, opt-in out-of-process crash reporting on Android ([#6347](https://github.com/getsentry/sentry-java/pull/6347))

### Improvements

Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/java/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ object Config {
val androidLibs = setOf(
"sentry-android-core",
"sentry-android-ndk",
"sentry-android-ndk-outproc",
"sentry-android-fragment",
"sentry-android-navigation",
"sentry-android-navigation3",
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.5" }
sentry-native-ndk-outproc = { module = "io.sentry:sentry-native-ndk-outproc", version = "0.17.5" }
servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" }
servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
Expand Down
2 changes: 1 addition & 1 deletion scripts/update-sentry-native-ndk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ set-version)

echo "Setting sentry-native-ndk version to '$version'"

PATTERN='(module = "io\.sentry:sentry-native-ndk", version = ")[0-9.]+(")'
PATTERN='(module = "io\.sentry:sentry-native-ndk(?:-outproc)?", version = ")[0-9.]+(")'
perl -pi -e "s/$PATTERN/\${1}$version\${2}/" "$GRADLE_NDK_FILEPATH"
;;
*)
Expand Down
2 changes: 2 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum {

public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration, java/io/Closeable {
public static final field SENTRY_NDK_CLASS_NAME Ljava/lang/String;
public static final field SENTRY_NDK_OUTPROC_CLASS_NAME Ljava/lang/String;
public fun <init> (Ljava/lang/Class;)V
public fun close ()V
public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
Expand Down Expand Up @@ -664,6 +665,7 @@ public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/se
public fun getLastReportedTimestamp ()Ljava/lang/Long;
public fun markReported (J)V
public fun matches (Landroid/app/ApplicationExitInfo;)Z
public static fun mergeNativeCrashes (Lio/sentry/SentryEvent;Lio/sentry/SentryEvent;)V
public fun shouldReportHistorical ()Z
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.sentry.android.core;

import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME;
import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_OUTPROC_CLASS_NAME;
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;

import android.annotation.SuppressLint;
Expand Down Expand Up @@ -435,8 +436,12 @@ static void installDefaultIntegrations(

// Integrations are registered in the same order. NDK before adding Watch outbox,
// because sentry-native move files around and we don't want to watch that.
final Class<?> sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger());
options.addIntegration(new NdkIntegration(sentryNdkClass));
// the outproc backend builds on the inproc one, so it goes first, and quietly
Class<?> sentryNdkClass = loadClass.loadClass(SENTRY_NDK_OUTPROC_CLASS_NAME, null);
if (sentryNdkClass == null) {
sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger());
}
options.addIntegration(new NdkIntegration(context, sentryNdkClass));

if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) {
options.addIntegration(new TombstoneIntegration(context));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

import android.content.Context;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.SentryLevel;
Expand All @@ -19,11 +20,20 @@ public final class NdkIntegration implements Integration, Closeable {

public static final String SENTRY_NDK_CLASS_NAME = "io.sentry.android.ndk.SentryNdk";

public static final String SENTRY_NDK_OUTPROC_CLASS_NAME =
"io.sentry.android.ndk.outproc.SentryNdkOutproc";

private final @Nullable Context context;
private final @Nullable Class<?> sentryNdkClass;

private @Nullable SentryAndroidOptions options;

public NdkIntegration(final @Nullable Class<?> sentryNdkClass) {
this(null, sentryNdkClass);
}

NdkIntegration(final @Nullable Context context, final @Nullable Class<?> sentryNdkClass) {
this.context = context != null ? ContextUtils.getApplicationContext(context) : null;
this.sentryNdkClass = sentryNdkClass;
}

Expand All @@ -50,9 +60,25 @@ public final void register(final @NotNull IScopes scopes, final @NotNull SentryO
}

try {
final Method method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class);
final Object[] args = new Object[1];
args[0] = this.options;
Method method = null;
Object[] args = null;
if (context != null) {
try {
method = sentryNdkClass.getMethod("init", Context.class, SentryAndroidOptions.class);
args = new Object[] {context, this.options};
} catch (NoSuchMethodException e) {
// an older sentry-android-ndk
this.options
.getLogger()
.log(
SentryLevel.WARNING,
"SentryNdk.init(Context, SentryAndroidOptions) not found, using SentryNdk.init(SentryAndroidOptions).");
}
}
if (method == null) {
method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class);
args = new Object[] {this.options};
}
method.invoke(null, args);

this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration installed.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.os.Build;
import android.os.Process;
import android.os.SystemClock;
import android.os.Trace;
Expand Down Expand Up @@ -48,6 +49,9 @@ public final class SentryAndroid {
private static final String FRAGMENT_CLASS_NAME =
"androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks";

// the process of SentryNdkCrashService in sentry-android-ndk-outproc
private static final String NDK_CRASH_SERVICE_PROCESS_SUFFIX = ".sentry_ndk_crash";

protected static final @NotNull AutoClosableReentrantLock staticLock =
new AutoClosableReentrantLock();

Expand Down Expand Up @@ -96,6 +100,12 @@ public static void init(
@NotNull final Context context,
@NotNull ILogger logger,
@NotNull Sentry.OptionsConfiguration<SentryAndroidOptions> configuration) {
if (isNdkCrashServiceProcess()) {
logger.log(
SentryLevel.DEBUG, "Skipping SentryAndroid.init in the NDK crash service process.");
return;
}

// Started before acquiring the lock so it stays balanced with the endSection() in the finally
// even if acquire() throws.
Trace.beginSection("SentryAndroid.init");
Expand Down Expand Up @@ -281,4 +291,14 @@ private static void deduplicateIntegrations(
}
}
}

// the crash service only runs on API 30+
@SuppressLint("NewApi")
private static boolean isNdkCrashServiceProcess() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return false;
}
final @Nullable String processName = Application.getProcessName();
return processName != null && processName.endsWith(NDK_CRASH_SERVICE_PROCESS_SUFFIX);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ private void addNativeAttachmentsToTombstoneHint(
}
}

private void mergeNativeCrashes(
public static void mergeNativeCrashes(
final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) {
// we take the event data verbatim from the Native SDK and only apply tombstone data where we
// are sure that it will improve the outcome:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -125,6 +126,7 @@ class AndroidOptionsInitializerTest {

fun initSutWithClassLoader(
classesToLoad: List<String> = emptyList(),
loadedClasses: Map<String, Class<*>> = emptyMap(),
isFragmentAvailable: Boolean = false,
isTimberAvailable: Boolean = false,
isReplayAvailable: Boolean = false,
Expand All @@ -138,6 +140,9 @@ class AndroidOptionsInitializerTest {
sentryOptions.isDebug = true
val buildInfo = createBuildInfo()
val loadClass = createClassMock(classesToLoad)
loadedClasses.forEach { (name, clazz) ->
whenever(loadClass.loadClass(eq(name), anyOrNull())).thenReturn(clazz)
}
val activityFramesTracker = ActivityFramesTracker(loadClass, sentryOptions)

AndroidOptionsInitializer.loadDefaultAndMetadataOptions(
Expand Down Expand Up @@ -640,6 +645,17 @@ class AndroidOptionsInitializerTest {
assertNotNull((actual as NdkIntegration).sentryNdkClass)
}

@Test
fun `NdkIntegration prefers the outproc backend class`() {
fixture.initSutWithClassLoader(
classesToLoad = listOfNotNull(NdkIntegration.SENTRY_NDK_CLASS_NAME),
loadedClasses = mapOf(NdkIntegration.SENTRY_NDK_OUTPROC_CLASS_NAME to Any::class.java),
)

val actual = fixture.sentryOptions.integrations.firstOrNull { it is NdkIntegration }
assertEquals(Any::class.java, (actual as NdkIntegration).sentryNdkClass)
}

@Test
fun `NdkIntegration won't be enabled, if class not found`() {
fixture.initSutWithClassLoader(classesToLoad = emptyList())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.sentry.android.core

import android.content.Context
import io.sentry.ILogger
import io.sentry.IScopes
import io.sentry.SentryLevel
Expand All @@ -18,6 +19,8 @@ class NdkIntegrationTest {
val logger = mock<ILogger>()

fun getSut(clazz: Class<*>? = SentryNdk::class.java): NdkIntegration = NdkIntegration(clazz)

fun getSut(context: Context, clazz: Class<*>): NdkIntegration = NdkIntegration(context, clazz)
}

private val fixture = Fixture()
Expand Down Expand Up @@ -95,6 +98,20 @@ class NdkIntegrationTest {
assertFalse(options.isEnableScopeSync)
}

@Test
fun `NdkIntegration falls back to init without context`() {
val integration = fixture.getSut(mock<Context>(), SentryNdk::class.java)

val options = getOptions()

integration.register(fixture.scopes, options)

verify(fixture.logger).log(eq(SentryLevel.WARNING), any<String>())
verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any<String>(), any())
assertTrue(options.isEnableNdk)
assertTrue(options.isEnableScopeSync)
}

@Test
fun `NdkIntegration won't close if close method is not available`() {
val integration = fixture.getSut(SentryNdkNoClose::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import org.robolectric.annotation.Config
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.ShadowActivityManager
import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder
import org.robolectric.shadows.ShadowApplication

@RunWith(AndroidJUnit4::class)
@Config(sdk = [Build.VERSION_CODES.N], shadows = [SentryShadowProcess::class])
Expand Down Expand Up @@ -573,6 +574,18 @@ class SentryAndroidTest {
assertTrue(optionsRef.eventProcessors.any { it is ApplicationExitInfoEventProcessor })
}

@Test
@Config(sdk = [30])
fun `init does nothing in the NDK crash service process`() {
ShadowApplication.setProcessName("${context.packageName}.sentry_ndk_crash")
var configured = false

fixture.initSut { configured = true }

assertFalse(configured)
assertFalse(Sentry.isEnabled())
}

private fun prefillScopeCache(options: SentryOptions, cacheDir: String) {
val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() }
val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build()
Expand Down
28 changes: 28 additions & 0 deletions sentry-android-ndk-outproc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# sentry-android-ndk-outproc

Experimental opt-in alternative to the inproc native backend, with an Android
service that runs the native crash daemon. Builds on `sentry-android-ndk` and
replaces its `sentry-native-ndk` with `sentry-native-ndk-outproc`. Select it
through the Sentry Gradle plugin:

```kotlin
sentry {
ndkBackend.set("native")
}
```

The default is the inproc backend.

Without the plugin, add `sentry-android-ndk-outproc`. With the `sentry-android`
bundle, which brings `sentry-native-ndk`, also exclude it:

```kotlin
configurations.configureEach {
exclude(group = "io.sentry", module = "sentry-native-ndk")
}
```

Otherwise, the build fails because it contains both native backends.

The crash service needs API 30+. On older versions, native crash reporting is
disabled.
19 changes: 19 additions & 0 deletions sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
public final class io/sentry/android/ndk/outproc/BuildConfig {
public static final field BUILD_TYPE Ljava/lang/String;
public static final field DEBUG Z
public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
public static final field VERSION_NAME Ljava/lang/String;
public fun <init> ()V
}

public final class io/sentry/android/ndk/outproc/SentryNdkCrashService : android/app/Service {
public fun <init> ()V
public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder;
}

public final class io/sentry/android/ndk/outproc/SentryNdkOutproc {
public static fun close ()V
public static fun init (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V
public static fun init (Lio/sentry/android/core/SentryAndroidOptions;)V
}

Loading
Loading