@bugsession/react-native 0.1.0-beta.3 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,17 @@ Configure once while idle. Wire an automatic stop timer into your recording UI,
56
56
 
57
57
  React Navigation integration: call the function returned by `BugSession.navigationListener(navigationRef)` from both `NavigationContainer.onReady` and `onStateChange`. Expo Router apps can call `trackNavigation` when the committed pathname changes. Use `BugSession.Touchable` to instrument a Pressable, or `trackAction` directly. Send caught errors through `BugSession.captureError(error)`.
58
58
 
59
- `<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. It is draggable by default, stays within screen bounds, and remembers its moved position across screen changes and trigger remounts while the app remains open. Render it once above the app navigator so it stays visible during navigation. Configure its initial corner with `position` (`bottom-right` by default; also `bottom-left`, `top-right`, or `top-left`) and adjust `offset` in points. Set `draggable={false}` to lock it. Pass `onError` to surface permission, recorder, and upload failures. Supply `children` to replace the default mark.
59
+ Render errors are caught by Expo Router before they reach the global handler. Export the SDK's boundary from your root layout so they are captured: `export const ErrorBoundary = BugSession.ErrorBoundary;`. Unhandled promise rejections are captured in every build; in development they still reach LogBox.
60
+
61
+ `configure()` installs fetch, XMLHttpRequest (axios and most HTTP clients), console (`log`, `info`, `debug`, `warn`, `error`), error and rejection hooks for the app's lifetime; each request is recorded once, and the SDK's own uploads are skipped. 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. After Stop, events keep being collected for `postRecordSeconds` (default 5, maximum 30, `0` disables) while the video uploads, and are attached with `postRecording: true`, so the response to the tester's last tap is kept. Nothing leaves the device unless a recording starts. Set `preRecordSeconds` (maximum 300) to change the earlier window, or `0` to collect only while recording. Recorders deployed before pre- or post-record support accept the recording and ignore these events.
62
+
63
+ Failed requests (status 400 or higher, text or JSON responses) include `responseExcerpt`: the first 1,000 characters of the response body, with JSON fields named like passwords, tokens, secrets, keys, cookies and sessions masked, plus the usual bearer and URL redaction. Successful responses and request bodies are never captured.
64
+
65
+ Wrap your navigator once in `<BugSession.Capture>` to record every tap as an `action` event with `auto: true`, the tap position in points, the current screen and, when one can be read, the touched element's label (accessibility label, `aria-label`, title, placeholder, visible text or `testID`; never a text field's value). It only observes touches and never blocks gestures. `BugSession.Touchable` and `trackAction` remain available for explicit names.
66
+
67
+ `<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.
68
+
69
+ 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
70
 
61
71
  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
72
 
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,53 @@ 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
+ /** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
55
+ export declare function safeBody(text: unknown): string | undefined;
56
+ /** Requests to these URLs (the recorder itself) are never recorded. */
57
+ export declare function ignoreRequests(match: (url: string) => boolean): void;
58
+ export declare function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch;
59
+ type XhrLike = {
60
+ open(method: string, url: string | URL, ...rest: unknown[]): void;
61
+ send(body?: unknown): void;
62
+ addEventListener(type: string, listener: () => void): void;
63
+ status: number;
64
+ responseType?: string;
65
+ responseText?: string;
66
+ getResponseHeader?(name: string): string | null;
67
+ };
68
+ /** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
69
+ export declare function instrumentXhr(Xhr: {
70
+ prototype: XhrLike;
71
+ } | undefined, collector: TelemetrySink): void;
72
+ export {};
package/dist/core.js CHANGED
@@ -1,9 +1,12 @@
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
+ exports.safeBody = safeBody;
7
+ exports.ignoreRequests = ignoreRequests;
6
8
  exports.instrumentFetch = instrumentFetch;
9
+ exports.instrumentXhr = instrumentXhr;
7
10
  function safeUrl(input) {
8
11
  try {
9
12
  const u = new URL(input, 'http://relative.invalid');
@@ -29,6 +32,8 @@ class SessionClock {
29
32
  this.epoch = source();
30
33
  }
31
34
  now() { return Math.max(0, this.source() - this.epoch); }
35
+ /** Monotonic source reading at which this clock reads zero. */
36
+ get origin() { return this.epoch; }
32
37
  }
33
38
  exports.SessionClock = SessionClock;
34
39
  class Collector {
@@ -39,22 +44,117 @@ class Collector {
39
44
  this.dropped = 0;
40
45
  }
41
46
  add(type, data) {
47
+ this.addAt(type, data, this.clock.now());
48
+ }
49
+ addAt(type, data, timestampMs) {
42
50
  if (this.events.length >= this.limit) {
43
51
  this.dropped++;
44
52
  return;
45
53
  }
46
- this.events.push({ ...data, type, timestampMs: this.clock.now() });
54
+ this.events.push({ ...data, type, timestampMs });
47
55
  }
48
56
  }
