@bugsession/react-native 0.1.0-beta.4 → 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
@@ -58,7 +58,11 @@ React Navigation integration: call the function returned by `BugSession.navigati
58
58
 
59
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
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.
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.
62
66
 
63
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.
64
68
 
package/dist/core.d.ts CHANGED
@@ -51,4 +51,22 @@ export declare class Telemetry implements TelemetrySink {
51
51
  end(): void;
52
52
  private prune;
53
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;
54
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
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
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');
@@ -110,14 +113,48 @@ exports.Telemetry = Telemetry;
110
113
  function shiftRequestTime(data, offsetMs) {
111
114
  return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
112
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;
113
131
  function instrumentFetch(original, collector) {
114
132
  return (async (input, init) => {
115
- 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;
116
134
  const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
117
135
  const start = collector.clock.now();
136
+ let pending;
137
+ insideFetch++;
138
+ try {
139
+ pending = original(input, init);
140
+ }
141
+ finally {
142
+ insideFetch--;
143
+ }
144
+ if (sdkRequests.ignored(rawUrl))
145
+ return pending;
146
+ const url = safeUrl(rawUrl);
118
147
  try {
119
- const response = await original(input, init);
120
- collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
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 } : {}) });
121
158
  return response;
122
159
  }
123
160
  catch (error) {
@@ -126,3 +163,40 @@ function instrumentFetch(original, collector) {
126
163
  }
127
164
  });
128
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
@@ -22,6 +22,11 @@ export type BugSessionOptions = {
22
22
  * kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
23
23
  */
24
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;
25
30
  };
26
31
  export type BugSessionErrorBoundaryProps = {
27
32
  error: Error;
@@ -39,6 +44,13 @@ export declare const BugSession: {
39
44
  }): void;
40
45
  trackNavigation(screen: string): void;
41
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;
42
54
  Trigger: ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: import("./trigger").BugSessionTriggerProps) => React.JSX.Element;
43
55
  /**
44
56
  * Expo Router error boundary that reports render errors (which never reach the
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ const react_1 = __importDefault(require("react"));
9
9
  const react_native_1 = require("react-native");
10
10
  const trigger_1 = require("./trigger");
11
11
  const core_1 = require("./core");
12
+ const touches_1 = require("./touches");
12
13
  const capture_1 = require("./capture");
13
14
  var core_2 = require("./core");
14
15
  Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
@@ -22,6 +23,7 @@ let collector = null;
22
23
  const telemetry = new core_1.Telemetry();
23
24
  let installed = false;
24
25
  let preRecordEvents = [];
26
+ const MAX_POST_RECORD_SECONDS = 30;
25
27
  let session = null;
26
28
  let state = 'idle';
27
29
  let currentScreen = 'Home';
@@ -100,11 +102,14 @@ async function request(path, data) {
100
102
  // recording starts can be attached to it (see Telemetry).
101
103
  function install() {
102
104
  installed = true;
105
+ // The recorder itself, and in development Metro's own symbolication and log requests.
106
+ (0, core_1.ignoreRequests)(url => (!!options && url.startsWith(options.recorderUrl)) || (__DEV__ && /\/(symbolicate|logs)(\?|$)/.test(url)));
103
107
  globalThis.fetch = (0, core_1.instrumentFetch)(rawFetch, telemetry);
108
+ (0, core_1.instrumentXhr)(globalThis.XMLHttpRequest, telemetry);
104
109
  for (const level of ['log', 'info', 'debug', 'warn', 'error']) {
105
110
  const original = console[level];
106
111
  console[level] = (...args) => {
107
- if (telemetry.active) {
112
+ if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
108
113
  const error = args.find((a) => a instanceof Error);
109
114
  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
115
  }
@@ -146,9 +151,26 @@ function rejectionMessage(rejection) {
146
151
  return String(rejection);
147
152
  }
148
153
  }
154
+ // React Native also prints captured errors (such as unhandled rejections in development)
155
+ // through console.error; those repeats are skipped for a moment after capture.
156
+ const recentErrors = [];
157
+ function repeatsCapturedError(args) {
158
+ const now = Date.now();
159
+ while (recentErrors.length && now - recentErrors[0].at > 2000)
160
+ recentErrors.shift();
161
+ if (!recentErrors.length)
162
+ return false;
163
+ const printed = args.map(a => a instanceof Error ? a.message : typeof a === 'string' ? a : '').join(' ');
164
+ return recentErrors.some(e => e.message.length > 8 && printed.includes(e.message));
165
+ }
149
166
  function captureError(error, source = 'boundary') {
150
167
  if (!telemetry.active)
151
168
  return;
169
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
170
+ if (message && repeatsCapturedError([message]))
171
+ return;
172
+ if (message)
173
+ recentErrors.push({ message: message.slice(0, 200), at: Date.now() });
152
174
  telemetry.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
153
175
  }
154
176
  exports.BugSession = {
@@ -238,10 +260,20 @@ exports.BugSession = {
238
260
  const c = collector;
239
261
  const active = session;
240
262
  c.add('session-stop', {});
241
- telemetry.end();
242
- // Sent separately from `events` so recorders that predate pre-record support ignore them.
263
+ const stoppedAtMs = c.clock.now();
264
+ const postRecordMs = Math.max(0, Math.min(options.postRecordSeconds ?? 5, MAX_POST_RECORD_SECONDS)) * 1000;
265
+ // Sent separately from `events` so recorders that predate pre/post-record support ignore them.
243
266
  const before = preRecordEvents;
244
267
  preRecordEvents = [];
268
+ // App events keep flowing to this session while media uploads; anything after Stop is
269
+ // reported separately as post-recording evidence.
270
+ const split = () => {
271
+ telemetry.end();
272
+ return {
273
+ events: c.events.filter(e => e.timestampMs <= stoppedAtMs),
274
+ after: c.events.filter(e => e.timestampMs > stoppedAtMs && e.timestampMs <= stoppedAtMs + postRecordMs).slice(0, 1000).map(e => ({ ...e, postRecording: true })),
275
+ };
276
+ };
245
277
  try {
246
278
  let media;
247
279
  if (active.captureBackend === 'replaykit') {
@@ -271,8 +303,13 @@ exports.BugSession = {
271
303
  await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
272
304
  media = { ...active, audioEndMs };
273
305
  }
306
+ const remainingMs = stoppedAtMs + postRecordMs - c.clock.now();
307
+ if (remainingMs > 0)
308
+ await new Promise(resolve => setTimeout(resolve, remainingMs));
309
+ const { events, after } = split();
274
310
  const result = await request(`/sessions/${active.id}/stop`, {
275
- durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped, preRecordEvents: before,
311
+ durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
312
+ ...(after.length ? { postRecordEvents: after } : {}),
276
313
  media,
277
314
  });
278
315
  setPhase({ kind: 'sent' });
@@ -282,11 +319,13 @@ exports.BugSession = {
282
319
  if (active.captureBackend !== 'adb')
283
320
  await options.capture.cancel().catch(() => { });
284
321
  // Release the device recorder, but preserve partial files for recovery.
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(() => { });
322
+ const { events, after } = split();
323
+ await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events, preRecordEvents: before, ...(after.length ? { postRecordEvents: after } : {}), media: active, durationMs: stoppedAtMs }).catch(() => { });
286
324
  setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
287
325
  throw error;
288
326
  }
289
327
  finally {
328
+ telemetry.end();
290
329
  collector = null;
291
330
  session = null;
292
331
  state = 'idle';
@@ -302,6 +341,13 @@ exports.BugSession = {
302
341
  currentScreen = screen;
303
342
  },
304
343
  captureError,
344
+ /**
345
+ * Wrap the app once (inside the navigator's container, around your screens) to record
346
+ * every tap automatically: position, current screen and the element's label when available.
347
+ */
348
+ Capture: (0, touches_1.createCapture)(() => telemetry.active ? tap => {
349
+ telemetry.add('action', { target: (0, core_1.safeText)(tap.label ?? `Tap at (${tap.x}, ${tap.y})`), screen: currentScreen, x: tap.x, y: tap.y, auto: true });
350
+ } : null),
305
351
  Trigger: (0, trigger_1.createTrigger)({
306
352
  getPhase: () => phase,
307
353
  subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Best-effort name for the touched element: walks up from the touched host component
3
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
4
+ * values. React internals differ between renderers, so every failure yields undefined.
5
+ */
6
+ export declare function tapLabel(event: unknown): string | undefined;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tapLabel = tapLabel;
4
+ const LABEL_DEPTH = 12;
5
+ const PRESSABLE_DEPTH = 4;
6
+ const DESCENDANT_BUDGET = 40;
7
+ function text(value) {
8
+ if (typeof value === 'string' || typeof value === 'number')
9
+ return String(value).trim() || undefined;
10
+ if (Array.isArray(value)) {
11
+ const joined = value.map(text).filter(Boolean).join(' ');
12
+ return joined || undefined;
13
+ }
14
+ return undefined;
15
+ }
16
+ /**
17
+ * Best-effort name for the touched element: walks up from the touched host component
18
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
19
+ * values. React internals differ between renderers, so every failure yields undefined.
20
+ */
21
+ function tapLabel(event) {
22
+ try {
23
+ const native = event;
24
+ const target = native?._targetInst ?? native?.target?.__internalInstanceHandle;
25
+ let fiber = target;
26
+ for (let depth = 0; fiber && depth < LABEL_DEPTH; depth++, fiber = fiber.return) {
27
+ const label = ownLabel(fiber.memoizedProps);
28
+ if (label)
29
+ return label.slice(0, 80);
30
+ // A tap on a button's padding targets the button itself; its text is inside it.
31
+ if (depth < PRESSABLE_DEPTH && pressable(fiber.memoizedProps)) {
32
+ const inner = descendantText(fiber.child);
33
+ if (inner)
34
+ return inner.slice(0, 80);
35
+ }
36
+ }
37
+ }
38
+ catch { /* renderer internals unavailable */ }
39
+ return undefined;
40
+ }
41
+ function ownLabel(props) {
42
+ if (!props || typeof props !== 'object')
43
+ return undefined;
44
+ return text(props.accessibilityLabel) ?? text(props['aria-label']) ?? text(props.title)
45
+ ?? (typeof props.placeholder === 'string' ? props.placeholder : undefined)
46
+ ?? (props.editable === undefined && props.value === undefined ? text(props.children) : undefined)
47
+ ?? text(props.testID);
48
+ }
49
+ function pressable(props) {
50
+ if (!props || typeof props !== 'object')
51
+ return false;
52
+ const role = props.accessibilityRole ?? props.role;
53
+ return typeof props.onPress === 'function' || typeof props.onClick === 'function' || role === 'button' || role === 'link'
54
+ || typeof props.onResponderRelease === 'function';
55
+ }
56
+ /** First readable text inside a pressable, breadth-first and bounded; never input values. */
57
+ function descendantText(first) {
58
+ const queue = [];
59
+ for (let f = first; f; f = f.sibling)
60
+ queue.push(f);
61
+ for (let visited = 0; queue.length && visited < DESCENDANT_BUDGET; visited++) {
62
+ const fiber = queue.shift();
63
+ const props = fiber.memoizedProps;
64
+ if (props && typeof props === 'object' && props.editable === undefined && props.value === undefined) {
65
+ const label = text(props.accessibilityLabel) ?? text(props.children);
66
+ if (label)
67
+ return label;
68
+ }
69
+ for (let f = fiber.child; f; f = f.sibling)
70
+ queue.push(f);
71
+ }
72
+ return undefined;
73
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { type ViewProps } from 'react-native';
3
+ export type TapSink = (tap: {
4
+ label?: string;
5
+ x: number;
6
+ y: number;
7
+ }) => void;
8
+ /**
9
+ * Wraps the app once and records every tap (position, screen and the element's label
10
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
11
+ * It only observes touches; it never becomes the responder or blocks gestures.
12
+ */
13
+ export declare function createCapture(sink: () => TapSink | null): ({ children, style, ...props }: ViewProps & {
14
+ children?: React.ReactNode;
15
+ }) => React.JSX.Element;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCapture = createCapture;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const react_native_1 = require("react-native");
7
+ const tap_label_1 = require("./tap-label");
8
+ const TAP_MAX_MOVE = 10;
9
+ const TAP_MAX_MS = 800;
10
+ /**
11
+ * Wraps the app once and records every tap (position, screen and the element's label
12
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
13
+ * It only observes touches; it never becomes the responder or blocks gestures.
14
+ */
15
+ function createCapture(sink) {
16
+ return function Capture({ children, style, ...props }) {
17
+ const start = (0, react_1.useRef)(null);
18
+ return (0, jsx_runtime_1.jsx)(react_native_1.View, { ...props, style: [styles.fill, style], onTouchStartCapture: e => {
19
+ props.onTouchStartCapture?.(e);
20
+ start.current = e.nativeEvent.touches.length > 1 ? null : { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY, at: Date.now() };
21
+ }, onTouchEndCapture: e => {
22
+ props.onTouchEndCapture?.(e);
23
+ const began = start.current;
24
+ start.current = null;
25
+ const add = sink();
26
+ if (!began || !add)
27
+ return;
28
+ const { pageX, pageY } = e.nativeEvent;
29
+ if (Math.hypot(pageX - began.x, pageY - began.y) > TAP_MAX_MOVE || Date.now() - began.at > TAP_MAX_MS)
30
+ return;
31
+ add({ label: (0, tap_label_1.tapLabel)(e), x: Math.round(pageX), y: Math.round(pageY) });
32
+ }, children: children });
33
+ };
34
+ }
35
+ const styles = react_native_1.StyleSheet.create({ fill: { flex: 1 } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bugsession/react-native",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/core.ts CHANGED
@@ -94,14 +94,42 @@ function shiftRequestTime<T extends Record<string, unknown>>(data: T, offsetMs:
94
94
  return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
95
95
  }
96
96
 
97
+ /** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
98
+ export function safeBody(text: unknown): string | undefined {
99
+ if (typeof text !== 'string' || !text.trim()) return undefined;
100
+ return safeText(text.slice(0, 4000)
101
+ .replace(/("(?:[\w-]*(?:password|passwd|secret|token|authorization|cookie|api[-_]?key|session)[\w-]*)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)/gi, '$1"[redacted]"'))
102
+ .slice(0, 1000);
103
+ }
104
+ const textual = /json|text|xml|html|problem/i;
105
+ const sdkRequests = { ignored: (_url: string) => false };
106
+ /** Requests to these URLs (the recorder itself) are never recorded. */
107
+ export function ignoreRequests(match: (url: string) => boolean) { sdkRequests.ignored = match; }
108
+ // React Native's fetch is built on XMLHttpRequest. While a fetch call creates its
109
+ // request synchronously, the XHR hook skips it so each request is recorded once.
110
+ let insideFetch = 0;
111
+
97
112
  export function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch {
98
113
  return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
99
- const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
114
+ const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
100
115
  const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
101
116
  const start = collector.clock.now();
117
+ let pending: Promise<Response>;
118
+ insideFetch++;
119
+ try { pending = original(input, init); } finally { insideFetch--; }
120
+ if (sdkRequests.ignored(rawUrl)) return pending;
121
+ const url = safeUrl(rawUrl);
102
122
  try {
103
- const response = await original(input, init);
104
- collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
123
+ const response = await pending;
124
+ const durationMs = collector.clock.now() - start;
125
+ let responseExcerpt: string | undefined;
126
+ if (response.status >= 400 && textual.test(response.headers?.get?.('content-type') ?? 'text')) {
127
+ responseExcerpt = await Promise.race([
128
+ response.clone().text().then(safeBody, () => undefined),
129
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
130
+ ]);
131
+ }
132
+ collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs, ...(responseExcerpt ? { responseExcerpt } : {}) });
105
133
  return response;
106
134
  } catch (error) {
107
135
  collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
@@ -109,3 +137,42 @@ export function instrumentFetch(original: typeof fetch, collector: TelemetrySink
109
137
  }
110
138
  }) as typeof fetch;
111
139
  }
140
+
141
+ type XhrLike = {
142
+ open(method: string, url: string | URL, ...rest: unknown[]): void;
143
+ send(body?: unknown): void;
144
+ addEventListener(type: string, listener: () => void): void;
145
+ status: number; responseType?: string; responseText?: string;
146
+ getResponseHeader?(name: string): string | null;
147
+ };
148
+ /** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
149
+ export function instrumentXhr(Xhr: { prototype: XhrLike } | undefined, collector: TelemetrySink) {
150
+ if (!Xhr?.prototype?.open || !Xhr.prototype.send) return;
151
+ const proto = Xhr.prototype;
152
+ const open = proto.open, send = proto.send;
153
+ const meta = new WeakMap<object, { method: string; url: string }>();
154
+ proto.open = function (this: XhrLike, method: string, url: string | URL, ...rest: unknown[]) {
155
+ const href = String(url);
156
+ if (!insideFetch && !sdkRequests.ignored(href)) meta.set(this, { method: String(method || 'GET').toUpperCase(), url: safeUrl(href) });
157
+ else meta.delete(this);
158
+ return open.call(this, method, url, ...rest);
159
+ };
160
+ proto.send = function (this: XhrLike, body?: unknown) {
161
+ const request = meta.get(this);
162
+ if (request) {
163
+ const start = collector.clock.now();
164
+ let error: string | undefined;
165
+ for (const type of ['error', 'timeout', 'abort']) this.addEventListener(type, () => { error ??= `Request ${type}`; });
166
+ this.addEventListener('loadend', () => {
167
+ const status = error || !this.status ? null : this.status;
168
+ let responseExcerpt: string | undefined;
169
+ try {
170
+ if (status !== null && status >= 400 && (!this.responseType || this.responseType === 'text') && textual.test(this.getResponseHeader?.('content-type') ?? 'text')) responseExcerpt = safeBody(this.responseText);
171
+ } catch { /* response not readable */ }
172
+ collector.add('network', { requestTimestampMs: start, method: request.method, url: request.url, status, durationMs: collector.clock.now() - start, client: 'xhr',
173
+ ...(error ? { error } : {}), ...(responseExcerpt ? { responseExcerpt } : {}) });
174
+ });
175
+ }
176
+ return send.call(this, body);
177
+ };
178
+ }
package/src/index.tsx CHANGED
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { Dimensions, Pressable, StyleSheet, Text, View, type PressableProps, Platform } from 'react-native';
3
3
  import { createTrigger, type IndicatorPhase } from './trigger';
4
4
  export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
5
- import { Collector, instrumentFetch, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
5
+ import { Collector, ignoreRequests, instrumentFetch, instrumentXhr, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
6
+ import { createCapture } from './touches';
6
7
  import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
7
8
  export type { NativeCaptureAdapter } from './capture';
8
9
  export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
@@ -25,6 +26,11 @@ export type BugSessionOptions = {
25
26
  * kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
26
27
  */
27
28
  preRecordSeconds?: number;
29
+ /**
30
+ * Seconds of app events still collected after the tester stops, while the video uploads,
31
+ * so a request or error that lands just after Stop is kept. Defaults to 5, max 30; 0 disables.
32
+ */
33
+ postRecordSeconds?: number;
28
34
  };
29
35
  export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
30
36
  type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
@@ -38,6 +44,7 @@ let collector: Collector | null = null;
38
44
  const telemetry = new Telemetry();
39
45
  let installed = false;
40
46
  let preRecordEvents: Evidence[] = [];
47
+ const MAX_POST_RECORD_SECONDS = 30;
41
48
  let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
42
49
  let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
43
50
  let currentScreen = 'Home';
@@ -108,11 +115,14 @@ async function request(path: string, data?: unknown) {
108
115
  // recording starts can be attached to it (see Telemetry).
109
116
  function install() {
110
117
  installed = true;
118
+ // The recorder itself, and in development Metro's own symbolication and log requests.
119
+ ignoreRequests(url => (!!options && url.startsWith(options.recorderUrl)) || (__DEV__ && /\/(symbolicate|logs)(\?|$)/.test(url)));
111
120
  globalThis.fetch = instrumentFetch(rawFetch, telemetry);
121
+ instrumentXhr((globalThis as { XMLHttpRequest?: Parameters<typeof instrumentXhr>[0] }).XMLHttpRequest, telemetry);
112
122
  for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) {
113
123
  const original = console[level];
114
124
  console[level] = (...args: unknown[]) => {
115
- if (telemetry.active) {
125
+ if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
116
126
  const error = args.find((a): a is Error => a instanceof Error);
117
127
  telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: error?.stack ? safeText(error.stack) : undefined, screen: currentScreen });
118
128
  }
@@ -144,8 +154,21 @@ function rejectionMessage(rejection: unknown) {
144
154
  if (typeof rejection === 'string') return rejection;
145
155
  try { return JSON.stringify(rejection) ?? String(rejection); } catch { return String(rejection); }
146
156
  }
157
+ // React Native also prints captured errors (such as unhandled rejections in development)
158
+ // through console.error; those repeats are skipped for a moment after capture.
159
+ const recentErrors: { message: string; at: number }[] = [];
160
+ function repeatsCapturedError(args: unknown[]) {
161
+ const now = Date.now();
162
+ while (recentErrors.length && now - recentErrors[0].at > 2000) recentErrors.shift();
163
+ if (!recentErrors.length) return false;
164
+ const printed = args.map(a => a instanceof Error ? a.message : typeof a === 'string' ? a : '').join(' ');
165
+ return recentErrors.some(e => e.message.length > 8 && printed.includes(e.message));
166
+ }
147
167
  function captureError(error: unknown, source = 'boundary') {
148
168
  if (!telemetry.active) return;
169
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
170
+ if (message && repeatsCapturedError([message])) return;
171
+ if (message) recentErrors.push({ message: message.slice(0, 200), at: Date.now() });
149
172
  telemetry.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
150
173
  }
151
174
 
@@ -217,10 +240,20 @@ export const BugSession = {
217
240
  const c = collector;
218
241
  const active = session;
219
242
  c.add('session-stop', {});
220
- telemetry.end();
221
- // Sent separately from `events` so recorders that predate pre-record support ignore them.
243
+ const stoppedAtMs = c.clock.now();
244
+ const postRecordMs = Math.max(0, Math.min(options.postRecordSeconds ?? 5, MAX_POST_RECORD_SECONDS)) * 1000;
245
+ // Sent separately from `events` so recorders that predate pre/post-record support ignore them.
222
246
  const before = preRecordEvents;
223
247
  preRecordEvents = [];
248
+ // App events keep flowing to this session while media uploads; anything after Stop is
249
+ // reported separately as post-recording evidence.
250
+ const split = () => {
251
+ telemetry.end();
252
+ return {
253
+ events: c.events.filter(e => e.timestampMs <= stoppedAtMs),
254
+ after: c.events.filter(e => e.timestampMs > stoppedAtMs && e.timestampMs <= stoppedAtMs + postRecordMs).slice(0, 1000).map(e => ({ ...e, postRecording: true })),
255
+ };
256
+ };
224
257
  try {
225
258
  let media: Record<string, unknown>;
226
259
  if (active.captureBackend === 'replaykit') {
@@ -246,8 +279,12 @@ export const BugSession = {
246
279
  await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
247
280
  media = { ...active, audioEndMs };
248
281
  }
282
+ const remainingMs = stoppedAtMs + postRecordMs - c.clock.now();
283
+ if (remainingMs > 0) await new Promise(resolve => setTimeout(resolve, remainingMs));
284
+ const { events, after } = split();
249
285
  const result = await request(`/sessions/${active.id}/stop`, {
250
- durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped, preRecordEvents: before,
286
+ durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
287
+ ...(after.length ? { postRecordEvents: after } : {}),
251
288
  media,
252
289
  });
253
290
  setPhase({ kind: 'sent' });
@@ -255,10 +292,11 @@ export const BugSession = {
255
292
  } catch (error) {
256
293
  if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
257
294
  // Release the device recorder, but preserve partial files for recovery.
258
- await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, preRecordEvents: before, media: active, durationMs: c.clock.now() }).catch(() => {});
295
+ const { events, after } = split();
296
+ await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events, preRecordEvents: before, ...(after.length ? { postRecordEvents: after } : {}), media: active, durationMs: stoppedAtMs }).catch(() => {});
259
297
  setPhase({ kind: 'failed', message: safeText(error) });
260
298
  throw error;
261
- } finally { collector = null; session = null; state = 'idle'; }
299
+ } finally { telemetry.end(); collector = null; session = null; state = 'idle'; }
262
300
  },
263
301
  trackAction(action: { name: string; screen?: string }) {
264
302
  if (telemetry.active) telemetry.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
@@ -268,6 +306,13 @@ export const BugSession = {
268
306
  currentScreen = screen;
269
307
  },
270
308
  captureError,
309
+ /**
310
+ * Wrap the app once (inside the navigator's container, around your screens) to record
311
+ * every tap automatically: position, current screen and the element's label when available.
312
+ */
313
+ Capture: createCapture(() => telemetry.active ? tap => {
314
+ telemetry.add('action', { target: safeText(tap.label ?? `Tap at (${tap.x}, ${tap.y})`), screen: currentScreen, x: tap.x, y: tap.y, auto: true });
315
+ } : null),
271
316
  Trigger: createTrigger({
272
317
  getPhase: () => phase,
273
318
  subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
@@ -0,0 +1,69 @@
1
+ /** Pure helpers for automatic tap capture; no React Native imports so they can be unit tested. */
2
+ type Fiber = { memoizedProps?: Record<string, unknown> | null; return?: Fiber | null; child?: Fiber | null; sibling?: Fiber | null };
3
+
4
+ const LABEL_DEPTH = 12;
5
+ const PRESSABLE_DEPTH = 4;
6
+ const DESCENDANT_BUDGET = 40;
7
+
8
+ function text(value: unknown): string | undefined {
9
+ if (typeof value === 'string' || typeof value === 'number') return String(value).trim() || undefined;
10
+ if (Array.isArray(value)) {
11
+ const joined = value.map(text).filter(Boolean).join(' ');
12
+ return joined || undefined;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /**
18
+ * Best-effort name for the touched element: walks up from the touched host component
19
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
20
+ * values. React internals differ between renderers, so every failure yields undefined.
21
+ */
22
+ export function tapLabel(event: unknown): string | undefined {
23
+ try {
24
+ const native = event as { _targetInst?: Fiber; target?: { __internalInstanceHandle?: Fiber } } | null;
25
+ const target = native?._targetInst ?? native?.target?.__internalInstanceHandle;
26
+ let fiber: Fiber | null | undefined = target;
27
+ for (let depth = 0; fiber && depth < LABEL_DEPTH; depth++, fiber = fiber.return) {
28
+ const label = ownLabel(fiber.memoizedProps);
29
+ if (label) return label.slice(0, 80);
30
+ // A tap on a button's padding targets the button itself; its text is inside it.
31
+ if (depth < PRESSABLE_DEPTH && pressable(fiber.memoizedProps)) {
32
+ const inner = descendantText(fiber.child);
33
+ if (inner) return inner.slice(0, 80);
34
+ }
35
+ }
36
+ } catch { /* renderer internals unavailable */ }
37
+ return undefined;
38
+ }
39
+
40
+ function ownLabel(props: Record<string, unknown> | null | undefined): string | undefined {
41
+ if (!props || typeof props !== 'object') return undefined;
42
+ return text(props.accessibilityLabel) ?? text(props['aria-label']) ?? text(props.title)
43
+ ?? (typeof props.placeholder === 'string' ? props.placeholder : undefined)
44
+ ?? (props.editable === undefined && props.value === undefined ? text(props.children) : undefined)
45
+ ?? text(props.testID);
46
+ }
47
+
48
+ function pressable(props: Record<string, unknown> | null | undefined) {
49
+ if (!props || typeof props !== 'object') return false;
50
+ const role = props.accessibilityRole ?? props.role;
51
+ return typeof props.onPress === 'function' || typeof props.onClick === 'function' || role === 'button' || role === 'link'
52
+ || typeof props.onResponderRelease === 'function';
53
+ }
54
+
55
+ /** First readable text inside a pressable, breadth-first and bounded; never input values. */
56
+ function descendantText(first: Fiber | null | undefined): string | undefined {
57
+ const queue: Fiber[] = [];
58
+ for (let f = first; f; f = f.sibling) queue.push(f);
59
+ for (let visited = 0; queue.length && visited < DESCENDANT_BUDGET; visited++) {
60
+ const fiber = queue.shift()!;
61
+ const props = fiber.memoizedProps;
62
+ if (props && typeof props === 'object' && props.editable === undefined && props.value === undefined) {
63
+ const label = text(props.accessibilityLabel) ?? text(props.children);
64
+ if (label) return label;
65
+ }
66
+ for (let f = fiber.child; f; f = f.sibling) queue.push(f);
67
+ }
68
+ return undefined;
69
+ }
@@ -0,0 +1,37 @@
1
+ import React, { useRef } from 'react';
2
+ import { StyleSheet, View, type ViewProps } from 'react-native';
3
+ import { tapLabel } from './tap-label';
4
+
5
+ export type TapSink = (tap: { label?: string; x: number; y: number }) => void;
6
+
7
+ const TAP_MAX_MOVE = 10;
8
+ const TAP_MAX_MS = 800;
9
+ /**
10
+ * Wraps the app once and records every tap (position, screen and the element's label
11
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
12
+ * It only observes touches; it never becomes the responder or blocks gestures.
13
+ */
14
+ export function createCapture(sink: () => TapSink | null) {
15
+ return function Capture({ children, style, ...props }: ViewProps & { children?: React.ReactNode }) {
16
+ const start = useRef<{ x: number; y: number; at: number } | null>(null);
17
+ return <View {...props} style={[styles.fill, style]}
18
+ onTouchStartCapture={e => {
19
+ props.onTouchStartCapture?.(e);
20
+ start.current = e.nativeEvent.touches.length > 1 ? null : { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY, at: Date.now() };
21
+ }}
22
+ onTouchEndCapture={e => {
23
+ props.onTouchEndCapture?.(e);
24
+ const began = start.current;
25
+ start.current = null;
26
+ const add = sink();
27
+ if (!began || !add) return;
28
+ const { pageX, pageY } = e.nativeEvent;
29
+ if (Math.hypot(pageX - began.x, pageY - began.y) > TAP_MAX_MOVE || Date.now() - began.at > TAP_MAX_MS) return;
30
+ add({ label: tapLabel(e), x: Math.round(pageX), y: Math.round(pageY) });
31
+ }}>
32
+ {children}
33
+ </View>;
34
+ };
35
+ }
36
+
37
+ const styles = StyleSheet.create({ fill: { flex: 1 } });