Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
66b14d8
touch events on android
vaind May 31, 2024
a9139c6
fix touchevent tests
vaind Jun 3, 2024
fb1bb90
Update android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumb…
vaind Jun 4, 2024
e725aa9
ios breadcrumb converter
vaind Jun 5, 2024
d5c8e67
delegate to default breadcrumb converter
vaind Jun 10, 2024
3d76fae
fix: module access
vaind Jun 11, 2024
9092169
Merge branch 'feat/replay' into feat/replay-breadcrumbs
vaind Jun 18, 2024
b4a1d8f
move replay stuff to RNSentrySessionReplay
vaind Jun 19, 2024
2ebf638
move setBreadcrumbConverter to init
vaind Jun 19, 2024
0f17b72
try to fix CI
vaind Jun 19, 2024
ae558fe
navigation breadcrumbs
vaind Jun 19, 2024
04c5c9d
try to fix CI
vaind Jun 19, 2024
6211bbf
pin xcode to 15.4 for e2e and sample app builds
krystofwoldrich Jun 20, 2024
96c7aab
chore: update cocoa breadcrumb converter to match latest changes in t…
vaind Jun 24, 2024
1a13827
more updates based on upstream changes
vaind Jun 24, 2024
f7f722f
add(replay): Add network breadcrumbs
krystofwoldrich Jun 24, 2024
73664a0
fix text encoder and add ios conversion
krystofwoldrich Jun 24, 2024
64482a2
fix cocoa conversion
krystofwoldrich Jun 24, 2024
1270935
add android conversion
krystofwoldrich Jun 24, 2024
af1d285
fix: data keys should be camel case
krystofwoldrich Jun 24, 2024
4150bea
fix export for tests
krystofwoldrich Jun 24, 2024
4d9d415
fix convertor rn convertor
krystofwoldrich Jun 25, 2024
b467133
refactor convertor, drop native navigation
krystofwoldrich Jun 25, 2024
5564c88
Revert "pin xcode to 15.4 for e2e and sample app builds"
krystofwoldrich Jun 25, 2024
8ce3ffb
Merge branch 'feat/replay-breadcrumbs' into kw/add-replay-network-bre…
krystofwoldrich Jun 25, 2024
db20918
add changelog
krystofwoldrich Jun 25, 2024
8d4f937
review changes
vaind Jun 25, 2024
272b4bb
fix changelog
vaind Jun 25, 2024
0a95f72
Merge branch 'feat/replay' into feat/replay-breadcrumbs
vaind Jun 26, 2024
30ed290
cleanup TODOs
vaind Jun 26, 2024
974961f
refactor replay network utils and add tests
krystofwoldrich Jun 26, 2024
89bf4ed
Merge remote-tracking branch 'origin/feat/replay-breadcrumbs' into kw…
krystofwoldrich Jun 26, 2024
f71fb66
touch event path for replay breadcrumbs
vaind Jun 26, 2024
2f2ff79
remove native navigation breadcrumbs on iOS
vaind Jun 26, 2024
dcc9753
renames
vaind Jun 26, 2024
a138db1
ignore native navigation breadcrumbs on android
vaind Jun 26, 2024
e3aaee8
fix android
vaind Jun 26, 2024
8c2383b
Merge remote-tracking branch 'origin/feat/replay-breadcrumbs' into kw…
krystofwoldrich Jun 26, 2024
56eb282
Merge remote-tracking branch 'origin/feat/replay' into kw/add-replay-…
krystofwoldrich Jun 27, 2024
ceb8122
Update src/js/replay/networkUtils.ts
krystofwoldrich Jun 27, 2024
18f51e1
Update src/js/replay/networkUtils.ts
krystofwoldrich Jun 27, 2024
2b5a2f4
fix java
krystofwoldrich Jun 27, 2024
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 @@ -6,6 +6,7 @@

