@bugsession/react-native 0.1.0-beta.3 → 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 +7 -1
- 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 -11
- package/dist/index.js +153 -113
- 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 +159 -124
- package/src/trigger.tsx +364 -0
package/README.md
CHANGED
|
@@ -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,15 +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
|
-
draggable?: boolean;
|
|
24
|
-
style?: StyleProp<ViewStyle>;
|
|
25
|
-
accessibilityLabel?: string;
|
|
26
|
-
children?: React.ReactNode;
|
|
27
|
-
onError?: (error: unknown) => void;
|
|
26
|
+
export type BugSessionErrorBoundaryProps = {
|
|
27
|
+
error: Error;
|
|
28
|
+
retry: () => void | Promise<void>;
|
|
28
29
|
};
|
|
29
30
|
declare function captureError(error: unknown, source?: string): void;
|
|
30
31
|
export declare const BugSession: {
|
|
@@ -38,6 +39,13 @@ export declare const BugSession: {
|
|
|
38
39
|
}): void;
|
|
39
40
|
trackNavigation(screen: string): void;
|
|
40
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;
|
|
41
49
|
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
42
50
|
navigationListener(ref: {
|
|
43
51
|
getCurrentRoute(): {
|
|
@@ -48,5 +56,4 @@ export declare const BugSession: {
|
|
|
48
56
|
name: string;
|
|
49
57
|
screen?: string;
|
|
50
58
|
}): React.JSX.Element;
|
|
51
|
-
Trigger({ position, offset, draggable, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
|
|
52
59
|
};
|
package/dist/index.js
CHANGED
|
@@ -3,31 +3,73 @@ 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;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
28
70
|
return {
|
|
29
|
-
|
|
30
|
-
|
|
71
|
+
device: compact({ ...device, ...native?.device, ...options.device }),
|
|
72
|
+
application: compact({ ...application, ...native?.application, ...options.application }),
|
|
31
73
|
};
|
|
32
74
|
}
|
|
33
75
|
function authHeaders() {
|
|
@@ -54,42 +96,71 @@ async function request(path, data) {
|
|
|
54
96
|
throw new Error(`Recorder ${response.status}: ${await response.text()}`);
|
|
55
97
|
return response.json();
|
|
56
98
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
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];
|
|
63
106
|
console[level] = (...args) => {
|
|
64
|
-
|
|
65
|
-
|
|
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);
|
|
66
112
|
};
|
|
67
113
|
}
|
|
68
114
|
const utils = globals.ErrorUtils;
|
|
69
115
|
const handler = utils?.getGlobalHandler();
|
|
70
|
-
const onError = (error, fatal) => {
|
|
71
|
-
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
72
|
-
handler?.(error, fatal);
|
|
73
|
-
};
|
|
74
116
|
if (utils && handler)
|
|
75
|
-
utils.setGlobalHandler(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
148
|
}
|
|
85
149
|
function captureError(error, source = 'boundary') {
|
|
86
|
-
|
|
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 });
|
|
87
153
|
}
|
|
88
154
|
exports.BugSession = {
|
|
89
155
|
configure(config) {
|
|
90
156
|
if (state !== 'idle')
|
|
91
157
|
throw new Error('Cannot configure an active BugSession');
|
|
92
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
|
+
}
|
|
93
164
|
},
|
|
94
165
|
get status() { return state; },
|
|
95
166
|
async start() {
|
|
@@ -104,7 +175,7 @@ exports.BugSession = {
|
|
|
104
175
|
if (state !== 'idle')
|
|
105
176
|
throw new Error(`BugSession is ${state}`);
|
|
106
177
|
state = 'starting';
|
|
107
|
-
|
|
178
|
+
setPhase({ kind: 'starting' });
|
|
108
179
|
let createdId = null;
|
|
109
180
|
let audioStarted = false;
|
|
110
181
|
const isIOS = react_native_1.Platform.OS === 'ios';
|
|
@@ -119,7 +190,8 @@ exports.BugSession = {
|
|
|
119
190
|
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
120
191
|
collector = new core_1.Collector(clock);
|
|
121
192
|
const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
|
|
122
|
-
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() });
|
|
123
195
|
createdId = result.id;
|
|
124
196
|
const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
|
|
125
197
|
const audioStartMs = clock.now();
|
|
@@ -136,8 +208,9 @@ exports.BugSession = {
|
|
|
136
208
|
uncertaintyMs: best.rtt / 2, captureBackend,
|
|
137
209
|
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
138
210
|
};
|
|
139
|
-
|
|
211
|
+
preRecordEvents = telemetry.begin(collector);
|
|
140
212
|
state = 'recording';
|
|
213
|
+
setPhase({ kind: 'recording', startedAt: Date.now() });
|
|
141
214
|
collector.add('navigation', { from: null, to: currentScreen });
|
|
142
215
|
return session.id;
|
|
143
216
|
}
|
|
@@ -148,9 +221,12 @@ exports.BugSession = {
|
|
|
148
221
|
await options.audio.stop().catch(() => null);
|
|
149
222
|
if (createdId)
|
|
150
223
|
await request(`/sessions/${createdId}/abort`, {}).catch(() => { });
|
|
224
|
+
telemetry.end();
|
|
225
|
+
preRecordEvents = [];
|
|
151
226
|
collector = null;
|
|
152
227
|
session = null;
|
|
153
228
|
state = 'idle';
|
|
229
|
+
setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
|
|
154
230
|
throw error;
|
|
155
231
|
}
|
|
156
232
|
},
|
|
@@ -158,11 +234,14 @@ exports.BugSession = {
|
|
|
158
234
|
if (state !== 'recording' || !session || !collector)
|
|
159
235
|
throw new Error('No recording is active');
|
|
160
236
|
state = 'stopping';
|
|
237
|
+
setPhase({ kind: 'uploading' });
|
|
161
238
|
const c = collector;
|
|
162
239
|
const active = session;
|
|
163
240
|
c.add('session-stop', {});
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
telemetry.end();
|
|
242
|
+
// Sent separately from `events` so recorders that predate pre-record support ignore them.
|
|
243
|
+
const before = preRecordEvents;
|
|
244
|
+
preRecordEvents = [];
|
|
166
245
|
try {
|
|
167
246
|
let media;
|
|
168
247
|
if (active.captureBackend === 'replaykit') {
|
|
@@ -192,16 +271,19 @@ exports.BugSession = {
|
|
|
192
271
|
await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
193
272
|
media = { ...active, audioEndMs };
|
|
194
273
|
}
|
|
195
|
-
|
|
196
|
-
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,
|
|
197
276
|
media,
|
|
198
277
|
});
|
|
278
|
+
setPhase({ kind: 'sent' });
|
|
279
|
+
return result;
|
|
199
280
|
}
|
|
200
281
|
catch (error) {
|
|
201
282
|
if (active.captureBackend !== 'adb')
|
|
202
283
|
await options.capture.cancel().catch(() => { });
|
|
203
284
|
// Release the device recorder, but preserve partial files for recovery.
|
|
204
|
-
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) });
|
|
205
287
|
throw error;
|
|
206
288
|
}
|
|
207
289
|
finally {
|
|
@@ -211,14 +293,40 @@ exports.BugSession = {
|
|
|
211
293
|
}
|
|
212
294
|
},
|
|
213
295
|
trackAction(action) {
|
|
214
|
-
|
|
296
|
+
if (telemetry.active)
|
|
297
|
+
telemetry.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
|
|
215
298
|
},
|
|
216
299
|
trackNavigation(screen) {
|
|
217
|
-
if (currentScreen !== screen)
|
|
218
|
-
|
|
300
|
+
if (currentScreen !== screen && telemetry.active)
|
|
301
|
+
telemetry.add('navigation', { from: currentScreen, to: screen });
|
|
219
302
|
currentScreen = screen;
|
|
220
303
|
},
|
|
221
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
|
+
},
|
|
222
330
|
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
223
331
|
navigationListener(ref) {
|
|
224
332
|
return () => { const route = ref.getCurrentRoute(); if (route)
|
|
@@ -227,79 +335,11 @@ exports.BugSession = {
|
|
|
227
335
|
Touchable({ name, screen, onPress, ...props }) {
|
|
228
336
|
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
|
|
229
337
|
},
|
|
230
|
-
Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }) {
|
|
231
|
-
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
232
|
-
const [point, setPoint] = react_1.default.useState(() => triggerPosition
|
|
233
|
-
? clampTriggerPosition(triggerPosition, width, height)
|
|
234
|
-
: clampTriggerPosition({
|
|
235
|
-
left: position.includes('right') ? width - triggerSize - offset : offset,
|
|
236
|
-
top: position.includes('bottom') ? height - triggerSize - offset : offset,
|
|
237
|
-
}, width, height));
|
|
238
|
-
const pointRef = react_1.default.useRef(point);
|
|
239
|
-
pointRef.current = point;
|
|
240
|
-
const drag = react_1.default.useRef(null);
|
|
241
|
-
if (!drag.current)
|
|
242
|
-
drag.current = new react_native_1.Animated.ValueXY();
|
|
243
|
-
const dragStart = react_1.default.useRef(point);
|
|
244
|
-
react_1.default.useEffect(() => {
|
|
245
|
-
const next = clampTriggerPosition(pointRef.current, width, height);
|
|
246
|
-
pointRef.current = next;
|
|
247
|
-
setPoint(next);
|
|
248
|
-
if (triggerPosition)
|
|
249
|
-
triggerPosition = next;
|
|
250
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
251
|
-
}, [width, height]);
|
|
252
|
-
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
253
|
-
onStartShouldSetPanResponder: () => false,
|
|
254
|
-
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
255
|
-
onPanResponderGrant: () => { dragStart.current = pointRef.current; },
|
|
256
|
-
onPanResponderMove: (_, gesture) => {
|
|
257
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
258
|
-
drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
|
|
259
|
-
},
|
|
260
|
-
onPanResponderRelease: (_, gesture) => {
|
|
261
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
262
|
-
triggerPosition = next;
|
|
263
|
-
pointRef.current = next;
|
|
264
|
-
setPoint(next);
|
|
265
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
266
|
-
},
|
|
267
|
-
onPanResponderTerminate: (_, gesture) => {
|
|
268
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
269
|
-
triggerPosition = next;
|
|
270
|
-
pointRef.current = next;
|
|
271
|
-
setPoint(next);
|
|
272
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
273
|
-
},
|
|
274
|
-
}), [draggable, width, height]);
|
|
275
|
-
const [busy, setBusy] = react_1.default.useState(false);
|
|
276
|
-
const recording = state === 'recording';
|
|
277
|
-
const toggle = async () => {
|
|
278
|
-
if (busy)
|
|
279
|
-
return;
|
|
280
|
-
setBusy(true);
|
|
281
|
-
try {
|
|
282
|
-
if (state === 'recording')
|
|
283
|
-
await exports.BugSession.stop();
|
|
284
|
-
else
|
|
285
|
-
await exports.BugSession.start();
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
onError?.(error);
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
setBusy(false);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: styles.triggerLayer, children: (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { ...(draggable ? responder.panHandlers : {}), style: [styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), accessibilityHint: draggable ? 'Drag to move the BugSession button' : undefined, 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] })] }) }) }) });
|
|
295
|
-
},
|
|
296
338
|
};
|
|
297
339
|
const styles = react_native_1.StyleSheet.create({
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
|
|
304
|
-
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' },
|
|
305
345
|
});
|