Skip to content

Commit ac72abc

Browse files
feat(replay): Add network breadcrumbs (#3912)
1 parent 38d8d7c commit ac72abc

File tree

13 files changed

+445
-60
lines changed

13 files changed

+445
-60
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Improve touch event component info if annotated with [`@sentry/babel-plugin-component-annotate`](https://www.npmjs.com/package/@sentry/babel-plugin-component-annotate) ([#3899](https://github.com/getsentry/sentry-react-native/pull/3899))
88
- Add replay breadcrumbs for touch & navigation events ([#3846](https://github.com/getsentry/sentry-react-native/pull/3846))
9+
- Add network data to Session Replays ([#3912](https://github.com/getsentry/sentry-react-native/pull/3912))
910

1011
### Dependencies
1112

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

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,71 +4,131 @@
44
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter;
55
import io.sentry.rrweb.RRWebEvent;
66
import io.sentry.rrweb.RRWebBreadcrumbEvent;
7+
import io.sentry.rrweb.RRWebSpanEvent;
8+
79
import java.util.ArrayList;
810
import java.util.HashMap;
911
import org.jetbrains.annotations.NotNull;
1012
import org.jetbrains.annotations.Nullable;
13+
import org.jetbrains.annotations.TestOnly;
14+
15+
import java.util.HashMap;
1116

1217
public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadcrumbConverter {
1318
public RNSentryReplayBreadcrumbConverter() {
1419
}
1520

1621
@Override
1722
public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) {
18-
RRWebBreadcrumbEvent rrwebBreadcrumb = new RRWebBreadcrumbEvent();
19-
assert rrwebBreadcrumb.getCategory() == null;
23+
if (breadcrumb.getCategory() == null) {
24+
return null;
25+
}
2026

2127
if (breadcrumb.getCategory().equals("touch")) {
22-
rrwebBreadcrumb.setCategory("ui.tap");
23-
ArrayList path = (ArrayList) breadcrumb.getData("path");
24-
if (path != null) {
25-
StringBuilder message = new StringBuilder();
26-
for (int i = Math.min(3, path.size()); i >= 0; i--) {
27-
HashMap item = (HashMap) path.get(i);
28-
message.append(item.get("name"));
29-
if (item.containsKey("element") || item.containsKey("file")) {
30-
message.append('(');
31-
if (item.containsKey("element")) {
32-
message.append(item.get("element"));
33-
if (item.containsKey("file")) {
34-
message.append(", ");
35-
message.append(item.get("file"));
36-
}
37-
} else if (item.containsKey("file")) {
38-
message.append(item.get("file"));
39-
}
40-
message.append(')');
41-
}
42-
if (i > 0) {
43-
message.append(" > ");
44-
}
45-
}
46-
rrwebBreadcrumb.setMessage(message.toString());
47-
}
48-
rrwebBreadcrumb.setData(breadcrumb.getData());
49-
} else if (breadcrumb.getCategory().equals("navigation")) {
50-
rrwebBreadcrumb.setCategory(breadcrumb.getCategory());
51-
rrwebBreadcrumb.setData(breadcrumb.getData());
28+
return convertTouchBreadcrumb(breadcrumb);
5229
}
53-
54-
if (rrwebBreadcrumb.getCategory() != null && !rrwebBreadcrumb.getCategory().isEmpty()) {
55-
rrwebBreadcrumb.setLevel(breadcrumb.getLevel());
56-
rrwebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime());
57-
rrwebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0);
58-
rrwebBreadcrumb.setBreadcrumbType("default");
59-
return rrwebBreadcrumb;
30+
if (breadcrumb.getCategory().equals("navigation")) {
31+
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();
32+
rrWebBreadcrumb.setCategory(breadcrumb.getCategory());
33+
rrWebBreadcrumb.setData(breadcrumb.getData());
34+
return rrWebBreadcrumb;
35+
}
36+
if (breadcrumb.getCategory().equals("xhr")) {
37+
return convertNetworkBreadcrumb(breadcrumb);
38+
}
39+
if (breadcrumb.getCategory().equals("http")) {
40+
// Drop native http breadcrumbs to avoid duplicates
41+
return null;
6042
}
6143

6244
RRWebEvent nativeBreadcrumb = super.convert(breadcrumb);
6345

6446
// ignore native navigation breadcrumbs
6547
if (nativeBreadcrumb instanceof RRWebBreadcrumbEvent) {
66-
rrwebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb;
67-
if (rrwebBreadcrumb.getCategory() != null && rrwebBreadcrumb.getCategory().equals("navigation")) {
48+
final RRWebBreadcrumbEvent rrWebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb;
49+
if (rrWebBreadcrumb.getCategory() != null && rrWebBreadcrumb.getCategory().equals("navigation")) {
6850
return null;
6951
}
7052
}
7153

7254
return nativeBreadcrumb;
7355
}
56+
57+
@TestOnly
58+
public @NotNull RRWebEvent convertTouchBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
59+
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();
60+
61+
rrWebBreadcrumb.setCategory("ui.tap");
62+
ArrayList path = (ArrayList) breadcrumb.getData("path");
63+
if (path != null) {
64+
StringBuilder message = new StringBuilder();
65+
for (int i = Math.min(3, path.size()); i >= 0; i--) {
66+
HashMap item = (HashMap) path.get(i);
67+
message.append(item.get("name"));
68+
if (item.containsKey("element") || item.containsKey("file")) {
69+
message.append('(');
70+
if (item.containsKey("element")) {
71+
message.append(item.get("element"));
72+
if (item.containsKey("file")) {
73+
message.append(", ");
74+
message.append(item.get("file"));
75+
}
76+
} else if (item.containsKey("file")) {
77+
message.append(item.get("file"));
78+
}
79+
message.append(')');
80+
}
81+
if (i > 0) {
82+
message.append(" > ");
83+
}
84+
}
85+
rrWebBreadcrumb.setMessage(message.toString());
86+
}
87+
88+
rrWebBreadcrumb.setLevel(breadcrumb.getLevel());
89+
rrWebBreadcrumb.setData(breadcrumb.getData());
90+
rrWebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime());
91+
rrWebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0);
92+
rrWebBreadcrumb.setBreadcrumbType("default");
93+
return rrWebBreadcrumb;
94+
}
95+
96+
@TestOnly
97+
public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
98+
final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number
99+
? (Double) breadcrumb.getData("start_timestamp") : null;
100+
final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number
101+
? (Double) breadcrumb.getData("end_timestamp") : null;
102+
final String url = breadcrumb.getData("url") instanceof String
103+
? (String) breadcrumb.getData("url") : null;
104+
105+
if (startTimestamp == null || endTimestamp == null || url == null) {
106+
return null;
107+
}
108+
109+
final HashMap<String, Object> data = new HashMap<>();
110+
if (breadcrumb.getData("method") instanceof String) {
111+
data.put("method", breadcrumb.getData("method"));
112+
}
113+
if (breadcrumb.getData("status_code") instanceof Double) {
114+
final Double statusCode = (Double) breadcrumb.getData("status_code");
115+
if (statusCode > 0) {
116+
data.put("statusCode", statusCode.intValue());
117+
}
118+
}
119+
if (breadcrumb.getData("request_body_size") instanceof Double) {
120+
data.put("requestBodySize", breadcrumb.getData("request_body_size"));
121+
}
122+
if (breadcrumb.getData("response_body_size") instanceof Double) {
123+
data.put("responseBodySize", breadcrumb.getData("response_body_size"));
124+
}
125+
126+
final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent();
127+
rrWebSpanEvent.setOp("resource.http");
128+
rrWebSpanEvent.setStartTimestamp(startTimestamp / 1000.0);
129+
rrWebSpanEvent.setEndTimestamp(endTimestamp / 1000.0);
130+
rrWebSpanEvent.setDescription(url);
131+
rrWebSpanEvent.setData(data);
132+
return rrWebSpanEvent;
133+
}
74134
}

ios/RNSentryReplayBreadcrumbConverter.m

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ - (instancetype _Nonnull)init {
2020
(SentryBreadcrumb *_Nonnull)breadcrumb {
2121
assert(breadcrumb.timestamp != nil);
2222

23+
if ([breadcrumb.category isEqualToString:@"http"]) {
24+
// Drop native network breadcrumbs to avoid duplicates
25+
return nil;
26+
}
27+
if ([breadcrumb.type isEqualToString:@"navigation"] && ![breadcrumb.category isEqualToString:@"navigation"]) {
28+
// Drop native navigation breadcrumbs to avoid duplicates
29+
return nil;
30+
}
31+
2332
if ([breadcrumb.category isEqualToString:@"touch"]) {
2433
NSMutableString *message;
2534
if (breadcrumb.data) {
@@ -54,28 +63,70 @@ - (instancetype _Nonnull)init {
5463
message:message
5564
level:breadcrumb.level
5665
data:breadcrumb.data];
57-
} else if ([breadcrumb.category isEqualToString:@"navigation"]) {
66+
}
67+
68+
if ([breadcrumb.category isEqualToString:@"navigation"]) {
5869
return [SentrySessionReplayIntegration
5970
createBreadcrumbwithTimestamp:breadcrumb.timestamp
6071
category:breadcrumb.category
6172
message:nil
6273
level:breadcrumb.level
6374
data:breadcrumb.data];
64-
} else {
65-
SentryRRWebEvent *nativeBreadcrumb =
66-
[self->defaultConverter convertFrom:breadcrumb];
67-
68-
// ignore native navigation breadcrumbs
69-
if (nativeBreadcrumb && nativeBreadcrumb.data &&
70-
nativeBreadcrumb.data[@"payload"] &&
71-
nativeBreadcrumb.data[@"payload"][@"category"] &&
72-
[nativeBreadcrumb.data[@"payload"][@"category"]
73-
isEqualToString:@"navigation"]) {
74-
return nil;
75-
}
76-
return nativeBreadcrumb;
7775
}
76+
77+
if ([breadcrumb.category isEqualToString:@"xhr"]) {
78+
return [self convertNavigation:breadcrumb];
79+
}
80+
81+
SentryRRWebEvent *nativeBreadcrumb =
82+
[self->defaultConverter convertFrom:breadcrumb];
83+
84+
// ignore native navigation breadcrumbs
85+
if (nativeBreadcrumb && nativeBreadcrumb.data &&
86+
nativeBreadcrumb.data[@"payload"] &&
87+
nativeBreadcrumb.data[@"payload"][@"category"] &&
88+
[nativeBreadcrumb.data[@"payload"][@"category"]
89+
isEqualToString:@"navigation"]) {
90+
return nil;
91+
}
92+
93+
return nativeBreadcrumb;
94+
}
95+
96+
- (id<SentryRRWebEvent> _Nullable)convertNavigation: (SentryBreadcrumb *_Nonnull)breadcrumb {
97+
NSNumber* startTimestamp = [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]]
98+
? breadcrumb.data[@"start_timestamp"] : nil;
99+
NSNumber* endTimestamp = [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]]
100+
? breadcrumb.data[@"end_timestamp"] : nil;
101+
NSString* url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]]
102+
? breadcrumb.data[@"url"] : nil;
103+
104+
if (startTimestamp == nil || endTimestamp == nil || url == nil) {
105+
return nil;
106+
}
107+
108+
NSMutableDictionary* data = [[NSMutableDictionary alloc] init];
109+
if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) {
110+
data[@"method"] = breadcrumb.data[@"method"];
111+
}
112+
if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) {
113+
data[@"statusCode"] = breadcrumb.data[@"status_code"];
114+
}
115+
if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) {
116+
data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"];
117+
}
118+
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
119+
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
120+
}
121+
122+
return [SentrySessionReplayIntegration
123+
createNetworkBreadcrumbWithTimestamp:[NSDate dateWithTimeIntervalSince1970:(startTimestamp.doubleValue / 1000)]
124+
endTimestamp:[NSDate dateWithTimeIntervalSince1970:(endTimestamp.doubleValue / 1000)]
125+
operation:@"resource.http"
126+
description:url
127+
data:data];
78128
}
79129

