@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 +8 -2
- package/dist/capture.d.ts +12 -0
- package/dist/core.d.ts +33 -1
- package/dist/core.js +65 -2
- package/dist/index.d.ts +18 -10
- package/dist/index.js +156 -65
- package/dist/trigger.d.ts +55 -0
- package/dist/trigger.js +297 -0
- package/package.json +8 -2
- package/src/capture.ts +6 -0
- package/src/core.ts +66 -2
- package/src/index.tsx +162 -74
- package/src/trigger.tsx +364 -0
package/src/index.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Pressable, StyleSheet, Text, View, type PressableProps,
|
|
3
|
-
import {
|
|
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 = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
124
|
+
if (utils && handler) utils.setGlobalHandler((error, fatal) => {
|
|
66
125
|
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
67
|
-
handler
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
});
|