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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Features

- Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278))
- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288))
- Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269))

### Dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import io.sentry.Breadcrumb
import io.sentry.SentryLevel
import io.sentry.react.RNSentryReplayBreadcrumbConverter
import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebSpanEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
Expand Down Expand Up @@ -247,6 +250,93 @@ class RNSentryReplayBreadcrumbConverterTest {
assertEquals("label5(element5, file5) > label4(file4) > label3(element3) > label2", actual)
}

@Test
fun convertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() {
val converter = RNSentryReplayBreadcrumbConverter()
val testBreadcrumb = Breadcrumb()
testBreadcrumb.category = "xhr"
testBreadcrumb.setData("url", "https://api.example.com/users")
testBreadcrumb.setData("method", "POST")
testBreadcrumb.setData("start_timestamp", 1_000.0)
testBreadcrumb.setData("end_timestamp", 2_000.0)
testBreadcrumb.setData(
"request",
mapOf(
"body" to "{\"hello\":\"world\"}",
"headers" to mapOf("content-type" to "application/json"),
"_meta" to mapOf("warnings" to listOf("MAX_BODY_SIZE_EXCEEDED")),
),
)
testBreadcrumb.setData(
"response",
mapOf(
"body" to "[UNPARSEABLE_BODY_TYPE]",
"_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE")),
),
)

val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
val data = actual.data!!

@Suppress("UNCHECKED_CAST")
val request = data["request"] as Map<Any, Any>
assertEquals("{\"hello\":\"world\"}", request["body"])
assertEquals(mapOf("content-type" to "application/json"), request["headers"])
assertNull("_meta must be stripped before forwarding to native rrweb", request["_meta"])

@Suppress("UNCHECKED_CAST")
val response = data["response"] as Map<Any, Any>
assertEquals("[UNPARSEABLE_BODY_TYPE]", response["body"])
assertNull(response["_meta"])
}

@Test
fun convertNetworkBreadcrumbAcceptsNonDoubleNumberFields() {
val converter = RNSentryReplayBreadcrumbConverter()
val testBreadcrumb = Breadcrumb()
testBreadcrumb.category = "xhr"
testBreadcrumb.setData("url", "https://api.example.com/users")
// RN bridge may surface numeric breadcrumb data as Long/Integer rather than
// Double; the converter must accept all Number subtypes without crashing or
// silently dropping the field.
testBreadcrumb.setData("start_timestamp", 1_000L)
testBreadcrumb.setData("end_timestamp", 2_000)
testBreadcrumb.setData("status_code", 201L)
testBreadcrumb.setData("request_body_size", 42)
testBreadcrumb.setData("response_body_size", 100L)

val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
assertEquals(1.0, actual.startTimestamp, 0.001)
assertEquals(2.0, actual.endTimestamp, 0.001)
val data = actual.data!!
assertEquals(201, data["statusCode"])
assertEquals(42.0, data["requestBodySize"])
assertEquals(100.0, data["responseBodySize"])
}

@Test
fun convertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() {
val converter = RNSentryReplayBreadcrumbConverter()
val testBreadcrumb = Breadcrumb()
testBreadcrumb.category = "xhr"
testBreadcrumb.setData("url", "https://api.example.com/users")
testBreadcrumb.setData("start_timestamp", 1_000.0)
testBreadcrumb.setData("end_timestamp", 2_000.0)
// Request side contains only `_meta` โ€” once stripped, nothing remains.
testBreadcrumb.setData(
"request",
mapOf("_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE"))),
)
// Response side is not a map (or missing) โ€” should also be dropped.
testBreadcrumb.setData("response", "not-a-map")

val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
val data = actual.data!!

assertTrue("empty-after-strip request side must be omitted", !data.containsKey("request"))
assertTrue("non-map response side must be omitted", !data.containsKey("response"))
}

