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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

## Unreleased

### Features

- Populate `trace_ids` in mobile replays so replays can be searched by trace ID ([#6786](https://github.com/getsentry/sentry-react-native/pull/6786))

### Dependencies

- Bump Android SDK from v8.57.0 to v8.58.0 ([#6778](https://github.com/getsentry/sentry-react-native/pull/6778))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,21 @@ public void flushReplay(Promise promise) {
}
}

public void registerReplayTraceId(String traceId) {
// Forward a trace id seen on a sent event to Session Replay so it lands in
// the current segment's `trace_ids`. The controller owns dedup, the
// per-segment cap, and the no-op when no replay is recording.
// `new SentryId(String)` normalizes the un-hyphenated 32-char hex from JS.
try {
Sentry.getCurrentScopes()
.getOptions()
.getReplayController()
.registerTraceId(new SentryId(traceId));
} catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app
logger.log(SentryLevel.ERROR, "Failed to register replay trace id", e);
}
}

public @Nullable String getCurrentReplayId() {
// Prefer the replay controller's id: it is assigned when recording starts
// (buffer or session) and is therefore available BEFORE a replay is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ public void flushReplay(Promise promise) {
this.impl.flushReplay(promise);
}

@Override
public void registerReplayTraceId(String traceId) {
this.impl.registerReplayTraceId(traceId);
}

@Override
public void crashedLastRun(Promise promise) {
this.impl.crashedLastRun(promise);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ public void flushReplay(Promise promise) {
this.impl.flushReplay(promise);
}

@ReactMethod
public void registerReplayTraceId(String traceId) {
this.impl.registerReplayTraceId(traceId);
}

@ReactMethod
public void crashedLastRun(Promise promise) {
this.impl.crashedLastRun(promise);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import io.sentry.IReplayApi;
import io.sentry.IScopes;
import io.sentry.ReplayController;
import io.sentry.Sentry;
import io.sentry.SentryOptions;
import io.sentry.protocol.SentryId;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
Expand Down Expand Up @@ -122,4 +126,22 @@ public void flushReplayCallsSdkAndResolvesNull() {
verify(promise).resolve(null);
}
}

@Test
public void registerReplayTraceIdForwardsSentryIdToController() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class)) {
final ReplayController replayController = mock(ReplayController.class);
final SentryOptions options = mock(SentryOptions.class);
when(options.getReplayController()).thenReturn(replayController);
final IScopes scopes = mock(IScopes.class);
when(scopes.getOptions()).thenReturn(options);
sentry.when(Sentry::getCurrentScopes).thenReturn(scopes);

final String traceId = "d6566d2f24e848fe864f8d4df192d67c";
module.registerReplayTraceId(traceId);

// new SentryId(String) normalizes the un-hyphenated 32-char hex from JS.
verify(replayController).registerTraceId(new SentryId(traceId));
}
}
}
11 changes: 11 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,17 @@ + (BOOL)captureReplayWithReturnValue
#endif
}

RCT_EXPORT_METHOD(registerReplayTraceId : (NSString *)traceId)
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
@try {
[RNSentryInternal registerReplayTraceId:traceId];
} @catch (NSException *exception) {
NSLog(@"[RNSentry] Failed to call registerReplayTraceId: %@", exception);
}
#endif
}

#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
static BOOL
RNSentryIsPathUnderAllowedRoots(NSString *path)
Expand Down
31 changes: 19 additions & 12 deletions packages/core/ios/RNSentryInternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ import Foundation
screenshotProvider: nil
)
}

@_spi(Private) @objc public static func registerReplayTraceId(_ traceId: String) {
SentrySDK.internal.replay.registerTraceId(sentryId(fromTraceId: traceId))
}
#else
@_spi(Private) @objc public static func captureReplay() -> Bool { false }
@_spi(Private) @objc public static func startReplay() {}
Expand All @@ -200,6 +204,7 @@ import Foundation
@_spi(Private) @objc public static func configureReplay(
breadcrumbConverter: SentryReplayBreadcrumbConverter
) {}
@_spi(Private) @objc public static func registerReplayTraceId(_ traceId: String) {}
#endif

