@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/src/index.tsx CHANGED
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
- import { Animated, PanResponder, Pressable, StyleSheet, Text, View, useWindowDimensions, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
3
- import { Collector, instrumentFetch, safeText, safeUrl, SessionClock } from './core';
2
+ import { Dimensions, Pressable, StyleSheet, Text, View, type PressableProps, Platform } from 'react-native';
3
+ import { createTrigger, type IndicatorPhase } from './trigger';
4
+ export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
5
+ import { Collector, instrumentFetch, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
4
6
  import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
5
7
  export type { NativeCaptureAdapter } from './capture';
6
- export { Collector, SessionClock, safeText, safeUrl } from './core';
8
+ export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
7
9
 
8
10
  export type AudioAdapter = {
9
11
  prepare(): Promise<void>;
@@ -11,31 +13,75 @@ export type AudioAdapter = {
11
13
  stop(): Promise<string | null>;
12
14
  upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
13
15
  };
14
- export type BugSessionOptions = { recorderUrl: string; recorderToken?: string; audio?: AudioAdapter; capture?: NativeCaptureAdapter; device: Record<string, unknown>; application: Record<string, unknown> };
15
- export type BugSessionTriggerProps = {
16
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
17
- offset?: number;
18
- draggable?: boolean;
19
- style?: StyleProp<ViewStyle>;
20
- accessibilityLabel?: string;
21
- children?: React.ReactNode;
22
- onError?: (error: unknown) => void;
16
+ export type BugSessionOptions = {
17
+ recorderUrl: string;
18
+ recorderToken?: string;
19
+ audio?: AudioAdapter;
20
+ capture?: NativeCaptureAdapter;
21
+ device: Record<string, unknown>;
22
+ application: Record<string, unknown>;
23
+ /**
24
+ * Seconds of app events (logs, errors, network metadata, actions, navigation; never video or audio)
25
+ * kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
26
+ */
27
+ preRecordSeconds?: number;
23
28
  };
29
+ export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
24
30
  type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
25
- const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
31
+ type RejectionTracker = { allRejections: boolean; onUnhandled(id: number, rejection: unknown): void; onHandled(id: number): void };
32
+ const globals = globalThis as typeof globalThis & {
33
+ ErrorUtils?: ErrorUtilsType;
34
+ HermesInternal?: { enablePromiseRejectionTracker?(options: RejectionTracker): void };
35
+ };
26
36
  let options: BugSessionOptions;
27
37
  let collector: Collector | null = null;
38
+ const telemetry = new Telemetry();
39
+ let installed = false;
40
+ let preRecordEvents: Evidence[] = [];
28
41
  let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
29
42
  let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
30
- let restore: (() => void) | null = null;
31
43
  let currentScreen = 'Home';
32
44
  let rawFetch: typeof fetch;
33
- let triggerPosition: { left: number; top: number } | null = null;
34
- const triggerSize = 58;
35
- function clampTriggerPosition(point: { left: number; top: number }, width: number, height: number) {
45
+ // Status shown by the trigger's indicator; also reflects start()/stop() called directly by the app.
46
+ let phase: IndicatorPhase = { kind: 'idle' };
47
+ let phaseTimer: ReturnType<typeof setTimeout> | undefined;
48
+ const phaseListeners = new Set<() => void>();
49
+ function setPhase(next: IndicatorPhase) {
50
+ phase = next;
51
+ clearTimeout(phaseTimer);
52
+ if (next.kind === 'sent' || next.kind === 'failed') phaseTimer = setTimeout(() => setPhase({ kind: 'idle' }), next.kind === 'sent' ? 3000 : 6000);
53
+ phaseListeners.forEach(listener => listener());
54
+ }
55
+ function compact(values: Record<string, unknown>) {
56
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
57
+ }
58
+ /**
59
+ * App and device facts for the recording's metadata. Native values (bundle id, hardware
60
+ * model) come from the capture module when available; values passed to configure() win.
61
+ */
62
+ async function recordingContext() {
63
+ const constants = (Platform.constants ?? {}) as Record<string, unknown>;
64
+ const rn = constants.reactNativeVersion as { major: number; minor: number; patch: number } | undefined;
65
+ const screen = Dimensions.get('screen');
66
+ let locale: string | undefined;
67
+ let timeZone: string | undefined;
68
+ try { ({ locale, timeZone } = Intl.DateTimeFormat().resolvedOptions()); } catch { /* Intl unavailable */ }
69
+ const device: Record<string, unknown> = {
70
+ platform: Platform.OS,
71
+ osVersion: Platform.OS === 'android' ? constants.Release : String(Platform.Version),
72
+ screen: { width: Math.round(screen.width), height: Math.round(screen.height), scale: screen.scale },
73
+ locale, timeZone,
74
+ ...(Platform.OS === 'android' ? { model: constants.Model, manufacturer: constants.Manufacturer } : { idiom: constants.interfaceIdiom }),
75
+ };
76
+ const application: Record<string, unknown> = {
77
+ reactNative: rn ? `${rn.major}.${rn.minor}.${rn.patch}` : undefined,
78
+ jsEngine: globals.HermesInternal ? 'hermes' : 'jsc',
79
+ buildType: __DEV__ ? 'development' : 'release',
80
+ };
81
+ const native = await options.capture?.appContext?.().catch(() => null) ?? null;
36
82
  return {
37
- left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
38
- top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
83
+ device: compact({ ...device, ...native?.device, ...options.device }),
84
+ application: compact({ ...application, ...native?.application, ...options.application }),
39
85
  };
40
86
  }
41
87
  function authHeaders(): Record<string, string> {
@@ -58,38 +104,60 @@ async function request(path: string, data?: unknown) {
58
104
  if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
59
105
  return response.json();
60
106
  }
61
- function install(c: Collector) {
62
- const originalFetch = globalThis.fetch;
63
- const wrappedFetch = instrumentFetch(rawFetch, c);
64
- globalThis.fetch = wrappedFetch;
65
- const originals = { log: console.log, warn: console.warn, error: console.error };
66
- for (const level of ['log', 'warn', 'error'] as const) {
107
+ // Installed once at configure() for the app's lifetime, so events from before a
108
+ // recording starts can be attached to it (see Telemetry).
109
+ function install() {
110
+ installed = true;
111
+ globalThis.fetch = instrumentFetch(rawFetch, telemetry);
112
+ for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) {
113
+ const original = console[level];
67
114
  console[level] = (...args: unknown[]) => {
68
- c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? safeText((args.find(a => a instanceof Error) as Error).stack) : undefined });
69
- originals[level](...args);
115
+ if (telemetry.active) {
116
+ const error = args.find((a): a is Error => a instanceof Error);
117
+ telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: error?.stack ? safeText(error.stack) : undefined, screen: currentScreen });
118
+ }
119
+ original(...args);
70
120
  };
71
121
  }
72
122
  const utils = globals.ErrorUtils;
73
123
  const handler = utils?.getGlobalHandler();
74
- const onError = (error: Error, fatal?: boolean) => {
124
+ if (utils && handler) utils.setGlobalHandler((error, fatal) => {
75
125
  captureError(error, fatal ? 'fatal' : 'uncaught');
76
- handler?.(error, fatal);
77
- };
78
- if (utils && handler) utils.setGlobalHandler(onError);
79
- return () => {
80
- if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
81
- for (const level of ['log', 'warn', 'error'] as const) console[level] = originals[level];
82
- if (utils?.getGlobalHandler() === onError && handler) utils.setGlobalHandler(handler);
83
- };
126
+ handler(error, fatal);
127
+ });
128
+ // React Native only tracks unhandled rejections in development, and reports them
129
+ // through ExceptionsManager rather than the global handler. Track them in every
130
+ // build, and keep React Native's development behavior (LogBox) by forwarding.
131
+ globals.HermesInternal?.enablePromiseRejectionTracker?.({
132
+ allRejections: true,
133
+ onUnhandled: (id, rejection) => {
134
+ captureError(rejection instanceof Error ? rejection : new Error(rejectionMessage(rejection)), 'unhandled-rejection');
135
+ if (__DEV__) handler?.(new Error(`Uncaught (in promise, id: ${id}): "${rejectionMessage(rejection)}"`), false);
136
+ },
137
+ onHandled: id => {
138
+ if (__DEV__) console.warn(`Promise rejection handled (id: ${id})`);
139
+ },
140
+ });
141
+ }
142
+ function rejectionMessage(rejection: unknown) {
143
+ if (rejection instanceof Error) return rejection.toString();
144
+ if (typeof rejection === 'string') return rejection;
145
+ try { return JSON.stringify(rejection) ?? String(rejection); } catch { return String(rejection); }
84
146
  }
85
147
  function captureError(error: unknown, source = 'boundary') {
86
- collector?.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
148
+ if (!telemetry.active) return;
149
+ telemetry.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
87
150
  }
88
151
 
89
152
  export const BugSession = {
90
153
  configure(config: BugSessionOptions) {
91
154
  if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
92
155
  options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
156
+ telemetry.configure((config.preRecordSeconds ?? 60) * 1000);
157
+ if (!installed) {
158
+ rawFetch = globalThis.fetch.bind(globalThis);
159
+ install();
160
+ }
93
161
  },
94
162
  get status() { return state; },
95
163
  async start() {
@@ -99,7 +167,7 @@ export const BugSession = {
99
167
  if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
100
168
  if (state !== 'idle') throw new Error(`BugSession is ${state}`);
101
169
  state = 'starting';
102
- rawFetch = globalThis.fetch.bind(globalThis);
170
+ setPhase({ kind: 'starting' });
103
171
  let createdId: string | null = null;
104
172
  let audioStarted = false;
105
173
  const isIOS = Platform.OS === 'ios';
@@ -112,7 +180,8 @@ export const BugSession = {
112
180
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
113
181
  collector = new Collector(clock);
114
182
  const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
115
- const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
183
+ const context = await recordingContext();
184
+ const result = await request('/sessions/start', { device: context.device, application: context.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
116
185
  createdId = result.id;
117
186
  const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
118
187
  const audioStartMs = clock.now();
@@ -126,24 +195,32 @@ export const BugSession = {
126
195
  uncertaintyMs: best.rtt / 2, captureBackend,
127
196
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
128
197
  };
129
- restore = install(collector);
198
+ preRecordEvents = telemetry.begin(collector);
130
199
  state = 'recording';
200
+ setPhase({ kind: 'recording', startedAt: Date.now() });
131
201
  collector.add('navigation', { from: null, to: currentScreen });
132
202
  return session.id;
133
203
  } catch (error) {
134
204
  if (useNativeCapture) await options.capture?.cancel().catch(() => {});
135
205
  if (audioStarted) await options.audio!.stop().catch(() => null);
136
206
  if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
137
- collector = null; session = null; state = 'idle'; throw error;
207
+ telemetry.end(); preRecordEvents = [];
208
+ collector = null; session = null; state = 'idle';
209
+ setPhase({ kind: 'failed', message: safeText(error) });
210
+ throw error;
138
211
  }
139
212
  },
140
213
  async stop() {
141
214
  if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
142
215
  state = 'stopping';
216
+ setPhase({ kind: 'uploading' });
143
217
  const c = collector;
144
218
  const active = session;
145
219
  c.add('session-stop', {});
146
- restore?.(); restore = null;
220
+ telemetry.end();
221
+ // Sent separately from `events` so recorders that predate pre-record support ignore them.
222
+ const before = preRecordEvents;
223
+ preRecordEvents = [];
147
224
  try {
148
225
  let media: Record<string, unknown>;
149
226
  if (active.captureBackend === 'replaykit') {
@@ -169,25 +246,55 @@ export const BugSession = {
169
246
  await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
170
247
  media = { ...active, audioEndMs };
171
248
  }
172
- return await request(`/sessions/${active.id}/stop`, {
173
- durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
249
+ const result = await request(`/sessions/${active.id}/stop`, {
250
+ durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped, preRecordEvents: before,
174
251
  media,
175
252
  });
253
+ setPhase({ kind: 'sent' });
254
+ return result;
176
255
  } catch (error) {
177
256
  if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
178
257
  // Release the device recorder, but preserve partial files for recovery.
179
- await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
258
+ await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, preRecordEvents: before, media: active, durationMs: c.clock.now() }).catch(() => {});
259
+ setPhase({ kind: 'failed', message: safeText(error) });
180
260
  throw error;
181
261
  } finally { collector = null; session = null; state = 'idle'; }
182
262
  },
183
263
  trackAction(action: { name: string; screen?: string }) {
184
- collector?.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
264
+ if (telemetry.active) telemetry.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
185
265
  },
186
266
  trackNavigation(screen: string) {
187
- if (currentScreen !== screen) collector?.add('navigation', { from: currentScreen, to: screen });
267
+ if (currentScreen !== screen && telemetry.active) telemetry.add('navigation', { from: currentScreen, to: screen });
188
268
  currentScreen = screen;
189
269
  },
190
270
  captureError,
271
+ Trigger: createTrigger({
272
+ getPhase: () => phase,
273
+ subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
274
+ async toggle() {
275
+ if (state === 'recording') await BugSession.stop();
276
+ else if (state === 'idle') await BugSession.start();
277
+ },
278
+ fail(error) { if (phase.kind !== 'failed') setPhase({ kind: 'failed', message: safeText(error) }); },
279
+ onAudioLevel: listener => options?.capture?.onAudioLevel?.(listener) ?? null,
280
+ safeAreaInsets: async () => options?.capture?.safeAreaInsets ? options.capture.safeAreaInsets() : null,
281
+ annotate(annotation) { if (state === 'recording') telemetry.add('annotation', { ...annotation, screen: currentScreen }); },
282
+ }),
283
+ /**
284
+ * Expo Router error boundary that reports render errors (which never reach the
285
+ * global error handler). In a route or layout file:
286
+ * `export const ErrorBoundary = BugSession.ErrorBoundary;`
287
+ */
288
+ ErrorBoundary({ error, retry }: BugSessionErrorBoundaryProps) {
289
+ React.useEffect(() => { captureError(error, 'render'); }, [error]);
290
+ return <View style={styles.boundary}>
291
+ <Text style={styles.boundaryTitle}>Something went wrong</Text>
292
+ <Text style={styles.boundaryMessage}>{error.message}</Text>
293
+ <Pressable accessibilityRole="button" onPress={() => { void retry(); }} style={styles.boundaryRetry}>
294
+ <Text style={styles.boundaryRetryText}>Try again</Text>
295
+ </Pressable>
296
+ </View>;
297
+ },
191
298
  /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
192
299
  navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
193
300
  return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
@@ -195,84 +302,12 @@ export const BugSession = {
195
302
  Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
196
303
  return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
197
304
  },
198
- Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
199
- const { width, height } = useWindowDimensions();
200
- const [point, setPoint] = React.useState(() => triggerPosition
201
- ? clampTriggerPosition(triggerPosition, width, height)
202
- : clampTriggerPosition({
203
- left: position.includes('right') ? width - triggerSize - offset : offset,
204
- top: position.includes('bottom') ? height - triggerSize - offset : offset,
205
- }, width, height));
206
- const pointRef = React.useRef(point);
207
- pointRef.current = point;
208
- const drag = React.useRef<Animated.ValueXY | null>(null);
209
- if (!drag.current) drag.current = new Animated.ValueXY();
210
- const dragStart = React.useRef(point);
211
- React.useEffect(() => {
212
- const next = clampTriggerPosition(pointRef.current, width, height);
213
- pointRef.current = next;
214
- setPoint(next);
215
- if (triggerPosition) triggerPosition = next;
216
- drag.current?.setValue({ x: 0, y: 0 });
217
- }, [width, height]);
218
- const responder = React.useMemo(() => PanResponder.create({
219
- onStartShouldSetPanResponder: () => false,
220
- onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
221
- onPanResponderGrant: () => { dragStart.current = pointRef.current; },
222
- onPanResponderMove: (_, gesture) => {
223
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
224
- drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
225
- },
226
- onPanResponderRelease: (_, gesture) => {
227
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
228
- triggerPosition = next;
229
- pointRef.current = next;
230
- setPoint(next);
231
- drag.current?.setValue({ x: 0, y: 0 });
232
- },
233
- onPanResponderTerminate: (_, gesture) => {
234
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
235
- triggerPosition = next;
236
- pointRef.current = next;
237
- setPoint(next);
238
- drag.current?.setValue({ x: 0, y: 0 });
239
- },
240
- }), [draggable, width, height]);
241
- const [busy, setBusy] = React.useState(false);
242
- const recording = state === 'recording';
243
- const toggle = async () => {
244
- if (busy) return;
245
- setBusy(true);
246
- try {
247
- if (state === 'recording') await BugSession.stop();
248
- else await BugSession.start();
249
- } catch (error) { onError?.(error); }
250
- finally { setBusy(false); }
251
- };
252
- return <View pointerEvents="box-none" style={styles.triggerLayer}>
253
- <Animated.View {...(draggable ? responder.panHandlers : {})} style={[styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }]}>
254
- <Pressable
255
- accessibilityRole="button"
256
- accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
257
- accessibilityHint={draggable ? 'Drag to move the BugSession button' : undefined}
258
- accessibilityState={{ busy, selected: recording }}
259
- disabled={busy}
260
- onPress={() => { void toggle(); }}
261
- style={[styles.trigger, recording && styles.triggerRecording, style]}
262
- >
263
- {children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
264
- </Pressable>
265
- </Animated.View>
266
- </View>;
267
- },
268
305
  };
269
306
 
270
307
  const styles = StyleSheet.create({
271
- triggerLayer: { ...StyleSheet.absoluteFill, zIndex: 9999 },
272
- triggerPosition: { position: 'absolute' },
273
- 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 } },
274
- triggerRecording: { backgroundColor: '#B9382F' },
275
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
276
- triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
277
- triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
308
+ boundary: { flex: 1, justifyContent: 'center', padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
309
+ boundaryTitle: { fontSize: 22, fontWeight: '700', color: '#111111' },
310
+ boundaryMessage: { fontSize: 15, color: '#555555' },
311
+ boundaryRetry: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#173B34' },
312
+ boundaryRetryText: { color: '#F4F4EC', fontWeight: '700' },
278
313
  });