diff --git a/CHANGELOG.md b/CHANGELOG.md index bb788c78668..41bb6f63daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Features +- Enable capturing additional network details in your session replays (okhttp). + - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies. + - To enable, configure your sentry SDK using the "io.sentry.session-replay.network-*" options via [manifest](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205) + - Or manually specify SentryReplayOptions via [SentryAndroid#init](https://github.com/getsentry/sentry-java/blob/c83e427e8baca17098f882f8b45fc7c5a80c1d8c/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java#L16-L28) - Implement OpenFeature Integration that tracks Feature Flag evaluations ([#4910](https://github.com/getsentry/sentry-java/pull/4910)) - To make use of it, add the `sentry-openfeature` dependency and register the the hook using: `openFeatureApiInstance.addHooks(new SentryOpenFeatureHook());` - Implement LaunchDarkly Integrations that track Feature Flag evaluations ([#4917](https://github.com/getsentry/sentry-java/pull/4917)) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index fc894ec3768..7475f09443b 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -10,6 +10,7 @@ import io.sentry.TypeCheckHint import io.sentry.transport.CurrentDateProvider import io.sentry.util.Platform import io.sentry.util.UrlUtils +import io.sentry.util.network.NetworkRequestData import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -27,6 +28,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques internal val callSpan: ISpan? private var response: Response? = null private var clientErrorResponse: Response? = null + private var networkDetails: NetworkRequestData? = null internal val isEventFinished = AtomicBoolean(false) private var url: String private var method: String @@ -135,6 +137,11 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques } } + /** Sets the [NetworkRequestData] for network detail capture. */ + fun setNetworkDetails(networkRequestData: NetworkRequestData?) { + this.networkDetails = networkRequestData + } + /** Record event start if the callRootSpan is not null. */ fun onEventStart(event: String) { callSpan ?: return @@ -163,6 +170,9 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques hint.set(TypeCheckHint.OKHTTP_REQUEST, request) response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + // Include network details in the hint for session replay + networkDetails?.let { hint.set(TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, it) } + // needs this as unix timestamp for rrweb breadcrumb.setData( SpanDataConvention.HTTP_END_TIMESTAMP, diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index bfcc5a033d4..af7b25a782e 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -33,6 +33,7 @@ import okhttp3.Interceptor import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.jetbrains.annotations.VisibleForTesting /** * The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span @@ -209,6 +210,9 @@ public open class SentryOkHttpInterceptor( ) } + // Set network details on the OkHttpEvent so it can include them in the breadcrumb hint + okHttpEvent?.setNetworkDetails(networkDetailData) + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call @@ -260,10 +264,19 @@ public open class SentryOkHttpInterceptor( } /** Extracts headers from OkHttp Headers object into a map */ - private fun okhttp3.Headers.toMap(): Map { + @VisibleForTesting + internal fun okhttp3.Headers.toMap(): Map { val headers = linkedMapOf() for (i in 0 until size) { - headers[name(i)] = value(i) + val name = name(i) + val value = value(i) + val existingValue = headers[name] + if (existingValue != null) { + // Concatenate duplicate headers with semicolon separator + headers[name] = "$existingValue; $value" + } else { + headers[name] = value + } } return headers } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index ff671ac153f..5570e37787b 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -15,6 +15,7 @@ import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.exception.SentryHttpClientException import io.sentry.test.getProperty +import io.sentry.util.network.NetworkRequestData import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -425,6 +426,34 @@ class SentryOkHttpEventTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `when finish is called, the breadcrumb sent includes network details data on its hint`() { + val sut = fixture.getSut() + val networkRequestData = NetworkRequestData("GET") + + sut.setNetworkDetails(networkRequestData) + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, + ) + } + + @Test + fun `when setNetworkDetails is not called, no network details data is captured`() { + val sut = fixture.getSut() + + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, + ) + } + /** Retrieve all the spans started in the event using reflection. */ private fun SentryOkHttpEvent.getEventDates() = getProperty>("eventDates") diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index bbdb3a86516..6e8b8548731 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -680,4 +680,39 @@ class SentryOkHttpInterceptorTest { assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) assertNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER)) } + + @Test + fun `toMap handles duplicate headers correctly`() { + // Create a response with duplicate headers + val mockResponse = + MockResponse() + .setResponseCode(200) + .setBody("test") + .addHeader("Set-Cookie", "sessionId=123") + .addHeader("Set-Cookie", "userId=456") + .addHeader("Set-Cookie", "theme=dark") + .addHeader("Accept", "text/html") + .addHeader("Accept", "application/json") + .addHeader("Single-Header", "value") + + fixture.server.enqueue(mockResponse) + + // Execute request to get response with headers + val sut = fixture.getSut() + val response = sut.newCall(getRequest()).execute() + val headers = response.headers + + // Optional: verify OkHttp preserves duplicate headers + assertEquals(3, headers.values("Set-Cookie").size) + assertEquals(2, headers.values("Accept").size) + assertEquals(1, headers.values("Single-Header").size) + + val interceptor = SentryOkHttpInterceptor(fixture.scopes) + val headerMap = with(interceptor) { headers.toMap() } + + // Duplicate headers will be collapsed into 1 concatenated entry with "; " separator + assertEquals("sessionId=123; userId=456; theme=dark", headerMap["Set-Cookie"]) + assertEquals("text/html; application/json", headerMap["Accept"]) + assertEquals("value", headerMap["Single-Header"]) + } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java index 486b558a717..5671a044437 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -10,6 +10,7 @@ import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Sentry; +import io.sentry.okhttp.SentryOkHttpEventListener; import io.sentry.okhttp.SentryOkHttpInterceptor; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -80,14 +81,14 @@ private void initializeViews() { private void setupOkHttpClient() { // OkHttpClient with Sentry integration for monitoring HTTP requests + // Both SentryOkHttpEventListener and SentryOkHttpInterceptor are enabled to test + // network detail capture when both components are used together okHttpClient = new OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - // performance monitoring - // .eventListener(new SentryOkHttpEventListener()) - // breadcrumbs and failed request capture + .eventListener(new SentryOkHttpEventListener()) .addInterceptor(new SentryOkHttpInterceptor()) .build(); } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index 3ed8e1e1674..1fd2b32b7cc 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -1,9 +1,12 @@ package io.sentry.util.network; -import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; /** * Utility class for network capture operations shared across HTTP client integrations. Provides @@ -115,19 +118,26 @@ private static boolean shouldCaptureUrl( return false; } - private static @NotNull Map getCaptureHeaders( + @VisibleForTesting + static @NotNull Map getCaptureHeaders( @Nullable final Map allHeaders, @NotNull final String[] allowedHeaders) { - Map capturedHeaders = new HashMap<>(); - - if (allHeaders == null) { + final Map capturedHeaders = new LinkedHashMap<>(); + if (allHeaders == null || allowedHeaders.length == 0) { return capturedHeaders; } + // Convert to lowercase for case-insensitive matching + Set normalizedAllowed = new HashSet<>(); for (String header : allowedHeaders) { - String value = allHeaders.get(header); - if (value != null) { - capturedHeaders.put(header, value); + if (header != null) { + normalizedAllowed.add(header.toLowerCase()); + } + } + + for (Map.Entry entry : allHeaders.entrySet()) { + if (normalizedAllowed.contains(entry.getKey().toLowerCase())) { + capturedHeaders.put(entry.getKey(), entry.getValue()); } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt new file mode 100644 index 00000000000..c3b3faef61f --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -0,0 +1,104 @@ +package io.sentry.util.network + +import java.util.LinkedHashMap +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test + +class NetworkDetailCaptureUtilsTest { + + @Test + fun `getCaptureHeaders should match headers case-insensitively`() { + // Setup: allHeaders with mixed case keys + val allHeaders = + LinkedHashMap().apply { + put("Content-Type", "application/json") + put("Authorization", "Bearer token123") + put("X-Custom-Header", "custom-value") + put("accept", "application/json") + } + + // Test: allowedHeaders with different casing + val allowedHeaders = arrayOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + // All headers should be matched despite case differences + assertEquals(4, result.size) + + // Original casing should be preserved in output + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + assertEquals("custom-value", result["X-Custom-Header"]) + assertEquals("application/json", result["accept"]) + + // Verify keys maintain original casing from allHeaders + assertTrue(result.containsKey("Content-Type")) + assertTrue(result.containsKey("Authorization")) + assertTrue(result.containsKey("X-Custom-Header")) + assertTrue(result.containsKey("accept")) + } + + @Test + fun `getCaptureHeaders should handle null allHeaders`() { + val allowedHeaders = arrayOf("content-type") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(null, allowedHeaders) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getCaptureHeaders should handle empty allowedHeaders`() { + val allHeaders = mapOf("Content-Type" to "application/json") + val allowedHeaders = arrayOf() + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getCaptureHeaders should only capture allowed headers`() { + val allHeaders = + mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token123", + "X-Unwanted-Header" to "should-not-appear", + ) + + val allowedHeaders = arrayOf("content-type", "authorization") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + assertEquals(2, result.size) + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + + // Unwanted header should not be present + assertTrue(!result.containsKey("X-Unwanted-Header")) + } + + @Test + fun `getCaptureHeaders should handle null elements in allowedHeaders`() { + val allHeaders = + mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token123", + "X-Custom-Header" to "custom-value", + ) + + // allowedHeaders contains null elements which should be ignored + val allowedHeaders = arrayOf(null, "content-type", null, "authorization", null) + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + // Only non-null allowed headers should be matched + assertEquals(2, result.size) + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + + // X-Custom-Header should not be present as it's not in the allowed list + assertTrue(!result.containsKey("X-Custom-Header")) + } +}