Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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());`
- Detect oversized events and reduce their size ([#4903](https://github.com/getsentry/sentry-java/pull/4903))
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 @@ -209,6 +209,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
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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -115,19 +118,24 @@ private static boolean shouldCaptureUrl(
return false;
}

private static @NotNull Map<String, String> getCaptureHeaders(
@VisibleForTesting
static @NotNull Map<String, String> getCaptureHeaders(
@Nullable final Map<String, String> allHeaders, @NotNull final String[] allowedHeaders) {

Map<String, String> capturedHeaders = new HashMap<>();

if (allHeaders == null) {
final Map<String, String> capturedHeaders = new LinkedHashMap<>();
if (allHeaders == null || allowedHeaders.length == 0) {
return capturedHeaders;
}

// Convert to lowercase for case-insensitive matching
Set<String> normalizedAllowed = new HashSet<>();
for (String header : allowedHeaders) {
String value = allHeaders.get(header);
if (value != null) {
capturedHeaders.put(header, value);
normalizedAllowed.add(header.toLowerCase());
}

for (Map.Entry<String, String> entry : allHeaders.entrySet()) {
if (normalizedAllowed.contains(entry.getKey().toLowerCase())) {
capturedHeaders.put(entry.getKey(), entry.getValue());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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<String, String>().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<String>()

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"))
}
}
Loading