@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/src/index.tsx CHANGED
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
- import { Pressable, StyleSheet, Text, View, 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,24 +13,77 @@ 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
- style?: StyleProp<ViewStyle>;
19
- accessibilityLabel?: string;
20
- children?: React.ReactNode;
21
- 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;
22
28
  };
29
+ export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
23
30
  type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
24
- 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
+ };
25
36
  let options: BugSessionOptions;
26
37
  let collector: Collector | null = null;
38
+ const telemetry = new Telemetry();
39
+ let installed = false;
40
+ let preRecordEvents: Evidence[] = [];
27
41
  let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
28
42
  let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
29
- let restore: (() => void) | null = null;
30
43
  let currentScreen = 'Home';
31
44
  let rawFetch: typeof fetch;
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;
82
+ return {
83
+ device: compact({ ...device, ...native?.device, ...options.device }),
84
+ application: compact({ ...application, ...native?.application, ...options.application }),
85
+ };
86
+ }
32
87
  function authHeaders(): Record<string, string> {
33
88
  return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
34
89
  }
@@ -49,38 +104,60 @@ async function request(path: string, data?: unknown) {
49
104
  if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
50
105
  return response.json();
51
106
  }
52
- function install(c: Collector) {
53
- const originalFetch = globalThis.fetch;
54
- const wrappedFetch = instrumentFetch(rawFetch, c);
55
- globalThis.fetch = wrappedFetch;
56
- const originals = { log: console.log, warn: console.warn, error: console.error };
57
- 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];
58
114
  console[level] = (...args: unknown[]) => {
59
- 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 });
60
- 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);
61
120
  };
62
121
  }
63
122
  const utils = globals.ErrorUtils;
64
123
  const handler = utils?.getGlobalHandler();
65
- const onError = (error: Error, fatal?: boolean) => {
124
+ if (utils && handler) utils.setGlobalHandler((error, fatal) => {
66
125
  captureError(error, fatal ? 'fatal' : 'uncaught');
67
- handler?.(error, fatal);
68
- };
69
- if (utils && handler) utils.setGlobalHandler(onError);
70
- return () => {
71
- if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
72
- for (const level of ['log', 'warn', 'error'] as const) console[level] = originals[level];
73
- if (utils?.getGlobalHandler() === onError && handler) utils.setGlobalHandler(handler);
74
- };
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); }
75
146
  }
76
147
  function captureError(error: unknown, source = 'boundary') {
77
- 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 });
78
150
  }
79
151
 
80
152
  export const BugSession = {
81
153
  configure(config: BugSessionOptions) {
82
154
  if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
83
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
+ }
84
161
  },
85
162
  get status() { return state; },
86
163
  async start() {
@@ -90,7 +167,7 @@ export const BugSession = {
90
167
  if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
91
168
  if (state !== 'idle') throw new Error(`BugSession is ${state}`);
92
169
  state = 'starting';
93
- rawFetch = globalThis.fetch.bind(globalThis);
170
+ setPhase({ kind: 'starting' });
94
171
  let createdId: string | null = null;
95
172
  let audioStarted = false;
96
173
  const isIOS = Platform.OS === 'ios';
@@ -103,7 +180,8 @@ export const BugSession = {
103
180
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
104
181
  collector = new Collector(clock);
105
182
  const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
106
- 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() });
107
185
  createdId = result.id;
108
186
  const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
109
187
  const audioStartMs = clock.now();
@@ -117,24 +195,32 @@ export const BugSession = {
117
195
  uncertaintyMs: best.rtt / 2, captureBackend,
118
196
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
119
197
  };
120
- restore = install(collector);
198
+ preRecordEvents = telemetry.begin(collector);
121
199
  state = 'recording';
200
+ setPhase({ kind: 'recording', startedAt: Date.now() });
122
201
  collector.add('navigation', { from: null, to: currentScreen });
123
202
  return session.id;
124
203
  } catch (error) {
125
204
  if (useNativeCapture) await options.capture?.cancel().catch(() => {});
126
205
  if (audioStarted) await options.audio!.stop().catch(() => null);
127
206
  if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
128
- 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;
129
211
  }
130
212
  },
131
213
  async stop() {
132
214
  if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
133
215
  state = 'stopping';
216
+ setPhase({ kind: 'uploading' });
134
217
  const c = collector;
135
218
  const active = session;
136
219
  c.add('session-stop', {});
137
- 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 = [];
138
224
  try {
139
225
  let media: Record<string, unknown>;
140
226
  if (active.captureBackend === 'replaykit') {
@@ -160,25 +246,55 @@ export const BugSession = {
160
246
  await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
161
247
  media = { ...active, audioEndMs };
162
248
  }
163
- return await request(`/sessions/${active.id}/stop`, {
164
- 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,
165
251
  media,
166
252
  });
253
+ setPhase({ kind: 'sent' });
254
+ return result;
167
255
  } catch (error) {
168
256
  if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
169
257
  // Release the device recorder, but preserve partial files for recovery.
170
- 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) });
171
260
  throw error;
172
261
  } finally { collector = null; session = null; state = 'idle'; }
173
262
  },
174
263
  trackAction(action: { name: string; screen?: string }) {
175
- 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) });
176
265
  },
177
266
  trackNavigation(screen: string) {
178
- if (currentScreen !== screen) collector?.add('navigation', { from: currentScreen, to: screen });
267
+ if (currentScreen !== screen && telemetry.active) telemetry.add('navigation', { from: currentScreen, to: screen });
179
268
  currentScreen = screen;
180
269
  },
181
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
+ },
182
298
  /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
183
299
  navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
184
300
  return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
@@ -186,40 +302,12 @@ export const BugSession = {
186
302
  Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
187
303
  return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
188
304
  },
189
- Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
190
- const [busy, setBusy] = React.useState(false);
191
- const recording = state === 'recording';
192
- const edge = position.includes('right') ? { right: offset } : { left: offset };
193
- const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
194
- const toggle = async () => {
195
- if (busy) return;
196
- setBusy(true);
197
- try {
198
- if (state === 'recording') await BugSession.stop();
199
- else await BugSession.start();
200
- } catch (error) { onError?.(error); }
201
- finally { setBusy(false); }
202
- };
203
- return <View pointerEvents="box-none" style={[styles.triggerLayer, edge, vertical]}>
204
- <Pressable
205
- accessibilityRole="button"
206
- accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
207
- accessibilityState={{ busy, selected: recording }}
208
- disabled={busy}
209
- onPress={() => { void toggle(); }}
210
- style={[styles.trigger, recording && styles.triggerRecording, style]}
211
- >
212
- {children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
213
- </Pressable>
214
- </View>;
215
- },
216
305
  };
217
306
 
218
307
  const styles = StyleSheet.create({
219
- triggerLayer: { position: 'absolute', zIndex: 9999 },
220
- 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 } },
221
- triggerRecording: { backgroundColor: '#B9382F' },
222
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
223
- triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
224
- 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' },
225
313
  });