@bugsession/react-native 0.1.0-beta.2 → 0.1.0-beta.4
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 +8 -2
- package/dist/capture.d.ts +12 -0
- package/dist/core.d.ts +33 -1
- package/dist/core.js +65 -2
- package/dist/index.d.ts +18 -10
- package/dist/index.js +156 -65
- package/dist/trigger.d.ts +55 -0
- package/dist/trigger.js +297 -0
- package/package.json +8 -2
- package/src/capture.ts +6 -0
- package/src/core.ts +66 -2
- package/src/index.tsx +162 -74
- package/src/trigger.tsx +364 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Opt-in screen recording, microphone narration and bounded runtime telemetry for
|
|
|
4
4
|
|
|
5
5
|
## Compatibility
|
|
6
6
|
|
|
7
|
-
The SDK supports React 18.3.1–19.x and React Native 0.76–0.88. Expo SDK 54–58 are supported; SDK 52–53 are best effort. CI builds Android against SDK 52
|
|
7
|
+
The SDK supports React 18.3.1–19.x and React Native 0.76–0.88. Expo SDK 54–58 are supported; SDK 52–53 are best effort. CI builds Android against SDK 52–58 and iOS simulators against SDK 52 and 54–58. Each Expo SDK selects its matching React and React Native versions; do not mix versions manually. iOS and Android require a native build and `@bugsession/capture-native`; Expo Go cannot capture screen video. Android uses MediaProjection and asks for screen-capture consent every session. The hosted recorder admits one session at a time.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -56,7 +56,13 @@ Configure once while idle. Wire an automatic stop timer into your recording UI,
|
|
|
56
56
|
|
|
57
57
|
React Navigation integration: call the function returned by `BugSession.navigationListener(navigationRef)` from both `NavigationContainer.onReady` and `onStateChange`. Expo Router apps can call `trackNavigation` when the committed pathname changes. Use `BugSession.Touchable` to instrument a Pressable, or `trackAction` directly. Send caught errors through `BugSession.captureError(error)`.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Render errors are caught by Expo Router before they reach the global handler. Export the SDK's boundary from your root layout so they are captured: `export const ErrorBoundary = BugSession.ErrorBoundary;`. Unhandled promise rejections are captured in every build; in development they still reach LogBox.
|
|
60
|
+
|
|
61
|
+
`configure()` installs fetch, console (`log`, `info`, `debug`, `warn`, `error`), error and rejection hooks for the app's lifetime. By default the SDK keeps the last 60 seconds of these app events (at most 500; never video or audio) in memory. When a recording starts, they are attached with negative timestamps and `preRecording: true`, so a bug that happened just before the tester pressed record keeps its context. Nothing leaves the device unless a recording starts. Set `preRecordSeconds` (maximum 300) to change the window, or `0` to collect only while recording. Recorders deployed before pre-record support accept the recording and ignore these events.
|
|
62
|
+
|
|
63
|
+
`<BugSession.Trigger />` renders a slim tab docked to a screen edge, so it never covers bottom buttons or tab bars. Tap it to start recording. Drag it along the edge or across to the other side; it remembers its placement while the app is open. Render it once above the app navigator. `position` picks the initial edge and half (`bottom-right` by default), `offset` the distance from the top or bottom safe area, and `draggable={false}` locks it. Supply `children` to replace the idle record mark.
|
|
64
|
+
|
|
65
|
+
While recording, the tab shows a pulsing dot, elapsed time and live microphone levels. Tap it for the options panel: **Draw on screen** and **Stop & send**. The panel hides itself after a few seconds so it stays out of the video as much as possible; the tab itself is part of the screen recording. Draw opens a pen layer over the app: testers circle the problem while narrating, then use Undo, Clear or Done. Drawings appear in the video, and each stroke is recorded as an `annotation` event with its bounds in screen points. After stopping, a toast at the top shows sending progress, then "Report sent" or "Not sent" with the reason; it also reflects `BugSession.start()`/`stop()` called directly. Recording stops automatically after `maxDurationSeconds` (default 150) so uploads stay within the recorder limit. Pass `onError` to also receive permission, recorder, and upload failures. With `react-native-screens` installed (included with Expo Router), the tab renders above native modals. Live levels and exact safe-area placement need `@bugsession/capture-native` 0.1.0-beta.4 or later in a rebuilt native app; older builds show static bars.
|
|
60
66
|
|
|
61
67
|
Android uses `AndroidCapture` from `@bugsession/capture-native` plus an `audio` adapter implementing `prepare`, `start`, `stop` (returns a local URI), and `upload`. `prepare` must request microphone permission. `expo-audio` can provide this adapter. Android shows a system screen-capture prompt each session and a persistent capture notification; keep the app foregrounded through upload.
|
|
62
68
|
|
package/dist/capture.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export type NativeCaptureAdapter = {
|
|
|
14
14
|
stop(): Promise<NativeRecording>;
|
|
15
15
|
cancel(): Promise<void>;
|
|
16
16
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
17
|
+
/** Optional: live microphone level (0-1) for the recording indicator. Returns an unsubscribe function. */
|
|
18
|
+
onAudioLevel?(listener: (level: number) => void): () => void;
|
|
19
|
+
/** Optional: safe-area insets in points, used to place the recording controls above the home indicator. */
|
|
20
|
+
safeAreaInsets?(): Promise<{
|
|
21
|
+
top: number;
|
|
22
|
+
bottom: number;
|
|
23
|
+
} | null>;
|
|
24
|
+
/** Optional: native app and device facts merged into each recording's metadata. */
|
|
25
|
+
appContext?(): Promise<{
|
|
26
|
+
application: Record<string, unknown>;
|
|
27
|
+
device: Record<string, unknown>;
|
|
28
|
+
} | null>;
|
|
17
29
|
};
|
|
18
30
|
export declare function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number): {
|
|
19
31
|
videoStartMs: number;
|
package/dist/core.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare class SessionClock {
|
|
|
10
10
|
private epoch;
|
|
11
11
|
constructor(source?: () => number);
|
|
12
12
|
now(): number;
|
|
13
|
+
/** Monotonic source reading at which this clock reads zero. */
|
|
14
|
+
get origin(): number;
|
|
13
15
|
}
|
|
14
16
|
export declare class Collector {
|
|
15
17
|
clock: SessionClock;
|
|
@@ -18,5 +20,35 @@ export declare class Collector {
|
|
|
18
20
|
dropped: number;
|
|
19
21
|
constructor(clock?: SessionClock, limit?: number);
|
|
20
22
|
add(type: string, data: Record<string, unknown>): void;
|
|
23
|
+
addAt(type: string, data: Record<string, unknown>, timestampMs: number): void;
|
|
21
24
|
}
|
|
22
|
-
export
|
|
25
|
+
export type TelemetrySink = {
|
|
26
|
+
add(type: string, data: Record<string, unknown>): void;
|
|
27
|
+
clock: {
|
|
28
|
+
now(): number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare const MAX_PRE_RECORD_MS = 300000;
|
|
32
|
+
/**
|
|
33
|
+
* Routes app events to the active session, or, between sessions, into a bounded
|
|
34
|
+
* in-memory buffer of the last `preRecordMs`. The buffer never leaves the device
|
|
35
|
+
* unless a recording starts. Timestamps are kept on one app-lifetime clock and
|
|
36
|
+
* converted to the session clock, so requests spanning the start stay consistent.
|
|
37
|
+
*/
|
|
38
|
+
export declare class Telemetry implements TelemetrySink {
|
|
39
|
+
private maxBuffered;
|
|
40
|
+
readonly clock: SessionClock;
|
|
41
|
+
private buffer;
|
|
42
|
+
private session;
|
|
43
|
+
private preRecordMs;
|
|
44
|
+
constructor(preRecordMs?: number, maxBuffered?: number, source?: () => number);
|
|
45
|
+
/** False when nothing would be kept, so callers can skip formatting work. */
|
|
46
|
+
get active(): boolean;
|
|
47
|
+
configure(preRecordMs: number, maxBuffered?: number): void;
|
|
48
|
+
add(type: string, data: Record<string, unknown>): void;
|
|
49
|
+
/** Routes events to `collector` and returns the buffered events on its clock (negative times). */
|
|
50
|
+
begin(collector: Collector): Evidence[];
|
|
51
|
+
end(): void;
|
|
52
|
+
private prune;
|
|
53
|
+
}
|
|
54
|
+
export declare function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch;
|
package/dist/core.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Collector = exports.SessionClock = void 0;
|
|
3
|
+
exports.Telemetry = exports.MAX_PRE_RECORD_MS = exports.Collector = exports.SessionClock = void 0;
|
|
4
4
|
exports.safeUrl = safeUrl;
|
|
5
5
|
exports.safeText = safeText;
|
|
6
6
|
exports.instrumentFetch = instrumentFetch;
|
|
@@ -29,6 +29,8 @@ class SessionClock {
|
|
|
29
29
|
this.epoch = source();
|
|
30
30
|
}
|
|
31
31
|
now() { return Math.max(0, this.source() - this.epoch); }
|
|
32
|
+
/** Monotonic source reading at which this clock reads zero. */
|
|
33
|
+
get origin() { return this.epoch; }
|
|
32
34
|
}
|
|
33
35
|
exports.SessionClock = SessionClock;
|
|
34
36
|
class Collector {
|
|
@@ -39,14 +41,75 @@ class Collector {
|
|
|
39
41
|
this.dropped = 0;
|
|
40
42
|
}
|
|
41
43
|
add(type, data) {
|
|
44
|
+
this.addAt(type, data, this.clock.now());
|
|
45
|
+
}
|
|
46
|
+
addAt(type, data, timestampMs) {
|
|
42
47
|
if (this.events.length >= this.limit) {
|
|
43
48
|
this.dropped++;
|
|
44
49
|
return;
|
|
45
50
|
}
|
|
46
|
-
this.events.push({ ...data, type, timestampMs
|
|
51
|
+
this.events.push({ ...data, type, timestampMs });
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
exports.Collector = Collector;
|
|
55
|
+
exports.MAX_PRE_RECORD_MS = 300000;
|
|
56
|
+
/**
|
|
57
|
+
* Routes app events to the active session, or, between sessions, into a bounded
|
|
58
|
+
* in-memory buffer of the last `preRecordMs`. The buffer never leaves the device
|
|
59
|
+
* unless a recording starts. Timestamps are kept on one app-lifetime clock and
|
|
60
|
+
* converted to the session clock, so requests spanning the start stay consistent.
|
|
61
|
+
*/
|
|
62
|
+
class Telemetry {
|
|
63
|
+
constructor(preRecordMs = 60000, maxBuffered = 500, source = () => performance.now()) {
|
|
64
|
+
this.maxBuffered = maxBuffered;
|
|
65
|
+
this.buffer = [];
|
|
66
|
+
this.session = null;
|
|
67
|
+
this.clock = new SessionClock(source);
|
|
68
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, exports.MAX_PRE_RECORD_MS));
|
|
69
|
+
}
|
|
70
|
+
/** False when nothing would be kept, so callers can skip formatting work. */
|
|
71
|
+
get active() { return !!this.session || this.preRecordMs > 0; }
|
|
72
|
+
configure(preRecordMs, maxBuffered = this.maxBuffered) {
|
|
73
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, exports.MAX_PRE_RECORD_MS));
|
|
74
|
+
this.maxBuffered = maxBuffered;
|
|
75
|
+
this.prune(this.clock.now());
|
|
76
|
+
}
|
|
77
|
+
add(type, data) {
|
|
78
|
+
const now = this.clock.now();
|
|
79
|
+
if (this.session) {
|
|
80
|
+
const { collector, offsetMs } = this.session;
|
|
81
|
+
collector.addAt(type, shiftRequestTime(data, offsetMs), now - offsetMs);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (this.preRecordMs <= 0)
|
|
85
|
+
return;
|
|
86
|
+
this.buffer.push({ ...data, type, timestampMs: now });
|
|
87
|
+
this.prune(now);
|
|
88
|
+
}
|
|
89
|
+
/** Routes events to `collector` and returns the buffered events on its clock (negative times). */
|
|
90
|
+
begin(collector) {
|
|
91
|
+
const offsetMs = collector.clock.origin - this.clock.origin;
|
|
92
|
+
this.prune(this.clock.now());
|
|
93
|
+
const before = this.buffer.map(e => ({ ...shiftRequestTime(e, offsetMs), timestampMs: e.timestampMs - offsetMs, preRecording: true }));
|
|
94
|
+
this.buffer = [];
|
|
95
|
+
this.session = { collector, offsetMs };
|
|
96
|
+
return before;
|
|
97
|
+
}
|
|
98
|
+
end() { this.session = null; }
|
|
99
|
+
prune(now) {
|
|
100
|
+
const cutoff = now - this.preRecordMs;
|
|
101
|
+
let drop = 0;
|
|
102
|
+
while (drop < this.buffer.length && this.buffer[drop].timestampMs < cutoff)
|
|
103
|
+
drop++;
|
|
104
|
+
drop = Math.max(drop, this.buffer.length - this.maxBuffered);
|
|
105
|
+
if (drop > 0)
|
|
106
|
+
this.buffer.splice(0, drop);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.Telemetry = Telemetry;
|
|
110
|
+
function shiftRequestTime(data, offsetMs) {
|
|
111
|
+
return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
|
|
112
|
+
}
|
|
50
113
|
function instrumentFetch(original, collector) {
|
|
51
114
|
return (async (input, init) => {
|
|
52
115
|
const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { type PressableProps
|
|
2
|
+
import { type PressableProps } from 'react-native';
|
|
3
|
+
export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
|
|
3
4
|
import { type NativeCaptureAdapter } from './capture';
|
|
4
5
|
export type { NativeCaptureAdapter } from './capture';
|
|
5
|
-
export { Collector, SessionClock, safeText, safeUrl } from './core';
|
|
6
|
+
export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
|
|
6
7
|
export type AudioAdapter = {
|
|
7
8
|
prepare(): Promise<void>;
|
|
8
9
|
start(): void;
|
|
@@ -16,14 +17,15 @@ export type BugSessionOptions = {
|
|
|
16
17
|
capture?: NativeCaptureAdapter;
|
|
17
18
|
device: Record<string, unknown>;
|
|
18
19
|
application: Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* Seconds of app events (logs, errors, network metadata, actions, navigation; never video or audio)
|
|
22
|
+
* kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
|
|
23
|
+
*/
|
|
24
|
+
preRecordSeconds?: number;
|
|
19
25
|
};
|
|
20
|
-
export type
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
style?: StyleProp<ViewStyle>;
|
|
24
|
-
accessibilityLabel?: string;
|
|
25
|
-
children?: React.ReactNode;
|
|
26
|
-
onError?: (error: unknown) => void;
|
|
26
|
+
export type BugSessionErrorBoundaryProps = {
|
|
27
|
+
error: Error;
|
|
28
|
+
retry: () => void | Promise<void>;
|
|
27
29
|
};
|
|
28
30
|
declare function captureError(error: unknown, source?: string): void;
|
|
29
31
|
export declare const BugSession: {
|
|
@@ -37,6 +39,13 @@ export declare const BugSession: {
|
|
|
37
39
|
}): void;
|
|
38
40
|
trackNavigation(screen: string): void;
|
|
39
41
|
captureError: typeof captureError;
|
|
42
|
+
Trigger: ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: import("./trigger").BugSessionTriggerProps) => React.JSX.Element;
|
|
43
|
+
/**
|
|
44
|
+
* Expo Router error boundary that reports render errors (which never reach the
|
|
45
|
+
* global error handler). In a route or layout file:
|
|
46
|
+
* `export const ErrorBoundary = BugSession.ErrorBoundary;`
|
|
47
|
+
*/
|
|
48
|
+
ErrorBoundary({ error, retry }: BugSessionErrorBoundaryProps): React.JSX.Element;
|
|
40
49
|
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
41
50
|
navigationListener(ref: {
|
|
42
51
|
getCurrentRoute(): {
|
|
@@ -47,5 +56,4 @@ export declare const BugSession: {
|
|
|
47
56
|
name: string;
|
|
48
57
|
screen?: string;
|
|
49
58
|
}): React.JSX.Element;
|
|
50
|
-
Trigger({ position, offset, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
|
|
51
59
|
};
|
package/dist/index.js
CHANGED
|
@@ -3,25 +3,75 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.BugSession = exports.safeUrl = exports.safeText = exports.SessionClock = exports.Collector = void 0;
|
|
6
|
+
exports.BugSession = exports.safeUrl = exports.safeText = exports.Telemetry = exports.SessionClock = exports.Collector = void 0;
|
|
7
7
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
8
|
const react_1 = __importDefault(require("react"));
|
|
9
9
|
const react_native_1 = require("react-native");
|
|
10
|
+
const trigger_1 = require("./trigger");
|
|
10
11
|
const core_1 = require("./core");
|
|
11
12
|
const capture_1 = require("./capture");
|
|
12
13
|
var core_2 = require("./core");
|
|
13
14
|
Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
|
|
14
15
|
Object.defineProperty(exports, "SessionClock", { enumerable: true, get: function () { return core_2.SessionClock; } });
|
|
16
|
+
Object.defineProperty(exports, "Telemetry", { enumerable: true, get: function () { return core_2.Telemetry; } });
|
|
15
17
|
Object.defineProperty(exports, "safeText", { enumerable: true, get: function () { return core_2.safeText; } });
|
|
16
18
|
Object.defineProperty(exports, "safeUrl", { enumerable: true, get: function () { return core_2.safeUrl; } });
|
|
17
19
|
const globals = globalThis;
|
|
18
20
|
let options;
|
|
19
21
|
let collector = null;
|
|
22
|
+
const telemetry = new core_1.Telemetry();
|
|
23
|
+
let installed = false;
|
|
24
|
+
let preRecordEvents = [];
|
|
20
25
|
let session = null;
|
|
21
26
|
let state = 'idle';
|
|
22
|
-
let restore = null;
|
|
23
27
|
let currentScreen = 'Home';
|
|
24
28
|
let rawFetch;
|
|
29
|
+
// Status shown by the trigger's indicator; also reflects start()/stop() called directly by the app.
|
|
30
|
+
let phase = { kind: 'idle' };
|
|
31
|
+
let phaseTimer;
|
|
32
|
+
const phaseListeners = new Set();
|
|
33
|
+
function setPhase(next) {
|
|
34
|
+
phase = next;
|
|
35
|
+
clearTimeout(phaseTimer);
|
|
36
|
+
if (next.kind === 'sent' || next.kind === 'failed')
|
|
37
|
+
phaseTimer = setTimeout(() => setPhase({ kind: 'idle' }), next.kind === 'sent' ? 3000 : 6000);
|
|
38
|
+
phaseListeners.forEach(listener => listener());
|
|
39
|
+
}
|
|
40
|
+
function compact(values) {
|
|
41
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* App and device facts for the recording's metadata. Native values (bundle id, hardware
|
|
45
|
+
* model) come from the capture module when available; values passed to configure() win.
|
|
46
|
+
*/
|
|
47
|
+
async function recordingContext() {
|
|
48
|
+
const constants = (react_native_1.Platform.constants ?? {});
|
|
49
|
+
const rn = constants.reactNativeVersion;
|
|
50
|
+
const screen = react_native_1.Dimensions.get('screen');
|
|
51
|
+
let locale;
|
|
52
|
+
let timeZone;
|
|
53
|
+
try {
|
|
54
|
+
({ locale, timeZone } = Intl.DateTimeFormat().resolvedOptions());
|
|
55
|
+
}
|
|
56
|
+
catch { /* Intl unavailable */ }
|
|
57
|
+
const device = {
|
|
58
|
+
platform: react_native_1.Platform.OS,
|
|
59
|
+
osVersion: react_native_1.Platform.OS === 'android' ? constants.Release : String(react_native_1.Platform.Version),
|
|
60
|
+
screen: { width: Math.round(screen.width), height: Math.round(screen.height), scale: screen.scale },
|
|
61
|
+
locale, timeZone,
|
|
62
|
+
...(react_native_1.Platform.OS === 'android' ? { model: constants.Model, manufacturer: constants.Manufacturer } : { idiom: constants.interfaceIdiom }),
|
|
63
|
+
};
|
|
64
|
+
const application = {
|
|
65
|
+
reactNative: rn ? `${rn.major}.${rn.minor}.${rn.patch}` : undefined,
|
|
66
|
+
jsEngine: globals.HermesInternal ? 'hermes' : 'jsc',
|
|
67
|
+
buildType: __DEV__ ? 'development' : 'release',
|
|
68
|
+
};
|
|
69
|
+
const native = await options.capture?.appContext?.().catch(() => null) ?? null;
|
|
70
|
+
return {
|
|
71
|
+
device: compact({ ...device, ...native?.device, ...options.device }),
|
|
72
|
+
application: compact({ ...application, ...native?.application, ...options.application }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
25
75
|
function authHeaders() {
|
|
26
76
|
return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
|
|
27
77
|
}
|
|
@@ -46,42 +96,71 @@ async function request(path, data) {
|
|
|
46
96
|
throw new Error(`Recorder ${response.status}: ${await response.text()}`);
|
|
47
97
|
return response.json();
|
|
48
98
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for (const level of ['log', 'warn', 'error']) {
|
|
99
|
+
// Installed once at configure() for the app's lifetime, so events from before a
|
|
100
|
+
// recording starts can be attached to it (see Telemetry).
|
|
101
|
+
function install() {
|
|
102
|
+
installed = true;
|
|
103
|
+
globalThis.fetch = (0, core_1.instrumentFetch)(rawFetch, telemetry);
|
|
104
|
+
for (const level of ['log', 'info', 'debug', 'warn', 'error']) {
|
|
105
|
+
const original = console[level];
|
|
55
106
|
console[level] = (...args) => {
|
|
56
|
-
|
|
57
|
-
|
|
107
|
+
if (telemetry.active) {
|
|
108
|
+
const error = args.find((a) => a instanceof Error);
|
|
109
|
+
telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: error?.stack ? (0, core_1.safeText)(error.stack) : undefined, screen: currentScreen });
|
|
110
|
+
}
|
|
111
|
+
original(...args);
|
|
58
112
|
};
|
|
59
113
|
}
|
|
60
114
|
const utils = globals.ErrorUtils;
|
|
61
115
|
const handler = utils?.getGlobalHandler();
|
|
62
|
-
const onError = (error, fatal) => {
|
|
63
|
-
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
64
|
-
handler?.(error, fatal);
|
|
65
|
-
};
|
|
66
116
|
if (utils && handler)
|
|
67
|
-
utils.setGlobalHandler(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
117
|
+
utils.setGlobalHandler((error, fatal) => {
|
|
118
|
+
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
119
|
+
handler(error, fatal);
|
|
120
|
+
});
|
|
121
|
+
// React Native only tracks unhandled rejections in development, and reports them
|
|
122
|
+
// through ExceptionsManager rather than the global handler. Track them in every
|
|
123
|
+
// build, and keep React Native's development behavior (LogBox) by forwarding.
|
|
124
|
+
globals.HermesInternal?.enablePromiseRejectionTracker?.({
|
|
125
|
+
allRejections: true,
|
|
126
|
+
onUnhandled: (id, rejection) => {
|
|
127
|
+
captureError(rejection instanceof Error ? rejection : new Error(rejectionMessage(rejection)), 'unhandled-rejection');
|
|
128
|
+
if (__DEV__)
|
|
129
|
+
handler?.(new Error(`Uncaught (in promise, id: ${id}): "${rejectionMessage(rejection)}"`), false);
|
|
130
|
+
},
|
|
131
|
+
onHandled: id => {
|
|
132
|
+
if (__DEV__)
|
|
133
|
+
console.warn(`Promise rejection handled (id: ${id})`);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function rejectionMessage(rejection) {
|
|
138
|
+
if (rejection instanceof Error)
|
|
139
|
+
return rejection.toString();
|
|
140
|
+
if (typeof rejection === 'string')
|
|
141
|
+
return rejection;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.stringify(rejection) ?? String(rejection);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return String(rejection);
|
|
147
|
+
}
|
|
76
148
|
}
|
|
77
149
|
function captureError(error, source = 'boundary') {
|
|
78
|
-
|
|
150
|
+
if (!telemetry.active)
|
|
151
|
+
return;
|
|
152
|
+
telemetry.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
|
|
79
153
|
}
|
|
80
154
|
exports.BugSession = {
|
|
81
155
|
configure(config) {
|
|
82
156
|
if (state !== 'idle')
|
|
83
157
|
throw new Error('Cannot configure an active BugSession');
|
|
84
158
|
options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
|
|
159
|
+
telemetry.configure((config.preRecordSeconds ?? 60) * 1000);
|
|
160
|
+
if (!installed) {
|
|
161
|
+
rawFetch = globalThis.fetch.bind(globalThis);
|
|
162
|
+
install();
|
|
163
|
+
}
|
|
85
164
|
},
|
|
86
165
|
get status() { return state; },
|
|
87
166
|
async start() {
|
|
@@ -96,7 +175,7 @@ exports.BugSession = {
|
|
|
96
175
|
if (state !== 'idle')
|
|
97
176
|
throw new Error(`BugSession is ${state}`);
|
|
98
177
|
state = 'starting';
|
|
99
|
-
|
|
178
|
+
setPhase({ kind: 'starting' });
|
|
100
179
|
let createdId = null;
|
|
101
180
|
let audioStarted = false;
|
|
102
181
|
const isIOS = react_native_1.Platform.OS === 'ios';
|
|
@@ -111,7 +190,8 @@ exports.BugSession = {
|
|
|
111
190
|
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
112
191
|
collector = new core_1.Collector(clock);
|
|
113
192
|
const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
|
|
114
|
-
const
|
|
193
|
+
const context = await recordingContext();
|
|
194
|
+
const result = await request('/sessions/start', { device: context.device, application: context.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
115
195
|
createdId = result.id;
|
|
116
196
|
const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
|
|
117
197
|
const audioStartMs = clock.now();
|
|
@@ -128,8 +208,9 @@ exports.BugSession = {
|
|
|
128
208
|
uncertaintyMs: best.rtt / 2, captureBackend,
|
|
129
209
|
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
130
210
|
};
|
|
131
|
-
|
|
211
|
+
preRecordEvents = telemetry.begin(collector);
|
|
132
212
|
state = 'recording';
|
|
213
|
+
setPhase({ kind: 'recording', startedAt: Date.now() });
|
|
133
214
|
collector.add('navigation', { from: null, to: currentScreen });
|
|
134
215
|
return session.id;
|
|
135
216
|
}
|
|
@@ -140,9 +221,12 @@ exports.BugSession = {
|
|
|
140
221
|
await options.audio.stop().catch(() => null);
|
|
141
222
|
if (createdId)
|
|
142
223
|
await request(`/sessions/${createdId}/abort`, {}).catch(() => { });
|
|
224
|
+
telemetry.end();
|
|
225
|
+
preRecordEvents = [];
|
|
143
226
|
collector = null;
|
|
144
227
|
session = null;
|
|
145
228
|
state = 'idle';
|
|
229
|
+
setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
|
|
146
230
|
throw error;
|
|
147
231
|
}
|
|
148
232
|
},
|
|
@@ -150,11 +234,14 @@ exports.BugSession = {
|
|
|
150
234
|
if (state !== 'recording' || !session || !collector)
|
|
151
235
|
throw new Error('No recording is active');
|
|
152
236
|
state = 'stopping';
|
|
237
|
+
setPhase({ kind: 'uploading' });
|
|
153
238
|
const c = collector;
|
|
154
239
|
const active = session;
|
|
155
240
|
c.add('session-stop', {});
|
|
156
|
-
|
|
157
|
-
|
|
241
|
+
telemetry.end();
|
|
242
|
+
// Sent separately from `events` so recorders that predate pre-record support ignore them.
|
|
243
|
+
const before = preRecordEvents;
|
|
244
|
+
preRecordEvents = [];
|
|
158
245
|
try {
|
|
159
246
|
let media;
|
|
160
247
|
if (active.captureBackend === 'replaykit') {
|
|
@@ -184,16 +271,19 @@ exports.BugSession = {
|
|
|
184
271
|
await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
185
272
|
media = { ...active, audioEndMs };
|
|
186
273
|
}
|
|
187
|
-
|
|
188
|
-
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
|
|
274
|
+
const result = await request(`/sessions/${active.id}/stop`, {
|
|
275
|
+
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped, preRecordEvents: before,
|
|
189
276
|
media,
|
|
190
277
|
});
|
|
278
|
+
setPhase({ kind: 'sent' });
|
|
279
|
+
return result;
|
|
191
280
|
}
|
|
192
281
|
catch (error) {
|
|
193
282
|
if (active.captureBackend !== 'adb')
|
|
194
283
|
await options.capture.cancel().catch(() => { });
|
|
195
284
|
// Release the device recorder, but preserve partial files for recovery.
|
|
196
|
-
await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
|
|
285
|
+
await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, preRecordEvents: before, media: active, durationMs: c.clock.now() }).catch(() => { });
|
|
286
|
+
setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
|
|
197
287
|
throw error;
|
|
198
288
|
}
|
|
199
289
|
finally {
|
|
@@ -203,14 +293,40 @@ exports.BugSession = {
|
|
|
203
293
|
}
|
|
204
294
|
},
|
|
205
295
|
trackAction(action) {
|
|
206
|
-
|
|
296
|
+
if (telemetry.active)
|
|
297
|
+
telemetry.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
|
|
207
298
|
},
|
|
208
299
|
trackNavigation(screen) {
|
|
209
|
-
if (currentScreen !== screen)
|
|
210
|
-
|
|
300
|
+
if (currentScreen !== screen && telemetry.active)
|
|
301
|
+
telemetry.add('navigation', { from: currentScreen, to: screen });
|
|
211
302
|
currentScreen = screen;
|
|
212
303
|
},
|
|
213
304
|
captureError,
|
|
305
|
+
Trigger: (0, trigger_1.createTrigger)({
|
|
306
|
+
getPhase: () => phase,
|
|
307
|
+
subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
|
|
308
|
+
async toggle() {
|
|
309
|
+
if (state === 'recording')
|
|
310
|
+
await exports.BugSession.stop();
|
|
311
|
+
else if (state === 'idle')
|
|
312
|
+
await exports.BugSession.start();
|
|
313
|
+
},
|
|
314
|
+
fail(error) { if (phase.kind !== 'failed')
|
|
315
|
+
setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) }); },
|
|
316
|
+
onAudioLevel: listener => options?.capture?.onAudioLevel?.(listener) ?? null,
|
|
317
|
+
safeAreaInsets: async () => options?.capture?.safeAreaInsets ? options.capture.safeAreaInsets() : null,
|
|
318
|
+
annotate(annotation) { if (state === 'recording')
|
|
319
|
+
telemetry.add('annotation', { ...annotation, screen: currentScreen }); },
|
|
320
|
+
}),
|
|
321
|
+
/**
|
|
322
|
+
* Expo Router error boundary that reports render errors (which never reach the
|
|
323
|
+
* global error handler). In a route or layout file:
|
|
324
|
+
* `export const ErrorBoundary = BugSession.ErrorBoundary;`
|
|
325
|
+
*/
|
|
326
|
+
ErrorBoundary({ error, retry }) {
|
|
327
|
+
react_1.default.useEffect(() => { captureError(error, 'render'); }, [error]);
|
|
328
|
+
return (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.boundary, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryTitle, children: "Something went wrong" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryMessage, children: error.message }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => { void retry(); }, style: styles.boundaryRetry, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryRetryText, children: "Try again" }) })] });
|
|
329
|
+
},
|
|
214
330
|
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
215
331
|
navigationListener(ref) {
|
|
216
332
|
return () => { const route = ref.getCurrentRoute(); if (route)
|
|
@@ -219,36 +335,11 @@ exports.BugSession = {
|
|
|
219
335
|
Touchable({ name, screen, onPress, ...props }) {
|
|
220
336
|
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
|
|
221
337
|
},
|
|
222
|
-
Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }) {
|
|
223
|
-
const [busy, setBusy] = react_1.default.useState(false);
|
|
224
|
-
const recording = state === 'recording';
|
|
225
|
-
const edge = position.includes('right') ? { right: offset } : { left: offset };
|
|
226
|
-
const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
|
|
227
|
-
const toggle = async () => {
|
|
228
|
-
if (busy)
|
|
229
|
-
return;
|
|
230
|
-
setBusy(true);
|
|
231
|
-
try {
|
|
232
|
-
if (state === 'recording')
|
|
233
|
-
await exports.BugSession.stop();
|
|
234
|
-
else
|
|
235
|
-
await exports.BugSession.start();
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
onError?.(error);
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
setBusy(false);
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: [styles.triggerLayer, edge, vertical], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), accessibilityState: { busy, selected: recording }, disabled: busy, onPress: () => { void toggle(); }, style: [styles.trigger, recording && styles.triggerRecording, style], children: children ?? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.triggerMark, children: "b" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.triggerDot, recording && styles.triggerDotRecording] })] }) }) });
|
|
245
|
-
},
|
|
246
338
|
};
|
|
247
339
|
const styles = react_native_1.StyleSheet.create({
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
|
|
340
|
+
boundary: { flex: 1, justifyContent: 'center', padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
|
|
341
|
+
boundaryTitle: { fontSize: 22, fontWeight: '700', color: '#111111' },
|
|
342
|
+
boundaryMessage: { fontSize: 15, color: '#555555' },
|
|
343
|
+
boundaryRetry: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#173B34' },
|
|
344
|
+
boundaryRetryText: { color: '#F4F4EC', fontWeight: '700' },
|
|
254
345
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
export type IndicatorPhase = {
|
|
4
|
+
kind: 'idle';
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'starting';
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'recording';
|
|
9
|
+
startedAt: number;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'uploading';
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'sent';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'failed';
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export type Annotation = {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
screenWidth: number;
|
|
24
|
+
screenHeight: number;
|
|
25
|
+
points: number;
|
|
26
|
+
};
|
|
27
|
+
/** What the trigger needs from the SDK; injected to avoid a circular import. */
|
|
28
|
+
export type TriggerController = {
|
|
29
|
+
getPhase(): IndicatorPhase;
|
|
30
|
+
subscribe(listener: () => void): () => void;
|
|
31
|
+
toggle(): Promise<void>;
|
|
32
|
+
fail(error: unknown): void;
|
|
33
|
+
annotate(annotation: Annotation): void;
|
|
34
|
+
onAudioLevel(listener: (level: number) => void): (() => void) | null;
|
|
35
|
+
safeAreaInsets(): Promise<{
|
|
36
|
+
top: number;
|
|
37
|
+
bottom: number;
|
|
38
|
+
} | null>;
|
|
39
|
+
};
|
|
40
|
+
export type BugSessionTriggerProps = {
|
|
41
|
+
/** Initial screen edge (left/right) and half (top/bottom) of the side tab. */
|
|
42
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
43
|
+
/** Initial distance in points between the tab and the top or bottom safe area. */
|
|
44
|
+
offset?: number;
|
|
45
|
+
/** Drag the tab along either edge; it snaps to the nearer side. */
|
|
46
|
+
draggable?: boolean;
|
|
47
|
+
style?: StyleProp<ViewStyle>;
|
|
48
|
+
accessibilityLabel?: string;
|
|
49
|
+
/** Replaces the idle tab's record mark. */
|
|
50
|
+
children?: React.ReactNode;
|
|
51
|
+
onError?: (error: unknown) => void;
|
|
52
|
+
/** Stops automatically so the upload stays under the recorder's limit. Defaults to 150. */
|
|
53
|
+
maxDurationSeconds?: number;
|
|
54
|
+
};
|
|
55
|
+
export declare function createTrigger(controller: TriggerController): ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: BugSessionTriggerProps) => React.JSX.Element;
|