private fun assertRRWebBreadcrumbDefaults(actual: RRWebBreadcrumbEvent) {
assertEquals("default", actual.breadcrumbType)
assertEquals(actual.breadcrumbTimestamp * 1000, actual.timestamp.toDouble(), 0.05)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,72 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase {
XCTAssertEqual(actual, "label5(element5, file5) > label4(file4) > label3(element3) > label2")
}

func testConvertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() {
let converter = RNSentryReplayBreadcrumbConverter()
let testBreadcrumb = Breadcrumb()
testBreadcrumb.timestamp = Date()
testBreadcrumb.category = "xhr"
testBreadcrumb.data = [
"url": "https://api.example.com/users",
"method": "POST",
"start_timestamp": NSNumber(value: 1_000.0),
"end_timestamp": NSNumber(value: 2_000.0),
"request": [
"body": "{\"hello\":\"world\"}",
"headers": ["content-type": "application/json"],
"_meta": ["warnings": ["MAX_BODY_SIZE_EXCEEDED"]]
],
"response": [
"body": "[UNPARSEABLE_BODY_TYPE]",
"_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]]
]
]

let actual = converter.convert(from: testBreadcrumb)
XCTAssertNotNil(actual)
let event = actual!.serialize()
let eventData = event["data"] as! [String: Any?]
let payload = eventData["payload"] as! [String: Any?]
let data = payload["data"] as! [String: Any?]

let request = data["request"] as! [String: Any]
XCTAssertEqual("{\"hello\":\"world\"}", request["body"] as! String)
XCTAssertEqual(["content-type": "application/json"], request["headers"] as! [String: String])
XCTAssertNil(request["_meta"], "_meta must be stripped before forwarding to native rrweb")

let response = data["response"] as! [String: Any]
XCTAssertEqual("[UNPARSEABLE_BODY_TYPE]", response["body"] as! String)
XCTAssertNil(response["_meta"])
}

func testConvertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() {
let converter = RNSentryReplayBreadcrumbConverter()
let testBreadcrumb = Breadcrumb()
testBreadcrumb.timestamp = Date()
testBreadcrumb.category = "xhr"
testBreadcrumb.data = [
"url": "https://api.example.com/users",
"start_timestamp": NSNumber(value: 1_000.0),
"end_timestamp": NSNumber(value: 2_000.0),
// Request side contains only `_meta` โ€” once stripped, nothing remains.
"request": [
"_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]]
],
// Response side is not a dict โ€” should also be dropped.
"response": "not-a-dict"
]

let actual = converter.convert(from: testBreadcrumb)
XCTAssertNotNil(actual)
let event = actual!.serialize()
let eventData = event["data"] as! [String: Any?]
let payload = eventData["payload"] as! [String: Any?]
let data = payload["data"] as! [String: Any?]

XCTAssertNil(data["request"] ?? nil, "empty-after-strip request side must be omitted")
XCTAssertNil(data["response"] ?? nil, "non-dict response side must be omitted")
}

private func assertRRWebBreadcrumbDefaults(actual: [String: Any?]) {
let data = actual["data"] as! [String: Any?]
let payload = data["payload"] as! [String: Any?]
Expand Down
Comment thread
alwx marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,16 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc

@TestOnly
public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
// Use Number.doubleValue() rather than a direct (Double) cast: the RN bridge can
// surface timestamps as Long/Integer, which pass `instanceof Number` but would
// throw `ClassCastException` on a direct cast to Double.
final Double startTimestamp =
breadcrumb.getData("start_timestamp") instanceof Number
? (Double) breadcrumb.getData("start_timestamp")
? ((Number) breadcrumb.getData("start_timestamp")).doubleValue()
: null;
final Double endTimestamp =
breadcrumb.getData("end_timestamp") instanceof Number
? (Double) breadcrumb.getData("end_timestamp")
? ((Number) breadcrumb.getData("end_timestamp")).doubleValue()
: null;
final String url =
breadcrumb.getData("url") instanceof String ? (String) breadcrumb.getData("url") : null;
Expand All @@ -163,17 +166,26 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
if (breadcrumb.getData("method") instanceof String) {
data.put("method", breadcrumb.getData("method"));
}
if (breadcrumb.getData("status_code") instanceof Double) {
final Double statusCode = (Double) breadcrumb.getData("status_code");
// Accept any Number subtype (Double/Long/Integer) โ€” the RN bridge does not guarantee Double.
if (breadcrumb.getData("status_code") instanceof Number) {
final int statusCode = ((Number) breadcrumb.getData("status_code")).intValue();
if (statusCode > 0) {
data.put("statusCode", statusCode.intValue());
data.put("statusCode", statusCode);
}
}
if (breadcrumb.getData("request_body_size") instanceof Double) {
data.put("requestBodySize", breadcrumb.getData("request_body_size"));
if (breadcrumb.getData("request_body_size") instanceof Number) {
data.put("requestBodySize", ((Number) breadcrumb.getData("request_body_size")).doubleValue());
}
if (breadcrumb.getData("response_body_size") instanceof Double) {
data.put("responseBodySize", breadcrumb.getData("response_body_size"));
if (breadcrumb.getData("response_body_size") instanceof Number) {
data.put("responseBodySize", ((Number) breadcrumb.getData("response_body_size")).doubleValue());
}
final Map<Object, Object> requestSide = sanitizeNetworkSide(breadcrumb.getData("request"));
if (!requestSide.isEmpty()) {
data.put("request", requestSide);
}
final Map<Object, Object> responseSide = sanitizeNetworkSide(breadcrumb.getData("response"));
if (!responseSide.isEmpty()) {
data.put("response", responseSide);
}

final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent();
Expand All @@ -185,6 +197,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
return rrWebSpanEvent;
}

/**
* Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` warnings field
* so it does not leak into the native rrweb span event. Returns an empty map when the input is
* not a Map or has no remaining fields.
*/
private @NotNull Map<Object, Object> sanitizeNetworkSide(final @Nullable Object raw) {
if (!(raw instanceof Map)) {
Comment thread
sentry[bot] marked this conversation as resolved.
return new HashMap<>();
}
final Map<?, ?> source = (Map<?, ?>) raw;
final Map<Object, Object> out = new HashMap<>(source);
out.remove("_meta");
return out;
Comment thread
alwx marked this conversation as resolved.
}

private void setRRWebEventDefaultsFrom(
final @NotNull RRWebBreadcrumbEvent rrWebBreadcrumb, final @NotNull Breadcrumb breadcrumb) {
rrWebBreadcrumb.setLevel(breadcrumb.getLevel());
Expand Down
20 changes: 20 additions & 0 deletions packages/core/ios/RNSentryReplayBreadcrumbConverter.m
Comment thread
alwx marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
}
NSDictionary *requestSide = [self sanitizeNetworkSide:breadcrumb.data[@"request"]];
if (requestSide != nil) {
data[@"request"] = requestSide;
}
NSDictionary *responseSide = [self sanitizeNetworkSide:breadcrumb.data[@"response"]];
if (responseSide != nil) {
data[@"response"] = responseSide;
}

return [SentrySessionReplayHybridSDK
createNetworkBreadcrumbWithTimestamp:[NSDate
Expand All @@ -194,6 +202,18 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
data:data];
}

// Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta`
// warnings field so it does not leak into the native rrweb span event.
- (NSDictionary *_Nullable)sanitizeNetworkSide:(id _Nullable)raw
{
if (![raw isKindOfClass:[NSDictionary class]]) {
return nil;
}
NSMutableDictionary *out = [(NSDictionary *)raw mutableCopy];
[out removeObjectForKey:@"_meta"];
return out.count > 0 ? [out copy] : nil;
}

@end

#endif
Loading
Loading