diff --git a/CHANGELOG.md b/CHANGELOG.md index af6310208c..096ab4ab28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Features + +- Add binder IPC instrumentation adapter for the Sentry Android Gradle plugin ([#5326](https://github.com/getsentry/sentry-java/pull/5326)) + - New opt-in `SentryAndroidOptions.enableBinderTracing` creates a `binder.ipc` child span per instrumented binder call + - New opt-in `SentryAndroidOptions.enableBinderLogs` emits a Sentry log per instrumented binder call + - Both enrich the span/log with `thread.id` and `thread.name` + ## 8.39.1 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548..38aed420e9 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -385,6 +385,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z + public fun isEnableBinderLogs ()Z + public fun isEnableBinderTracing ()Z public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z public fun isEnableNetworkEventBreadcrumbs ()Z @@ -415,6 +417,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAppLifecycleBreadcrumbs (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V + public fun setEnableBinderLogs (Z)V + public fun setEnableBinderTracing (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V @@ -443,6 +447,11 @@ public final class io/sentry/android/core/SentryInitProvider { public fun shutdown ()V } +public final class io/sentry/android/core/SentryIpcTracer { + public static fun onCallEnd (I)V + public static fun onCallStart (Ljava/lang/String;Ljava/lang/String;)I +} + public final class io/sentry/android/core/SentryLogcatAdapter { public fun ()V public static fun d (Ljava/lang/String;Ljava/lang/String;)I diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 4cd76f9a20..f0ddb07b02 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -53,6 +53,11 @@ -keepnames class io.sentry.android.core.ApplicationNotResponding +# Bytecode-instrumented IPC tracer — the Sentry Android Gradle plugin emits +# direct static calls to these symbols, so both the class name and its static +# methods must be preserved. +-keep class io.sentry.android.core.SentryIpcTracer { *; } + ##---------------End: proguard configuration for android-core ---------- diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 6d90bb5ca8..334b88c455 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -153,6 +153,12 @@ final class ManifestMetadataReader { static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; + @ApiStatus.Experimental + static final String ENABLE_BINDER_TRACING = "io.sentry.traces.binder-ipc.enable"; + + @ApiStatus.Experimental + static final String ENABLE_BINDER_LOGS = "io.sentry.logs.binder-ipc.enable"; + static final String DEADLINE_TIMEOUT = "io.sentry.traces.deadline-timeout"; static final String FEEDBACK_NAME_REQUIRED = "io.sentry.feedback.is-name-required"; @@ -508,6 +514,12 @@ static void applyMetadata( ENABLE_AUTO_TRACE_ID_GENERATION, options.isEnableAutoTraceIdGeneration())); + options.setEnableBinderTracing( + readBool(metadata, logger, ENABLE_BINDER_TRACING, options.isEnableBinderTracing())); + + options.setEnableBinderLogs( + readBool(metadata, logger, ENABLE_BINDER_LOGS, options.isEnableBinderLogs())); + options.setDeadlineTimeout( readLong(metadata, logger, DEADLINE_TIMEOUT, options.getDeadlineTimeout())); 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 054e43322a..f9dea5f007 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 @@ -257,6 +257,21 @@ public interface BeforeCaptureCallback { private boolean enableAnrFingerprinting = true; + /** + * Enables Binder IPC tracing. When enabled and a transaction is active, the SDK creates a child + * span (op {@code binder.ipc}) around each binder call instrumented by the Sentry Android Gradle + * plugin. Requires the Sentry Android Gradle plugin with binder IPC instrumentation enabled. + * Defaults to {@code false}. + */ + private boolean enableBinderTracing = false; + + /** + * Enables Binder IPC logs. When enabled, the SDK emits a Sentry log entry for each binder call + * instrumented by the Sentry Android Gradle plugin. Requires the Sentry Android Gradle plugin + * with binder IPC instrumentation enabled and Sentry logs enabled. Defaults to {@code false}. + */ + private boolean enableBinderLogs = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -741,6 +756,26 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } + @ApiStatus.Experimental + public boolean isEnableBinderTracing() { + return enableBinderTracing; + } + + @ApiStatus.Experimental + public void setEnableBinderTracing(final boolean enableBinderTracing) { + this.enableBinderTracing = enableBinderTracing; + } + + @ApiStatus.Experimental + public boolean isEnableBinderLogs() { + return enableBinderLogs; + } + + @ApiStatus.Experimental + public void setEnableBinderLogs(final boolean enableBinderLogs) { + this.enableBinderLogs = enableBinderLogs; + } + static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler { @Override public void showDialog( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryIpcTracer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryIpcTracer.java new file mode 100644 index 0000000000..c6080b416f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryIpcTracer.java @@ -0,0 +1,114 @@ +package io.sentry.android.core; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.SentryAttribute; +import io.sentry.SentryAttributes; +import io.sentry.SentryLogLevel; +import io.sentry.SentryOptions; +import io.sentry.SpanDataConvention; +import io.sentry.logger.SentryLogParameters; +import io.sentry.util.thread.IThreadChecker; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Entry point invoked by bytecode instrumented by the Sentry Android Gradle plugin around binder + * IPC call sites. The instrumentation emits calls to {@link #onCallStart(String, String)} and + * {@link #onCallEnd(int)} wrapped in a try/finally, so both methods MUST NOT throw and MUST stay + * cheap when the feature is disabled. + */ +@ApiStatus.Internal +public final class SentryIpcTracer { + + private static final String OP_BINDER = "binder.ipc"; + private static final int DISABLED = -1; + + private static final AtomicInteger COUNTER = new AtomicInteger(); + private static final ConcurrentHashMap IN_FLIGHT = new ConcurrentHashMap<>(); + + private SentryIpcTracer() {} + + public static int onCallStart(final @NotNull String component, final @NotNull String method) { + try { + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + if (!(options instanceof SentryAndroidOptions)) { + return DISABLED; + } + final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options; + final boolean tracingEnabled = androidOptions.isEnableBinderTracing(); + final boolean logsEnabled = androidOptions.isEnableBinderLogs(); + if (!tracingEnabled && !logsEnabled) { + return DISABLED; + } + + final @NotNull IThreadChecker threadChecker = options.getThreadChecker(); + final @NotNull String threadId = String.valueOf(threadChecker.currentThreadSystemId()); + final @NotNull String threadName = threadChecker.getCurrentThreadName(); + + if (logsEnabled) { + final @NotNull SentryAttributes attributes = + SentryAttributes.of( + SentryAttribute.stringAttribute(SpanDataConvention.THREAD_ID, threadId), + SentryAttribute.stringAttribute(SpanDataConvention.THREAD_NAME, threadName)); + scopes + .logger() + .log( + SentryLogLevel.INFO, + SentryLogParameters.create(attributes), + "Binder IPC %s.%s", + component, + method); + } + + if (tracingEnabled) { + final @Nullable ISpan parent = scopes.getSpan(); + if (parent == null) { + return DISABLED; + } + final ISpan child = parent.startChild(OP_BINDER, component + "." + method); + child.setData(SpanDataConvention.THREAD_ID, threadId); + child.setData(SpanDataConvention.THREAD_NAME, threadName); + // keep cookies non-negative so they never collide with the DISABLED sentinel + // even after AtomicInteger overflow past Integer.MAX_VALUE + final int cookie = COUNTER.incrementAndGet() & Integer.MAX_VALUE; + IN_FLIGHT.put(cookie, child); + return cookie; + } + } catch (Throwable ignored) { + // never throw from an instrumented call site + } + return DISABLED; + } + + public static void onCallEnd(final int cookie) { + if (cookie == DISABLED) { + return; + } + try { + final @Nullable ISpan span = IN_FLIGHT.remove(cookie); + if (span != null) { + span.finish(); + } + } catch (Throwable ignored) { + // never throw from an instrumented call site + } + } + + @TestOnly + static void resetForTest() { + IN_FLIGHT.clear(); + COUNTER.set(0); + } + + @TestOnly + static int inFlightCount() { + return IN_FLIGHT.size(); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 81b73d5dea..55050e162a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2511,4 +2511,54 @@ class ManifestMetadataReaderTest { // Assert assertEquals("12345", fixture.options.orgId) } + + @Test + fun `applyMetadata reads enableBinderTracing to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_BINDER_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableBinderTracing) + } + + @Test + fun `applyMetadata reads enableBinderTracing and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableBinderTracing) + } + + @Test + fun `applyMetadata reads enableBinderLogs to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_BINDER_LOGS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableBinderLogs) + } + + @Test + fun `applyMetadata reads enableBinderLogs and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableBinderLogs) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc..bfb923707a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -233,6 +233,22 @@ class SentryAndroidOptionsTest { sentryOptions.anrProfilingSampleRate = 2.0 } + @Test + fun `binder tracing and logs are disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableBinderTracing) + assertFalse(sentryOptions.isEnableBinderLogs) + } + + @Test + fun `binder tracing and logs can be toggled`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.isEnableBinderTracing = true + sentryOptions.isEnableBinderLogs = true + assertTrue(sentryOptions.isEnableBinderTracing) + assertTrue(sentryOptions.isEnableBinderLogs) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryIpcTracerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryIpcTracerTest.kt new file mode 100644 index 0000000000..264f739545 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryIpcTracerTest.kt @@ -0,0 +1,215 @@ +package io.sentry.android.core + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Sentry +import io.sentry.SentryLogLevel +import io.sentry.SpanDataConvention +import io.sentry.logger.ILoggerApi +import io.sentry.logger.SentryLogParameters +import io.sentry.util.thread.IThreadChecker +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class SentryIpcTracerTest { + + private class Fixture { + val scopes: IScopes = mock() + val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/123" } + val logger: ILoggerApi = mock() + val activeSpan: ISpan = mock() + val childSpan: ISpan = mock() + val threadChecker: IThreadChecker = mock() + + init { + whenever(scopes.options).thenReturn(options) + whenever(scopes.logger()).thenReturn(logger) + whenever(activeSpan.startChild(any(), any())).thenReturn(childSpan) + whenever(threadChecker.currentThreadSystemId()).thenReturn(42L) + whenever(threadChecker.getCurrentThreadName()).thenReturn("test-thread") + options.threadChecker = threadChecker + } + } + + private lateinit var fixture: Fixture + + @BeforeTest + fun setup() { + fixture = Fixture() + SentryIpcTracer.resetForTest() + } + + @AfterTest + fun teardown() { + SentryIpcTracer.resetForTest() + } + + private fun withMockedSentry(block: () -> Unit) { + mockStatic(Sentry::class.java).use { mocked -> + mocked.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + block() + } + } + + @Test + fun `both flags disabled returns DISABLED cookie and touches nothing`() { + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("Settings.Secure", "getString") + assertEquals(-1, cookie) + verify(fixture.activeSpan, never()).startChild(any(), any()) + verifyNoInteractions(fixture.logger) + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } + + @Test + fun `logs only emits a log with thread attributes and returns DISABLED`() { + fixture.options.isEnableBinderLogs = true + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("Settings.Secure", "getString") + assertEquals(-1, cookie) + verify(fixture.logger) + .log( + eq(SentryLogLevel.INFO), + any(), + eq("Binder IPC %s.%s"), + eq("Settings.Secure"), + eq("getString"), + ) + verify(fixture.activeSpan, never()).startChild(any(), any()) + } + } + + @Test + fun `tracing enabled without active span does not create a span`() { + fixture.options.isEnableBinderTracing = true + whenever(fixture.scopes.span).thenReturn(null) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("Settings.Secure", "getString") + assertEquals(-1, cookie) + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } + + @Test + fun `tracing enabled starts a child span with thread data and onCallEnd finishes it`() { + fixture.options.isEnableBinderTracing = true + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("Settings.Secure", "getString") + assertNotEquals(-1, cookie) + verify(fixture.activeSpan).startChild(eq("binder.ipc"), eq("Settings.Secure.getString")) + verify(fixture.childSpan).setData(eq(SpanDataConvention.THREAD_ID), eq("42")) + verify(fixture.childSpan).setData(eq(SpanDataConvention.THREAD_NAME), eq("test-thread")) + assertEquals(1, SentryIpcTracer.inFlightCount()) + + SentryIpcTracer.onCallEnd(cookie) + verify(fixture.childSpan).finish() + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } + + @Test + fun `both flags enabled emits log and creates span`() { + fixture.options.isEnableBinderTracing = true + fixture.options.isEnableBinderLogs = true + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("ContentResolver", "query") + assertNotEquals(-1, cookie) + verify(fixture.logger) + .log( + eq(SentryLogLevel.INFO), + any(), + eq("Binder IPC %s.%s"), + eq("ContentResolver"), + eq("query"), + ) + verify(fixture.activeSpan).startChild(eq("binder.ipc"), eq("ContentResolver.query")) + + SentryIpcTracer.onCallEnd(cookie) + verify(fixture.childSpan).finish() + } + } + + @Test + fun `onCallEnd with DISABLED cookie is a no-op`() { + SentryIpcTracer.onCallEnd(-1) + } + + @Test + fun `onCallEnd with unknown cookie is a no-op`() { + SentryIpcTracer.onCallEnd(9999) + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + + @Test + fun `onCallStart swallows exceptions and returns DISABLED`() { + fixture.options.isEnableBinderTracing = true + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan) + whenever(fixture.activeSpan.startChild(any(), any())) + .thenThrow(RuntimeException("boom")) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("ContentResolver", "query") + assertEquals(-1, cookie) + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } + + @Test + fun `onCallEnd swallows exceptions`() { + fixture.options.isEnableBinderTracing = true + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan) + whenever(fixture.childSpan.finish()).thenThrow(RuntimeException("boom")) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("ContentResolver", "query") + SentryIpcTracer.onCallEnd(cookie) + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } + + @Test + fun `non-android options returns DISABLED even when flags would be on`() { + val coreOptions = io.sentry.SentryOptions().apply { dsn = "https://key@sentry.io/123" } + whenever(fixture.scopes.options).thenReturn(coreOptions) + withMockedSentry { + val cookie = SentryIpcTracer.onCallStart("ContentResolver", "query") + assertEquals(-1, cookie) + } + } + + @Test + fun `nested calls produce distinct cookies and finish independently`() { + fixture.options.isEnableBinderTracing = true + val innerChild: ISpan = mock() + whenever(fixture.childSpan.startChild(any(), any())).thenReturn(innerChild) + whenever(fixture.scopes.span).thenReturn(fixture.activeSpan, fixture.childSpan) + + withMockedSentry { + val outer = SentryIpcTracer.onCallStart("ContentResolver", "query") + val inner = SentryIpcTracer.onCallStart("PackageManager", "getPackageInfo") + assertNotEquals(outer, inner) + assertEquals(2, SentryIpcTracer.inFlightCount()) + + SentryIpcTracer.onCallEnd(inner) + verify(innerChild).finish() + assertEquals(1, SentryIpcTracer.inFlightCount()) + + SentryIpcTracer.onCallEnd(outer) + verify(fixture.childSpan).finish() + assertEquals(0, SentryIpcTracer.inFlightCount()) + } + } +}