@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 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, 53, 54, 57 and 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.
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
- `<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. Its default position is `bottom-right`; configure `position` as `bottom-left`, `top-right`, or `top-left`, and adjust `offset` in points. Render it as an overlay above the app navigator. 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,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 BugSessionTriggerProps = {
21
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
22
- offset?: number;
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
- function install(c) {
50
- const originalFetch = globalThis.fetch;
51
- const wrappedFetch = (0, core_1.instrumentFetch)(rawFetch, c);
52
- globalThis.fetch = wrappedFetch;
53
- const originals = { log: console.log, warn: console.warn, error: console.error };
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
- 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 });
57
- 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);
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(onError);
68
- return () => {
69
- if (globalThis.fetch === wrappedFetch)
70
- globalThis.fetch = originalFetch;
71
- for (const level of ['log', 'warn', 'error'])
72
- console[level] = originals[level];
73
- if (utils?.getGlobalHandler() === onError && handler)
74
- utils.setGlobalHandler(handler);
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
- 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 });
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
- rawFetch = globalThis.fetch.bind(globalThis);
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 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() });
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
- restore = install(collector);
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
- restore?.();
157
- 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 = [];
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
- return await request(`/sessions/${active.id}/stop`, {
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
- 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) });
207
298
  },
208
299
  trackNavigation(screen) {
209
- if (currentScreen !== screen)
210
- collector?.add('navigation', { from: currentScreen, to: screen });
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
- triggerLayer: { position: 'absolute', zIndex: 9999 },
249
- 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 } },
250
- triggerRecording: { backgroundColor: '#B9382F' },
251
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
252
- triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
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;