From 31f399a22b762a730e7127aefd0b0b0acb78fd14 Mon Sep 17 00:00:00 2001 From: Archish Date: Fri, 31 Jul 2026 13:46:01 +0530 Subject: [PATCH] fix: for ios session recording & recording apis --- README.md | 49 ++++++++++++++ android/build.gradle | 4 +- .../MiddlewareReactNativeModule.java | 23 +++++++ example/src/screens/RumLabScreen.tsx | 24 +++++++ ios/MiddlewareReactNative.m | 9 +++ ios/MiddlewareReactNative.swift | 34 ++++++++++ middleware-react-native.podspec | 2 +- package.json | 2 +- src/middlewareRum.ts | 65 +++++++++++++++++++ src/native.ts | 12 ++++ 10 files changed, 220 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 04ca00c..1a5be0a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/android/build.gradle b/android/build.gradle index 1740a8d..72a0b36 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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 diff --git a/android/src/main/java/com/middlewarereactnative/MiddlewareReactNativeModule.java b/android/src/main/java/com/middlewarereactnative/MiddlewareReactNativeModule.java index 2ee724c..185a221 100644 --- a/android/src/main/java/com/middlewarereactnative/MiddlewareReactNativeModule.java +++ b/android/src/main/java/com/middlewarereactnative/MiddlewareReactNativeModule.java @@ -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); diff --git a/example/src/screens/RumLabScreen.tsx b/example/src/screens/RumLabScreen.tsx index 4a26964..0f45496 100644 --- a/example/src/screens/RumLabScreen.tsx +++ b/example/src/screens/RumLabScreen.tsx @@ -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 ( diff --git a/ios/MiddlewareReactNative.m b/ios/MiddlewareReactNative.m index 388f838..07e670e 100644 --- a/ios/MiddlewareReactNative.m +++ b/ios/MiddlewareReactNative.m @@ -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) diff --git a/ios/MiddlewareReactNative.swift b/ios/MiddlewareReactNative.swift index 4b6adfc..46bf5b5 100644 --- a/ios/MiddlewareReactNative.swift +++ b/ios/MiddlewareReactNative.swift @@ -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(_ 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]) ?? [:]) diff --git a/middleware-react-native.podspec b/middleware-react-native.podspec index 3926127..ce0339d 100644 --- a/middleware-react-native.podspec +++ b/middleware-react-native.podspec @@ -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 diff --git a/package.json b/package.json index cca39b9..ac7f973 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/middlewareRum.ts b/src/middlewareRum.ts index f6dd2b8..795191f 100644 --- a/src/middlewareRum.ts +++ b/src/middlewareRum.ts @@ -33,7 +33,10 @@ import { error, info, initializeNativeSdk, + isNativeRecording, setNativeSessionId, + startNativeRecording, + stopNativeRecording, testNativeAnr, testNativeCrash, warn, @@ -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; + /** + * Stops session recording. Sticky: recording stays off across session + * rotations until `startRecording()` is called. + */ + stopRecording: () => Promise; + /** Whether session recording is currently running. */ + isRecording: () => Promise; info: (message: String) => void; debug: (message: String) => void; warn: (message: String) => void; @@ -128,6 +146,13 @@ 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, @@ -135,6 +160,22 @@ const updateLocation = (latitude: number, longitude: number) => { }); }; +const startRecording = async (): Promise => { + const started = await startNativeRecording(); + if (started) { + isRecordingActive = true; + } + return started; +}; + +const stopRecording = async (): Promise => { + const stopped = await stopNativeRecording(); + if (stopped) { + isRecordingActive = false; + } + return stopped; +}; + export const MiddlewareRum: MiddlewareRumType = { appStartEnd: null, finishAppStart() { @@ -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). @@ -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()) ); @@ -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, diff --git a/src/native.ts b/src/native.ts index 682826b..5a0ffa5 100644 --- a/src/native.ts +++ b/src/native.ts @@ -67,6 +67,18 @@ export const setNativeGlobalAttributes = ( return MiddlewareReactNative.setGlobalAttributes({ ...attributes }); }; +export const startNativeRecording = (): Promise => { + return MiddlewareReactNative.startRecording(); +}; + +export const stopNativeRecording = (): Promise => { + return MiddlewareReactNative.stopRecording(); +}; + +export const isNativeRecording = (): Promise => { + 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