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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,55 @@ const MiddlewareConfig: ReactNativeConfiguration = {
};
```

#### Recording options

Tune how the recording is captured with `recordingOptions`:

```js
const MiddlewareConfig: ReactNativeConfiguration = {
// ...
sessionRecording: true,
recordingOptions: {
frequency: 'standard', // 'low' (~1 fps, default) | 'standard' | 'high'
quality: 'standard', // 'low' | 'standard' (default) | 'high'
maskAllTextInputs: true, // default true
maskAllImages: true, // default true
},
// Fraction of sessions that get recorded (0.0 - 1.0). Defaults to 1.0.
sessionSamplingRatio: 1.0,
// Fall back to the legacy (v2) screenshot recorder. v3 (rrweb replay) is the default.
disableSessionRecordingV3: false,
};
```

#### Starting and stopping recording at runtime

Recording can be controlled after initialization — useful when you only want to
record a specific flow:

```js
import { MiddlewareRum } from '@middleware.io/middleware-react-native';

await MiddlewareRum.startRecording(); // -> boolean: recording is running
await MiddlewareRum.stopRecording(); // -> boolean: recording was stopped
await MiddlewareRum.isRecording(); // -> boolean
```

Both calls are **sticky**: they survive session rotation and override the session
sampler, so recording stays in the state you asked for until you change it again.

`startRecording()` also overrides `sessionRecording: false`, which lets you keep
recording off by default and turn it on only where you need it:

```js
MiddlewareRum.init({ ...config, sessionRecording: false });