- 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))
- Add replay breadcrumbs for touch & navigation events ([#3846](https://github.com/getsentry/sentry-react-native/pull/3846))
- Add network data to Session Replays ([#3912](https://github.com/getsentry/sentry-react-native/pull/3912))

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,131 @@
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter;
import io.sentry.rrweb.RRWebEvent;
import io.sentry.rrweb.RRWebBreadcrumbEvent;
import io.sentry.rrweb.RRWebSpanEvent;

import java.util.ArrayList;
import java.util.HashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

import java.util.HashMap;

public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadcrumbConverter {
public RNSentryReplayBreadcrumbConverter() {
}

@Override
public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) {
RRWebBreadcrumbEvent rrwebBreadcrumb = new RRWebBreadcrumbEvent();
assert rrwebBreadcrumb.getCategory() == null;
if (breadcrumb.getCategory() == null) {
return null;
}

if (breadcrumb.getCategory().equals("touch")) {
rrwebBreadcrumb.setCategory("ui.tap");
ArrayList path = (ArrayList) breadcrumb.getData("path");
if (path != null) {
StringBuilder message = new StringBuilder();
for (int i = Math.min(3, path.size()); i >= 0; i--) {
HashMap item = (HashMap) path.get(i);
message.append(item.get("name"));
if (item.containsKey("element") || item.containsKey("file")) {
message.append('(');
if (item.containsKey("element")) {
message.append(item.get("element"));
if (item.containsKey("file")) {
message.append(", ");
message.append(item.get("file"));
}
} else if (item.containsKey("file")) {
message.append(item.get("file"));
}
message.append(')');
}
if (i > 0) {
message.append(" > ");
}
}
rrwebBreadcrumb.setMessage(message.toString());
}
rrwebBreadcrumb.setData(breadcrumb.getData());
} else if (breadcrumb.getCategory().equals("navigation")) {
rrwebBreadcrumb.setCategory(breadcrumb.getCategory());
rrwebBreadcrumb.setData(breadcrumb.getData());
return convertTouchBreadcrumb(breadcrumb);
}

if (rrwebBreadcrumb.getCategory() != null && !rrwebBreadcrumb.getCategory().isEmpty()) {
rrwebBreadcrumb.setLevel(breadcrumb.getLevel());
rrwebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime());
rrwebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0);
rrwebBreadcrumb.setBreadcrumbType("default");
return rrwebBreadcrumb;
if (breadcrumb.getCategory().equals("navigation")) {
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();
rrWebBreadcrumb.setCategory(breadcrumb.getCategory());
rrWebBreadcrumb.setData(breadcrumb.getData());
return rrWebBreadcrumb;
}
if (breadcrumb.getCategory().equals("xhr")) {
return convertNetworkBreadcrumb(breadcrumb);
}
if (breadcrumb.getCategory().equals("http")) {
// Drop native http breadcrumbs to avoid duplicates
return null;
}

RRWebEvent nativeBreadcrumb = super.convert(breadcrumb);

// ignore native navigation breadcrumbs
if (nativeBreadcrumb instanceof RRWebBreadcrumbEvent) {
rrwebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb;
if (rrwebBreadcrumb.getCategory() != null && rrwebBreadcrumb.getCategory().equals("navigation")) {
final RRWebBreadcrumbEvent rrWebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb;
if (rrWebBreadcrumb.getCategory() != null && rrWebBreadcrumb.getCategory().equals("navigation")) {
return null;
}
}

return nativeBreadcrumb;
}

@TestOnly
public @NotNull RRWebEvent convertTouchBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();

rrWebBreadcrumb.setCategory("ui.tap");
ArrayList path = (ArrayList) breadcrumb.getData("path");
if (path != null) {
StringBuilder message = new StringBuilder();
for (int i = Math.min(3, path.size()); i >= 0; i--) {
HashMap item = (HashMap) path.get(i);
message.append(item.get("name"));
if (item.containsKey("element") || item.containsKey("file")) {
message.append('(');
if (item.containsKey("element")) {
message.append(item.get("element"));
if (item.containsKey("file")) {
message.append(", ");
message.append(item.get("file"));
}
} else if (item.containsKey("file")) {
message.append(item.get("file"));
}
message.append(')');
}
if (i > 0) {
message.append(" > ");
}
}
rrWebBreadcrumb.setMessage(message.toString());
}

rrWebBreadcrumb.setLevel(breadcrumb.getLevel());
rrWebBreadcrumb.setData(breadcrumb.getData());
rrWebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime());
rrWebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0);
rrWebBreadcrumb.setBreadcrumbType("default");
return rrWebBreadcrumb;
}

@TestOnly
public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number
? (Double) breadcrumb.getData("start_timestamp") : null;
final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number
? (Double) breadcrumb.getData("end_timestamp") : null;
final String url = breadcrumb.getData("url") instanceof String
? (String) breadcrumb.getData("url") : null;

if (startTimestamp == null || endTimestamp == null || url == null) {
return null;
}

final HashMap<String, Object> data = new HashMap<>();
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");
if (statusCode > 0) {
data.put("statusCode", statusCode.intValue());
}
}
if (breadcrumb.getData("request_body_size") instanceof Double) {
data.put("requestBodySize", breadcrumb.getData("request_body_size"));
}
if (breadcrumb.getData("response_body_size") instanceof Double) {
data.put("responseBodySize", breadcrumb.getData("response_body_size"));
}

