diff --git a/android-test-app/build.gradle.kts b/android-test-app/build.gradle.kts index 345ea057f26f..8c94fc219e98 100644 --- a/android-test-app/build.gradle.kts +++ b/android-test-app/build.gradle.kts @@ -45,7 +45,7 @@ android { dependencies { implementation(libs.playservices.safetynet) - implementation(projects.okhttp) + "friendsImplementation"(projects.okhttp) implementation(libs.androidx.activity) androidTestImplementation(libs.androidx.junit) diff --git a/android-test-app/src/main/kotlin/okhttp/android/testapp/MainActivity.kt b/android-test-app/src/main/kotlin/okhttp/android/testapp/MainActivity.kt index f3c145008210..ca00b9ad0ec8 100644 --- a/android-test-app/src/main/kotlin/okhttp/android/testapp/MainActivity.kt +++ b/android-test-app/src/main/kotlin/okhttp/android/testapp/MainActivity.kt @@ -20,9 +20,10 @@ import androidx.activity.ComponentActivity import okhttp3.Call import okhttp3.Callback import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient +import okhttp3.OkHttpClient.Builder import okhttp3.Request import okhttp3.Response +import okhttp3.android.addTracing import okhttp3.internal.platform.AndroidPlatform import okio.IOException @@ -30,7 +31,10 @@ open class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val client = OkHttpClient() + val client = + Builder() + .addTracing() + .build() // Ensure we are compiling against the right variant println(AndroidPlatform.isSupported) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1ffb3080959..bd823058fd4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ androidx-annotation = "1.9.1" androidx-espresso-core = "3.7.0" androidx-junit = "1.3.0" androidx-test-runner = "1.7.0" +androidx-tracing = "1.3.0" animalsniffer = "2.0.1" animalsniffer-annotations = "1.27" assertk = "0.28.1" @@ -75,6 +76,7 @@ androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-j androidx-lint-gradle = { module = "androidx.lint:lint-gradle", version.ref = "lint-gradle" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startup-runtime" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version.ref = "androidx-tracing" } animalsniffer-annotations = { module = "org.codehaus.mojo:animal-sniffer-annotations", version.ref = "animalsniffer-annotations" } aqute-bnd = { module = "biz.aQute.bnd:biz.aQute.bnd", version.ref = "bnd" } aqute-resolve = { module = "biz.aQute.bnd:biz.aQute.resolve", version.ref = "bnd" } diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/RecordingConnectionListener.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/RecordingConnectionListener.kt index e4aa31c52997..ee916af74655 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/RecordingConnectionListener.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/RecordingConnectionListener.kt @@ -148,11 +148,13 @@ internal open class RecordingConnectionListener( } override fun connectStart( + connectionId: Long, route: Route, call: Call, ) = logEvent(ConnectionEvent.ConnectStart(System.nanoTime(), route, call)) override fun connectFailed( + connectionId: Long, route: Route, call: Call, failure: IOException, diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 5e15372ee64d..36edd76a3ea9 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -123,6 +123,7 @@ kotlin { compileOnly(libs.conscrypt.openjdk) implementation(libs.androidx.annotation) implementation(libs.androidx.startup.runtime) + implementation(libs.androidx.tracing.ktx) } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/Tracing.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/Tracing.kt new file mode 100644 index 000000000000..44529e4c216f --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/Tracing.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * 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. + */ +package okhttp3.android + +import okhttp3.ConnectionPool +import okhttp3.OkHttpClient +import okhttp3.internal.tracing.AndroidxTracingConnectionListener +import okhttp3.internal.tracing.AndroidxTracingInterceptor + +/** + * Configures the [OkHttpClient] with AndroidX Tracing support. + * + * This enables visibility into network requests and connection lifecycle events + * within Android System Tracing (Perfetto/systrace). It installs a custom + * [ConnectionPool] and a network interceptor to capture tracing spans. + * + * @return This builder for method chaining. + */ +fun OkHttpClient.Builder.addTracing(): OkHttpClient.Builder = + this.connectionPool(ConnectionPool(connectionListener = AndroidxTracingConnectionListener())) + .addNetworkInterceptor(AndroidxTracingInterceptor()) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingConnectionListener.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingConnectionListener.kt new file mode 100644 index 000000000000..cac2f8757fab --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingConnectionListener.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * 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. + */ +@file:Suppress( + "CANNOT_OVERRIDE_INVISIBLE_MEMBER", + "INVISIBLE_MEMBER", + "INVISIBLE_REFERENCE", +) + +package okhttp3.internal.tracing + +import androidx.tracing.Trace +import okhttp3.Call +import okhttp3.Connection +import okhttp3.Route +import okhttp3.internal.connection.ConnectionListener +import okio.IOException + +/** + * Tracing implementation of ConnectionListener that marks the lifetime of each connection + * in Perfetto traces. + */ +internal class AndroidxTracingConnectionListener( + private val delegate: ConnectionListener = NONE, + val traceLabel: (Route) -> String = { it.defaultTracingLabel }, +) : ConnectionListener() { + override fun connectStart( + connectionId: Long, + route: Route, + call: Call, + ) { + Trace.beginAsyncSection(labelForTrace(route), connectionId.toInt()) + delegate.connectStart(connectionId, route, call) + } + + override fun connectFailed( + connectionId: Long, + route: Route, + call: Call, + failure: IOException, + ) { + Trace.endAsyncSection(labelForTrace(route), connectionId.toInt()) + delegate.connectFailed(connectionId, route, call, failure) + } + + override fun connectEnd( + connection: Connection, + route: Route, + call: Call, + ) { + delegate.connectEnd(connection, route, call) + } + + override fun connectionClosed(connection: Connection) { + Trace.endAsyncSection(labelForTrace(connection.route()), connection.id.toInt()) + delegate.connectionClosed(connection) + } + + private fun labelForTrace(route: Route): String = traceLabel(route).take(AndroidxTracingInterceptor.Companion.MAX_TRACE_LABEL_LENGTH) + + override fun connectionAcquired( + connection: Connection, + call: Call, + ) { + delegate.connectionAcquired(connection, call) + } + + override fun connectionReleased( + connection: Connection, + call: Call, + ) { + delegate.connectionReleased(connection, call) + } + + override fun noNewExchanges(connection: Connection) { + delegate.noNewExchanges(connection) + } + + companion object { + val Route.defaultTracingLabel: String + get() = this.address.url.host + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingInterceptor.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingInterceptor.kt new file mode 100644 index 000000000000..301aa6487e94 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/tracing/AndroidxTracingInterceptor.kt @@ -0,0 +1,28 @@ +package okhttp3.internal.tracing + +import androidx.tracing.trace +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +/** + * Tracing implementation of Interceptor that marks each Call in a Perfetto + * trace. Typically used as a network interceptor. + */ +internal class AndroidxTracingInterceptor( + val traceLabel: (Request) -> String = { it.defaultTracingLabel }, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response = + trace(traceLabel(chain.request()).take(MAX_TRACE_LABEL_LENGTH)) { + chain.proceed(chain.request()) + } + + companion object { + internal const val MAX_TRACE_LABEL_LENGTH = 127 + + val Request.defaultTracingLabel: String + get() { + return url.encodedPath + } + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt index 20dda3de9dc7..af7e558004fb 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt @@ -67,6 +67,10 @@ import java.net.Socket * been found. But only complete the stream once its data stream has been exhausted. */ interface Connection { + /** Unique id of this connection, assigned at the time of the attempt. */ + val id: Long + get() = 0L + /** Returns the route used by this connection. */ fun route(): Route diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index e90421ccdc44..5b53a2339154 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -24,6 +24,7 @@ import java.net.Socket as JavaNetSocket import java.net.UnknownServiceException import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocket import okhttp3.CertificatePinner @@ -74,6 +75,8 @@ class ConnectPlan internal constructor( internal val isTlsFallback: Boolean, ) : RoutePlanner.Plan, ExchangeCodec.Carrier { + private val id = idGenerator.incrementAndGet() + /** True if this connect was canceled; typically because it lost a race. */ @Volatile private var canceled = false @@ -130,7 +133,7 @@ class ConnectPlan internal constructor( call.plansToCancel += this try { call.eventListener.connectStart(call, route.socketAddress, route.proxy) - connectionPool.connectionListener.connectStart(route, call) + connectionPool.connectionListener.connectStart(id, route, call) connectSocket() success = true @@ -145,7 +148,7 @@ class ConnectPlan internal constructor( ) } call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, e) - connectionPool.connectionListener.connectFailed(route, call, e) + connectionPool.connectionListener.connectFailed(id, route, call, e) return ConnectResult(plan = this, throwable = e) } finally { call.plansToCancel -= this @@ -236,7 +239,7 @@ class ConnectPlan internal constructor( return ConnectResult(plan = this) } catch (e: IOException) { call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, e) - connectionPool.connectionListener.connectFailed(route, call, e) + connectionPool.connectionListener.connectFailed(id, route, call, e) if (!retryOnConnectionFailure || !retryTlsHandshake(e)) { retryTlsConnection = null @@ -330,7 +333,7 @@ class ConnectPlan internal constructor( "Too many tunnel connections attempted: $MAX_TUNNEL_ATTEMPTS", ) call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, failure) - connectionPool.connectionListener.connectFailed(route, call, failure) + connectionPool.connectionListener.connectFailed(id, route, call, failure) return ConnectResult(plan = this, throwable = failure) } } @@ -564,5 +567,6 @@ class ConnectPlan internal constructor( companion object { private const val NPE_THROW_WITH_NULL = "throw with null exception" private const val MAX_TUNNEL_ATTEMPTS = 21 + private val idGenerator = AtomicLong(0) } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectionListener.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectionListener.kt index 0157820594b0..750161a6937e 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectionListener.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectionListener.kt @@ -32,6 +32,7 @@ internal abstract class ConnectionListener { * Invoked as soon as a call causes a connection to be started. */ open fun connectStart( + connectionId: Long, route: Route, call: Call, ) {} @@ -40,6 +41,7 @@ internal abstract class ConnectionListener { * Invoked when a connection fails to be established. */ open fun connectFailed( + connectionId: Long, route: Route, call: Call, failure: IOException,