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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -33,6 +34,7 @@
332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = "<group>"; };
3339C47F2D6625260088EB3A /* RNSentry+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentry+Test.h"; sourceTree = "<group>"; };
3339C4802D6625570088EB3A /* RNSentryUserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUserTests.m; sourceTree = "<group>"; };
3339C4822D6625570088EB3A /* RNSentryReplayControlTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryReplayControlTests.m; sourceTree = "<group>"; };
336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = "<group>"; };
3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = "<group>"; };
3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#import <XCTest/XCTest.h>
@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
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,66 @@ public void captureReplay(boolean isHardCrash, Promise promise) {
promise.resolve(getReplayIdFromScope());
}

public void startReplay(Promise promise) {
Comment thread
sentry[bot] marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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}).
*
* <p>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> 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> 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> 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> 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> 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> 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);
}
}
}
Loading
Loading