Skip to content

Commit 4742e23

Browse files
authored
feat(replay): Capture network request/response details when using SentryOkHttpListener (#4919)
* Extract NetworkRequestData into Breadcrumb Hint when using SentryOkHttpEventListener -> Reuse existing logic that retrieves optional SentryOkHttpEvent for the okhttp3.Call, and optionally provide NetworkRequestData for adding to Breadcrumb Hint in SentryOkHttpEvent#finish Couple related changes as well: * Use case-insensitive comparision when extracting headers * Update SentryReplayOptions network request/response API to accept List<String> everywhere * CHANGELOG for Network Details extraction
1 parent e761994 commit 4742e23

File tree

14 files changed

+357
-103
lines changed

14 files changed

+357
-103
lines changed

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,55 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919))
8+
- Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies
9+
- To enable, add url regexes via the `io.sentry.session-replay.network-detail-allow-urls` metadata tag in AndroidManifest ([code sample](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205))
10+
- Or you can manually specify SentryReplayOptions via `SentryAndroid#init`:
11+
_(Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false)_
12+
13+
<details>
14+
<summary>Kotlin</summary>
15+
16+
```kotlin
17+
SentryAndroid.init(
18+
this,
19+
options -> {
20+
// options.dsn = "https://[email protected]/0"
21+
// options.sessionReplay.sessionSampleRate = 1.0
22+
// options.sessionReplay.onErrorSampleRate = 1.0
23+
// ..
24+
25+
options.sessionReplay.networkDetailAllowUrls = listOf(".*")
26+
options.sessionReplay.networkDetailDenyUrls = listOf(".*deny.*")
27+
options.sessionReplay.networkRequestHeaders = listOf("Authorization", "X-Custom-Header", "X-Test-Request")
28+
options.sessionReplay.networkResponseHeaders = listOf("X-Response-Time", "X-Cache-Status", "X-Test-Response")
29+
});
30+
```
31+
32+
</details>
33+
34+
<details>
35+
<summary>Java</summary>
36+
37+
```java
38+
SentryAndroid.init(
39+
this,
40+
options -> {
41+
options.getSessionReplay().setNetworkDetailAllowUrls(Arrays.asList(".*"));
42+
options.getSessionReplay().setNetworkDetailDenyUrls(Arrays.asList(".*deny.*"));
43+
options.getSessionReplay().setNetworkRequestHeaders(
44+
Arrays.asList("Authorization", "X-Custom-Header", "X-Test-Request"));
45+
options.getSessionReplay().setNetworkResponseHeaders(
46+
Arrays.asList("X-Response-Time", "X-Cache-Status", "X-Test-Response"));
47+
});
48+
49+
```
50+
51+
</details>
52+
53+
554
### Improvements
655

756
- Avoid forking `rootScopes` for Reactor if current thread has `NoOpScopes` ([#4793](https://github.com/getsentry/sentry-java/pull/4793))

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ static void applyMetadata(
507507
}
508508

509509
// Network Details Configuration
510-
if (options.getSessionReplay().getNetworkDetailAllowUrls().length == 0) {
510+
if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) {
511511
final @Nullable List<String> allowUrls =
512512
readList(metadata, logger, REPLAYS_NETWORK_DETAIL_ALLOW_URLS);
513513
if (allowUrls != null && !allowUrls.isEmpty()) {
@@ -519,14 +519,12 @@ static void applyMetadata(
519519
}
520520
}
521521
if (!filteredUrls.isEmpty()) {
522-
options
523-
.getSessionReplay()
524-
.setNetworkDetailAllowUrls(filteredUrls.toArray(new String[0]));
522+
options.getSessionReplay().setNetworkDetailAllowUrls(filteredUrls);
525523
}
526524
}
527525
}
528526

529-
if (options.getSessionReplay().getNetworkDetailDenyUrls().length == 0) {
527+
if (options.getSessionReplay().getNetworkDetailDenyUrls().isEmpty()) {
530528
final @Nullable List<String> denyUrls =
531529
readList(metadata, logger, REPLAYS_NETWORK_DETAIL_DENY_URLS);
532530
if (denyUrls != null && !denyUrls.isEmpty()) {
@@ -538,9 +536,7 @@ static void applyMetadata(
538536
}
539537
}
540538
if (!filteredUrls.isEmpty()) {
541-
options
542-
.getSessionReplay()
543-
.setNetworkDetailDenyUrls(filteredUrls.toArray(new String[0]));
539+
options.getSessionReplay().setNetworkDetailDenyUrls(filteredUrls);
544540
}
545541
}
546542
}
@@ -554,7 +550,7 @@ static void applyMetadata(
554550
REPLAYS_NETWORK_CAPTURE_BODIES,
555551
options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */));
556552

