From b25fdd322bb8fd846e8cb7cb1e8fe423096c47da Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 25 Sep 2026 10:45:34 +0200 Subject: [PATCH] feat(replay): Populate trace_ids in mobile replay events Forward the trace id of each sent event to the native Session Replay so the current segment carries it under `trace_ids`, making replays searchable by trace id in Explore. The native SDKs (Cocoa 9.29.1, Java 8.58.0) own dedup, the 100-per-segment cap, and the no-op when no replay is recording; the RN side is a thin forward across the bridge. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ .../io/sentry/react/RNSentryModuleImpl.java | 15 +++++ .../java/io/sentry/react/RNSentryModule.java | 5 ++ .../java/io/sentry/react/RNSentryModule.java | 5 ++ .../react/RNSentryReplayControlTest.java | 22 +++++++ packages/core/ios/RNSentry.mm | 11 ++++ packages/core/ios/RNSentryInternal.swift | 31 +++++---- packages/core/src/js/NativeRNSentry.ts | 1 + packages/core/src/js/replay/mobilereplay.ts | 7 +- packages/core/src/js/replay/replayTraceIds.ts | 21 ++++++ packages/core/src/js/wrapper.ts | 25 +++++++ packages/core/test/mockWrapper.ts | 1 + .../core/test/replay/mobilereplay.test.ts | 43 ++++++++++++ .../core/test/replay/replayTraceIds.test.ts | 50 ++++++++++++++ packages/core/test/wrapper.test.ts | 66 +++++++++++++++++++ 15 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/js/replay/replayTraceIds.ts create mode 100644 packages/core/test/replay/replayTraceIds.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff534d847e..3b1b55c2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 7da9c7eb8c..e61da008d2 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -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 diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index e669a08aa6..af4e1decf5 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -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); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 582a3c3a53..31c1c8fa69 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -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); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java index aa9823bf76..2d6ab0d1c1 100644 --- a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java @@ -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; @@ -122,4 +126,22 @@ public void flushReplayCallsSdkAndResolvesNull() { verify(promise).resolve(null); } } + + @Test + public void registerReplayTraceIdForwardsSentryIdToController() { + try (MockedStatic 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)); + } + } } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index a809117e1a..5d85c45cfe 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -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) diff --git a/packages/core/ios/RNSentryInternal.swift b/packages/core/ios/RNSentryInternal.swift index 607c085bf9..e00b5d2299 100644 --- a/packages/core/ios/RNSentryInternal.swift +++ b/packages/core/ios/RNSentryInternal.swift @@ -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() {} @@ -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 @@ -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) } } diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 9d96a45e69..7c29e88e24 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -61,6 +61,7 @@ export interface Spec extends TurboModule { pauseReplay(): Promise; resumeReplay(): Promise; flushReplay(): Promise; + registerReplayTraceId(traceId: string): void; crashedLastRun(): Promise; getDataFromUri(uri: string): Promise; popTimeToDisplayFor(key: string): Promise; diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 47348754dd..a2b79a6edc 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -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, @@ -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, () => {}); }); } diff --git a/packages/core/src/js/replay/replayTraceIds.ts b/packages/core/src/js/replay/replayTraceIds.ts new file mode 100644 index 0000000000..861dc7e708 --- /dev/null +++ b/packages/core/src/js/replay/replayTraceIds.ts @@ -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); + } +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index ff1998d5a7..f9c9ba2a45 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -146,6 +146,7 @@ interface SentryNativeWrapper { pauseReplay(): Promise; resumeReplay(): Promise; flushReplay(): Promise; + registerReplayTraceId(traceId: string): void; crashedLastRun(): Promise; getNewScreenTimeToDisplay(): Promise; @@ -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 { if (!this.enableNative) { return null; diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index e7ac788854..4a71d6b2cb 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -71,6 +71,7 @@ const NATIVE: MockInterface = { pauseReplay: jest.fn(), resumeReplay: jest.fn(), flushReplay: jest.fn(), + registerReplayTraceId: jest.fn(), crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn(), diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index aed7a3e094..60423b9fbf 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -666,6 +666,49 @@ describe('Mobile Replay Integration', () => { }); }); + describe('trace id registration on send', () => { + const TRACE_ID = 'd6566d2f24e848fe864f8d4df192d67c'; + let mockRegisterReplayTraceId: jest.MockedFunction; + + 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); diff --git a/packages/core/test/replay/replayTraceIds.test.ts b/packages/core/test/replay/replayTraceIds.test.ts new file mode 100644 index 0000000000..66ee14223f --- /dev/null +++ b/packages/core/test/replay/replayTraceIds.test.ts @@ -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; + + 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(); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 1d4b90d111..b29d98da35 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -69,6 +69,7 @@ jest.mock('react-native', () => { pauseReplay: jest.fn(() => Promise.resolve()), resumeReplay: jest.fn(() => Promise.resolve()), flushReplay: jest.fn(() => Promise.resolve()), + registerReplayTraceId: jest.fn(), }; return { @@ -1622,6 +1623,71 @@ describe('Tests Native Wrapper', () => { ); }); + describe('registerReplayTraceId', () => { + const TRACE_ID = 'd6566d2f24e848fe864f8d4df192d67c'; + + it('forwards the trace id to native when enabled', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + NATIVE.registerReplayTraceId(TRACE_ID); + + expect(RNSentry.registerReplayTraceId).toHaveBeenCalledWith(TRACE_ID); + }); + + it('does not call native when enableNative is false', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: false, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + NATIVE.registerReplayTraceId(TRACE_ID); + + expect(RNSentry.registerReplayTraceId).not.toHaveBeenCalled(); + }); + + it('is a no-op when the method is missing on the binary', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + const original = (RNSentry as unknown as Record).registerReplayTraceId; + (RNSentry as unknown as Record).registerReplayTraceId = undefined; + + try { + expect(() => NATIVE.registerReplayTraceId(TRACE_ID)).not.toThrow(); + } finally { + (RNSentry as unknown as Record).registerReplayTraceId = original; + } + }); + + it('swallows native errors instead of throwing into the caller', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + (RNSentry.registerReplayTraceId as jest.Mock).mockImplementationOnce(() => { + throw new Error('native boom'); + }); + + expect(() => NATIVE.registerReplayTraceId(TRACE_ID)).not.toThrow(); + }); + }); + describe('primitiveProcessor and _setPrimitiveProcessor', () => { describe('primitiveProcessor', () => { it('default primitiveProcessor returns value as string', () => {