49
57
  exports.Collector = Collector;
58
+ exports.MAX_PRE_RECORD_MS = 300000;
59
+ /**
60
+ * Routes app events to the active session, or, between sessions, into a bounded
61
+ * in-memory buffer of the last `preRecordMs`. The buffer never leaves the device
62
+ * unless a recording starts. Timestamps are kept on one app-lifetime clock and
63
+ * converted to the session clock, so requests spanning the start stay consistent.
64
+ */
65
+ class Telemetry {
66
+ constructor(preRecordMs = 60000, maxBuffered = 500, source = () => performance.now()) {
67
+ this.maxBuffered = maxBuffered;
68
+ this.buffer = [];
69
+ this.session = null;
70
+ this.clock = new SessionClock(source);
71
+ this.preRecordMs = Math.max(0, Math.min(preRecordMs, exports.MAX_PRE_RECORD_MS));
72
+ }
73
+ /** False when nothing would be kept, so callers can skip formatting work. */
74
+ get active() { return !!this.session || this.preRecordMs > 0; }
75
+ configure(preRecordMs, maxBuffered = this.maxBuffered) {
76
+ this.preRecordMs = Math.max(0, Math.min(preRecordMs, exports.MAX_PRE_RECORD_MS));
77
+ this.maxBuffered = maxBuffered;
78
+ this.prune(this.clock.now());
79
+ }
80
+ add(type, data) {
81
+ const now = this.clock.now();
82
+ if (this.session) {
83
+ const { collector, offsetMs } = this.session;
84
+ collector.addAt(type, shiftRequestTime(data, offsetMs), now - offsetMs);
85
+ return;
86
+ }
87
+ if (this.preRecordMs <= 0)
88
+ return;
89
+ this.buffer.push({ ...data, type, timestampMs: now });
90
+ this.prune(now);
91
+ }
92
+ /** Routes events to `collector` and returns the buffered events on its clock (negative times). */
93
+ begin(collector) {
94
+ const offsetMs = collector.clock.origin - this.clock.origin;
95
+ this.prune(this.clock.now());
96
+ const before = this.buffer.map(e => ({ ...shiftRequestTime(e, offsetMs), timestampMs: e.timestampMs - offsetMs, preRecording: true }));
97
+ this.buffer = [];
98
+ this.session = { collector, offsetMs };
99
+ return before;
100
+ }
101
+ end() { this.session = null; }
102
+ prune(now) {
103
+ const cutoff = now - this.preRecordMs;
104
+ let drop = 0;
105
+ while (drop < this.buffer.length && this.buffer[drop].timestampMs < cutoff)
106
+ drop++;
107
+ drop = Math.max(drop, this.buffer.length - this.maxBuffered);
108
+ if (drop > 0)
109
+ this.buffer.splice(0, drop);
110
+ }
111
+ }
112
+ exports.Telemetry = Telemetry;
113
+ function shiftRequestTime(data, offsetMs) {
114
+ return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
115
+ }
116
+ /** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
117
+ function safeBody(text) {
118
+ if (typeof text !== 'string' || !text.trim())
119
+ return undefined;
120
+ return safeText(text.slice(0, 4000)
121
+ .replace(/("(?:[\w-]*(?:password|passwd|secret|token|authorization|cookie|api[-_]?key|session)[\w-]*)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)/gi, '$1"[redacted]"'))
122
+ .slice(0, 1000);
123
+ }
124
+ const textual = /json|text|xml|html|problem/i;
125
+ const sdkRequests = { ignored: (_url) => false };
126
+ /** Requests to these URLs (the recorder itself) are never recorded. */
127
+ function ignoreRequests(match) { sdkRequests.ignored = match; }
128
+ // React Native's fetch is built on XMLHttpRequest. While a fetch call creates its
129
+ // request synchronously, the XHR hook skips it so each request is recorded once.
130
+ let insideFetch = 0;
50
131
  function instrumentFetch(original, collector) {
51
132
  return (async (input, init) => {
52
- const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
133
+ const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
53
134
  const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
54
135
  const start = collector.clock.now();
136
+ let pending;
137
+ insideFetch++;
55
138
  try {
56
- const response = await original(input, init);
57
- collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
139
+ pending = original(input, init);
140
+ }
141
+ finally {
142
+ insideFetch--;
143
+ }
144
+ if (sdkRequests.ignored(rawUrl))
145
+ return pending;
146
+ const url = safeUrl(rawUrl);
147
+ try {
148
+ const response = await pending;
149
+ const durationMs = collector.clock.now() - start;
150
+ let responseExcerpt;
151
+ if (response.status >= 400 && textual.test(response.headers?.get?.('content-type') ?? 'text')) {
152
+ responseExcerpt = await Promise.race([
153
+ response.clone().text().then(safeBody, () => undefined),
154
+ new Promise(resolve => setTimeout(() => resolve(undefined), 500)),
155
+ ]);
156
+ }
157
+ collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs, ...(responseExcerpt ? { responseExcerpt } : {}) });
58
158
  return response;
59
159
  }
60
160
  catch (error) {
@@ -63,3 +163,40 @@ function instrumentFetch(original, collector) {
63
163
  }
64
164
  });