80130
@end
131+
81132
#endif

src/js/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
1616
import { Alert } from 'react-native';
1717

1818
import { createIntegration } from './integrations/factory';
19-
import type { mobileReplayIntegration } from './integrations/mobilereplay';
20-
import { MOBILE_REPLAY_INTEGRATION_NAME } from './integrations/mobilereplay';
2119
import { defaultSdkInfo } from './integrations/sdkinfo';
2220
import type { ReactNativeClientOptions } from './options';
21+
import type { mobileReplayIntegration } from './replay/mobilereplay';
22+
import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
2323
import { ReactNativeTracing } from './tracing';
2424
import { createUserFeedbackEnvelope, items } from './utils/envelope';
2525
import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs';

src/js/integrations/exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export { screenshotIntegration } from './screenshot';
1212
export { viewHierarchyIntegration } from './viewhierarchy';
1313
export { expoContextIntegration } from './expocontext';
1414
export { spotlightIntegration } from './spotlight';
15-
export { mobileReplayIntegration } from './mobilereplay';
15+
export { mobileReplayIntegration } from '../replay/mobilereplay';
1616

1717
export {
1818
breadcrumbsIntegration,

src/js/integrations/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ export { Screenshot } from './screenshot';
1414
export { ViewHierarchy } from './viewhierarchy';
1515
export { ExpoContext } from './expocontext';
1616
export { Spotlight } from './spotlight';
17-
export { mobileReplayIntegration } from './mobilereplay';
17+
export { mobileReplayIntegration } from '../replay/mobilereplay';

src/js/integrations/mobilereplay.ts renamed to src/js/replay/mobilereplay.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isHardCrash } from '../misc';
55
import { hasHooks } from '../utils/clientutils';
66
import { isExpoGo, notMobileOs } from '../utils/environment';
77
import { NATIVE } from '../wrapper';
8+
import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils';
89