557-
if (options.getSessionReplay().getNetworkRequestHeaders().length
553+
if (options.getSessionReplay().getNetworkRequestHeaders().size()
558554
== SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults
559555
final @Nullable List<String> requestHeaders =
560556
readList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS);
@@ -572,7 +568,7 @@ static void applyMetadata(
572568
}
573569
}
574570

575-
if (options.getSessionReplay().getNetworkResponseHeaders().length
571+
if (options.getSessionReplay().getNetworkResponseHeaders().size()
576572
== SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults
577573
final @Nullable List<String> responseHeaders =
578574
readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS);

sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.sentry.TypeCheckHint
1010
import io.sentry.transport.CurrentDateProvider
1111
import io.sentry.util.Platform
1212
import io.sentry.util.UrlUtils
13+
import io.sentry.util.network.NetworkRequestData
1314
import java.util.concurrent.ConcurrentHashMap
1415
import java.util.concurrent.TimeUnit
1516
import java.util.concurrent.atomic.AtomicBoolean
@@ -27,6 +28,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques
2728
internal val callSpan: ISpan?
2829
private var response: Response? = null
2930
private var clientErrorResponse: Response? = null
31+
private var networkDetails: NetworkRequestData? = null
3032
internal val isEventFinished = AtomicBoolean(false)
3133
private var url: String
3234
private var method: String
@@ -135,6 +137,11 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques
135137
}
136138
}
137139

140+
/** Sets the [NetworkRequestData] for network detail capture. */
141+
fun setNetworkDetails(networkRequestData: NetworkRequestData?) {
142+
this.networkDetails = networkRequestData
143+
}
144+
138145
/** Record event start if the callRootSpan is not null. */
139146
fun onEventStart(event: String) {
140147
callSpan ?: return
@@ -163,6 +170,9 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques
163170
hint.set(TypeCheckHint.OKHTTP_REQUEST, request)
164171
response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) }
165172