65
165
  }
166
+ /** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
167
+ function instrumentXhr(Xhr, collector) {
168
+ if (!Xhr?.prototype?.open || !Xhr.prototype.send)
169
+ return;
170
+ const proto = Xhr.prototype;
171
+ const open = proto.open, send = proto.send;
172
+ const meta = new WeakMap();
173
+ proto.open = function (method, url, ...rest) {
174
+ const href = String(url);
175
+ if (!insideFetch && !sdkRequests.ignored(href))
176
+ meta.set(this, { method: String(method || 'GET').toUpperCase(), url: safeUrl(href) });
177
+ else
178
+ meta.delete(this);
179
+ return open.call(this, method, url, ...rest);
180
+ };
181
+ proto.send = function (body) {
182
+ const request = meta.get(this);
183
+ if (request) {
184
+ const start = collector.clock.now();
185
+ let error;
186
+ for (const type of ['error', 'timeout', 'abort'])
187
+ this.addEventListener(type, () => { error ?? (error = `Request ${type}`); });
188
+ this.addEventListener('loadend', () => {
189
+ const status = error || !this.status ? null : this.status;
190
+ let responseExcerpt;
191
+ try {
192
+ if (status !== null && status >= 400 && (!this.responseType || this.responseType === 'text') && textual.test(this.getResponseHeader?.('content-type') ?? 'text'))
193
+ responseExcerpt = safeBody(this.responseText);
194
+ }
195
+ catch { /* response not readable */ }
196
+ collector.add('network', { requestTimestampMs: start, method: request.method, url: request.url, status, durationMs: collector.clock.now() - start, client: 'xhr',
197
+ ...(error ? { error } : {}), ...(responseExcerpt ? { responseExcerpt } : {}) });
198
+ });
199
+ }
200
+ return send.call(this, body);
201
+ };
202
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import React from 'react';
2
- import { type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { type PressableProps } from 'react-native';
3
+ export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
3
4
  import { type NativeCaptureAdapter } from './capture';
4
5
  export type { NativeCaptureAdapter } from './capture';
5
- export { Collector, SessionClock, safeText, safeUrl } from './core';
6
+ export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
6
7
  export type AudioAdapter = {
7
8
  prepare(): Promise<void>;
8
9
  start(): void;
@@ -16,15 +17,20 @@ 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;
25
+ /**
26
+ * Seconds of app events still collected after the tester stops, while the video uploads,
27
+ * so a request or error that lands just after Stop is kept. Defaults to 5, max 30; 0 disables.
28
+ */
29
+ postRecordSeconds?: number;
19
30
  };
20
- export type BugSessionTriggerProps = {
21
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
22
- offset?: number;
23
- draggable?: boolean;
24
- style?: StyleProp<ViewStyle>;
25
- accessibilityLabel?: string;
26
- children?: React.ReactNode;
27
- onError?: (error: unknown) => void;
31
+ export type BugSessionErrorBoundaryProps = {
32
+ error: Error;
33
+ retry: () => void | Promise<void>;
28
34
  };
29
35
  declare function captureError(error: unknown, source?: string): void;
30
36
  export declare const BugSession: {
@@ -38,6 +44,20 @@ export declare const BugSession: {
38
44
  }): void;
39
45
  trackNavigation(screen: string): void;
40
46
  captureError: typeof captureError;
47
+ /**
48
+ * Wrap the app once (inside the navigator's container, around your screens) to record
49
+ * every tap automatically: position, current screen and the element's label when available.
50
+ */
51
+ Capture: ({ children, style, ...props }: import("react-native").ViewProps & {
52
+ children?: React.ReactNode;
53
+ }) => React.JSX.Element;
54
+ Trigger: ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: import("./trigger").BugSessionTriggerProps) => React.JSX.Element;
55
+ /**
56
+ * Expo Router error boundary that reports render errors (which never reach the
57
+ * global error handler). In a route or layout file:
58
+ * `export const ErrorBoundary = BugSession.ErrorBoundary;`
59
+ */
60
+ ErrorBoundary({ error, retry }: BugSessionErrorBoundaryProps): React.JSX.Element;
41
61
  /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
42
62
  navigationListener(ref: {
43
63
  getCurrentRoute(): {
@@ -48,5 +68,4 @@ export declare const BugSession: {
48
68
  name: string;
49
69
  screen?: string;
50
70
  }): React.JSX.Element;
51
- Trigger({ position, offset, draggable, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
52
71
  };