Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
60d07df
feat(replay): Add Mobile Replay Alpha (#3714)
krystofwoldrich May 21, 2024
41f816a
Merge branch 'main' into feat/replay
vaind Jun 4, 2024
c3ff79f
Merge branch 'main' into feat/replay
vaind Jun 18, 2024
13a9360
feat(sample): add running indicator (animation overlay) (#3903)
krystofwoldrich Jun 21, 2024
1f1e41e
Merge branch 'main' into feat/replay
vaind Jun 26, 2024
38d8d7c
feat(replay): Add breadcrumbs mapping from RN to RRWeb format (#3846)
vaind Jun 26, 2024
ac72abc
feat(replay): Add network breadcrumbs (#3912)
krystofwoldrich Jun 27, 2024
5f3a5c8
fix(replay): Add tests for touch events (#3924)
krystofwoldrich Jul 2, 2024
b4aaa68
feat(replay): Filter Sentry event breadcrumbs (#3925)
krystofwoldrich Jul 2, 2024
00aa520
Merge branch 'main' into feat/replay
krystofwoldrich Jul 2, 2024
cfd5aee
fix(changelog): Add latest native SDKs details
krystofwoldrich Jul 2, 2024
83f5a0c
release: 5.25.0-alpha.2
getsentry-bot Jul 2, 2024
8e9c3a0
Merge branch 'release/5.25.0-alpha.2' into feat/replay
Jul 2, 2024
9672e88
misc(samples): Add console anything examples for replay testing (#3928)
krystofwoldrich Jul 3, 2024
6067ecc
feat: Add Sentry Babel Transformer (#3916)
krystofwoldrich Jul 3, 2024
3b28c40
fix(replay): Add app lifecycle breadcrumbs conversion tests (#3932)
krystofwoldrich Jul 5, 2024
e25da10
Merge remote-tracking branch 'origin/main' into feat/replay
krystofwoldrich Jul 9, 2024
6d8c4dd
chore(deps): bump sentry-android to 7.12.0-alpha.3
krystofwoldrich Jul 9, 2024
6fd2a34
chore(deps): bump sentry-android to 7.12.0-alpha.4
krystofwoldrich Jul 9, 2024
3dcf57b
fix(replay): Mask SVGs from `react-native-svg` when `maskAllVectors=t…
krystofwoldrich Jul 10, 2024
361867e
Merge remote-tracking branch 'origin/main' into feat/replay
krystofwoldrich Jul 10, 2024
f96cf53
Merge remote-tracking branch 'origin/main' into feat/replay
krystofwoldrich Jul 12, 2024
254b772
fix(replay): Add missing properties to android nav breadcrumbs (#3942)
krystofwoldrich Jul 12, 2024
7b9baca
release: 5.26.0-alpha.3
getsentry-bot Jul 12, 2024
222c9dd
Merge branch 'release/5.26.0-alpha.3' into feat/replay
Jul 12, 2024
0b74944
misc(replay): Add Mobile Replay Public Beta changelog (#3943)
krystofwoldrich Jul 15, 2024
4088ea8
Merge branch 'main' into feat/replay
romtsn Jul 15, 2024
a1da3bf
Merge branch 'main' into feat/replay
bruno-garcia Jul 15, 2024
b9a7f9d
Merge branch 'main' into feat/replay
bruno-garcia Jul 15, 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
Prev Previous commit
Next Next commit
feat(replay): Add network breadcrumbs (#3912)
  • Loading branch information
krystofwoldrich authored Jun 27, 2024
commit ac72abc65e16ea39c119a279f43d9a766cf3c536
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