173+
// Include network details in the hint for session replay
174+
networkDetails?.let { hint.set(TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, it) }
175+
166176
// needs this as unix timestamp for rrweb
167177
breadcrumb.setData(
168178
SpanDataConvention.HTTP_END_TIMESTAMP,

sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import okhttp3.Interceptor
3333
import okhttp3.Request
3434
import okhttp3.RequestBody.Companion.toRequestBody
3535
import okhttp3.Response
36+
import org.jetbrains.annotations.VisibleForTesting
3637

3738
/**
3839
* The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span
@@ -209,6 +210,9 @@ public open class SentryOkHttpInterceptor(
209210
)
210211
}
211212

213+
// Set network details on the OkHttpEvent so it can include them in the breadcrumb hint
214+
okHttpEvent?.setNetworkDetails(networkDetailData)
215+
212216
finishSpan(span, request, response, isFromEventListener, okHttpEvent)
213217

214218
// The SentryOkHttpEventListener will send the breadcrumb itself if used for this call
@@ -260,10 +264,19 @@ public open class SentryOkHttpInterceptor(
260264
}
261265

262266
/** Extracts headers from OkHttp Headers object into a map */
263-
private fun okhttp3.Headers.toMap(): Map<String, String> {
267+
@VisibleForTesting
268+
internal fun okhttp3.Headers.toMap(): Map<String, String> {
264269
val headers = linkedMapOf<String, String>()
265270
for (i in 0 until size) {
266-
headers[name(i)] = value(i)
271+
val name = name(i)
272+
val value = value(i)
273+
val existingValue = headers[name]
274+
if (existingValue != null) {
275+
// Concatenate duplicate headers with semicolon separator
276+
headers[name] = "$existingValue; $value"
277+
} else {
278+
headers[name] = value
279+
}
267280
}
268281
return headers
269282
}

sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.sentry.TransactionContext
1515
import io.sentry.TypeCheckHint
1616
import io.sentry.exception.SentryHttpClientException
1717
import io.sentry.test.getProperty
18+
import io.sentry.util.network.NetworkRequestData
1819
import kotlin.test.Test
1920
import kotlin.test.assertEquals
2021
import kotlin.test.assertFalse
@@ -425,6 +426,34 @@ class SentryOkHttpEventTest {
425426
verify(fixture.scopes, never()).captureEvent(any(), any<Hint>())
426427
}
427428

429+
@Test
430+
fun `when finish is called, the breadcrumb sent includes network details data on its hint`() {
431+
val sut = fixture.getSut()
432+
val networkRequestData = NetworkRequestData("GET")
433+
434+
sut.setNetworkDetails(networkRequestData)
435+
sut.finish()
436+
437+
verify(fixture.scopes)
438+
.addBreadcrumb(
439+
any<Breadcrumb>(),
440+
check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) },
441+
)
442+
}
443+
444+
@Test
445+
fun `when setNetworkDetails is not called, no network details data is captured`() {
446+
val sut = fixture.getSut()
447+
448+
sut.finish()
449+
450+
verify(fixture.scopes)
451+
.addBreadcrumb(
452+
any<Breadcrumb>(),
453+
check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) },
454+
)
455+
}
456+
428457
/** Retrieve all the spans started in the event using reflection. */
429458
private fun SentryOkHttpEvent.getEventDates() =
430459
getProperty<MutableMap<String, SentryDate>>("eventDates")

sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,4 +680,39 @@ class SentryOkHttpInterceptorTest {
680680
assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER))
681681
assertNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER))
682682
}
683+
684+
@Test
685+
fun `toMap handles duplicate headers correctly`() {
686+
// Create a response with duplicate headers
687+
val mockResponse =
688+
MockResponse()
689+
.setResponseCode(200)
690+
.setBody("test")
691+
.addHeader("Set-Cookie", "sessionId=123")
692+
.addHeader("Set-Cookie", "userId=456")
693+
.addHeader("Set-Cookie", "theme=dark")
694+
.addHeader("Accept", "text/html")
695+
.addHeader("Accept", "application/json")
696+
.addHeader("Single-Header", "value")
697+
698+
fixture.server.enqueue(mockResponse)
699+
700+
// Execute request to get response with headers
701+
val sut = fixture.getSut()
702+
val response = sut.newCall(getRequest()).execute()
703+
val headers = response.headers
704+
705+
// Optional: verify OkHttp preserves duplicate headers
706+
assertEquals(3, headers.values("Set-Cookie").size)
707+
assertEquals(2, headers.values("Accept").size)
708+
assertEquals(1, headers.values("Single-Header").size)
709+
710+
val interceptor = SentryOkHttpInterceptor(fixture.scopes)
711+
val headerMap = with(interceptor) { headers.toMap() }
712+
713+
// Duplicate headers will be collapsed into 1 concatenated entry with "; " separator
714+
assertEquals("sessionId=123; userId=456; theme=dark", headerMap["Set-Cookie"])
715+
assertEquals("text/html; application/json", headerMap["Accept"])
716+
assertEquals("value", headerMap["Single-Header"])
717+
}
683718
}

sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import android.widget.Toast;
1111
import androidx.appcompat.app.AppCompatActivity;
1212
import io.sentry.Sentry;
13+
import io.sentry.okhttp.SentryOkHttpEventListener;
1314
import io.sentry.okhttp.SentryOkHttpInterceptor;
1415
import java.io.ByteArrayInputStream;
1516
import java.io.IOException;
@@ -80,14 +81,14 @@ private void initializeViews() {
8081

8182
private void setupOkHttpClient() {
8283
// OkHttpClient with Sentry integration for monitoring HTTP requests
84+
// Both SentryOkHttpEventListener and SentryOkHttpInterceptor are enabled to test
85+
// network detail capture when both components are used together
8386
okHttpClient =
8487
new OkHttpClient.Builder()
8588
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
8689
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
8790
.writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
88-
// performance monitoring
89-
// .eventListener(new SentryOkHttpEventListener())
90-
// breadcrumbs and failed request capture
91+
.eventListener(new SentryOkHttpEventListener())
9192
.addInterceptor(new SentryOkHttpInterceptor())
9293
.build();
9394
}

sentry/api/sentry.api

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3801,11 +3801,11 @@ public final class io/sentry/SentryReplayOptions {
38013801
public fun getFrameRate ()I
38023802
public fun getMaskViewClasses ()Ljava/util/Set;
38033803
public fun getMaskViewContainerClass ()Ljava/lang/String;
3804-
public fun getNetworkDetailAllowUrls ()[Ljava/lang/String;
3805-
public fun getNetworkDetailDenyUrls ()[Ljava/lang/String;
3804+
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
3805+
public fun getNetworkDetailDenyUrls ()Ljava/util/List;
38063806
public static fun getNetworkDetailsDefaultHeaders ()Ljava/util/List;
3807-
public fun getNetworkRequestHeaders ()[Ljava/lang/String;
3808-
public fun getNetworkResponseHeaders ()[Ljava/lang/String;
3807+
public fun getNetworkRequestHeaders ()Ljava/util/List;
3808+
public fun getNetworkResponseHeaders ()Ljava/util/List;
38093809
public fun getOnErrorSampleRate ()Ljava/lang/Double;
38103810
public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality;
38113811
public fun getScreenshotStrategy ()Lio/sentry/ScreenshotStrategyType;
@@ -3825,8 +3825,8 @@ public final class io/sentry/SentryReplayOptions {
38253825
public fun setMaskAllText (Z)V
38263826
public fun setMaskViewContainerClass (Ljava/lang/String;)V
38273827
public fun setNetworkCaptureBodies (Z)V
3828-
public fun setNetworkDetailAllowUrls ([Ljava/lang/String;)V
3829-
public fun setNetworkDetailDenyUrls ([Ljava/lang/String;)V
3828+
public fun setNetworkDetailAllowUrls (Ljava/util/List;)V
3829+
public fun setNetworkDetailDenyUrls (Ljava/util/List;)V
38303830
public fun setNetworkRequestHeaders (Ljava/util/List;)V
38313831
public fun setNetworkResponseHeaders (Ljava/util/List;)V
38323832
public fun setOnErrorSampleRate (Ljava/lang/Double;)V
@@ -7496,9 +7496,9 @@ public final class io/sentry/util/network/NetworkBodyParser {
74967496
}
74977497

74987498
public final class io/sentry/util/network/NetworkDetailCaptureUtils {
7499-
public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;[Ljava/lang/String;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
7500-
public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;[Ljava/lang/String;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
7501-
public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;)Lio/sentry/util/network/NetworkRequestData;
7499+
public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
7500+
public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
7501+
public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData;
75027502
}
75037503

75047504
public abstract interface class io/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor {

0 commit comments

Comments
 (0)