final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent();
rrWebSpanEvent.setOp("resource.http");
rrWebSpanEvent.setStartTimestamp(startTimestamp / 1000.0);
rrWebSpanEvent.setEndTimestamp(endTimestamp / 1000.0);
rrWebSpanEvent.setDescription(url);
rrWebSpanEvent.setData(data);
return rrWebSpanEvent;
}
}
79 changes: 65 additions & 14 deletions ios/RNSentryReplayBreadcrumbConverter.m
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ - (instancetype _Nonnull)init {
(SentryBreadcrumb *_Nonnull)breadcrumb {
assert(breadcrumb.timestamp != nil);

if ([breadcrumb.category isEqualToString:@"http"]) {
// Drop native network breadcrumbs to avoid duplicates
return nil;
}
if ([breadcrumb.type isEqualToString:@"navigation"] && ![breadcrumb.category isEqualToString:@"navigation"]) {
// Drop native navigation breadcrumbs to avoid duplicates
return nil;
}

if ([breadcrumb.category isEqualToString:@"touch"]) {
NSMutableString *message;
if (breadcrumb.data) {
Expand Down Expand Up @@ -54,28 +63,70 @@ - (instancetype _Nonnull)init {
message:message
level:breadcrumb.level
data:breadcrumb.data];
} else if ([breadcrumb.category isEqualToString:@"navigation"]) {
}

if ([breadcrumb.category isEqualToString:@"navigation"]) {
return [SentrySessionReplayIntegration
createBreadcrumbwithTimestamp:breadcrumb.timestamp
category:breadcrumb.category
message:nil
level:breadcrumb.level
data:breadcrumb.data];
} else {
SentryRRWebEvent *nativeBreadcrumb =
[self->defaultConverter convertFrom:breadcrumb];

// ignore native navigation breadcrumbs
if (nativeBreadcrumb && nativeBreadcrumb.data &&
nativeBreadcrumb.data[@"payload"] &&
nativeBreadcrumb.data[@"payload"][@"category"] &&
[nativeBreadcrumb.data[@"payload"][@"category"]
isEqualToString:@"navigation"]) {
return nil;
}
return nativeBreadcrumb;
}

if ([breadcrumb.category isEqualToString:@"xhr"]) {
return [self convertNavigation:breadcrumb];
}

SentryRRWebEvent *nativeBreadcrumb =
[self->defaultConverter convertFrom:breadcrumb];

// ignore native navigation breadcrumbs
if (nativeBreadcrumb && nativeBreadcrumb.data &&
nativeBreadcrumb.data[@"payload"] &&
nativeBreadcrumb.data[@"payload"][@"category"] &&
[nativeBreadcrumb.data[@"payload"][@"category"]
isEqualToString:@"navigation"]) {
return nil;
}

return nativeBreadcrumb;
}

- (id<SentryRRWebEvent> _Nullable)convertNavigation: (SentryBreadcrumb *_Nonnull)breadcrumb {
NSNumber* startTimestamp = [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]]
? breadcrumb.data[@"start_timestamp"] : nil;
NSNumber* endTimestamp = [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]]
? breadcrumb.data[@"end_timestamp"] : nil;
NSString* url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]]
? breadcrumb.data[@"url"] : nil;

