Skip to content

Commit 260b7d4

Browse files
committed
chore: merge main into sample-apps-rn-0.86.0 branch
2 parents 26e09d6 + 293621c commit 260b7d4

18 files changed

Lines changed: 1075 additions & 253 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Features
1212

1313
- 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))
14+
- 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))
1415
- Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269))
1516

1617
### Dependencies

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,14 @@
101101
"glob@npm:13.0.0/minimatch": "^10.2.3",
102102
"axios": "^1.16.0",
103103
"fast-xml-parser": "^5.7.0",
104-
"form-data": "4.0.5",
104+
"form-data": "4.0.6",
105105
"qs": "^6.15.2",
106106
"lodash": "^4.18.1",
107107
"tar-fs": "^3.1.1",
108108
"basic-ftp": "^5.3.1",
109109
"on-headers": "^1.1.0",
110110
"diff": "^5.2.2",
111-
"tar": "^7.5.11",
111+
"tar": "^7.5.16",
112112
"tmp": "^0.2.4",
113113
"@expo/cli@npm:0.24.11/picomatch": "^3.0.2",
114114
"@expo/cli@npm:55.0.15/picomatch": "^4.0.4",
@@ -133,7 +133,8 @@
133133
"postcss": "^8.5.10",
134134
"socks": "^2.8.8",
135135
"@appium/support@npm:7.0.6/uuid": "^13.0.1",
136-
"node-simctl@npm:8.1.6/uuid": "^13.0.1"
136+
"node-simctl@npm:8.1.6/uuid": "^13.0.1",
137+
"shell-quote": "^1.8.4"
137138
},
138139
"version": "0.0.0",
139140
"name": "sentry-react-native",

packages/core/.oxlintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"jsPlugins": [
55
{
66
"name": "sdk",
7-
"specifier": "@sentry-internal/eslint-plugin-sdk"
7+
"specifier": "@sentry/eslint-plugin-sdk"
88
}
99
],
1010
"categories": {},

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import io.sentry.Breadcrumb
44
import io.sentry.SentryLevel
55
import io.sentry.react.RNSentryReplayBreadcrumbConverter
66
import io.sentry.rrweb.RRWebBreadcrumbEvent
7+
import io.sentry.rrweb.RRWebSpanEvent
78
import org.junit.Assert.assertEquals
89
import org.junit.Assert.assertNotNull
10+
import org.junit.Assert.assertNull
11+
import org.junit.Assert.assertTrue
912
import org.junit.Test
1013
import org.junit.runner.RunWith
1114
import org.junit.runners.JUnit4
@@ -247,6 +250,93 @@ class RNSentryReplayBreadcrumbConverterTest {
247250
assertEquals("label5(element5, file5) > label4(file4) > label3(element3) > label2", actual)
248251
}
249252

