diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb229ea16..38402660c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ ## Unreleased +### Features + +- Add Session Replay runtime controls to start, stop, and manage recording manually ([#6703](https://github.com/getsentry/sentry-react-native/pull/6703)) + - Use `Sentry.getReplay()` to access the active replay and control it at runtime with the same API on iOS, Android, and Web: + + ```js + const replay = Sentry.getReplay(); + replay?.start(); // start recording a session replay + replay?.startBuffering(); // start recording in buffering mode + replay?.stop(); // stop recording + replay?.pause(); // pause recording (no-op on Web) + replay?.resume(); // resume a paused recording (no-op on Web) + replay?.flush(); // flush the buffered replay to Sentry + ``` + ### Fixes - Background root spans (app-start, expo-updates) no longer overwrite the native propagation context of an active navigation trace ([#6720](https://github.com/getsentry/sentry-react-native/pull/6720)) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 508193ce80..0c84b8d7ef 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */; }; 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3339C4802D6625570088EB3A /* RNSentryUserTests.m */; }; + 3339C4832D6625570088EB3A /* RNSentryReplayControlTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3339C4822D6625570088EB3A /* RNSentryReplayControlTests.m */; }; 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; @@ -33,6 +34,7 @@ 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; 3339C47F2D6625260088EB3A /* RNSentry+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentry+Test.h"; sourceTree = ""; }; 3339C4802D6625570088EB3A /* RNSentryUserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUserTests.m; sourceTree = ""; }; + 3339C4822D6625570088EB3A /* RNSentryReplayControlTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryReplayControlTests.m; sourceTree = ""; }; 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; @@ -115,6 +117,7 @@ 33F58ACF2977037D008F60EA /* RNSentryTests.m */, 33F58ACE2977037D008F60EB /* RNSentryScreenshotSourceTests.swift */, 3339C4802D6625570088EB3A /* RNSentryUserTests.m */, + 3339C4822D6625570088EB3A /* RNSentryReplayControlTests.m */, 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */, E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */, 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */, @@ -272,6 +275,7 @@ 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */, 33F58AD12977037D008F60EB /* RNSentryScreenshotSourceTests.swift in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */, + 3339C4832D6625570088EB3A /* RNSentryReplayControlTests.m in Sources */, B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */, 2639D71D3BD04F17B0BAC987 /* RNSentryTurboModulePerfControllerTests.mm in Sources */, 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m new file mode 100644 index 0000000000..afafba5b41 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m @@ -0,0 +1,56 @@ +#import +@import RNSentry.Swift; + +/** + * Coverage for the Session Replay runtime controls bridged through + * `RNSentryInternal` (`startReplay`, `startReplayBuffering`, `stopReplay`, + * `pauseReplay`, `resumeReplay`, `flushReplay`). + * + * These forward to `SentrySDK.internal.replay.*`. Without a started SDK there is + * no active replay, so every control must be a safe no-op that does not crash, + * and `replayId` must stay nil. That mirrors the "call uninitialized, assert a + * safe default" convention used by the other `RNSentryInternal` tests. + */ +@interface RNSentryReplayControlTests : XCTestCase + +@end + +@implementation RNSentryReplayControlTests + +- (void)testStartReplayDoesNotCrashWhenNotRunning +{ + [RNSentryInternal startReplay]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testStartReplayBufferingDoesNotCrashWhenNotRunning +{ + [RNSentryInternal startReplayBuffering]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testStopReplayDoesNotCrashWhenNotRunning +{ + [RNSentryInternal stopReplay]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testPauseReplayDoesNotCrashWhenNotRunning +{ + [RNSentryInternal pauseReplay]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testResumeReplayDoesNotCrashWhenNotRunning +{ + [RNSentryInternal resumeReplay]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testFlushReplayDoesNotCrashWhenNotRunning +{ + [RNSentryInternal flushReplay]; + XCTAssertNil(RNSentryInternal.replayId); +} + +@end 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 005102edc4..c0ee29fa17 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 @@ -489,6 +489,66 @@ public void captureReplay(boolean isHardCrash, Promise promise) { promise.resolve(getReplayIdFromScope()); } + public void startReplay(Promise promise) { + try { + Sentry.replay().start(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to start replay", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void startReplayBuffering(Promise promise) { + try { + Sentry.replay().startBuffering(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to start replay buffering", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void stopReplay(Promise promise) { + try { + Sentry.replay().stop(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to stop replay", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void pauseReplay(Promise promise) { + try { + Sentry.replay().pause(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to pause replay", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void resumeReplay(Promise promise) { + try { + Sentry.replay().resume(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to resume replay", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void flushReplay(Promise promise) { + try { + Sentry.replay().flush(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to flush replay", e); + promise.reject("SentryReactNative", e.getMessage(), 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 035bb7306b..e669a08aa6 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 @@ -204,6 +204,36 @@ public String getCurrentReplayId() { return this.impl.getCurrentReplayId(); } + @Override + public void startReplay(Promise promise) { + this.impl.startReplay(promise); + } + + @Override + public void startReplayBuffering(Promise promise) { + this.impl.startReplayBuffering(promise); + } + + @Override + public void stopReplay(Promise promise) { + this.impl.stopReplay(promise); + } + + @Override + public void pauseReplay(Promise promise) { + this.impl.pauseReplay(promise); + } + + @Override + public void resumeReplay(Promise promise) { + this.impl.resumeReplay(promise); + } + + @Override + public void flushReplay(Promise promise) { + this.impl.flushReplay(promise); + } + @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 aeecfcdd44..582a3c3a53 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 @@ -204,6 +204,36 @@ public String getCurrentReplayId() { return this.impl.getCurrentReplayId(); } + @ReactMethod + public void startReplay(Promise promise) { + this.impl.startReplay(promise); + } + + @ReactMethod + public void startReplayBuffering(Promise promise) { + this.impl.startReplayBuffering(promise); + } + + @ReactMethod + public void stopReplay(Promise promise) { + this.impl.stopReplay(promise); + } + + @ReactMethod + public void pauseReplay(Promise promise) { + this.impl.pauseReplay(promise); + } + + @ReactMethod + public void resumeReplay(Promise promise) { + this.impl.resumeReplay(promise); + } + + @ReactMethod + public void flushReplay(Promise promise) { + this.impl.flushReplay(promise); + } + @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 new file mode 100644 index 0000000000..aa9823bf76 --- /dev/null +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java @@ -0,0 +1,125 @@ +package io.sentry.react; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import io.sentry.IReplayApi; +import io.sentry.Sentry; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +/** + * Coverage for the Session Replay runtime controls exposed on {@link RNSentryModuleImpl} ({@code + * startReplay}, {@code startReplayBuffering}, {@code stopReplay}, {@code pauseReplay}, {@code + * resumeReplay}, {@code flushReplay}). + * + *