// MARK: - Swizzle
Expand Down Expand Up @@ -283,18 +288,20 @@ import Foundation
// SentrySDK.internal.setTrace only accepts traceId/spanId; wiring sampling fields
// through would require a sentry-cocoa API change.
@_spi(Private) @objc public static func setCurrentScopePropagationContext(traceId: String, spanId: String) {
// JS traceId is a 32-char hex string without hyphens; SentryId(uuidString:) requires
// the standard hyphenated UUID format (8-4-4-4-12), otherwise it silently produces
// an empty SentryId and trace linking breaks.
let hyphenated: String
if traceId.count == 32 {
let s = traceId
hyphenated = "\(s.prefix(8))-\(s.dropFirst(8).prefix(4))-\(s.dropFirst(12).prefix(4))-\(s.dropFirst(16).prefix(4))-\(s.dropFirst(20))"
} else {
hyphenated = traceId
}
let sentryTraceId = SentryId(uuidString: hyphenated)
let sentrySpanId = SpanId(value: spanId)
SentrySDK.internal.setTrace(sentryTraceId, spanId: sentrySpanId)
SentrySDK.internal.setTrace(sentryId(fromTraceId: traceId), spanId: sentrySpanId)
}

// JS traceId is a 32-char hex string without hyphens; SentryId(uuidString:) requires
// the standard hyphenated UUID format (8-4-4-4-12), otherwise it silently produces
// an empty SentryId and trace linking breaks.
private static func sentryId(fromTraceId traceId: String) -> SentryId {
guard traceId.count == 32 else {
return SentryId(uuidString: traceId)
}
let s = traceId
let hyphenated =
"\(s.prefix(8))-\(s.dropFirst(8).prefix(4))-\(s.dropFirst(12).prefix(4))-\(s.dropFirst(16).prefix(4))-\(s.dropFirst(20))"
return SentryId(uuidString: hyphenated)
}
}
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface Spec extends TurboModule {
pauseReplay(): Promise<void>;
resumeReplay(): Promise<void>;
flushReplay(): Promise<void>;
registerReplayTraceId(traceId: string): void;
crashedLastRun(): Promise<boolean | undefined | null>;
getDataFromUri(uri: string): Promise<number[]>;
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { isExpoGo, notMobileOs } from '../utils/environment';
import { registerFeatureMarker } from '../utils/featureMarkers';
import { NATIVE } from '../wrapper';
import { setupForegroundReplayGuard } from './foregroundReplayGuard';
import { registerReplayTraceIdForEvent } from './replayTraceIds';
import {
buildResolvedNetworkBreadcrumb,
makeEnrichXhrBreadcrumbsForMobileReplay,
Expand Down Expand Up @@ -621,9 +622,9 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
// fires after the error `sampleRate` roll in `@sentry/core`, so an error
// dropped by sampling never triggers a replay upload (issue #6598).
client.on('afterSendEvent', (event: Event) => {
flushReplayForSentEvent(event).then(undefined, () => {
// errors are logged inside flushReplayForSentEvent
});
registerReplayTraceIdForEvent(event, getCachedReplayId());
// errors are logged inside flushReplayForSentEvent
flushReplayForSentEvent(event).then(undefined, () => {});
});
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/js/replay/replayTraceIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Event } from '@sentry/core';

import { NATIVE } from '../wrapper';

/**
* Forwards the trace id seen on a sent event to the native Session Replay so the
* current segment carries it under `trace_ids`, making the replay searchable by
* trace id.
*
* Fires for any sent event that carries a trace (transactions and errors). The
* native SDKs own dedup, the 100-per-segment cap, and the no-op when no replay
* is recording; this is a thin forward. The `replayId` guard only avoids a
* bridge crossing per event when nothing is recording โ€” pass the currently
* cached replay id (or `null` when there is none).
*/
export function registerReplayTraceIdForEvent(event: Event, replayId: string | null): void {
const traceId = event.contexts?.trace?.trace_id;
if (traceId && replayId) {
NATIVE.registerReplayTraceId(traceId);
}
}
25 changes: 25 additions & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ interface SentryNativeWrapper {
pauseReplay(): Promise<void>;
resumeReplay(): Promise<void>;
flushReplay(): Promise<void>;
registerReplayTraceId(traceId: string): void;

crashedLastRun(): Promise<boolean | null>;
getNewScreenTimeToDisplay(): Promise<number | null | undefined>;
Expand Down Expand Up @@ -960,6 +961,30 @@ export const NATIVE: SentryNativeWrapper = {
return RNSentry[method]();
},

/**
* Forwards a trace id seen on a sent event to the native Session Replay so it
* lands in the current segment's `trace_ids` (enabling search by trace id).
* The native SDKs own dedup, the 100-per-segment cap, and the no-op when no
* replay is recording - this is a thin forward. Degrades to a no-op when
* native is disabled, unlinked, or the running (possibly cached, older) native
* binary predates the method; never throws into the caller.
*/
registerReplayTraceId(traceId: string): void {
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
return undefined;
}
if (typeof RNSentry.registerReplayTraceId !== 'function') {
return undefined;
}

try {
RNSentry.registerReplayTraceId(traceId);
} catch (error) {
debug.error('Error:', error);
return undefined;
}
},

async crashedLastRun(): Promise<boolean | null> {
if (!this.enableNative) {
return null;
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/mockWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const NATIVE: MockInterface<NativeType> = {
pauseReplay: jest.fn(),
resumeReplay: jest.fn(),
flushReplay: jest.fn(),
registerReplayTraceId: jest.fn(),

crashedLastRun: jest.fn(),
getNewScreenTimeToDisplay: jest.fn(),
Expand Down
43 changes: 43 additions & 0 deletions packages/core/test/replay/mobilereplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,49 @@ describe('Mobile Replay Integration', () => {
});
});

describe('trace id registration on send', () => {
const TRACE_ID = 'd6566d2f24e848fe864f8d4df192d67c';
let mockRegisterReplayTraceId: jest.MockedFunction<typeof NATIVE.registerReplayTraceId>;

beforeEach(() => {
mockRegisterReplayTraceId = NATIVE.registerReplayTraceId as jest.MockedFunction<
typeof NATIVE.registerReplayTraceId
>;
});

const eventWithTrace = (): Event => ({ contexts: { trace: { trace_id: TRACE_ID } } }) as unknown as Event;

it('forwards the trace id to native when a replay is recording', async () => {
mockGetCurrentReplayId.mockReturnValue('test-replay-id');
const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

await fireAfterSendEvent(eventWithTrace());

expect(mockRegisterReplayTraceId).toHaveBeenCalledWith(TRACE_ID);
});

it('does not forward when no replay is recording', async () => {
mockGetCurrentReplayId.mockReturnValue(null);
const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

await fireAfterSendEvent(eventWithTrace());

expect(mockRegisterReplayTraceId).not.toHaveBeenCalled();
});

it('does not forward when the event carries no trace id', async () => {
mockGetCurrentReplayId.mockReturnValue('test-replay-id');
const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

await fireAfterSendEvent({ event_id: 'no-trace' } as Event);

expect(mockRegisterReplayTraceId).not.toHaveBeenCalled();
});
});

describe('runtime controls', () => {
beforeEach(() => {
(NATIVE.startReplay as jest.Mock).mockResolvedValue(undefined as never);
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/replay/replayTraceIds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Event } from '@sentry/core';

import { afterEach, describe, expect, it, jest } from '@jest/globals';

import { registerReplayTraceIdForEvent } from '../../src/js/replay/replayTraceIds';
import { NATIVE } from '../../src/js/wrapper';

jest.mock('../../src/js/wrapper');

describe('registerReplayTraceIdForEvent', () => {
const TRACE_ID = 'd6566d2f24e848fe864f8d4df192d67c';
const mockRegister = NATIVE.registerReplayTraceId as jest.MockedFunction<typeof NATIVE.registerReplayTraceId>;

afterEach(() => {
jest.clearAllMocks();
});

it('forwards the trace id to native when a replay is active', () => {
// Arrange
const event = { contexts: { trace: { trace_id: TRACE_ID } } } as unknown as Event;

// Act
registerReplayTraceIdForEvent(event, 'active-replay-id');

// Assert
expect(mockRegister).toHaveBeenCalledWith(TRACE_ID);
});

it('does not forward when no replay is active', () => {
// Arrange
const event = { contexts: { trace: { trace_id: TRACE_ID } } } as unknown as Event;

// Act
registerReplayTraceIdForEvent(event, null);

// Assert
expect(mockRegister).not.toHaveBeenCalled();
});

it('does not forward when the event carries no trace id', () => {
// Arrange
const event = { event_id: 'no-trace' } as Event;

// Act
registerReplayTraceIdForEvent(event, 'active-replay-id');

// Assert
expect(mockRegister).not.toHaveBeenCalled();
});
});
Loading
Loading