if (startTimestamp == nil || endTimestamp == nil || url == nil) {
return nil;
}

NSMutableDictionary* data = [[NSMutableDictionary alloc] init];
if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) {
data[@"method"] = breadcrumb.data[@"method"];
}
if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) {
data[@"statusCode"] = breadcrumb.data[@"status_code"];
}
if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) {
data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"];
}
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
}

return [SentrySessionReplayIntegration
createNetworkBreadcrumbWithTimestamp:[NSDate dateWithTimeIntervalSince1970:(startTimestamp.doubleValue / 1000)]
endTimestamp:[NSDate dateWithTimeIntervalSince1970:(endTimestamp.doubleValue / 1000)]
operation:@"resource.http"
description:url
data:data];
}

@end

#endif
4 changes: 2 additions & 2 deletions src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
import { Alert } from 'react-native';

import { createIntegration } from './integrations/factory';
import type { mobileReplayIntegration } from './integrations/mobilereplay';
import { MOBILE_REPLAY_INTEGRATION_NAME } from './integrations/mobilereplay';
import { defaultSdkInfo } from './integrations/sdkinfo';
import type { ReactNativeClientOptions } from './options';
import type { mobileReplayIntegration } from './replay/mobilereplay';
import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
import { ReactNativeTracing } from './tracing';
import { createUserFeedbackEnvelope, items } from './utils/envelope';
import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs';
Expand Down
2 changes: 1 addition & 1 deletion src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export { screenshotIntegration } from './screenshot';
export { viewHierarchyIntegration } from './viewhierarchy';
export { expoContextIntegration } from './expocontext';
export { spotlightIntegration } from './spotlight';
export { mobileReplayIntegration } from './mobilereplay';
export { mobileReplayIntegration } from '../replay/mobilereplay';

export {
breadcrumbsIntegration,
Expand Down
2 changes: 1 addition & 1 deletion src/js/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ export { Screenshot } from './screenshot';
export { ViewHierarchy } from './viewhierarchy';
export { ExpoContext } from './expocontext';
export { Spotlight } from './spotlight';
export { mobileReplayIntegration } from './mobilereplay';
export { mobileReplayIntegration } from '../replay/mobilereplay';
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isHardCrash } from '../misc';
import { hasHooks } from '../utils/clientutils';
import { isExpoGo, notMobileOs } from '../utils/environment';
import { NATIVE } from '../wrapper';
import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils';

export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay';

Expand Down Expand Up @@ -103,6 +104,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
dsc.replay_id = currentReplayId;
}
});

client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay);
}

// TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably
Expand Down
64 changes: 64 additions & 0 deletions src/js/replay/networkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
import { utf8ToBytes } from '../vendor';

/** Convert a Content-Length header to number/undefined. */
export function parseContentLengthHeader(header: string | null | undefined): number | undefined {
if (!header) {
return undefined;
}

const size = parseInt(header, 10);
return isNaN(size) ? undefined : size;
}

export type RequestBody = null | Blob | FormData | URLSearchParams | string | ArrayBuffer | undefined;

/** Get the size of a body. */
export function getBodySize(body: RequestBody): number | undefined {
if (!body) {
return undefined;
}

try {
if (typeof body === 'string') {
return _encode(body).length;
}

if (body instanceof URLSearchParams) {
return _encode(body.toString()).length;
}

if (body instanceof FormData) {
const formDataStr = _serializeFormData(body);
return _encode(formDataStr).length;
}

if (body instanceof Blob) {
return body.size;
}

if (body instanceof ArrayBuffer) {
return body.byteLength;
}

// Currently unhandled types: ArrayBufferView, ReadableStream
} catch {
// just return undefined
}

return undefined;
}

function _encode(input: string): number[] | Uint8Array {
if (RN_GLOBAL_OBJ.TextEncoder) {
return new RN_GLOBAL_OBJ.TextEncoder().encode(input);
}
return utf8ToBytes(input);
}

function _serializeFormData(formData: FormData): string {
// This is a bit simplified, but gives us a decent estimate
// This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13'
// @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
return new URLSearchParams(formData).toString();
}
Loading