Skip to content
Draft
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Add `pauseTouchTracking()` and `resumeTouchTracking()` to the Session Replay API ([#7030](https://github.com/getsentry/sentry-react-native/pull/7030))
- Use them to stop recording touch positions on sensitive screens, for example a custom PIN pad. Screenshots and breadcrumbs continue to be recorded.
- They are independent of `pause()` and `resume()`. Touch tracking stays paused across new replays until you call `resumeTouchTracking()`.
- Requires sentry-android 8.65.0 and sentry-cocoa 9.35.0 or newer. On older native SDKs and on Web, the calls do nothing.

```js
Sentry.getReplay()?.pauseTouchTracking();
// Show the PIN pad.
Sentry.getReplay()?.resumeTouchTracking();
```

## 8.35.0

### Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
/**
* Coverage for the Session Replay runtime controls bridged through
* `RNSentryInternal` (`startReplay`, `startReplayBuffering`, `stopReplay`,
* `pauseReplay`, `resumeReplay`, `flushReplay`).
* `pauseReplay`, `resumeReplay`, `pauseReplayTouchTracking`,
* `resumeReplayTouchTracking`, `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,
Expand Down Expand Up @@ -47,6 +48,18 @@ - (void)testResumeReplayDoesNotCrashWhenNotRunning
XCTAssertNil(RNSentryInternal.replayId);
}

- (void)testPauseReplayTouchTrackingDoesNotCrashWhenNotRunning
{
[RNSentryInternal pauseReplayTouchTracking];
XCTAssertNil(RNSentryInternal.replayId);
}

- (void)testResumeReplayTouchTrackingDoesNotCrashWhenNotRunning
{
[RNSentryInternal resumeReplayTouchTracking];
XCTAssertNil(RNSentryInternal.replayId);
}

- (void)testFlushReplayDoesNotCrashWhenNotRunning
{
[RNSentryInternal flushReplay];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,26 @@ public void resumeReplay(Promise promise) {
}
}

public void pauseReplayTouchTracking(Promise promise) {
try {
Sentry.replay().pauseTouchTracking();
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 touch tracking", e);
promise.reject("SentryReactNative", e.getMessage(), e);
}
}

public void resumeReplayTouchTracking(Promise promise) {
try {
Sentry.replay().resumeTouchTracking();
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 touch tracking", e);
promise.reject("SentryReactNative", e.getMessage(), e);
}
}

public void flushReplay(Promise promise) {
try {
Sentry.replay().flush();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ public void resumeReplay(Promise promise) {
this.impl.resumeReplay(promise);
}

@Override
public void pauseReplayTouchTracking(Promise promise) {
this.impl.pauseReplayTouchTracking(promise);
}

@Override
public void resumeReplayTouchTracking(Promise promise) {
this.impl.resumeReplayTouchTracking(promise);
}

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

@ReactMethod
public void pauseReplayTouchTracking(Promise promise) {
this.impl.pauseReplayTouchTracking(promise);
}

@ReactMethod
public void resumeReplayTouchTracking(Promise promise) {
this.impl.resumeReplayTouchTracking(promise);
}

@ReactMethod
public void flushReplay(Promise promise) {
this.impl.flushReplay(promise);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
/**
* Coverage for the Session Replay runtime controls exposed on {@link RNSentryModuleImpl} ({@code
* startReplay}, {@code startReplayBuffering}, {@code stopReplay}, {@code pauseReplay}, {@code
* resumeReplay}, {@code flushReplay}).
* resumeReplay}, {@code pauseReplayTouchTracking}, {@code resumeReplayTouchTracking}, {@code
* flushReplay}).
*
* <p>Each control forwards to the corresponding {@link IReplayApi} method returned by {@code
* Sentry.replay()} and then resolves the promise with {@code null}.
Expand Down Expand Up @@ -113,6 +114,34 @@ public void resumeReplayCallsSdkAndResolvesNull() {
}
}

@Test
public void pauseReplayTouchTrackingCallsSdkAndResolvesNull() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class)) {
final IReplayApi replay = mock(IReplayApi.class);
sentry.when(Sentry::replay).thenReturn(replay);

final Promise promise = mock(Promise.class);
module.pauseReplayTouchTracking(promise);

verify(replay).pauseTouchTracking();
verify(promise).resolve(null);
}
}

@Test
public void resumeReplayTouchTrackingCallsSdkAndResolvesNull() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class)) {
final IReplayApi replay = mock(IReplayApi.class);
sentry.when(Sentry::replay).thenReturn(replay);

final Promise promise = mock(Promise.class);
module.resumeReplayTouchTracking(promise);

verify(replay).resumeTouchTracking();
verify(promise).resolve(null);
}
}

@Test
public void flushReplayCallsSdkAndResolvesNull() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,9 @@ export interface Replay extends Integration {
getRecordingMode?(): ReplayRecordingMode | undefined;
getReplayId(): string | undefined | null;
pause(): void;
pauseTouchTracking(): void;
resume(): void;
resumeTouchTracking(): void;
start(): void;
startBuffering(): void;
stop(): Promise<void>;
Expand Down
32 changes: 32 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,38 @@ + (BOOL)captureReplayWithReturnValue
#endif
}

RCT_EXPORT_METHOD(pauseReplayTouchTracking : (RCTPromiseResolveBlock)resolve rejecter : (
RCTPromiseRejectBlock)reject)
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
@try {
[RNSentryInternalWrapper pauseReplayTouchTracking];
resolve(nil);
} @catch (NSException *exception) {
NSLog(@"[RNSentry] Failed to call pauseReplayTouchTracking: %@", exception);
reject(@"SentryReactNative", exception.reason, nil);
}
#else
resolve(nil);
#endif
}

RCT_EXPORT_METHOD(resumeReplayTouchTracking : (RCTPromiseResolveBlock)resolve rejecter : (
RCTPromiseRejectBlock)reject)
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
@try {
[RNSentryInternalWrapper resumeReplayTouchTracking];
resolve(nil);
} @catch (NSException *exception) {
NSLog(@"[RNSentry] Failed to call resumeReplayTouchTracking: %@", exception);
reject(@"SentryReactNative", exception.reason, nil);
}
#else
resolve(nil);
#endif
}

RCT_EXPORT_METHOD(
flushReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
{
Expand Down
4 changes: 4 additions & 0 deletions packages/core/ios/RNSentryInternalWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ NS_ASSUME_NONNULL_BEGIN

+ (void)resumeReplay;

+ (void)pauseReplayTouchTracking;

+ (void)resumeReplayTouchTracking;

+ (void)flushReplay;

+ (void)registerReplayTraceId:(NSString *)traceId;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/ios/RNSentryInternalWrapper.m
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ + (void)resumeReplay
[RNSentryInternal resumeReplay];
}

+ (void)pauseReplayTouchTracking
{
[RNSentryInternal pauseReplayTouchTracking];
}

+ (void)resumeReplayTouchTracking
{
[RNSentryInternal resumeReplayTouchTracking];
}

+ (void)flushReplay
{
[RNSentryInternal flushReplay];
Expand Down
10 changes: 10 additions & 0 deletions packages/core/ios/Swift/RNSentryInternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ import Foundation
SentrySDK.internal.replay.resume()
}

@_spi(Private) @objc public static func pauseReplayTouchTracking() {
SentrySDK.internal.replay.pauseTouchTracking()
}

@_spi(Private) @objc public static func resumeReplayTouchTracking() {
SentrySDK.internal.replay.resumeTouchTracking()
}

@_spi(Private) @objc public static func flushReplay() {
SentrySDK.internal.replay.flush()
}
Expand Down Expand Up @@ -207,6 +215,8 @@ import Foundation
@_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 pauseReplayTouchTracking() {}
@_spi(Private) @objc public static func resumeReplayTouchTracking() {}
@_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) {}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface Spec extends TurboModule {
stopReplay(): Promise<void>;
pauseReplay(): Promise<void>;
resumeReplay(): Promise<void>;
pauseReplayTouchTracking(): Promise<void>;
resumeReplayTouchTracking(): Promise<void>;
flushReplay(): Promise<void>;
registerReplayTraceId(traceId: string): void;
crashedLastRun(): Promise<boolean | undefined | null>;
Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/js/replay/browserReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@ const browserReplayIntegration = (options: ReplayConfiguration = {}): Replay =>
// `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.
// (and the touch tracking controls) 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.
// `pause`/`resume` and `pauseTouchTracking`/`resumeTouchTracking` 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;
integration.pauseTouchTracking = pauseTouchTrackingNoop;
integration.resumeTouchTracking = resumeTouchTrackingNoop;
return integration;
};

Expand All @@ -56,6 +59,14 @@ const resumeNoop = (): void => {
debug.log(`[${INTEGRATION_NAME}] \`resume()\` is not supported on Web. No-op.`);
};

const pauseTouchTrackingNoop = (): void => {
debug.log(`[${INTEGRATION_NAME}] \`pauseTouchTracking()\` is not supported on Web. No-op.`);
};

const resumeTouchTrackingNoop = (): void => {
debug.log(`[${INTEGRATION_NAME}] \`resumeTouchTracking()\` is not supported on Web. No-op.`);
};

const browserReplayIntegrationNoop = (): Replay => {
return {
name: INTEGRATION_NAME,
Expand All @@ -64,6 +75,8 @@ const browserReplayIntegrationNoop = (): Replay => {
stop: () => Promise.resolve(),
pause: () => {},
resume: () => {},
pauseTouchTracking: () => {},
resumeTouchTracking: () => {},
flush: () => Promise.resolve(),
getReplayId: () => undefined,
};
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
stop: () => settleAndInvalidateReplayId(NATIVE.stopReplay()),
pause: () => fireReplayControl(NATIVE.pauseReplay(), 'pause'),
resume: () => fireReplayControl(NATIVE.resumeReplay(), 'resume'),
pauseTouchTracking: () => fireReplayControl(NATIVE.pauseReplayTouchTracking(), 'pauseTouchTracking'),
resumeTouchTracking: () => fireReplayControl(NATIVE.resumeReplayTouchTracking(), 'resumeTouchTracking'),
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
Expand All @@ -768,8 +770,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau

/**
* 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
* `resume`/`pauseTouchTracking`/`resumeTouchTracking`) 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<void>, method: string): void {
Expand All @@ -788,6 +790,8 @@ const mobileReplayIntegrationNoop = (): MobileReplayIntegration => {
stop: () => Promise.resolve(),
pause: () => {},
resume: () => {},
pauseTouchTracking: () => {},
resumeTouchTracking: () => {},
flush: () => Promise.resolve(),
};
};
35 changes: 32 additions & 3 deletions packages/core/src/js/replay/replayInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import type { Integration, ReplayRecordingMode } from '@sentry/core';
* 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.
* On Web, `pause()`, `resume()`, `pauseTouchTracking()` and
* `resumeTouchTracking()` 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 {
/**
Expand Down Expand Up @@ -49,6 +50,34 @@ export interface Replay extends Integration {
*/
resume(): void;

/**
* Stop recording touch positions in the replay. Screenshots continue to be
* recorded, so a masked screen (for example a PIN pad) stays visible without
* revealing where the user tapped. Breadcrumbs are not affected.
*
* Independent of `pause()`/`resume()`: `resume()` does not resume touch
* tracking. The state persists across new replays and `stop()`/`start()`
* until `resumeTouchTracking()` is called, and it can be set before a replay
* starts.
*
* @note Requires a native SDK version that supports it (sentry-android
* 8.65.0, sentry-cocoa 9.35.0); no-op on older native SDKs.
* @note No-op on Web (logged) - the browser Session Replay SDK does not
* expose touch tracking controls.
*/
pauseTouchTracking(): void;

/**
* Resume recording touch positions after `pauseTouchTracking()`. Touches are
* still not recorded while the replay itself is paused with `pause()`.
*
* @note Requires a native SDK version that supports it (sentry-android
* 8.65.0, sentry-cocoa 9.35.0); no-op on older native SDKs.
* @note No-op on Web (logged) - the browser Session Replay SDK does not
* expose touch tracking controls.
*/
resumeTouchTracking(): void;

/**
* Flush the current replay data to Sentry, or start a full-session replay if
* recording is stopped.
Expand Down
Loading
Loading