@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 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
- `<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. It is draggable by default, stays within screen bounds, and remembers its moved position across screen changes and trigger remounts while the app remains open. Render it once above the app navigator so it stays visible during navigation. Configure its initial corner with `position` (`bottom-right` by default; also `bottom-left`, `top-right`, or `top-left`) and adjust `offset` in points. Set `draggable={false}` to lock it. Pass `onError` to surface permission, recorder, and upload failures. Supply `children` to replace the default mark.
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 declare function instrumentFetch(original: typeof fetch, collector: Collector): typeof fetch;
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: this.clock.now() });
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, type StyleProp, type ViewStyle } from 'react-native';
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 BugSessionTriggerProps = {
21
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
22
- offset?: number;
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
- let triggerPosition = null;
26
- const triggerSize = 58;
27
- function clampTriggerPosition(point, width, height) {
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
- left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
30
- top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
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
- function install(c) {
58
- const originalFetch = globalThis.fetch;
59
- const wrappedFetch = (0, core_1.instrumentFetch)(rawFetch, c);
60
- globalThis.fetch = wrappedFetch;
61
- const originals = { log: console.log, warn: console.warn, error: console.error };
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
- c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? (0, core_1.safeText)(args.find(a => a instanceof Error).stack) : undefined });
65
- originals[level](...args);
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(onError);
76
- return () => {
77
- if (globalThis.fetch === wrappedFetch)
78
- globalThis.fetch = originalFetch;
79
- for (const level of ['log', 'warn', 'error'])
80
- console[level] = originals[level];
81
- if (utils?.getGlobalHandler() === onError && handler)
82
- utils.setGlobalHandler(handler);
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
- collector?.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
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
- rawFetch = globalThis.fetch.bind(globalThis);
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 result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
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
- restore = install(collector);
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
- restore?.();
165
- restore = null;
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
- return await request(`/sessions/${active.id}/stop`, {
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
- collector?.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
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
- collector?.add('navigation', { from: currentScreen, to: screen });
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
- triggerLayer: { ...react_native_1.StyleSheet.absoluteFill, zIndex: 9999 },
299
- triggerPosition: { position: 'absolute' },
300
- trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
301
- triggerRecording: { backgroundColor: '#B9382F' },
302
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
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
  });