Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@

## Unreleased

### Features

- Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919))
- Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies
- 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))
- Or you can manually specify SentryReplayOptions via `SentryAndroid#init`:
_(Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false)_

<details>
<summary>Kotlin</summary>

```kotlin
SentryAndroid.init(
this,
options -> {
// options.dsn = "https://[email protected]/0"
// options.sessionReplay.sessionSampleRate = 1.0
// options.sessionReplay.onErrorSampleRate = 1.0
// ..

options.sessionReplay.networkDetailAllowUrls = listOf(".*")
options.sessionReplay.networkDetailDenyUrls = listOf(".*deny.*")
options.sessionReplay.networkRequestHeaders = listOf("Authorization", "X-Custom-Header", "X-Test-Request")
options.sessionReplay.networkResponseHeaders = listOf("X-Response-Time", "X-Cache-Status", "X-Test-Response")
});
```

</details>

<details>
<summary>Java</summary>

```java
SentryAndroid.init(
this,
options -> {
options.getSessionReplay().setNetworkDetailAllowUrls(Arrays.asList(".*"));
options.getSessionReplay().setNetworkDetailDenyUrls(Arrays.asList(".*deny.*"));
options.getSessionReplay().setNetworkRequestHeaders(
Arrays.asList("Authorization", "X-Custom-Header", "X-Test-Request"));
options.getSessionReplay().setNetworkResponseHeaders(
Arrays.asList("X-Response-Time", "X-Cache-Status", "X-Test-Response"));
});

```

</details>


### Improvements

- Avoid forking `rootScopes` for Reactor if current thread has `NoOpScopes` ([#4793](https://github.com/getsentry/sentry-java/pull/4793))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ static void applyMetadata(
}

// Network Details Configuration
if (options.getSessionReplay().getNetworkDetailAllowUrls().length == 0) {
if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) {
final @Nullable List<String> allowUrls =
readList(metadata, logger, REPLAYS_NETWORK_DETAIL_ALLOW_URLS);
if (allowUrls != null && !allowUrls.isEmpty()) {
Expand All @@ -519,14 +519,12 @@ static void applyMetadata(
}
}
if (!filteredUrls.isEmpty()) {
options
.getSessionReplay()
.setNetworkDetailAllowUrls(filteredUrls.toArray(new String[0]));
options.getSessionReplay().setNetworkDetailAllowUrls(filteredUrls);
}
}
}

if (options.getSessionReplay().getNetworkDetailDenyUrls().length == 0) {
if (options.getSessionReplay().getNetworkDetailDenyUrls().isEmpty()) {
final @Nullable List<String> denyUrls =
readList(metadata, logger, REPLAYS_NETWORK_DETAIL_DENY_URLS);
if (denyUrls != null && !denyUrls.isEmpty()) {
Expand All @@ -538,9 +536,7 @@ static void applyMetadata(
}
}
if (!filteredUrls.isEmpty()) {
options
.getSessionReplay()
.setNetworkDetailDenyUrls(filteredUrls.toArray(new String[0]));
options.getSessionReplay().setNetworkDetailDenyUrls(filteredUrls);
}
}
}
Expand All @@ -554,7 +550,7 @@ static void applyMetadata(
REPLAYS_NETWORK_CAPTURE_BODIES,
options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */));

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

if (options.getSessionReplay().getNetworkResponseHeaders().length
if (options.getSessionReplay().getNetworkResponseHeaders().size()
== SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults
final @Nullable List<String> responseHeaders =
readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -260,10 +264,19 @@ public open class SentryOkHttpInterceptor(
}

/** Extracts headers from OkHttp Headers object into a map */
private fun okhttp3.Headers.toMap(): Map<String, String> {
@VisibleForTesting
internal fun okhttp3.Headers.toMap(): Map<String, String> {
val headers = linkedMapOf<String, String>()
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -425,6 +426,34 @@ class SentryOkHttpEventTest {
verify(fixture.scopes, never()).captureEvent(any(), any<Hint>())
}

@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<Breadcrumb>(),
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<Breadcrumb>(),
check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) },
)
}

/** Retrieve all the spans started in the event using reflection. */
private fun SentryOkHttpEvent.getEventDates() =
getProperty<MutableMap<String, SentryDate>>("eventDates")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
18 changes: 9 additions & 9 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -3801,11 +3801,11 @@ public final class io/sentry/SentryReplayOptions {
public fun getFrameRate ()I
public fun getMaskViewClasses ()Ljava/util/Set;
public fun getMaskViewContainerClass ()Ljava/lang/String;
public fun getNetworkDetailAllowUrls ()[Ljava/lang/String;
public fun getNetworkDetailDenyUrls ()[Ljava/lang/String;
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
public fun getNetworkDetailDenyUrls ()Ljava/util/List;
public static fun getNetworkDetailsDefaultHeaders ()Ljava/util/List;
public fun getNetworkRequestHeaders ()[Ljava/lang/String;
public fun getNetworkResponseHeaders ()[Ljava/lang/String;
public fun getNetworkRequestHeaders ()Ljava/util/List;
public fun getNetworkResponseHeaders ()Ljava/util/List;
public fun getOnErrorSampleRate ()Ljava/lang/Double;
public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality;
public fun getScreenshotStrategy ()Lio/sentry/ScreenshotStrategyType;
Expand All @@ -3825,8 +3825,8 @@ public final class io/sentry/SentryReplayOptions {
public fun setMaskAllText (Z)V
public fun setMaskViewContainerClass (Ljava/lang/String;)V
public fun setNetworkCaptureBodies (Z)V
public fun setNetworkDetailAllowUrls ([Ljava/lang/String;)V
public fun setNetworkDetailDenyUrls ([Ljava/lang/String;)V
public fun setNetworkDetailAllowUrls (Ljava/util/List;)V
public fun setNetworkDetailDenyUrls (Ljava/util/List;)V
public fun setNetworkRequestHeaders (Ljava/util/List;)V
public fun setNetworkResponseHeaders (Ljava/util/List;)V
public fun setOnErrorSampleRate (Ljava/lang/Double;)V
Expand Down Expand Up @@ -7496,9 +7496,9 @@ public final class io/sentry/util/network/NetworkBodyParser {
}

public final class io/sentry/util/network/NetworkDetailCaptureUtils {
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;
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;
public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;)Lio/sentry/util/network/NetworkRequestData;
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;
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;
public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData;
}

public abstract interface class io/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor {
Expand Down
Loading
Loading