Each control forwards to the corresponding {@link IReplayApi} method returned by {@code + * Sentry.replay()} and then resolves the promise with {@code null}. + */ +public class RNSentryReplayControlTest { + + private RNSentryModuleImpl module; + + @Before + public void setUp() throws Exception { + ReactApplicationContext reactContext = mock(ReactApplicationContext.class); + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo()); + when(reactContext.getPackageManager()).thenReturn(packageManager); + when(reactContext.getPackageName()).thenReturn("com.test.app"); + module = new RNSentryModuleImpl(reactContext); + } + + @Test + public void startReplayCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.startReplay(promise); + + verify(replay).start(); + verify(promise).resolve(null); + } + } + + @Test + public void startReplayBufferingCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.startReplayBuffering(promise); + + verify(replay).startBuffering(); + verify(promise).resolve(null); + } + } + + @Test + public void stopReplayCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.stopReplay(promise); + + verify(replay).stop(); + verify(promise).resolve(null); + } + } + + @Test + public void pauseReplayCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.pauseReplay(promise); + + verify(replay).pause(); + verify(promise).resolve(null); + } + } + + @Test + public void resumeReplayCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.resumeReplay(promise); + + verify(replay).resume(); + verify(promise).resolve(null); + } + } + + @Test + public void flushReplayCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.flushReplay(promise); + + verify(replay).flush(); + verify(promise).resolve(null); + } + } +} diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 55de3c98ae..8396d93988 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -163,7 +163,6 @@ export { browserGlobalHandlersIntegration } export { browserLinkedErrorsIntegration } // Warning: (ae-forgotten-export) The symbol "ReplayConfiguration" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "Replay" needs to be exported by the entry point index.d.ts // // @public export const browserReplayIntegration: (options?: ReplayConfiguration) => Replay; @@ -403,6 +402,9 @@ export { getIsolationScope } // @public export function getReactNativeTracingIntegration(client: Client): ReactNativeTracingIntegration | undefined; +// @public +export function getReplay(): Replay | undefined; + export { getRootSpan } // @public @@ -635,6 +637,20 @@ export const reactNavigationIntegration: (input?: Partial; + getRecordingMode?(): ReplayRecordingMode | undefined; + getReplayId(): string | undefined | null; + pause(): void; + resume(): void; + start(): void; + startBuffering(): void; + stop(): Promise; +} + // @public export function reportFullyDisplayed(): void; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 6e6acd0adf..a809117e1a 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -870,6 +870,102 @@ + (BOOL)captureReplayWithReturnValue #endif } +RCT_EXPORT_METHOD( + startReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal startReplay]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call startReplay: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD( + startReplayBuffering : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal startReplayBuffering]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call startReplayBuffering: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD( + stopReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal stopReplay]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call stopReplay: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD( + pauseReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal pauseReplay]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call pauseReplay: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD( + resumeReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal resumeReplay]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call resumeReplay: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD( + flushReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternal flushReplay]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call flushReplay: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#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 368cf47202..607c085bf9 100644 --- a/packages/core/ios/RNSentryInternal.swift +++ b/packages/core/ios/RNSentryInternal.swift @@ -142,6 +142,30 @@ import Foundation SentrySDK.internal.replay.capture() } + @_spi(Private) @objc public static func startReplay() { + SentrySDK.internal.replay.start() + } + + @_spi(Private) @objc public static func startReplayBuffering() { + SentrySDK.internal.replay.startBuffering() + } + + @_spi(Private) @objc public static func stopReplay() { + SentrySDK.internal.replay.stop() + } + + @_spi(Private) @objc public static func pauseReplay() { + SentrySDK.internal.replay.pause() + } + + @_spi(Private) @objc public static func resumeReplay() { + SentrySDK.internal.replay.resume() + } + + @_spi(Private) @objc public static func flushReplay() { + SentrySDK.internal.replay.flush() + } + @_spi(Private) @objc public static var replayId: String? { SentrySDK.internal.replay.replayId } @@ -164,6 +188,12 @@ import Foundation } #else @_spi(Private) @objc public static func captureReplay() -> Bool { false } + @_spi(Private) @objc public static func startReplay() {} + @_spi(Private) @objc public static func startReplayBuffering() {} + @_spi(Private) @objc public static func stopReplay() {} + @_spi(Private) @objc public static func pauseReplay() {} + @_spi(Private) @objc public static func resumeReplay() {} + @_spi(Private) @objc public static func flushReplay() {} @_spi(Private) @objc public static var replayId: String? { nil } @_spi(Private) @objc public static func setReplayRedactContainerClass(_ containerClass: AnyClass) {} @_spi(Private) @objc public static func setReplayIgnoreContainerClass(_ containerClass: AnyClass) {} diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index f0b7770501..9d96a45e69 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -55,6 +55,12 @@ export interface Spec extends TurboModule { initNativeReactNavigationNewFrameTracking(): Promise; captureReplay(isHardCrash: boolean): Promise; getCurrentReplayId(): string | undefined | null; + startReplay(): Promise; + startReplayBuffering(): Promise; + stopReplay(): Promise; + pauseReplay(): Promise; + resumeReplay(): Promise; + flushReplay(): Promise; crashedLastRun(): Promise; getDataFromUri(uri: string): Promise; popTimeToDisplayFor(key: string): Promise; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index c09ee8f5e1..12809ced93 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -145,6 +145,8 @@ export { export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; +export { getReplay } from './replay/getReplay'; +export type { Replay } from './replay/replayInterface'; /** @deprecated The `FeedbackButton` component will be removed in a future major version. */ export { FeedbackButton } from './feedback/FeedbackButton'; diff --git a/packages/core/src/js/replay/browserReplay.ts b/packages/core/src/js/replay/browserReplay.ts index e5dcfe9f1d..b94f914cc8 100644 --- a/packages/core/src/js/replay/browserReplay.ts +++ b/packages/core/src/js/replay/browserReplay.ts @@ -1,3 +1,4 @@ +import { debug } from '@sentry/core'; import { replayIntegration } from '@sentry/react'; import type { Replay } from './replayInterface'; @@ -28,11 +29,31 @@ const browserReplayIntegration = (options: ReplayConfiguration = {}): Replay => return browserReplayIntegrationNoop(); } - return replayIntegration({ + // `replayIntegration` returns a class instance whose controls (`start`, + // `stop`, `flush`, …) and lifecycle hooks (`afterAllSetup`, `processSpan`) + // live on the prototype. It must NOT be spread — a spread would drop every + // prototype method and break the integration. We attach `pause`/`resume` + // directly on the instance instead. + const integration = replayIntegration({ ...options, mask: ['.sentry-react-native-mask', ...(options.mask || [])], unmask: ['.sentry-react-native-unmask:not(.sentry-react-native-mask *) > *', ...(options.unmask || [])], - }); + }) as unknown as Replay; + + // `pause`/`resume` are part of the shared `Replay` interface (native-backed on + // mobile) but the browser Session Replay SDK does not expose them, so they are + // no-ops that log on Web to keep the cross-platform API interchangeable. + integration.pause = pauseNoop; + integration.resume = resumeNoop; + return integration; +}; + +const pauseNoop = (): void => { + debug.log(`[${INTEGRATION_NAME}] \`pause()\` is not supported on Web. No-op.`); +}; + +const resumeNoop = (): void => { + debug.log(`[${INTEGRATION_NAME}] \`resume()\` is not supported on Web. No-op.`); }; const browserReplayIntegrationNoop = (): Replay => { @@ -41,9 +62,10 @@ const browserReplayIntegrationNoop = (): Replay => { start: () => {}, startBuffering: () => {}, stop: () => Promise.resolve(), + pause: () => {}, + resume: () => {}, flush: () => Promise.resolve(), getReplayId: () => undefined, - getRecordingMode: () => undefined, }; }; diff --git a/packages/core/src/js/replay/getReplay.ts b/packages/core/src/js/replay/getReplay.ts new file mode 100644 index 0000000000..07db091b89 --- /dev/null +++ b/packages/core/src/js/replay/getReplay.ts @@ -0,0 +1,36 @@ +import { getClient } from '@sentry/core'; + +import type { Replay } from './replayInterface'; + +import { notMobileOs } from '../utils/environment'; +import { BROWSER_REPLAY_INTEGRATION_NAME } from './browserReplay'; +import { MOBILE_REPLAY_INTEGRATION_NAME } from './mobilereplay'; + +/** + * Returns the active Session Replay integration, letting you control the replay + * at runtime with the same API on every platform: + * + * ```js + * Sentry.getReplay()?.start(); + * ``` + * + * Resolves the replay integration that is actually functional on the current + * platform: the browser integration on React Native Web, and the mobile (native + * iOS/Android) integration otherwise. This matters for universal apps that + * install both, because on Web the mobile integration is a no-op stub — so the + * browser integration must win there and the mobile one on native. Returns + * `undefined` when no replay integration is installed on the active client. + * + * @see {@link Replay} for the available controls. + */ +export function getReplay(): Replay | undefined { + const client = getClient(); + if (!client) { + return undefined; + } + + const mobileReplay = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME); + const browserReplay = client.getIntegrationByName(BROWSER_REPLAY_INTEGRATION_NAME); + + return notMobileOs() ? (browserReplay ?? mobileReplay) : (mobileReplay ?? browserReplay); +} diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 3129542af7..aebb1f095b 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -6,13 +6,13 @@ import type { ErrorEvent, Event, EventHint, - Integration, Metric, } from '@sentry/core'; import { debug } from '@sentry/core'; import type { ResolvedNetworkOptions } from './networkUtils'; +import type { Replay } from './replayInterface'; import { isHardCrash } from '../misc'; import { deferBreadcrumbNativeSync, syncBreadcrumbToNative } from '../scopeSync'; @@ -274,7 +274,7 @@ function mergeOptions(initOptions: Partial): MobileReplayOp return merged; } -type MobileReplayIntegration = Integration & { +type MobileReplayIntegration = Replay & { options: MobileReplayOptions; getReplayId: () => string | null; }; @@ -334,6 +334,14 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau cachedReplayId = replayId; } + // Invalidate the cache so the next `getReplayId()` re-reads the native replay + // id. The runtime controls (`start`/`startBuffering`/`stop`/`flush`) change the + // native replay identity, so a previously cached id would otherwise go stale and + // link traces/logs/metrics to an inactive or previous replay. + function invalidateCachedReplayId(): void { + cachedReplayId = null; + } + function getCachedReplayId(): string | null { if (cachedReplayId !== null) { return cachedReplayId; @@ -345,6 +353,19 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau return nativeReplayId; } + // Run a native control that changes the replay identity (`stop`/`flush`) and + // invalidate the cached id whether the native call resolves or rejects, then + // preserve the original outcome for the caller. On failure the native replay + // identity is unknown, so dropping the cache and letting the next + // `getReplayId()` re-read from native is always safe and prevents a stale id + // from lingering. + function settleAndInvalidateReplayId(promise: Promise): Promise { + return promise.then(invalidateCachedReplayId, (error: unknown) => { + invalidateCachedReplayId(); + throw error; + }); + } + // Error `sampleRate` sampling runs AFTER `beforeSend` in `@sentry/core` // (since 10.70.0, getsentry/sentry-javascript#22819). Flushing the buffered // replay inside `beforeSend` therefore uploads a replay even for errors that @@ -556,20 +577,51 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau return getCachedReplayId(); } - // TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably - // https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45 return { name: MOBILE_REPLAY_INTEGRATION_NAME, setup, options: options, getReplayId: getReplayId, + start: () => fireReplayControl(NATIVE.startReplay().then(invalidateCachedReplayId), 'start'), + startBuffering: () => + fireReplayControl(NATIVE.startReplayBuffering().then(invalidateCachedReplayId), 'startBuffering'), + stop: () => settleAndInvalidateReplayId(NATIVE.stopReplay()), + pause: () => fireReplayControl(NATIVE.pauseReplay(), 'pause'), + resume: () => fireReplayControl(NATIVE.resumeReplay(), 'resume'), + flush: (options?: { continueRecording?: boolean }) => { + // The native `flushReplay()` always keeps recording after the flush (a + // buffered replay is converted to a session and continues), which matches + // the web default of `continueRecording: true`. When the caller opts out, + // stop the replay once the flush has completed. + const flushed = NATIVE.flushReplay(); + const settled = options?.continueRecording === false ? flushed.then(() => NATIVE.stopReplay()) : flushed; + return settleAndInvalidateReplayId(settled); + }, }; }; +/** + * Runs a fire-and-forget replay control (`start`/`startBuffering`/`pause`/ + * `resume`) whose public signature is synchronous (`void`) to match the web + * Replay API. The underlying native call is async, so we swallow and log any + * rejection here to avoid an unhandled promise rejection. + */ +function fireReplayControl(promise: Promise, method: string): void { + promise.then(undefined, (error: unknown) => { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to ${method} replay`, error); + }); +} + const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { return { name: MOBILE_REPLAY_INTEGRATION_NAME, options: defaultOptions, getReplayId: () => null, // Mock implementation for noop version + start: () => {}, + startBuffering: () => {}, + stop: () => Promise.resolve(), + pause: () => {}, + resume: () => {}, + flush: () => Promise.resolve(), }; }; diff --git a/packages/core/src/js/replay/replayInterface.ts b/packages/core/src/js/replay/replayInterface.ts index 0308a5a385..5c748fb00f 100644 --- a/packages/core/src/js/replay/replayInterface.ts +++ b/packages/core/src/js/replay/replayInterface.ts @@ -5,16 +5,18 @@ import type { Integration, ReplayRecordingMode } from '@sentry/core'; /** * Common interface for React Native Replay integrations. * - * Both browser and mobile replay integrations should implement this interface - * to allow user manually control the replay. + * Both browser and mobile replay integrations implement this interface so users + * can control the replay with the same API regardless of platform. + * + * The shape mirrors the native (iOS/Android) Session Replay runtime controls. + * On Web, `pause()` and `resume()` are no-ops (logged) because the browser + * Session Replay SDK does not expose them; every other method is fully + * supported on all platforms. */ export interface Replay extends Integration { /** * Start a replay regardless of sampling rate. Calling this will always - * create a new session. Will log a message if replay is already in progress. - * - * Creates or loads a session, attaches listeners to varying events (DOM, - * PerformanceObserver, Recording, Sentry SDK, etc) + * create a new session. Does nothing if a replay is already recording. */ start(): void; @@ -25,33 +27,52 @@ export interface Replay extends Integration { startBuffering(): void; /** - * Currently, this needs to be manually called (e.g. for tests). Sentry SDK - * does not support a teardown + * Stop the current replay. A subsequent `start()` creates a fresh replay + * session. */ stop(): Promise; /** - * If not in "session" recording mode, flush event buffer which will create a new replay. - * If replay is not enabled, a new session replay is started. - * Unless `continueRecording` is false, the replay will continue to record and - * behave as a "session"-based replay. + * Pause the current replay. Recording stays paused across background/ + * foreground transitions and automatic restarts until `resume()` is called. + * + * @note No-op on Web (logged) - the browser Session Replay SDK does not + * expose pause/resume. + */ + pause(): void; + + /** + * Resume a replay paused with `pause()`. + * + * @note No-op on Web (logged) - the browser Session Replay SDK does not + * expose pause/resume. + */ + resume(): void; + + /** + * Flush the current replay data to Sentry, or start a full-session replay if + * recording is stopped. * - * Otherwise, queue up a flush. + * @param options.continueRecording When `false`, recording stops after the + * flush; otherwise (the default) recording continues. On mobile this stops + * recording in every mode, whereas on Web it only applies when a buffered + * replay is being converted to a session. */ flush(options?: { continueRecording?: boolean }): Promise; /** - * Get the current session ID. + * Get the current replay (session) ID, or a nullish value if no replay is + * active. */ - getReplayId(): string | undefined; + getReplayId(): string | undefined | null; /** - * Get the current recording mode. This can be either `session` or `buffer`. + * Get the current recording mode (`'session'` or `'buffer'`), or `undefined` + * if no replay is active. * - * `session`: Recording the whole session, sending it continuously - * `buffer`: Always keeping the last 60s of recording, requires: - * - having replaysOnErrorSampleRate > 0 to capture replay when an error occurs - * - or calling `flush()` to send the replay + * @note Web only. The browser Session Replay integration exposes this; the + * mobile integrations do not implement it, so it is optional on the shared + * interface. */ - getRecordingMode(): ReplayRecordingMode | undefined; + getRecordingMode?(): ReplayRecordingMode | undefined; } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 0c24a2d7de..ff1998d5a7 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -81,6 +81,9 @@ interface SentryNativeWrapper { _processLevel(level: SeverityLevel): SeverityLevel; _serializeObject(data: { [key: string]: unknown }): { [key: string]: string }; _isModuleLoaded(module: Spec | undefined): module is Spec; + _callReplayControl( + method: 'startReplay' | 'startReplayBuffering' | 'stopReplay' | 'pauseReplay' | 'resumeReplay' | 'flushReplay', + ): Promise; isNativeAvailable(): boolean; @@ -137,6 +140,12 @@ interface SentryNativeWrapper { captureReplay(isHardCrash: boolean): Promise; getCurrentReplayId(): string | null; + startReplay(): Promise; + startReplayBuffering(): Promise; + stopReplay(): Promise; + pauseReplay(): Promise; + resumeReplay(): Promise; + flushReplay(): Promise; crashedLastRun(): Promise; getNewScreenTimeToDisplay(): Promise; @@ -903,6 +912,54 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.getCurrentReplayId() || null; }, + startReplay(): Promise { + return this._callReplayControl('startReplay'); + }, + + startReplayBuffering(): Promise { + return this._callReplayControl('startReplayBuffering'); + }, + + stopReplay(): Promise { + return this._callReplayControl('stopReplay'); + }, + + pauseReplay(): Promise { + return this._callReplayControl('pauseReplay'); + }, + + resumeReplay(): Promise { + return this._callReplayControl('resumeReplay'); + }, + + flushReplay(): Promise { + return this._callReplayControl('flushReplay'); + }, + + /** + * Invokes a native Session Replay runtime control, degrading gracefully when + * native is disabled, the module isn't linked, or the running (possibly + * cached, older) native binary predates the method - it never throws. + */ + _callReplayControl( + method: 'startReplay' | 'startReplayBuffering' | 'stopReplay' | 'pauseReplay' | 'resumeReplay' | 'flushReplay', + ): Promise { + if (!this.enableNative) { + debug.warn(`[NATIVE] \`${method}\` is not available when native is disabled.`); + return Promise.resolve(); + } + if (!this._isModuleLoaded(RNSentry)) { + debug.warn(`[NATIVE] \`${method}\` is not available when native is not available.`); + return Promise.resolve(); + } + if (typeof RNSentry[method] !== 'function') { + debug.warn(`[NATIVE] \`${method}\` is not available in the current native SDK version.`); + return Promise.resolve(); + } + + return RNSentry[method](); + }, + async crashedLastRun(): Promise { if (!this.enableNative) { return null; diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 2c157b752e..e7ac788854 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -17,6 +17,7 @@ const NATIVE: MockInterface = { _processLevel: jest.fn(), _serializeObject: jest.fn(), _isModuleLoaded: jest.fn(), + _callReplayControl: jest.fn(), _setPrimitiveProcessor: jest.fn(), isNativeAvailable: jest.fn(), @@ -64,6 +65,12 @@ const NATIVE: MockInterface = { captureReplay: jest.fn(), getCurrentReplayId: jest.fn(), + startReplay: jest.fn(), + startReplayBuffering: jest.fn(), + stopReplay: jest.fn(), + pauseReplay: jest.fn(), + resumeReplay: jest.fn(), + flushReplay: jest.fn(), crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn(), @@ -97,6 +104,12 @@ NATIVE.fetchNativeStackFramesBy.mockReturnValue(null); NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve()); NATIVE.captureReplay.mockResolvedValue(null); NATIVE.getCurrentReplayId.mockReturnValue(null); +NATIVE.startReplay.mockResolvedValue(undefined); +NATIVE.startReplayBuffering.mockResolvedValue(undefined); +NATIVE.stopReplay.mockResolvedValue(undefined); +NATIVE.pauseReplay.mockResolvedValue(undefined); +NATIVE.resumeReplay.mockResolvedValue(undefined); +NATIVE.flushReplay.mockResolvedValue(undefined); NATIVE.crashedLastRun.mockResolvedValue(false); NATIVE.popTimeToDisplayFor.mockResolvedValue(null); NATIVE.getNewScreenTimeToDisplay.mockResolvedValue(null); diff --git a/packages/core/test/replay/browserReplay.test.ts b/packages/core/test/replay/browserReplay.test.ts index be12c27e21..a22449c0ef 100644 --- a/packages/core/test/replay/browserReplay.test.ts +++ b/packages/core/test/replay/browserReplay.test.ts @@ -1,4 +1,5 @@ import { describe, test } from '@jest/globals'; +import { debug } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { spyOn } from 'jest-mock'; @@ -21,4 +22,31 @@ describe('Browser Replay', () => { expect(integration).toBeDefined(); expect(SentryReact.replayIntegration).not.toHaveBeenCalled(); }); + + describe('pause/resume no-op on Web', () => { + const mockUpstreamIntegration = (): void => { + spyOn(environment, 'notWeb').mockReturnValue(false); + spyOn(SentryReact, 'replayIntegration').mockReturnValue({ + name: 'Replay', + } as ReturnType); + }; + + test('pause() is a no-op that logs', () => { + mockUpstreamIntegration(); + const debugLog = spyOn(debug, 'log').mockImplementation(() => {}); + + const integration = browserReplayIntegration(); + expect(() => integration.pause()).not.toThrow(); + expect(debugLog).toHaveBeenCalledWith(expect.stringContaining('`pause()` is not supported on Web')); + }); + + test('resume() is a no-op that logs', () => { + mockUpstreamIntegration(); + const debugLog = spyOn(debug, 'log').mockImplementation(() => {}); + + const integration = browserReplayIntegration(); + expect(() => integration.resume()).not.toThrow(); + expect(debugLog).toHaveBeenCalledWith(expect.stringContaining('`resume()` is not supported on Web')); + }); + }); }); diff --git a/packages/core/test/replay/getReplay.test.ts b/packages/core/test/replay/getReplay.test.ts new file mode 100644 index 0000000000..5ae0c6a265 --- /dev/null +++ b/packages/core/test/replay/getReplay.test.ts @@ -0,0 +1,80 @@ +import type { Client } from '@sentry/core'; + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as SentryCore from '@sentry/core'; + +import type { Replay } from '../../src/js/replay/replayInterface'; + +import { getReplay } from '../../src/js/replay/getReplay'; +import * as environment from '../../src/js/utils/environment'; + +describe('getReplay', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockClient = (integrations: Record): jest.Mocked> => ({ + getIntegrationByName: jest.fn(name => integrations[name]) as jest.Mocked, + }); + + const asReplay = (name: string): Replay => ({ name }) as unknown as Replay; + + it('returns undefined when there is no active client', () => { + jest.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + expect(getReplay()).toBeUndefined(); + }); + + it('returns the mobile replay integration when present', () => { + const mobile = asReplay('MobileReplay'); + jest.spyOn(SentryCore, 'getClient').mockReturnValue(mockClient({ MobileReplay: mobile }) as unknown as Client); + + expect(getReplay()).toBe(mobile); + }); + + it('falls back to the browser replay integration when mobile is absent', () => { + const browser = asReplay('Replay'); + jest.spyOn(SentryCore, 'getClient').mockReturnValue(mockClient({ Replay: browser }) as unknown as Client); + + expect(getReplay()).toBe(browser); + }); + + it('prefers the mobile replay integration over the browser one', () => { + const mobile = asReplay('MobileReplay'); + const browser = asReplay('Replay'); + jest + .spyOn(SentryCore, 'getClient') + .mockReturnValue(mockClient({ MobileReplay: mobile, Replay: browser }) as unknown as Client); + + expect(getReplay()).toBe(mobile); + }); + + it('returns undefined when no replay integration is installed', () => { + jest.spyOn(SentryCore, 'getClient').mockReturnValue(mockClient({}) as unknown as Client); + expect(getReplay()).toBeUndefined(); + }); + + describe('on Web (React Native Web)', () => { + beforeEach(() => { + // On Web the mobile integration is only a no-op stub, so the browser one + // must be preferred even when both are installed (universal apps). + jest.spyOn(environment, 'notMobileOs').mockReturnValue(true); + }); + + it('prefers the browser replay integration over the mobile no-op when both are installed', () => { + const mobile = asReplay('MobileReplay'); + const browser = asReplay('Replay'); + jest + .spyOn(SentryCore, 'getClient') + .mockReturnValue(mockClient({ MobileReplay: mobile, Replay: browser }) as unknown as Client); + + expect(getReplay()).toBe(browser); + }); + + it('falls back to the mobile integration when the browser one is absent', () => { + const mobile = asReplay('MobileReplay'); + jest.spyOn(SentryCore, 'getClient').mockReturnValue(mockClient({ MobileReplay: mobile }) as unknown as Client); + + expect(getReplay()).toBe(mobile); + }); + }); +}); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index f13e604ba9..392d7cb66f 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -9,6 +9,7 @@ import type { } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { debug } from '@sentry/core'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; @@ -663,6 +664,155 @@ describe('Mobile Replay Integration', () => { }); }); + describe('runtime controls', () => { + beforeEach(() => { + (NATIVE.startReplay as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.startReplayBuffering as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.stopReplay as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.pauseReplay as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.resumeReplay as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.flushReplay as jest.Mock).mockResolvedValue(undefined as never); + }); + + it('start() calls the native startReplay control', () => { + const integration = mobileReplayIntegration(); + integration.start(); + expect(NATIVE.startReplay).toHaveBeenCalledTimes(1); + }); + + it('startBuffering() calls the native startReplayBuffering control', () => { + const integration = mobileReplayIntegration(); + integration.startBuffering(); + expect(NATIVE.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + + it('stop() calls the native stopReplay control and resolves', async () => { + const integration = mobileReplayIntegration(); + await integration.stop(); + expect(NATIVE.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('pause() calls the native pauseReplay control', () => { + const integration = mobileReplayIntegration(); + integration.pause(); + expect(NATIVE.pauseReplay).toHaveBeenCalledTimes(1); + }); + + it('resume() calls the native resumeReplay control', () => { + const integration = mobileReplayIntegration(); + integration.resume(); + expect(NATIVE.resumeReplay).toHaveBeenCalledTimes(1); + }); + + it('flush() calls the native flushReplay control and resolves', async () => { + const integration = mobileReplayIntegration(); + await integration.flush(); + expect(NATIVE.flushReplay).toHaveBeenCalledTimes(1); + }); + + it('flush() keeps recording by default (does not call stopReplay)', async () => { + const integration = mobileReplayIntegration(); + await integration.flush(); + expect(NATIVE.flushReplay).toHaveBeenCalledTimes(1); + expect(NATIVE.stopReplay).not.toHaveBeenCalled(); + }); + + it('flush({ continueRecording: true }) keeps recording (does not call stopReplay)', async () => { + const integration = mobileReplayIntegration(); + await integration.flush({ continueRecording: true }); + expect(NATIVE.flushReplay).toHaveBeenCalledTimes(1); + expect(NATIVE.stopReplay).not.toHaveBeenCalled(); + }); + + it('flush({ continueRecording: false }) flushes then stops recording', async () => { + const integration = mobileReplayIntegration(); + await integration.flush({ continueRecording: false }); + expect(NATIVE.flushReplay).toHaveBeenCalledTimes(1); + expect(NATIVE.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('swallows and logs a rejected fire-and-forget control', async () => { + const error = new Error('native boom'); + (NATIVE.startReplay as jest.Mock).mockRejectedValue(error as never); + const debugError = jest.spyOn(debug, 'error').mockImplementation(() => {}); + + const integration = mobileReplayIntegration(); + // Must not throw synchronously despite the underlying rejection. + expect(() => integration.start()).not.toThrow(); + + await new Promise(resolve => setImmediate(resolve)); + expect(debugError).toHaveBeenCalledWith(expect.stringContaining('Failed to start replay'), error); + }); + + it('stop() invalidates the cached replay id so getReplayId re-reads native', async () => { + const integration = mobileReplayIntegration(); + // Prime the cache with an active replay id. + mockGetCurrentReplayId.mockReturnValue('old-replay-id'); + expect(integration.getReplayId()).toBe('old-replay-id'); + + // After stop the native replay is gone; the stale id must not be returned. + mockGetCurrentReplayId.mockReturnValue(null); + await integration.stop(); + + expect(integration.getReplayId()).toBeNull(); + }); + + it('start() invalidates the cached replay id so getReplayId reflects the new session', async () => { + const integration = mobileReplayIntegration(); + // Prime the cache with a previous session id. + mockGetCurrentReplayId.mockReturnValue('old-replay-id'); + expect(integration.getReplayId()).toBe('old-replay-id'); + + // A new session is created; getReplayId must pick up the fresh id. + mockGetCurrentReplayId.mockReturnValue('new-replay-id'); + integration.start(); + await new Promise(resolve => setImmediate(resolve)); + + expect(integration.getReplayId()).toBe('new-replay-id'); + }); + + it('flush() invalidates the cached replay id so getReplayId re-reads native', async () => { + const integration = mobileReplayIntegration(); + mockGetCurrentReplayId.mockReturnValue('old-replay-id'); + expect(integration.getReplayId()).toBe('old-replay-id'); + + mockGetCurrentReplayId.mockReturnValue('flushed-replay-id'); + await integration.flush(); + + expect(integration.getReplayId()).toBe('flushed-replay-id'); + }); + + it('stop() invalidates the cached replay id and rejects even when native stopReplay fails', async () => { + const error = new Error('native stop boom'); + (NATIVE.stopReplay as jest.Mock).mockRejectedValue(error as never); + + const integration = mobileReplayIntegration(); + mockGetCurrentReplayId.mockReturnValue('old-replay-id'); + expect(integration.getReplayId()).toBe('old-replay-id'); + + // The rejection is owned by the caller and must propagate. + await expect(integration.stop()).rejects.toThrow(error); + + // The cache must still be invalidated so a stale id is not returned. + mockGetCurrentReplayId.mockReturnValue(null); + expect(integration.getReplayId()).toBeNull(); + }); + + it('flush({ continueRecording: false }) invalidates the cached id and rejects when the trailing stopReplay fails', async () => { + const error = new Error('native stop boom'); + (NATIVE.stopReplay as jest.Mock).mockRejectedValue(error as never); + + const integration = mobileReplayIntegration(); + mockGetCurrentReplayId.mockReturnValue('old-replay-id'); + expect(integration.getReplayId()).toBe('old-replay-id'); + + await expect(integration.flush({ continueRecording: false })).rejects.toThrow(error); + + mockGetCurrentReplayId.mockReturnValue(null); + expect(integration.getReplayId()).toBeNull(); + }); + }); + describe('network detail feature markers', () => { let mockAddIntegration: jest.Mock; let mockGetIntegrationByName: jest.Mock; diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index ca86f409b2..bae5357274 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -63,6 +63,12 @@ jest.mock('react-native', () => { stopProfiling: jest.fn(), pauseAppHangTracking: jest.fn(), resumeAppHangTracking: jest.fn(), + startReplay: jest.fn(() => Promise.resolve()), + startReplayBuffering: jest.fn(() => Promise.resolve()), + stopReplay: jest.fn(() => Promise.resolve()), + pauseReplay: jest.fn(() => Promise.resolve()), + resumeReplay: jest.fn(() => Promise.resolve()), + flushReplay: jest.fn(() => Promise.resolve()), }; return { @@ -1417,6 +1423,80 @@ describe('Tests Native Wrapper', () => { }); }); + describe('replay controls', () => { + const controls: { + name: 'start' | 'startBuffering' | 'stop' | 'pause' | 'resume' | 'flush'; + call: () => Promise; + native: jest.Mock; + }[] = [ + { name: 'start', call: () => NATIVE.startReplay(), native: RNSentry.startReplay as jest.Mock }, + { + name: 'startBuffering', + call: () => NATIVE.startReplayBuffering(), + native: RNSentry.startReplayBuffering as jest.Mock, + }, + { name: 'stop', call: () => NATIVE.stopReplay(), native: RNSentry.stopReplay as jest.Mock }, + { name: 'pause', call: () => NATIVE.pauseReplay(), native: RNSentry.pauseReplay as jest.Mock }, + { name: 'resume', call: () => NATIVE.resumeReplay(), native: RNSentry.resumeReplay as jest.Mock }, + { name: 'flush', call: () => NATIVE.flushReplay(), native: RNSentry.flushReplay as jest.Mock }, + ]; + + it.each(controls)('$name calls the native replay control when enabled', async ({ call, native }) => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + await call(); + expect(native).toHaveBeenCalled(); + }); + + it.each(controls)('$name does not call native when enableNative is false', async ({ call, native }) => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: false, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + await call(); + expect(native).not.toHaveBeenCalled(); + }); + + it.each(controls)( + '$name resolves without calling native when the method is missing on the binary', + async ({ name, call, native }) => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + const methodName = ( + { + start: 'startReplay', + startBuffering: 'startReplayBuffering', + stop: 'stopReplay', + pause: 'pauseReplay', + resume: 'resumeReplay', + flush: 'flushReplay', + } as const + )[name]; + const original = (RNSentry as unknown as Record)[methodName]; + (RNSentry as unknown as Record)[methodName] = undefined; + try { + await expect(call()).resolves.toBeUndefined(); + expect(native).not.toHaveBeenCalled(); + } finally { + (RNSentry as unknown as Record)[methodName] = original; + } + }, + ); + }); + describe('primitiveProcessor and _setPrimitiveProcessor', () => { describe('primitiveProcessor', () => { it('default primitiveProcessor returns value as string', () => { diff --git a/samples/react-native/src/tabs/PlaygroundTab.tsx b/samples/react-native/src/tabs/PlaygroundTab.tsx index 774ec692b7..2d3b3ce713 100644 --- a/samples/react-native/src/tabs/PlaygroundTab.tsx +++ b/samples/react-native/src/tabs/PlaygroundTab.tsx @@ -105,6 +105,58 @@ export default function getPlaygroundTab() { }} /> + Replay Controls: +