// later, e.g. when the user enters the checkout flow
await MiddlewareRum.startRecording();
// ...
await MiddlewareRum.stopRecording();
```

#### Sanitizing views in session recording

Views will get blurred hiding sensitive information in session recording.
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ dependencies {
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
// Stable native SDK (main branch). 3.0.2 adds setScreenName + setResourceAttributes.
implementation 'io.github.middleware-labs:android-sdk:3.0.2'
// Stable native SDK (main branch). 3.1.0 adds startRecording/stopRecording/isRecording.
implementation 'io.github.middleware-labs:android-sdk:3.1.0'
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
// Compile-visible OTel for the JS-span reconstruction (SpanData etc.);
// runtime classes come transitively from android-sdk. Keep aligned with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,29 @@ public void setSessionId(String sessionId, double startTimeMs) {
this.nativeSessionStartTimeMs = startMsStr;
}

/**
* Starts session recording, overriding both {@code sessionRecording: false} and the
* session sampler. Sticky until stopRecording is called.
*/
@ReactMethod
public void startRecording(Promise promise) {
promise.resolve(Middleware.getInstance().startRecording());
}

/**
* Stops session recording. Sticky across session rotation until startRecording.
*/
@ReactMethod
public void stopRecording(Promise promise) {
Middleware.getInstance().stopRecording();
promise.resolve(!Middleware.getInstance().isRecording());
}

@ReactMethod
public void isRecording(Promise promise) {
promise.resolve(Middleware.getInstance().isRecording());
}

@ReactMethod
public void setGlobalAttributes(ReadableMap attributeMap) {
Attributes attributesFromMap = attributesFromMap(attributeMap);
Expand Down
24 changes: 24 additions & 0 deletions example/src/screens/RumLabScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ export default function RumLabScreen() {
},
],
['Update location', () => MiddlewareRum.updateLocation(23.03, 72.58)],
[
'Start recording',
() => {
MiddlewareRum.startRecording().then((started) =>
Alert.alert('Session recording', started ? 'started' : 'not started')
);
},
],
[
'Stop recording',
() => {
MiddlewareRum.stopRecording().then((stopped) =>
Alert.alert('Session recording', stopped ? 'stopped' : 'not stopped')
);
},
],
[
'Is recording?',
() => {
MiddlewareRum.isRecording().then((recording) =>
Alert.alert('Session recording', recording ? 'running' : 'stopped')
);
},
],
];

return (
Expand Down
9 changes: 9 additions & 0 deletions ios/MiddlewareReactNative.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ @interface RCT_EXTERN_MODULE(MiddlewareReactNative, NSObject)

RCT_EXTERN_METHOD(setScreenName:(NSString*)name)

RCT_EXTERN_METHOD(startRecording:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(stopRecording:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(isRecording:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(debug:(NSString*)message
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
Expand Down
34 changes: 34 additions & 0 deletions ios/MiddlewareReactNative.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ class MiddlewareReactNative: NSObject {
}
}

/// Starts session recording, overriding both `sessionRecording: false` and the
/// session sampler. Sticky until `stopRecording` is called.
@objc(startRecording:withRejecter:)
func startRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
resolve(onMainSync {
MiddlewareRum.startRecording()
return MiddlewareRum.isRecording()
})
}

/// Stops session recording. Sticky across session rotation until `startRecording`.
@objc(stopRecording:withRejecter:)
func stopRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
resolve(onMainSync {
MiddlewareRum.stopRecording()
return !MiddlewareRum.isRecording()
})
}

@objc(isRecording:withRejecter:)
func isRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
resolve(MiddlewareRum.isRecording())
}

/// MiddlewareRum applies recording state on the main thread, but RN runs module
/// methods on its own serial queue. Hop to main and wait so the promise resolves
/// with the settled state instead of a stale one.
private func onMainSync<T>(_ work: () -> T) -> T {
if Thread.isMainThread {
return work()
}
return DispatchQueue.main.sync(execute: work)
}

@objc(setGlobalAttributes:withResolver:withRejecter:)
func setGlobalAttributes(attributes: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
MiddlewareRum.setGlobalAttributes((attributes as? [String: Any]) ?? [:])
Expand Down
2 changes: 1 addition & 1 deletion middleware-react-native.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Pod::Spec.new do |s|
s.dependency "React-Core"
# Stable native SDK (2.1+ adds setNativeSession + exportRawSpans); brings
# PLCrashReporter/DeviceKit/SwiftProtobuf/SWCompression/Reachability transitively.
s.dependency "MiddlewareRum", "~> 2.1"
s.dependency "MiddlewareRum", "~> 2.2"

# Don't install the dependencies when we run `pod install` in the old architecture.
if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@middleware.io/middleware-react-native",
"version": "2.0.2",
"version": "2.1.0",
"description": "Middleware React Native real user monitoring SDK",
"main": "lib/commonjs/index",
"module": "lib/module/index",
Expand Down
65 changes: 65 additions & 0 deletions src/middlewareRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import {
error,
info,
initializeNativeSdk,
isNativeRecording,
setNativeSessionId,
startNativeRecording,
stopNativeRecording,
testNativeAnr,
testNativeCrash,
warn,
Expand Down Expand Up @@ -114,6 +117,21 @@ export interface MiddlewareRumType {
setGlobalAttributes: (attributes: Attributes) => void;
updateLocation: (latitude: number, longitude: number) => void;
getSessionId: () => string;
/**
* Starts session recording, overriding both `sessionRecording: false` and the
* session sampler. Sticky: recording keeps running across session rotations
* until `stopRecording()` is called.
*
* @returns whether recording is running after the call.
*/
startRecording: () => Promise<boolean>;
/**
* Stops session recording. Sticky: recording stays off across session
* rotations until `startRecording()` is called.
*/
stopRecording: () => Promise<boolean>;
/** Whether session recording is currently running. */
isRecording: () => Promise<boolean>;
info: (message: String) => void;
debug: (message: String) => void;
warn: (message: String) => void;
Expand All @@ -128,13 +146,36 @@ const DEFAULT_CONFIG = {
let appStartInfo: AppStartInfo | null = null;
let isInitialized = false;

// Live recording state, mirrored onto the provider resource so exported spans
// always carry the current `recording`/`recordingV3` values. Recording can be
// toggled at runtime, and these attributes are what tell the backend a session
// has a replay to play back.
let isRecordingActive = false;
let isRecordingV3Configured = true;

const updateLocation = (latitude: number, longitude: number) => {
setGlobalAttributes({
[LOCATION_LATITUDE]: latitude,
[LOCATION_LONGITUDE]: longitude,
});
};

const startRecording = async (): Promise<boolean> => {
const started = await startNativeRecording();
if (started) {
isRecordingActive = true;
}
return started;
};

const stopRecording = async (): Promise<boolean> => {
const stopped = await stopNativeRecording();
if (stopped) {
isRecordingActive = false;
}
return stopped;
};

export const MiddlewareRum: MiddlewareRumType = {
appStartEnd: null,
finishAppStart() {
Expand Down Expand Up @@ -191,6 +232,9 @@ export const MiddlewareRum: MiddlewareRumType = {
sessionRecording = 'true';
}

isRecordingActive = sessionRecording === 'true';
isRecordingV3Configured = !config.disableSessionRecordingV3;

const recordingAttr = sessionRecording === 'true' ? '1' : '0';
// recordingV3 routes bifrost to the rrweb player; matches the native
// SDKs' resource attribute (v3 is on by default when recording is on).
Expand Down Expand Up @@ -256,6 +300,24 @@ export const MiddlewareRum: MiddlewareRumType = {
enumerable: true,
});

// Read live so startRecording()/stopRecording() are reflected on every
// exported span rather than frozen at the value configured at init.
Object.defineProperty(provider.resource.attributes, 'recording', {
get() {
return isRecordingActive ? '1' : '0';
},
configurable: true,
enumerable: true,
});

Object.defineProperty(provider.resource.attributes, 'recordingV3', {
get() {
return isRecordingActive && isRecordingV3Configured ? '1' : '0';
},
configurable: true,
enumerable: true,
});

provider.addSpanProcessor(
new BatchSpanProcessor(new ReacNativeSpanExporter())
);
Expand Down Expand Up @@ -523,6 +585,9 @@ export const MiddlewareRum: MiddlewareRumType = {
setGlobalAttributes: setGlobalAttributes,
updateLocation: updateLocation,
getSessionId: getSessionId,
startRecording: startRecording,
stopRecording: stopRecording,
isRecording: isNativeRecording,
info: info,
error: error,
debug: debug,
Expand Down
12 changes: 12 additions & 0 deletions src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ export const setNativeGlobalAttributes = (
return MiddlewareReactNative.setGlobalAttributes({ ...attributes });
};

export const startNativeRecording = (): Promise<boolean> => {
return MiddlewareReactNative.startRecording();
};

export const stopNativeRecording = (): Promise<boolean> => {
return MiddlewareReactNative.stopRecording();
};

export const isNativeRecording = (): Promise<boolean> => {
return MiddlewareReactNative.isRecording();
};

/**
* Pushes the JS route name into the native screen-name store so native tap
* spans and the v3 session recording carry it instead of the host
Expand Down
Loading