From 409ff8b643967569e7f12d69880425c32384717f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:35:13 +0000 Subject: [PATCH 1/3] feat(android): Add experimental Cronet request instrumentation Co-Authored-By: David Quintas --- .github/ISSUE_TEMPLATE/bug_report_android.yml | 1 + README.md | 1 + buildSrc/src/main/java/Config.kt | 2 + gradle/libs.versions.toml | 1 + sentry-android-cronet/README.md | 34 +++ .../api/sentry-android-cronet.api | 22 ++ sentry-android-cronet/build.gradle.kts | 91 +++++++ sentry-android-cronet/proguard-rules.pro | 2 + .../android/cronet/SentryCronetCallback.kt | 131 ++++++++++ .../cronet/SentryCronetCallbackTest.kt | 225 ++++++++++++++++++ settings.gradle.kts | 1 + 11 files changed, 511 insertions(+) create mode 100644 sentry-android-cronet/README.md create mode 100644 sentry-android-cronet/api/sentry-android-cronet.api create mode 100644 sentry-android-cronet/build.gradle.kts create mode 100644 sentry-android-cronet/proguard-rules.pro create mode 100644 sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt create mode 100644 sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt diff --git a/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index b16ab5ae7e2..ba21d84f92c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -11,6 +11,7 @@ body: - sentry-android - sentry-android-ndk - sentry-android-timber + - sentry-android-cronet - sentry-android-fragment - sentry-android-navigation3 - sentry-android-sqlite diff --git a/README.md b/README.md index 849aaf74457..df3d19a36a8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Sentry SDK for Java and Android | sentry-android-core | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-core?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-core) | 21 | | sentry-android-distribution | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-distribution?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-distribution) | 21 | | sentry-android-ndk | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-ndk?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-ndk) | 21 | +| sentry-android-cronet | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-cronet?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-cronet) | 21 | | sentry-android-timber | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-timber?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-timber) | 21 | | sentry-android-fragment | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-fragment?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-fragment) | 21 | | sentry-android-navigation | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-navigation?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-navigation) | 21 | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index fbc210b6584..8ecd1f37f2c 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -44,6 +44,7 @@ object Config { object Sentry { val SENTRY_JAVA_SDK_NAME = "sentry.java" val SENTRY_ANDROID_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.android" + val SENTRY_CRONET_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.cronet" val SENTRY_TIMBER_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.timber" val SENTRY_LOGBACK_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.logback" val SENTRY_JUL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jul" @@ -100,6 +101,7 @@ object Config { "sentry-android-navigation", "sentry-android-navigation3", "sentry-android-timber", + "sentry-android-cronet", "sentry-compose-android", "sentry-android-sqlite", "sentry-android-replay" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8ee6b7790b..b77796138c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,7 @@ sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] +cronet = { module = "org.chromium.net:cronet-api", version = "119.6045.31" } animalsniffer-gradle-plugin = { module = "ru.vyarus:gradle-animalsniffer-plugin", version.ref = "animalsniffer" } apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-support", version.ref = "apollo" } diff --git a/sentry-android-cronet/README.md b/sentry-android-cronet/README.md new file mode 100644 index 00000000000..efcdc87e1e2 --- /dev/null +++ b/sentry-android-cronet/README.md @@ -0,0 +1,34 @@ +# sentry-android-cronet + +Experimental, opt-in request tracing and HTTP breadcrumbs for [Cronet](https://developer.android.com/develop/connectivity/cronet). + +## Usage + +Add `io.sentry:sentry-android-cronet` using the same version as your Sentry Android SDK. Supply your own Cronet implementation; this module does not select or bundle an engine. + +Wrap your existing callback once per request: + +```kotlin +val url = "https://example.com/api" +val callback = SentryCronetCallback(url, "GET", applicationCallback) +val request = cronetEngine.newUrlRequestBuilder(url, callback, executor) + .setHttpMethod("GET") + .build() +callback.start(request) // Instead of request.start() +``` + +Construct the callback in the initiating Sentry scope. Pass the same URL and method used by the request builder, including `POST` when using an upload provider. Call `start` on the callback with that request, on the executor required by Cronet. Do not reuse a callback for multiple requests. + +The callback starts one `http.client` child span under the active transaction when the request starts. It finishes the span and adds an HTTP breadcrumb on success, failure, or cancellation. Without an active transaction, it still adds a breadcrumb. Span data includes the HTTP method, response status, and Cronet's negotiated protocol (including `h3` when reported by Cronet). URL handling uses the same filtering utilities as the OkHttp integration. + +Your callback still controls redirects and response-body reads. The span covers the original request through redirects and body consumption, and retains the original URL and method. It does not represent individual redirect hops. Terminal callbacks run after Sentry finishes the span. Exceptions from nonterminal callbacks are left to Cronet, which reports them through `onFailed`. + +## Draft Scope + +- No automatic engine instrumentation or bytecode instrumentation. +- No `sentry-trace`, `baggage`, or `traceparent` header injection. Redirect-safe propagation needs a separate design. +- No separate DNS, connect, or TLS spans, request/response body capture, or automatic failed-request error events. +- No support for `BidirectionalStream` or Android's separate `android.net.http` API. +- This draft has not been validated with a real Cronet engine or device. Provider compatibility and the public API need Android SDK team review before release. + +Public Android integration documentation, release-registry registration, and the `.craft.yml` SDK entry are follow-ups before publishing this module. diff --git a/sentry-android-cronet/api/sentry-android-cronet.api b/sentry-android-cronet/api/sentry-android-cronet.api new file mode 100644 index 00000000000..bae250b2e6d --- /dev/null +++ b/sentry-android-cronet/api/sentry-android-cronet.api @@ -0,0 +1,22 @@ +public final class io/sentry/android/cronet/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 SENTRY_CRONET_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/cronet/SentryCronetCallback : org/chromium/net/UrlRequest$Callback { + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/chromium/net/UrlRequest$Callback;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/chromium/net/UrlRequest$Callback;Lio/sentry/IScopes;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/chromium/net/UrlRequest$Callback;Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun onCanceled (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;)V + public fun onFailed (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;Lorg/chromium/net/CronetException;)V + public fun onReadCompleted (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;Ljava/nio/ByteBuffer;)V + public fun onRedirectReceived (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;Ljava/lang/String;)V + public fun onResponseStarted (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;)V + public fun onSucceeded (Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;)V + public final fun start (Lorg/chromium/net/UrlRequest;)V +} + diff --git a/sentry-android-cronet/build.gradle.kts b/sentry-android-cronet/build.gradle.kts new file mode 100644 index 00000000000..2cb39fa1181 --- /dev/null +++ b/sentry-android-cronet/build.gradle.kts @@ -0,0 +1,91 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.detekt) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + compileSdkMinor = libs.versions.compileSdkMinor.get().toInt() + namespace = "io.sentry.android.cronet" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField( + "String", + "SENTRY_CRONET_SDK_NAME", + "\"${Config.Sentry.SENTRY_CRONET_SDK_NAME}\"", + ) + } + + buildTypes { + getByName("debug") { consumerProguardFiles("proguard-rules.pro") } + getByName("release") { consumerProguardFiles("proguard-rules.pro") } + } + + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + buildFeatures { buildConfig = true } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +kotlin { explicitApi() } + +dependencies { + api(projects.sentry) + + compileOnly(libs.cronet) + compileOnly(libs.jetbrains.annotations) + + implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) + + // tests + testImplementation(libs.cronet) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.google.truth) +} + +tasks.withType().configureEach { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-cronet/proguard-rules.pro b/sentry-android-cronet/proguard-rules.pro new file mode 100644 index 00000000000..aff11f48a7b --- /dev/null +++ b/sentry-android-cronet/proguard-rules.pro @@ -0,0 +1,2 @@ +# Preserve source information for stack traces. +-keepattributes LineNumberTable,SourceFile diff --git a/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt b/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt new file mode 100644 index 00000000000..c109d7fbbc0 --- /dev/null +++ b/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt @@ -0,0 +1,131 @@ +package io.sentry.android.cronet + +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.transport.CurrentDateProvider +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.SpanUtils +import io.sentry.util.UrlUtils +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean +import org.chromium.net.CronetException +import org.chromium.net.UrlRequest +import org.chromium.net.UrlResponseInfo +import org.jetbrains.annotations.ApiStatus + +/** + * Opt-in tracing and breadcrumbs for one Cronet request. Pass this callback to Cronet's request + * builder, then call [start] instead of [UrlRequest.start]. Do not reuse it for another request. + * + * The supplied method must match the request builder (including POST for uploads). Redirect and + * response-body flow control remain the delegate's responsibility. No request headers are added. + */ +@ApiStatus.Experimental +public class SentryCronetCallback +@JvmOverloads +public constructor( + private val url: String, + private val method: String, + private val delegate: UrlRequest.Callback, + private val scopes: IScopes = Sentry.getCurrentScopes(), +) : UrlRequest.Callback() { + private companion object { + private const val TRACE_ORIGIN = "auto.http.cronet" + + init { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-cronet", BuildConfig.VERSION_NAME) + } + } + + private val started = AtomicBoolean(false) + private val finished = AtomicBoolean(false) + private var span: ISpan? = null + private var startTimestamp: Long = 0 + + init { + addIntegrationToSdkVersion("Cronet") + } + + /** Starts the request and its span together, excluding time spent configuring the builder. */ + public fun start(request: UrlRequest) { + check(started.compareAndSet(false, true)) { "Use a new SentryCronetCallback for each request." } + startTimestamp = CurrentDateProvider.getInstance().currentTimeMillis + val details = UrlUtils.parse(url, scopes.options.dataCollectionResolver) + if (!SpanUtils.isIgnored(scopes.options.ignoredSpanOrigins, TRACE_ORIGIN)) { + span = scopes.transaction?.startChild("http.client", "$method ${details.urlOrFallback}") + span?.spanContext?.origin = TRACE_ORIGIN + span?.setData(SpanDataConvention.HTTP_METHOD_KEY, method) + details.applyToSpan(span) + } + try { + request.start() + } catch (e: RuntimeException) { + finish(null, SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + @Throws(Exception::class) + override fun onRedirectReceived( + request: UrlRequest, + info: UrlResponseInfo, + newLocationUrl: String, + ) { + delegate.onRedirectReceived(request, info, newLocationUrl) + } + + @Throws(Exception::class) + override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { + delegate.onResponseStarted(request, info) + } + + @Throws(Exception::class) + override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) { + delegate.onReadCompleted(request, info, byteBuffer) + } + + override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { + finish(info, SpanStatus.fromHttpStatusCode(info.httpStatusCode), null) + delegate.onSucceeded(request, info) + } + + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { + finish(info, SpanStatus.INTERNAL_ERROR, error) + delegate.onFailed(request, info, error) + } + + override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) { + finish(info, SpanStatus.CANCELLED, null) + delegate.onCanceled(request, info) + } + + private fun finish(info: UrlResponseInfo?, status: SpanStatus?, error: Throwable?) { + if (!started.get() || !finished.compareAndSet(false, true)) { + return + } + val breadcrumb = + Breadcrumb.http(url, method, info?.httpStatusCode, scopes.options.dataCollectionResolver) + if (info != null) { + span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, info.httpStatusCode) + if (info.negotiatedProtocol.isNotEmpty()) { + span?.setData("protocol", info.negotiatedProtocol) + breadcrumb.setData("protocol", info.negotiatedProtocol) + } + } + span?.throwable = error + span?.status = status + span?.finish() + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) + breadcrumb.setData( + SpanDataConvention.HTTP_END_TIMESTAMP, + CurrentDateProvider.getInstance().currentTimeMillis, + ) + scopes.addBreadcrumb(breadcrumb) + } +} diff --git a/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt b/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt new file mode 100644 index 00000000000..4715f8b0694 --- /dev/null +++ b/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt @@ -0,0 +1,225 @@ +package io.sentry.android.cronet + +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import java.nio.ByteBuffer +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.chromium.net.CronetException +import org.chromium.net.UrlRequest +import org.chromium.net.UrlResponseInfo +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doThrow +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 + +class SentryCronetCallbackTest { + private class Fixture { + val scopes = mock() + val options = SentryOptions() + val request = mock() + val delegate = mock() + val tracer: SentryTracer + val response = mock() + + init { + whenever(scopes.options).thenReturn(options) + tracer = SentryTracer(TransactionContext("test", "test"), scopes) + whenever(scopes.transaction).thenReturn(tracer) + whenever(response.httpStatusCode).thenReturn(200) + whenever(response.negotiatedProtocol).thenReturn("h3") + } + + fun callback(url: String = "https://example.com/api") = + SentryCronetCallback(url, "GET", delegate, scopes) + + fun breadcrumb(): Breadcrumb { + val captor = argumentCaptor() + verify(scopes).addBreadcrumb(captor.capture()) + return captor.firstValue + } + } + + private val fixture = Fixture() + + @Test + fun `starts tracing only when the request starts`() { + val callback = fixture.callback() + assertThat(fixture.tracer.spans).isEmpty() + callback.start(fixture.request) + verify(fixture.request).start() + val span = fixture.tracer.spans.single() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET https://example.com/api") + assertThat(span.spanContext.origin).isEqualTo("auto.http.cronet") + assertThat(span.getData(SpanDataConvention.HTTP_METHOD_KEY)).isEqualTo("GET") + assertThat(span.isFinished).isFalse() + } + + @Test + fun `success finishes span with status and negotiated HTTP3 protocol`() { + val callback = fixture.callback() + callback.start(fixture.request) + callback.onSucceeded(fixture.request, fixture.response) + val span = fixture.tracer.spans.single() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.OK) + assertThat(span.getData(SpanDataConvention.HTTP_STATUS_CODE_KEY)).isEqualTo(200) + assertThat(span.getData("protocol")).isEqualTo("h3") + val breadcrumb = fixture.breadcrumb() + assertThat(breadcrumb.category).isEqualTo("http") + assertThat(breadcrumb.getData("protocol")).isEqualTo("h3") + assertThat(breadcrumb.getData(SpanDataConvention.HTTP_START_TIMESTAMP)).isNotNull() + assertThat(breadcrumb.getData(SpanDataConvention.HTTP_END_TIMESTAMP)).isNotNull() + verify(fixture.delegate).onSucceeded(fixture.request, fixture.response) + } + + @Test + fun `HTTP errors use HTTP status mapping`() { + whenever(fixture.response.httpStatusCode).thenReturn(503) + val callback = fixture.callback() + callback.start(fixture.request) + callback.onSucceeded(fixture.request, fixture.response) + assertThat(fixture.tracer.spans.single().status).isEqualTo(SpanStatus.UNAVAILABLE) + } + + @Test + fun `network failure without response closes span and forwards the same error`() { + val error = mock() + val callback = fixture.callback() + callback.start(fixture.request) + callback.onFailed(fixture.request, null, error) + val span = fixture.tracer.spans.single() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(span.throwable).isSameInstanceAs(error) + assertThat(fixture.breadcrumb().getData("status_code")).isNull() + verify(fixture.delegate).onFailed(fixture.request, null, error) + } + + @Test + fun `cancellation after response overrides HTTP success status`() { + val callback = fixture.callback() + callback.start(fixture.request) + callback.onCanceled(fixture.request, fixture.response) + assertThat(fixture.tracer.spans.single().status).isEqualTo(SpanStatus.CANCELLED) + assertThat(fixture.tracer.spans.single().isFinished).isTrue() + verify(fixture.delegate).onCanceled(fixture.request, fixture.response) + } + + @Test + fun `cancellation before headers supports null response`() { + val callback = fixture.callback() + callback.start(fixture.request) + callback.onCanceled(fixture.request, null) + assertThat(fixture.tracer.spans.single().status).isEqualTo(SpanStatus.CANCELLED) + fixture.breadcrumb() + verify(fixture.delegate).onCanceled(fixture.request, null) + } + + @Test + fun `redirect response and read callbacks retain application flow control`() { + val callback = fixture.callback() + val buffer = ByteBuffer.allocateDirect(8) + callback.start(fixture.request) + callback.onRedirectReceived(fixture.request, fixture.response, "https://other.example/api") + callback.onResponseStarted(fixture.request, fixture.response) + callback.onReadCompleted(fixture.request, fixture.response, buffer) + verify(fixture.delegate) + .onRedirectReceived(fixture.request, fixture.response, "https://other.example/api") + verify(fixture.delegate).onResponseStarted(fixture.request, fixture.response) + verify(fixture.delegate).onReadCompleted(fixture.request, fixture.response, buffer) + verify(fixture.request, never()).followRedirect() + verify(fixture.request, never()).read(any()) + assertThat(fixture.tracer.spans.single().isFinished).isFalse() + verify(fixture.scopes, never()).addBreadcrumb(any()) + } + + @Test + fun `breadcrumbs work without an active transaction`() { + whenever(fixture.scopes.transaction).thenReturn(null) + val callback = fixture.callback() + callback.start(fixture.request) + callback.onSucceeded(fixture.request, fixture.response) + assertThat(fixture.tracer.spans).isEmpty() + assertThat(fixture.breadcrumb().getData("url")).isEqualTo("https://example.com/api") + } + + @Test + fun `synchronous start failure is rethrown and span is finished`() { + val error = IllegalStateException("invalid request") + doThrow(error).whenever(fixture.request).start() + val callback = fixture.callback() + assertThat(assertFailsWith { callback.start(fixture.request) }) + .isSameInstanceAs(error) + assertThat(fixture.tracer.spans.single().status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(fixture.tracer.spans.single().isFinished).isTrue() + fixture.breadcrumb() + } + + @Test + fun `terminal callback exceptions cannot leak spans`() { + val error = IllegalStateException("application failure") + doThrow(error).whenever(fixture.delegate).onSucceeded(fixture.request, fixture.response) + val callback = fixture.callback() + callback.start(fixture.request) + assertThat( + assertFailsWith { + callback.onSucceeded(fixture.request, fixture.response) + } + ) + .isSameInstanceAs(error) + assertThat(fixture.tracer.spans.single().isFinished).isTrue() + fixture.breadcrumb() + } + + @Test + fun `does not finish or breadcrumb twice`() { + val callback = fixture.callback() + callback.start(fixture.request) + callback.onSucceeded(fixture.request, fixture.response) + callback.onCanceled(fixture.request, null) + assertThat(fixture.tracer.spans.single().status).isEqualTo(SpanStatus.OK) + fixture.breadcrumb() + } + + @Test + fun `callback cannot be reused`() { + val callback = fixture.callback() + callback.start(fixture.request) + assertFailsWith { callback.start(fixture.request) } + verify(fixture.request, times(1)).start() + assertThat(fixture.tracer.spans).hasSize(1) + } + + @Test + fun `callbacks still delegate when start helper was not used`() { + val callback = fixture.callback() + callback.onSucceeded(fixture.request, fixture.response) + verify(fixture.delegate).onSucceeded(fixture.request, fixture.response) + assertThat(fixture.tracer.spans).isEmpty() + verify(fixture.scopes, never()).addBreadcrumb(any()) + } + + @Test + fun `URL credentials are filtered from descriptions and breadcrumbs`() { + val callback = fixture.callback("https://user:secret@example.com/api?key=value#fragment") + callback.start(fixture.request) + callback.onSucceeded(fixture.request, fixture.response) + val description = fixture.tracer.spans.single().description + assertThat(description).doesNotContain("user") + assertThat(description).doesNotContain("secret") + assertThat(description).doesNotContain("key=value") + assertThat(fixture.breadcrumb().getData("url").toString()).doesNotContain("secret") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e243795499d..0a73b4f2746 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,6 +49,7 @@ include( "sentry-android-ndk", "sentry-android", "sentry-android-timber", + "sentry-android-cronet", "sentry-android-fragment", "sentry-android-navigation", "sentry-android-navigation3", From 8095c0af9e08dd1e7d5a1a7a2e3e2f084c1a5a22 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:35:45 +0000 Subject: [PATCH 2/3] changelog Co-Authored-By: David Quintas --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5476f6d9831..e4fff8c55da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Add experimental, opt-in Cronet request spans and HTTP breadcrumbs with `sentry-android-cronet` ([#6185](https://github.com/getsentry/sentry-java/pull/6185)) + - Deprecate `sendDefaultPii` in favor of `dataCollection` ahead of its removal in 9.0 ([#6158](https://github.com/getsentry/sentry-java/pull/6158)) - Make the tombstone merge time threshold configurable via `SentryAndroidOptions.setTombstoneMergeTimeThresholdMillis` and the `io.sentry.tombstone.merge-time-threshold-millis` manifest option ([#6154](https://github.com/getsentry/sentry-java/pull/6154)) From 2663f811fa7a38d94808cc3f0f13851b0e933c89 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:42:54 +0000 Subject: [PATCH 3/3] fix(android): Narrow Cronet start exception handling --- .../android/cronet/SentryCronetCallback.kt | 15 ++++++++-- .../cronet/SentryCronetCallbackTest.kt | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt b/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt index c109d7fbbc0..019d731c550 100644 --- a/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt +++ b/sentry-android-cronet/src/main/java/io/sentry/android/cronet/SentryCronetCallback.kt @@ -12,6 +12,7 @@ import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.SpanUtils import io.sentry.util.UrlUtils import java.nio.ByteBuffer +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.atomic.AtomicBoolean import org.chromium.net.CronetException import org.chromium.net.UrlRequest @@ -65,12 +66,20 @@ public constructor( } try { request.start() - } catch (e: RuntimeException) { - finish(null, SpanStatus.INTERNAL_ERROR, e) - throw e + } catch (e: IllegalStateException) { + finishFailedStart(e) + } catch (e: IllegalArgumentException) { + finishFailedStart(e) + } catch (e: RejectedExecutionException) { + finishFailedStart(e) } } + private fun finishFailedStart(error: RuntimeException): Nothing { + finish(null, SpanStatus.INTERNAL_ERROR, error) + throw error + } + @Throws(Exception::class) override fun onRedirectReceived( request: UrlRequest, diff --git a/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt b/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt index 4715f8b0694..58876d4cb93 100644 --- a/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt +++ b/sentry-android-cronet/src/test/java/io/sentry/android/cronet/SentryCronetCallbackTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import java.nio.ByteBuffer +import java.util.concurrent.RejectedExecutionException import kotlin.test.Test import kotlin.test.assertFailsWith import org.chromium.net.CronetException @@ -167,6 +168,34 @@ class SentryCronetCallbackTest { fixture.breadcrumb() } + @Test + fun `invalid request arguments finish the span and are rethrown`() { + val error = IllegalArgumentException("invalid HTTP method") + doThrow(error).whenever(fixture.request).start() + val callback = fixture.callback() + assertThat(assertFailsWith { callback.start(fixture.request) }) + .isSameInstanceAs(error) + val span = fixture.tracer.spans.single() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(span.throwable).isSameInstanceAs(error) + fixture.breadcrumb() + } + + @Test + fun `executor rejection finishes the span and is rethrown`() { + val error = RejectedExecutionException("executor shut down") + doThrow(error).whenever(fixture.request).start() + val callback = fixture.callback() + assertThat(assertFailsWith { callback.start(fixture.request) }) + .isSameInstanceAs(error) + val span = fixture.tracer.spans.single() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(span.throwable).isSameInstanceAs(error) + fixture.breadcrumb() + } + @Test fun `terminal callback exceptions cannot leak spans`() { val error = IllegalStateException("application failure")