@obsrviq/react-native 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/lumera/replay/LumeraMaskViewManager.kt +39 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayModule.kt +530 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayPackage.kt +50 -0
- package/ios/LumeraMaskView.h +19 -0
- package/ios/LumeraMaskView.m +79 -0
- package/ios/LumeraMaskViewManager.m +29 -0
- package/ios/LumeraReplay.swift +703 -0
- package/ios/LumeraReplayModule.h +20 -0
- package/ios/LumeraReplayModule.mm +93 -0
- package/lumera-react-native.podspec +19 -0
- package/package.json +46 -0
- package/src/LumeraMask.tsx +52 -0
- package/src/config.ts +96 -0
- package/src/emit.ts +5 -0
- package/src/index.ts +292 -0
- package/src/instrument/console.ts +46 -0
- package/src/instrument/errors.ts +28 -0
- package/src/instrument/network.ts +219 -0
- package/src/session.ts +108 -0
- package/src/spec/NativeLumeraReplay.ts +70 -0
- package/src/transport.ts +40 -0
- package/src/util.ts +38 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <React/RCTBridgeModule.h>
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The native screenshot-capture TurboModule, exposed to JS as "LumeraReplay".
|
|
8
|
+
*
|
|
9
|
+
* This thin ObjC++ shell exists only to satisfy React Native's codegen'd New-Arch
|
|
10
|
+
* protocol (NativeLumeraReplaySpec, generated from src/spec/NativeLumeraReplay.ts via
|
|
11
|
+
* the `LumeraReplaySpec` codegen target) and to forward each call to the shared Swift
|
|
12
|
+
* engine `LumeraReplay`, which owns the entire capture → mask → encode → upload loop.
|
|
13
|
+
*
|
|
14
|
+
* No capture logic lives here. The implementation (.mm) conforms to the generated
|
|
15
|
+
* <NativeLumeraReplaySpec> protocol and implements `getTurboModule:` for the New Arch.
|
|
16
|
+
*/
|
|
17
|
+
@interface LumeraReplayModule : NSObject <RCTBridgeModule>
|
|
18
|
+
@end
|
|
19
|
+
|
|
20
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#import "LumeraReplayModule.h"
|
|
2
|
+
|
|
3
|
+
// The Swift engine (LumeraReplay, @objc-exposed) is surfaced to ObjC++ through the
|
|
4
|
+
// pod's auto-generated Swift umbrella header. CocoaPods names it
|
|
5
|
+
// "<module_name>-Swift.h"; for this pod (`lumera-react-native`) Clang sanitizes that
|
|
6
|
+
// to `lumera_react_native-Swift.h`. We tolerate either spelling so the bridge builds
|
|
7
|
+
// whether the host app links us as a pod (umbrella under the pod name) or compiles the
|
|
8
|
+
// sources into the app target (umbrella under the app's product module name).
|
|
9
|
+
#if __has_include(<lumera_react_native/lumera_react_native-Swift.h>)
|
|
10
|
+
#import <lumera_react_native/lumera_react_native-Swift.h>
|
|
11
|
+
#elif __has_include("lumera_react_native-Swift.h")
|
|
12
|
+
#import "lumera_react_native-Swift.h"
|
|
13
|
+
#elif __has_include("LumeraReactNative-Swift.h")
|
|
14
|
+
#import "LumeraReactNative-Swift.h"
|
|
15
|
+
#else
|
|
16
|
+
// Fallback: forward-declare the @objc surface we call so the bridge still compiles if
|
|
17
|
+
// the umbrella header name differs in a given app. The linker resolves it from the
|
|
18
|
+
// Swift object at link time.
|
|
19
|
+
@interface LumeraReplay : NSObject
|
|
20
|
+
@property (class, nonatomic, readonly, strong) LumeraReplay *shared;
|
|
21
|
+
- (void)startWithOptions:(NSDictionary *)options;
|
|
22
|
+
- (void)stopObjc;
|
|
23
|
+
- (void)configureWithOptions:(NSDictionary *)options;
|
|
24
|
+
- (void)setViewMasked:(NSNumber *)reactTag masked:(NSNumber *)masked;
|
|
25
|
+
- (NSDictionary *)getDiagnosticsObjc;
|
|
26
|
+
@end
|
|
27
|
+
#endif
|
|
28
|
+
|
|
29
|
+
// The codegen'd New-Arch spec header (target name `LumeraReplaySpec`, from
|
|
30
|
+
// package.json → codegenConfig.name). Present only when codegen has run (i.e. inside a
|
|
31
|
+
// real app build with the New Architecture). Guarded so this file also compiles on the
|
|
32
|
+
// old architecture, where the RCT_EXPORT_METHOD declarations below carry the module.
|
|
33
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
34
|
+
#import <LumeraReplaySpec/LumeraReplaySpec.h>
|
|
35
|
+
#endif
|
|
36
|
+
|
|
37
|
+
@implementation LumeraReplayModule
|
|
38
|
+
|
|
39
|
+
// Registers this as the TurboModule/NativeModule named "LumeraReplay" — the exact name
|
|
40
|
+
// TurboModuleRegistry.getEnforcing<Spec>('LumeraReplay') resolves on the JS side.
|
|
41
|
+
RCT_EXPORT_MODULE(LumeraReplay)
|
|
42
|
+
|
|
43
|
+
// All forwarding is trivial (dispatch onto the engine, which owns its own threads); no
|
|
44
|
+
// reason to hop onto a method queue.
|
|
45
|
+
+ (BOOL)requiresMainQueueSetup { return NO; }
|
|
46
|
+
|
|
47
|
+
#pragma mark - Spec methods (forward to the shared Swift engine)
|
|
48
|
+
|
|
49
|
+
// `start(options)` — object arg arrives as NSDictionary at the ObjC boundary.
|
|
50
|
+
RCT_EXPORT_METHOD(start:(NSDictionary *)options)
|
|
51
|
+
{
|
|
52
|
+
[LumeraReplay.shared startWithOptions:options];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `stop()`
|
|
56
|
+
RCT_EXPORT_METHOD(stop)
|
|
57
|
+
{
|
|
58
|
+
[LumeraReplay.shared stopObjc];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `configure(options)`
|
|
62
|
+
RCT_EXPORT_METHOD(configure:(NSDictionary *)options)
|
|
63
|
+
{
|
|
64
|
+
[LumeraReplay.shared configureWithOptions:options];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `setViewMasked(reactTag, masked)` — numbers cross as `double`/`BOOL` on New Arch.
|
|
68
|
+
RCT_EXPORT_METHOD(setViewMasked:(double)reactTag masked:(BOOL)masked)
|
|
69
|
+
{
|
|
70
|
+
[LumeraReplay.shared setViewMasked:@(reactTag) masked:@(masked)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `getDiagnostics(): Promise<NativeDiagnostics>` — resolves with the counters snapshot.
|
|
74
|
+
RCT_EXPORT_METHOD(getDiagnostics:(RCTPromiseResolveBlock)resolve
|
|
75
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
76
|
+
{
|
|
77
|
+
resolve([LumeraReplay.shared getDiagnosticsObjc]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#pragma mark - New Architecture (TurboModule) wiring
|
|
81
|
+
|
|
82
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
83
|
+
// Hands the runtime the C++ JSI binding generated for our spec. `NativeLumeraReplaySpecJSI`
|
|
84
|
+
// is emitted by codegen from src/spec/NativeLumeraReplay.ts (spec target `LumeraReplaySpec`)
|
|
85
|
+
// and dispatches each JSI call to the RCT_EXPORT_METHOD implementations above.
|
|
86
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
87
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
88
|
+
{
|
|
89
|
+
return std::make_shared<facebook::react::NativeLumeraReplaySpecJSI>(params);
|
|
90
|
+
}
|
|
91
|
+
#endif
|
|
92
|
+
|
|
93
|
+
@end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "lumera-react-native"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = "https://lumera.app"
|
|
10
|
+
s.license = "MIT"
|
|
11
|
+
s.authors = "Lumera"
|
|
12
|
+
s.platforms = { :ios => "13.4" }
|
|
13
|
+
s.source = { :git => "https://github.com/TedoraTech/lumera.git", :tag => "#{s.version}" }
|
|
14
|
+
|
|
15
|
+
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
16
|
+
|
|
17
|
+
# Pulls in React-Core, and (on New Arch) the codegen'd LumeraReplaySpec + RN-Core deps.
|
|
18
|
+
install_modules_dependencies(s)
|
|
19
|
+
end
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obsrviq/react-native",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lumera mobile capture SDK for React Native — privacy-first screenshot session replay + network/error/custom-event recording, engineered for zero added latency on device.",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"module": "src/index.ts",
|
|
10
|
+
"react-native": "src/index.ts",
|
|
11
|
+
"types": "src/index.ts",
|
|
12
|
+
"source": "src/index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"ios",
|
|
16
|
+
"android",
|
|
17
|
+
"lumera-react-native.podspec",
|
|
18
|
+
"!**/__tests__"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@obsrviq/types": "0.4.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "*",
|
|
25
|
+
"react-native": ">=0.76",
|
|
26
|
+
"@react-native-async-storage/async-storage": ">=1.21"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@react-native-async-storage/async-storage": "^2.1.0",
|
|
30
|
+
"@types/react": "^19.0.0",
|
|
31
|
+
"react": "^19.1.1",
|
|
32
|
+
"react-native": "0.82.0",
|
|
33
|
+
"typescript": "^5.7.2"
|
|
34
|
+
},
|
|
35
|
+
"codegenConfig": {
|
|
36
|
+
"name": "LumeraReplaySpec",
|
|
37
|
+
"type": "modules",
|
|
38
|
+
"jsSrcsDir": "src/spec",
|
|
39
|
+
"android": {
|
|
40
|
+
"javaPackageName": "app.lumera.replay"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Platform, View, type ViewProps, requireNativeComponent } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <LumeraMask> — privacy-by-region wrapper for the native screenshot replay.
|
|
6
|
+
*
|
|
7
|
+
* Wrap any subtree to control whether it is redacted in captured frames:
|
|
8
|
+
*
|
|
9
|
+
* <LumeraMask> …</LumeraMask> // force-mask this subtree
|
|
10
|
+
* <LumeraMask unmask> …</LumeraMask> // force-UNMASK (overrides maskAllText/Images)
|
|
11
|
+
*
|
|
12
|
+
* Under the hood the native view (un)registers its reactTag with the capture engine via
|
|
13
|
+
* `setViewMasked(reactTag, …)`. Masking is composited natively, off the UI thread, during
|
|
14
|
+
* capture — this component renders its children verbatim and adds no per-frame cost.
|
|
15
|
+
*
|
|
16
|
+
* The native view exists on iOS (and Android, task #96). On unsupported platforms /
|
|
17
|
+
* unlinked builds (e.g. Expo Go) it degrades to a plain <View> so layout is unaffected.
|
|
18
|
+
*/
|
|
19
|
+
export interface LumeraMaskProps extends ViewProps {
|
|
20
|
+
/** Force-unmask this subtree (overrides the global maskAllText/maskAllImages defaults). */
|
|
21
|
+
unmask?: boolean;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NativeMaskProps extends ViewProps {
|
|
26
|
+
masked: boolean;
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve the native component lazily + defensively: if the native module isn't linked
|
|
31
|
+
// the require throws, and we fall back to a plain View (capture simply won't mask here).
|
|
32
|
+
const NativeLumeraMask: React.ComponentType<NativeMaskProps> | null = (() => {
|
|
33
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null;
|
|
34
|
+
try {
|
|
35
|
+
return requireNativeComponent<NativeMaskProps>('LumeraMaskView');
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
export function LumeraMask({ unmask, children, ...rest }: LumeraMaskProps): React.ReactElement {
|
|
42
|
+
if (!NativeLumeraMask) {
|
|
43
|
+
return <View {...rest}>{children}</View>;
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
<NativeLumeraMask masked={!unmask} {...rest}>
|
|
47
|
+
{children}
|
|
48
|
+
</NativeLumeraMask>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default LumeraMask;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { IngestBatch } from '@obsrviq/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public init config for the Lumera React Native SDK. Mirrors the web tracker's
|
|
5
|
+
* `ObsrviqInitConfig` where it makes sense, swapping DOM-specific privacy knobs for
|
|
6
|
+
* the native screenshot-replay knobs (fps / jpegQuality / maskAll*).
|
|
7
|
+
*/
|
|
8
|
+
export interface LumeraConfig {
|
|
9
|
+
/** REQUIRED — your ingest key (pk_…). */
|
|
10
|
+
siteKey: string;
|
|
11
|
+
|
|
12
|
+
// ── Identity (optional; can also be set later via identify()/setMetadata()) ──
|
|
13
|
+
userId?: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
// ── Screenshot replay capture (native) ──
|
|
17
|
+
/** Record the screen (native screenshot stream). Default true. */
|
|
18
|
+
enableReplay?: boolean;
|
|
19
|
+
/** Capture cadence cap, frames/sec. Change-driven underneath. Default 1. */
|
|
20
|
+
fps?: number;
|
|
21
|
+
/** JPEG quality 0..1 on the encode path. Default 0.4 (~good/small, à la PostHog 0.3). */
|
|
22
|
+
jpegQuality?: number;
|
|
23
|
+
/** Cap the longer edge of each frame to this many px (downscaled on device before
|
|
24
|
+
* encode). THE storage/bandwidth lever: full native res (~1080×2400) is ~4× bigger
|
|
25
|
+
* for no replay gain. Default 1200 (≈ half res, ~25KB/frame). 0 = full native res. */
|
|
26
|
+
maxCaptureDim?: number;
|
|
27
|
+
/** Privacy-by-default: mask all text. Default true. */
|
|
28
|
+
maskAllText?: boolean;
|
|
29
|
+
/** Mask all images/media. Default true. */
|
|
30
|
+
maskAllImages?: boolean;
|
|
31
|
+
/** Capture taps + swipes as a touch overlay on the replay (native, non-blocking
|
|
32
|
+
* gesture observer — does not interfere with the app's own gestures). Default true. */
|
|
33
|
+
captureTouches?: boolean;
|
|
34
|
+
|
|
35
|
+
// ── Network capture (pure JS: fetch + XHR) ──
|
|
36
|
+
/** Capture request/response bodies (masked, size-capped). Default false. */
|
|
37
|
+
captureNetworkBodies?: boolean;
|
|
38
|
+
/** Header allowlist redaction. Default ['authorization','cookie','set-cookie']. */
|
|
39
|
+
redactHeaders?: string[];
|
|
40
|
+
|
|
41
|
+
// ── Console + error capture (pure JS) ──
|
|
42
|
+
/** Mirror console.* into the session. Default true. */
|
|
43
|
+
captureConsole?: boolean;
|
|
44
|
+
|
|
45
|
+
// ── Sampling / lifecycle ──
|
|
46
|
+
/** 0..1 — fraction of sessions recorded. Default 1. */
|
|
47
|
+
sampleRate?: number;
|
|
48
|
+
/** Resume the same session across app foreground/background within the window. Default false. */
|
|
49
|
+
continueSession?: boolean;
|
|
50
|
+
/** Inactivity window for continueSession, ms. Default 30 min. */
|
|
51
|
+
sessionTimeoutMs?: number;
|
|
52
|
+
|
|
53
|
+
// ── Transport ──
|
|
54
|
+
/** Ingest base URL. Default 'https://in.lumera.app'. */
|
|
55
|
+
endpoint?: string;
|
|
56
|
+
/** Flush cadence for the structured-event stream, ms. Default 5000. */
|
|
57
|
+
flushIntervalMs?: number;
|
|
58
|
+
|
|
59
|
+
// ── Consent ──
|
|
60
|
+
/** Gate all recording until setConsent(true). Default false. */
|
|
61
|
+
requireConsent?: boolean;
|
|
62
|
+
|
|
63
|
+
// ── Hooks ──
|
|
64
|
+
/** Inspect/drop/modify each outgoing batch (structured-event stream). Return null to drop. */
|
|
65
|
+
beforeSend?: (batch: IngestBatch) => IngestBatch | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ResolvedConfig = Required<
|
|
69
|
+
Omit<LumeraConfig, 'userId' | 'metadata' | 'beforeSend'>
|
|
70
|
+
> &
|
|
71
|
+
Pick<LumeraConfig, 'userId' | 'metadata' | 'beforeSend'>;
|
|
72
|
+
|
|
73
|
+
export const DEFAULTS: Omit<ResolvedConfig, 'siteKey' | 'userId' | 'metadata' | 'beforeSend'> = {
|
|
74
|
+
enableReplay: true,
|
|
75
|
+
fps: 1,
|
|
76
|
+
jpegQuality: 0.4,
|
|
77
|
+
maxCaptureDim: 1200,
|
|
78
|
+
maskAllText: true,
|
|
79
|
+
maskAllImages: true,
|
|
80
|
+
captureTouches: true,
|
|
81
|
+
captureNetworkBodies: false,
|
|
82
|
+
redactHeaders: ['authorization', 'cookie', 'set-cookie'],
|
|
83
|
+
captureConsole: true,
|
|
84
|
+
sampleRate: 1,
|
|
85
|
+
continueSession: false,
|
|
86
|
+
sessionTimeoutMs: 30 * 60 * 1000,
|
|
87
|
+
endpoint: 'https://in.lumera.app',
|
|
88
|
+
flushIntervalMs: 5000,
|
|
89
|
+
requireConsent: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function resolveConfig(config: LumeraConfig): ResolvedConfig {
|
|
93
|
+
const clean: Record<string, unknown> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(config)) if (v !== undefined) clean[k] = v;
|
|
95
|
+
return { ...DEFAULTS, ...(clean as Partial<ResolvedConfig>), siteKey: config.siteKey };
|
|
96
|
+
}
|
package/src/emit.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Dimensions, Platform } from 'react-native';
|
|
2
|
+
import type { IngestBatch, IngestSessionMeta, ObsrviqEvent } from '@obsrviq/types';
|
|
3
|
+
import NativeLumeraReplay from './spec/NativeLumeraReplay';
|
|
4
|
+
import { type LumeraConfig, type ResolvedConfig, resolveConfig } from './config';
|
|
5
|
+
import { Clock, uuid } from './util';
|
|
6
|
+
import { clearSession, persistProgress, resolveSession, type ResolvedSession } from './session';
|
|
7
|
+
import { Transport } from './transport';
|
|
8
|
+
import { installNetwork } from './instrument/network';
|
|
9
|
+
import { installErrors } from './instrument/errors';
|
|
10
|
+
import { installConsole } from './instrument/console';
|
|
11
|
+
import type { RawEvent } from './emit';
|
|
12
|
+
|
|
13
|
+
export interface Diagnostics {
|
|
14
|
+
recording: boolean;
|
|
15
|
+
sessionId: string | null;
|
|
16
|
+
flushes: number;
|
|
17
|
+
eventsSent: number;
|
|
18
|
+
eventsQueued: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lumera React Native capture SDK. Mirrors the web tracker's public API 1:1, so app
|
|
23
|
+
* code reads the same on web and mobile. The pure-JS side here owns identity, the
|
|
24
|
+
* structured-event stream (network / error / console / custom), and transport; the
|
|
25
|
+
* heavy screenshot replay stream is owned end-to-end by the native TurboModule
|
|
26
|
+
* (capture → mask → encode → upload), which never blocks the JS thread.
|
|
27
|
+
*/
|
|
28
|
+
class LumeraReplayClient {
|
|
29
|
+
private cfg: ResolvedConfig | null = null;
|
|
30
|
+
private clock: Clock | null = null;
|
|
31
|
+
private transport: Transport | null = null;
|
|
32
|
+
private _sessionId: string | null = null;
|
|
33
|
+
private seq = 0;
|
|
34
|
+
private meta: IngestSessionMeta | null = null;
|
|
35
|
+
private events: ObsrviqEvent[] = [];
|
|
36
|
+
private uninstalls: Array<() => void> = [];
|
|
37
|
+
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
private recording = false;
|
|
39
|
+
private consent = true;
|
|
40
|
+
private ready = false;
|
|
41
|
+
private diag = { flushes: 0, eventsSent: 0 };
|
|
42
|
+
|
|
43
|
+
/** Current session id (null until init resolves). */
|
|
44
|
+
get sessionId(): string | null {
|
|
45
|
+
return this._sessionId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Initialize the SDK. Idempotent. Session resolution is async (AsyncStorage); any
|
|
49
|
+
* events emitted before it resolves are buffered and timestamped correctly. */
|
|
50
|
+
init(config: LumeraConfig): void {
|
|
51
|
+
if (this.cfg) return;
|
|
52
|
+
const cfg = resolveConfig(config);
|
|
53
|
+
// Sampling: decide once, up front. A sampled-out session records nothing.
|
|
54
|
+
if (Math.random() >= cfg.sampleRate) return;
|
|
55
|
+
this.cfg = cfg;
|
|
56
|
+
this.consent = !cfg.requireConsent;
|
|
57
|
+
void this.bootstrap();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async bootstrap(): Promise<void> {
|
|
61
|
+
const cfg = this.cfg!;
|
|
62
|
+
const resolved = await resolveSession({
|
|
63
|
+
continueSession: cfg.continueSession,
|
|
64
|
+
timeoutMs: cfg.sessionTimeoutMs,
|
|
65
|
+
userId: cfg.userId,
|
|
66
|
+
});
|
|
67
|
+
this._sessionId = resolved.id;
|
|
68
|
+
this.seq = resolved.seq;
|
|
69
|
+
this.clock = new Clock(resolved.startedAt);
|
|
70
|
+
this.transport = new Transport(cfg.endpoint, cfg.siteKey);
|
|
71
|
+
this.meta = this.buildMeta(resolved);
|
|
72
|
+
this.ready = true;
|
|
73
|
+
|
|
74
|
+
// Backfill `t`/sessionId for events emitted before resolution completed.
|
|
75
|
+
for (const e of this.events) {
|
|
76
|
+
if (e.t < 0) {
|
|
77
|
+
(e as { t: number }).t = Math.max(0, e.ts - resolved.startedAt);
|
|
78
|
+
(e as { sessionId: string }).sessionId = resolved.id;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (this.consent) this.startRecording();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private buildMeta(r: ResolvedSession): IngestSessionMeta {
|
|
85
|
+
const cfg = this.cfg!;
|
|
86
|
+
const win = Dimensions.get('window');
|
|
87
|
+
return {
|
|
88
|
+
startedAt: r.startedAt,
|
|
89
|
+
entryUrl: '', // native app — no URL; left blank (server tolerates)
|
|
90
|
+
device: {
|
|
91
|
+
type: 'mobile',
|
|
92
|
+
os: Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS,
|
|
93
|
+
viewport: { w: Math.round(win.width), h: Math.round(win.height) },
|
|
94
|
+
},
|
|
95
|
+
userId: cfg.userId,
|
|
96
|
+
metadata: cfg.metadata,
|
|
97
|
+
visitorId: r.visitorId,
|
|
98
|
+
visitCount: r.visitCount,
|
|
99
|
+
firstSeenAt: r.firstSeenAt,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private startRecording(): void {
|
|
104
|
+
if (this.recording || !this.cfg || !this.clock) return;
|
|
105
|
+
const cfg = this.cfg;
|
|
106
|
+
this.recording = true;
|
|
107
|
+
|
|
108
|
+
// ── Pure-JS instrumentation (runs on the JS thread, off the capture path) ──
|
|
109
|
+
const now = () => this.clock!.now();
|
|
110
|
+
this.uninstalls.push(
|
|
111
|
+
installNetwork(this.enqueue, {
|
|
112
|
+
skipUrl: this.transport!.batchUrl,
|
|
113
|
+
captureBodies: cfg.captureNetworkBodies,
|
|
114
|
+
redactHeaders: cfg.redactHeaders,
|
|
115
|
+
now,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
this.uninstalls.push(installErrors(this.enqueue));
|
|
119
|
+
if (cfg.captureConsole) this.uninstalls.push(installConsole(this.enqueue));
|
|
120
|
+
|
|
121
|
+
// ── Native screenshot capture (owns capture + encode + upload, off the UI/JS thread) ──
|
|
122
|
+
if (cfg.enableReplay) {
|
|
123
|
+
try {
|
|
124
|
+
NativeLumeraReplay.start({
|
|
125
|
+
endpoint: cfg.endpoint,
|
|
126
|
+
siteKey: cfg.siteKey,
|
|
127
|
+
sessionId: this._sessionId!,
|
|
128
|
+
startedAtMs: this.clock.startedAt,
|
|
129
|
+
fps: cfg.fps,
|
|
130
|
+
jpegQuality: cfg.jpegQuality,
|
|
131
|
+
maxCaptureDim: cfg.maxCaptureDim,
|
|
132
|
+
maskAllText: cfg.maskAllText,
|
|
133
|
+
maskAllImages: cfg.maskAllImages,
|
|
134
|
+
captureTouches: cfg.captureTouches,
|
|
135
|
+
flushIntervalMs: cfg.flushIntervalMs,
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
/* native module not linked (e.g. Expo Go) — structured capture still works */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.flushTimer = setInterval(() => void this.flush(), cfg.flushIntervalMs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stamp the envelope + queue an event (arrow fn so instruments can pass it directly). */
|
|
146
|
+
private enqueue = (raw: RawEvent): void => {
|
|
147
|
+
if (this.ready && !this.recording) return; // recording stopped → drop
|
|
148
|
+
const ts = Date.now();
|
|
149
|
+
const t = this.clock ? this.clock.now() : -1; // -1 = backfilled on ready
|
|
150
|
+
this.events.push({
|
|
151
|
+
id: uuid(),
|
|
152
|
+
sessionId: this._sessionId ?? '',
|
|
153
|
+
t,
|
|
154
|
+
ts,
|
|
155
|
+
...raw,
|
|
156
|
+
} as unknown as ObsrviqEvent);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────── Public API (mirrors web) ───────────────────────────
|
|
160
|
+
|
|
161
|
+
identify(userId: string, traits?: Record<string, unknown>): void {
|
|
162
|
+
if (this.meta) {
|
|
163
|
+
this.meta.userId = userId;
|
|
164
|
+
if (traits) this.meta.metadata = { ...(this.meta.metadata ?? {}), ...traits };
|
|
165
|
+
} else if (this.cfg) {
|
|
166
|
+
this.cfg = { ...this.cfg, userId, metadata: { ...(this.cfg.metadata ?? {}), ...traits } };
|
|
167
|
+
}
|
|
168
|
+
this.enqueue({ type: 'custom', name: 'identify', props: { userId, ...traits } });
|
|
169
|
+
void this.flush();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setMetadata(props: Record<string, unknown>): void {
|
|
173
|
+
if (this.meta) this.meta.metadata = { ...(this.meta.metadata ?? {}), ...props };
|
|
174
|
+
this.enqueue({ type: 'custom', name: 'metadata', props });
|
|
175
|
+
void this.flush();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
track(name: string, props?: Record<string, unknown>): void {
|
|
179
|
+
this.enqueue({ type: 'custom', name, props });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Record a screen/route view — the mobile analog of a web pageview. Call from your
|
|
183
|
+
* navigation listener (e.g. React Navigation's onStateChange). Drives
|
|
184
|
+
* pages-per-session and path/journey analysis; `name` is the path key. */
|
|
185
|
+
screen(name: string, props?: Record<string, unknown>): void {
|
|
186
|
+
const clean = String(name || '').trim();
|
|
187
|
+
if (!clean) return;
|
|
188
|
+
this.enqueue({ type: 'pageview', url: clean, path: clean, title: clean, ...(props ? { props } : {}) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
conversion(name: string, props?: Record<string, unknown>): void {
|
|
192
|
+
this.enqueue({ type: 'custom', name, props: { ...props, conversion: true } });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
tag(...names: string[]): void {
|
|
196
|
+
if (names.length) this.enqueue({ type: 'custom', name: '_tag', props: { tags: names } });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
startTask(name: string, props?: Record<string, unknown>): { end: (endProps?: Record<string, unknown>) => void } {
|
|
200
|
+
const spanId = uuid();
|
|
201
|
+
this.enqueue({ type: 'custom', name: `${name}:started`, props: { ...props, spanId } });
|
|
202
|
+
return {
|
|
203
|
+
end: (endProps?: Record<string, unknown>) =>
|
|
204
|
+
this.enqueue({ type: 'custom', name: `${name}:ended`, props: { ...endProps, spanId } }),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
endTask(name: string, props?: Record<string, unknown>): void {
|
|
209
|
+
this.enqueue({ type: 'custom', name: `${name}:ended`, props });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setConsent(granted: boolean): void {
|
|
213
|
+
this.consent = granted;
|
|
214
|
+
if (granted && this.ready && !this.recording) this.startRecording();
|
|
215
|
+
else if (!granted) this.stop();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Logout: drop identity + the continuation record, then start a fresh session. */
|
|
219
|
+
reset(): void {
|
|
220
|
+
void clearSession();
|
|
221
|
+
this.stop();
|
|
222
|
+
if (this.cfg) {
|
|
223
|
+
this.cfg = { ...this.cfg, userId: undefined, metadata: undefined };
|
|
224
|
+
this._sessionId = null;
|
|
225
|
+
this.seq = 0;
|
|
226
|
+
this.ready = false;
|
|
227
|
+
this.events = [];
|
|
228
|
+
void this.bootstrap();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stop(): void {
|
|
233
|
+
if (this.flushTimer) {
|
|
234
|
+
clearInterval(this.flushTimer);
|
|
235
|
+
this.flushTimer = null;
|
|
236
|
+
}
|
|
237
|
+
for (const u of this.uninstalls.splice(0)) u();
|
|
238
|
+
if (this.cfg?.enableReplay) {
|
|
239
|
+
try {
|
|
240
|
+
NativeLumeraReplay.stop();
|
|
241
|
+
} catch {
|
|
242
|
+
/* not linked */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.recording = false;
|
|
246
|
+
void this.flush();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Force-send the structured-event stream now. */
|
|
250
|
+
async flush(): Promise<boolean> {
|
|
251
|
+
if (!this.transport || !this._sessionId || this.events.length === 0) return true;
|
|
252
|
+
const events = this.events.splice(0);
|
|
253
|
+
const seq = this.seq;
|
|
254
|
+
let batch: IngestBatch | null = {
|
|
255
|
+
siteKey: this.cfg!.siteKey,
|
|
256
|
+
sessionId: this._sessionId,
|
|
257
|
+
seq,
|
|
258
|
+
meta: this.meta ?? undefined,
|
|
259
|
+
events,
|
|
260
|
+
};
|
|
261
|
+
if (this.cfg!.beforeSend) batch = this.cfg!.beforeSend(batch);
|
|
262
|
+
if (!batch) return true;
|
|
263
|
+
|
|
264
|
+
const ok = await this.transport.send(batch);
|
|
265
|
+
if (ok) {
|
|
266
|
+
this.seq = seq + 1;
|
|
267
|
+
this.diag.flushes += 1;
|
|
268
|
+
this.diag.eventsSent += events.length;
|
|
269
|
+
void persistProgress(this._sessionId, this.seq, this.meta?.userId);
|
|
270
|
+
} else {
|
|
271
|
+
this.events.unshift(...events); // re-queue on failure
|
|
272
|
+
}
|
|
273
|
+
return ok;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getDiagnostics(): Diagnostics {
|
|
277
|
+
return {
|
|
278
|
+
recording: this.recording,
|
|
279
|
+
sessionId: this._sessionId,
|
|
280
|
+
flushes: this.diag.flushes,
|
|
281
|
+
eventsSent: this.diag.eventsSent,
|
|
282
|
+
eventsQueued: this.events.length,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const LumeraReplay = new LumeraReplayClient();
|
|
288
|
+
export default LumeraReplay;
|
|
289
|
+
export type { LumeraConfig };
|
|
290
|
+
|
|
291
|
+
// Privacy-by-region component for the native screenshot replay (<LumeraMask> / <LumeraMask unmask>).
|
|
292
|
+
export { LumeraMask, type LumeraMaskProps } from './LumeraMask';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ConsoleLevel, SerializedArg } from '@obsrviq/types';
|
|
2
|
+
import type { Emit } from '../emit';
|
|
3
|
+
|
|
4
|
+
const LEVELS: ConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
|
|
5
|
+
const CAP = 2000;
|
|
6
|
+
|
|
7
|
+
function serializeArg(a: unknown): SerializedArg {
|
|
8
|
+
if (typeof a === 'string') return { kind: 'string', value: a.slice(0, CAP), truncated: a.length > CAP };
|
|
9
|
+
if (a === null || typeof a === 'number' || typeof a === 'boolean')
|
|
10
|
+
return { kind: 'primitive', value: a as string | number | boolean | null };
|
|
11
|
+
if (a instanceof Error) return { kind: 'error', name: a.name, message: a.message, stack: a.stack };
|
|
12
|
+
try {
|
|
13
|
+
const json = JSON.stringify(a) ?? String(a);
|
|
14
|
+
return {
|
|
15
|
+
kind: 'object',
|
|
16
|
+
preview: Array.isArray(a) ? `Array(${a.length})` : 'Object',
|
|
17
|
+
json: json.slice(0, CAP),
|
|
18
|
+
truncated: json.length > CAP,
|
|
19
|
+
};
|
|
20
|
+
} catch {
|
|
21
|
+
return { kind: 'object', preview: String(a) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Mirrors console.* into the session as `console` events. Pure JS. */
|
|
26
|
+
export function installConsole(emit: Emit): () => void {
|
|
27
|
+
const c = console as unknown as Record<string, (...args: unknown[]) => void>;
|
|
28
|
+
const orig: Record<string, (...args: unknown[]) => void> = {};
|
|
29
|
+
for (const level of LEVELS) {
|
|
30
|
+
const o = c[level];
|
|
31
|
+
if (typeof o !== 'function') continue;
|
|
32
|
+
const bound = o.bind(console);
|
|
33
|
+
orig[level] = bound;
|
|
34
|
+
c[level] = (...args: unknown[]) => {
|
|
35
|
+
try {
|
|
36
|
+
emit({ type: 'console', level, args: args.map(serializeArg) });
|
|
37
|
+
} catch {
|
|
38
|
+
/* noop */
|
|
39
|
+
}
|
|
40
|
+
bound(...args);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return () => {
|
|
44
|
+
for (const level of LEVELS) if (orig[level]) c[level] = orig[level];
|
|
45
|
+
};
|
|
46
|
+
}
|