910
export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay';
1011

@@ -103,6 +104,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
103104
dsc.replay_id = currentReplayId;
104105
}
105106
});
107+
108+
client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay);
106109
}
107110

108111
// TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably

src/js/replay/networkUtils.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
2+
import { utf8ToBytes } from '../vendor';
3+
4+
/** Convert a Content-Length header to number/undefined. */
5+
export function parseContentLengthHeader(header: string | null | undefined): number | undefined {
6+
if (!header) {
7+
return undefined;
8+
}
9+
10+
const size = parseInt(header, 10);
11+
return isNaN(size) ? undefined : size;
12+
}
13+
14+
export type RequestBody = null | Blob | FormData | URLSearchParams | string | ArrayBuffer | undefined;
15+
16+
/** Get the size of a body. */
17+
export function getBodySize(body: RequestBody): number | undefined {
18+
if (!body) {
19+
return undefined;
20+
}
21+
22+
try {
23+
if (typeof body === 'string') {
24+
return _encode(body).length;
25+
}
26+
27+
if (body instanceof URLSearchParams) {
28+
return _encode(body.toString()).length;
29+
}
30+
31+
if (body instanceof FormData) {
32+
const formDataStr = _serializeFormData(body);
33+
return _encode(formDataStr).length;
34+
}
35+
36+
if (body instanceof Blob) {
37+
return body.size;
38+
}
39+
40+
if (body instanceof ArrayBuffer) {
41+
return body.byteLength;
42+
}
43+
44+
// Currently unhandled types: ArrayBufferView, ReadableStream
45+
} catch {
46+
// just return undefined
47+
}
48+
49+
return undefined;
50+
}
51+
52+
function _encode(input: string): number[] | Uint8Array {
53+
if (RN_GLOBAL_OBJ.TextEncoder) {
54+
return new RN_GLOBAL_OBJ.TextEncoder().encode(input);
55+
}
56+
return utf8ToBytes(input);
57+
}
58+
59+
function _serializeFormData(formData: FormData): string {
60+
// This is a bit simplified, but gives us a decent estimate
61+
// This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13'
62+
// @ts-expect-error passing FormData to URLSearchParams won't correctly serialize `File` entries, which is fine for this use-case. See https://github.com/microsoft/TypeScript/issues/30584
63+
return new URLSearchParams(formData).toString();
64+
}

0 commit comments

Comments
 (0)