253+
@Test
254+
fun convertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() {
255+
val converter = RNSentryReplayBreadcrumbConverter()
256+
val testBreadcrumb = Breadcrumb()
257+
testBreadcrumb.category = "xhr"
258+
testBreadcrumb.setData("url", "https://api.example.com/users")
259+
testBreadcrumb.setData("method", "POST")
260+
testBreadcrumb.setData("start_timestamp", 1_000.0)
261+
testBreadcrumb.setData("end_timestamp", 2_000.0)
262+
testBreadcrumb.setData(
263+
"request",
264+
mapOf(
265+
"body" to "{\"hello\":\"world\"}",
266+
"headers" to mapOf("content-type" to "application/json"),
267+
"_meta" to mapOf("warnings" to listOf("MAX_BODY_SIZE_EXCEEDED")),
268+
),
269+
)
270+
testBreadcrumb.setData(
271+
"response",
272+
mapOf(
273+
"body" to "[UNPARSEABLE_BODY_TYPE]",
274+
"_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE")),
275+
),
276+
)
277+
278+
val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
279+
val data = actual.data!!
280+
281+
@Suppress("UNCHECKED_CAST")
282+
val request = data["request"] as Map<Any, Any>
283+
assertEquals("{\"hello\":\"world\"}", request["body"])
284+
assertEquals(mapOf("content-type" to "application/json"), request["headers"])
285+
assertNull("_meta must be stripped before forwarding to native rrweb", request["_meta"])
286+
287+
@Suppress("UNCHECKED_CAST")
288+
val response = data["response"] as Map<Any, Any>
289+
assertEquals("[UNPARSEABLE_BODY_TYPE]", response["body"])
290+
assertNull(response["_meta"])
291+
}
292+
293+
@Test
294+
fun convertNetworkBreadcrumbAcceptsNonDoubleNumberFields() {
295+
val converter = RNSentryReplayBreadcrumbConverter()
296+
val testBreadcrumb = Breadcrumb()
297+
testBreadcrumb.category = "xhr"
298+
testBreadcrumb.setData("url", "https://api.example.com/users")
299+
// RN bridge may surface numeric breadcrumb data as Long/Integer rather than
300+
// Double; the converter must accept all Number subtypes without crashing or
301+
// silently dropping the field.
302+
testBreadcrumb.setData("start_timestamp", 1_000L)
303+
testBreadcrumb.setData("end_timestamp", 2_000)
304+
testBreadcrumb.setData("status_code", 201L)
305+
testBreadcrumb.setData("request_body_size", 42)
306+
testBreadcrumb.setData("response_body_size", 100L)
307+
308+
val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
309+
assertEquals(1.0, actual.startTimestamp, 0.001)
310+
assertEquals(2.0, actual.endTimestamp, 0.001)
311+
val data = actual.data!!
312+
assertEquals(201, data["statusCode"])
313+
assertEquals(42.0, data["requestBodySize"])
314+
assertEquals(100.0, data["responseBodySize"])
315+
}
316+
317+
@Test
318+
fun convertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() {
319+
val converter = RNSentryReplayBreadcrumbConverter()
320+
val testBreadcrumb = Breadcrumb()
321+
testBreadcrumb.category = "xhr"
322+
testBreadcrumb.setData("url", "https://api.example.com/users")
323+
testBreadcrumb.setData("start_timestamp", 1_000.0)
324+
testBreadcrumb.setData("end_timestamp", 2_000.0)
325+
// Request side contains only `_meta` — once stripped, nothing remains.
326+
testBreadcrumb.setData(
327+
"request",
328+
mapOf("_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE"))),
329+
)
330+
// Response side is not a map (or missing) — should also be dropped.
331+
testBreadcrumb.setData("response", "not-a-map")
332+
333+
val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent
334+
val data = actual.data!!
335+
336+
assertTrue("empty-after-strip request side must be omitted", !data.containsKey("request"))
337+
assertTrue("non-map response side must be omitted", !data.containsKey("response"))
338+
}
339+
250340
private fun assertRRWebBreadcrumbDefaults(actual: RRWebBreadcrumbEvent) {
251341
assertEquals("default", actual.breadcrumbType)
252342
assertEquals(actual.breadcrumbTimestamp * 1000, actual.timestamp.toDouble(), 0.05)

packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,72 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase {
232232
XCTAssertEqual(actual, "label5(element5, file5) > label4(file4) > label3(element3) > label2")
233233
}
234234

235+
func testConvertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() {
236+
let converter = RNSentryReplayBreadcrumbConverter()
237+
let testBreadcrumb = Breadcrumb()
238+
testBreadcrumb.timestamp = Date()
239+
testBreadcrumb.category = "xhr"
240+
testBreadcrumb.data = [
241+
"url": "https://api.example.com/users",
242+
"method": "POST",
243+
"start_timestamp": NSNumber(value: 1_000.0),
244+
"end_timestamp": NSNumber(value: 2_000.0),
245+
"request": [
246+
"body": "{\"hello\":\"world\"}",
247+
"headers": ["content-type": "application/json"],
248+
"_meta": ["warnings": ["MAX_BODY_SIZE_EXCEEDED"]]
249+
],
250+
"response": [
251+
"body": "[UNPARSEABLE_BODY_TYPE]",
252+
"_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]]
253+
]
254+
]
255+
256+
let actual = converter.convert(from: testBreadcrumb)
257+
XCTAssertNotNil(actual)
258+
let event = actual!.serialize()
259+
let eventData = event["data"] as! [String: Any?]
260+
let payload = eventData["payload"] as! [String: Any?]
261+
let data = payload["data"] as! [String: Any?]
262+
263+
let request = data["request"] as! [String: Any]
264+
XCTAssertEqual("{\"hello\":\"world\"}", request["body"] as! String)
265+
XCTAssertEqual(["content-type": "application/json"], request["headers"] as! [String: String])
266+
XCTAssertNil(request["_meta"], "_meta must be stripped before forwarding to native rrweb")
267+
268+
let response = data["response"] as! [String: Any]
269+
XCTAssertEqual("[UNPARSEABLE_BODY_TYPE]", response["body"] as! String)
270+
XCTAssertNil(response["_meta"])
271+
}
272+
273+
func testConvertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() {
274+
let converter = RNSentryReplayBreadcrumbConverter()
275+
let testBreadcrumb = Breadcrumb()
276+
testBreadcrumb.timestamp = Date()
277+
testBreadcrumb.category = "xhr"
278+
testBreadcrumb.data = [
279+
"url": "https://api.example.com/users",
280+
"start_timestamp": NSNumber(value: 1_000.0),
281+
"end_timestamp": NSNumber(value: 2_000.0),
282+
// Request side contains only `_meta` — once stripped, nothing remains.
283+
"request": [
284+
"_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]]
285+
],
286+
// Response side is not a dict — should also be dropped.
287+
"response": "not-a-dict"
288+
]
289+
290+
let actual = converter.convert(from: testBreadcrumb)
291+
XCTAssertNotNil(actual)
292+
let event = actual!.serialize()
293+
let eventData = event["data"] as! [String: Any?]
294+
let payload = eventData["payload"] as! [String: Any?]
295+
let data = payload["data"] as! [String: Any?]
296+
297+
XCTAssertNil(data["request"] ?? nil, "empty-after-strip request side must be omitted")
298+
XCTAssertNil(data["response"] ?? nil, "non-dict response side must be omitted")
299+
}
300+
235301
private func assertRRWebBreadcrumbDefaults(actual: [String: Any?]) {
236302
let data = actual["data"] as! [String: Any?]
237303
let payload = data["payload"] as! [String: Any?]

packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,16 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
144144

145145
@TestOnly
146146
public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
147+
// Use Number.doubleValue() rather than a direct (Double) cast: the RN bridge can
148+
// surface timestamps as Long/Integer, which pass `instanceof Number` but would
149+
// throw `ClassCastException` on a direct cast to Double.
147150
final Double startTimestamp =
148151
breadcrumb.getData("start_timestamp") instanceof Number
149-
? (Double) breadcrumb.getData("start_timestamp")
152+
? ((Number) breadcrumb.getData("start_timestamp")).doubleValue()
150153
: null;
151154
final Double endTimestamp =
152155
breadcrumb.getData("end_timestamp") instanceof Number
153-
? (Double) breadcrumb.getData("end_timestamp")
156+
? ((Number) breadcrumb.getData("end_timestamp")).doubleValue()
154157
: null;
155158
final String url =
156159
breadcrumb.getData("url") instanceof String ? (String) breadcrumb.getData("url") : null;
@@ -163,17 +166,26 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
163166
if (breadcrumb.getData("method") instanceof String) {
164167
data.put("method", breadcrumb.getData("method"));
165168
}
166-
if (breadcrumb.getData("status_code") instanceof Double) {
167-
final Double statusCode = (Double) breadcrumb.getData("status_code");
169+
// Accept any Number subtype (Double/Long/Integer) — the RN bridge does not guarantee Double.
170+
if (breadcrumb.getData("status_code") instanceof Number) {
171+
final int statusCode = ((Number) breadcrumb.getData("status_code")).intValue();
168172
if (statusCode > 0) {
169-
data.put("statusCode", statusCode.intValue());
173+
data.put("statusCode", statusCode);
170174
}
171175
}
172-
if (breadcrumb.getData("request_body_size") instanceof Double) {
173-
data.put("requestBodySize", breadcrumb.getData("request_body_size"));
176+
if (breadcrumb.getData("request_body_size") instanceof Number) {
177+
data.put("requestBodySize", ((Number) breadcrumb.getData("request_body_size")).doubleValue());
174178
}
175-
if (breadcrumb.getData("response_body_size") instanceof Double) {
176-
data.put("responseBodySize", breadcrumb.getData("response_body_size"));
179+
if (breadcrumb.getData("response_body_size") instanceof Number) {
180+
data.put("responseBodySize", ((Number) breadcrumb.getData("response_body_size")).doubleValue());
181+
}
182+
final Map<Object, Object> requestSide = sanitizeNetworkSide(breadcrumb.getData("request"));
183+
if (!requestSide.isEmpty()) {
184+
data.put("request", requestSide);
185+
}
186+
final Map<Object, Object> responseSide = sanitizeNetworkSide(breadcrumb.getData("response"));
187+
if (!responseSide.isEmpty()) {
188+
data.put("response", responseSide);
177189
}
178190

179191
final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent();
@@ -185,6 +197,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
185197
return rrWebSpanEvent;
186198
}
187199

200+
/**
201+
* Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` warnings field
202+
* so it does not leak into the native rrweb span event. Returns an empty map when the input is
203+
* not a Map or has no remaining fields.
204+
*/
205+
private @NotNull Map<Object, Object> sanitizeNetworkSide(final @Nullable Object raw) {
206+
if (!(raw instanceof Map)) {
207+
return new HashMap<>();
208+
}
209+
final Map<?, ?> source = (Map<?, ?>) raw;
210+
final Map<Object, Object> out = new HashMap<>(source);
211+
out.remove("_meta");
212+
return out;
213+
}
214+
188215
private void setRRWebEventDefaultsFrom(
189216
final @NotNull RRWebBreadcrumbEvent rrWebBreadcrumb, final @NotNull Breadcrumb breadcrumb) {
190217
rrWebBreadcrumb.setLevel(breadcrumb.getLevel());

packages/core/ios/RNSentryReplayBreadcrumbConverter.m

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
179179
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
180180
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
181181
}
182+
NSDictionary *requestSide = [self sanitizeNetworkSide:breadcrumb.data[@"request"]];
183+
if (requestSide != nil) {
184+
data[@"request"] = requestSide;
185+
}
186+
NSDictionary *responseSide = [self sanitizeNetworkSide:breadcrumb.data[@"response"]];
187+
if (responseSide != nil) {
188+
data[@"response"] = responseSide;
189+
}
182190

183191
return [SentrySessionReplayHybridSDK
184192
createNetworkBreadcrumbWithTimestamp:[NSDate
@@ -194,6 +202,18 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
194202
data:data];
195203
}
196204

205+
// Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta`
206+
// warnings field so it does not leak into the native rrweb span event.
207+
- (NSDictionary *_Nullable)sanitizeNetworkSide:(id _Nullable)raw
208+
{
209+
if (![raw isKindOfClass:[NSDictionary class]]) {
210+
return nil;
211+
}
212+
NSMutableDictionary *out = [(NSDictionary *)raw mutableCopy];
213+
[out removeObjectForKey:@"_meta"];
214+
return out.count > 0 ? [out copy] : nil;
215+
}
216+
197217
@end
198218

199219
#endif

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
"@microsoft/api-extractor": "^7.58.7",
8787
"@mswjs/interceptors": "^0.25.15",
8888
"@react-native/babel-preset": "0.80.0",
89-
"@sentry-internal/eslint-plugin-sdk": "10.57.0",
90-
"@sentry-internal/typescript": "10.57.0",
89+
"@sentry/eslint-plugin-sdk": "10.58.0",
90+
"@sentry/typescript": "10.58.0",
9191
"@sentry/wizard": "6.12.0",
9292
"@testing-library/react-native": "^13.2.2",
9393
"@types/jest": "^29.5.13",

0 commit comments

Comments
 (0)