@fixback/expo 0.1.0

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.
@@ -0,0 +1,731 @@
1
+ /**
2
+ * The trace **breadcrumb ring buffer** and its React Native capture
3
+ * instrumentation — a port of `packages/sdk/src/breadcrumbs.ts` kept
4
+ * file-parallel on purpose (ADR-0021): the entry shapes, stream budgets, caps,
5
+ * and eviction rules are the web SDK's exactly, so the server and dashboard
6
+ * cannot tell a mobile trace from a web one.
7
+ *
8
+ * Mobile mappings (spec 0004 §D): `console` wrapping is unchanged; network
9
+ * capture patches `XMLHttpRequest` only — React Native's `fetch` is a polyfill
10
+ * over XHR, so wrapping both would double-record; navigation crumbs come from
11
+ * the host app's `trackScreen` calls (there is no `history` to patch); the
12
+ * masked `ui.*` streams have no mobile source yet and simply never occur.
13
+ *
14
+ * Everything private is kept out **at the source**: network crumbs carry method
15
+ * + URL + status metadata only, **never** bodies; URLs are scrubbed as the
16
+ * crumb is built. The `beforeSend` choke point (`scrub.ts`) is the final gate.
17
+ * The SDK must never throw into the host app, so every instrumentation hook is
18
+ * wrapped: a capture failure is swallowed and the original behaviour always runs.
19
+ */
20
+ import { scrubUrl } from "./scrub";
21
+ /** Per-stream ring budgets — the web SDK's numbers, unchanged. */
22
+ export const DEFAULT_STREAM_BUDGETS = {
23
+ network: 100,
24
+ console: 80,
25
+ breadcrumbs: 40,
26
+ };
27
+ /** The shared age cap: entries older than this are pruned from every stream. */
28
+ export const DEFAULT_MAX_AGE_MS = 3 * 60 * 1000;
29
+ /** Console levels captured by default: **all** of them. */
30
+ export const DEFAULT_CONSOLE_LEVELS = [
31
+ "log",
32
+ "info",
33
+ "warn",
34
+ "error",
35
+ "assert",
36
+ "debug",
37
+ ];
38
+ /**
39
+ * The console levels **pinned** against eviction: when the console stream
40
+ * overflows, `warn`/`error`/`assert` are retained ahead of `log`/`info`/`debug`.
41
+ */
42
+ export const PINNED_CONSOLE_LEVELS = [
43
+ "warn",
44
+ "error",
45
+ "assert",
46
+ ];
47
+ function isPinnedConsoleLevel(level) {
48
+ return level !== undefined && PINNED_CONSOLE_LEVELS.includes(level);
49
+ }
50
+ /**
51
+ * The console levels for which `source` (`file:line`) is captured — reading a
52
+ * call site constructs a `new Error()` on every call, so only the levels that
53
+ * matter for debugging pay that cost. Mirrors {@link PINNED_CONSOLE_LEVELS}.
54
+ */
55
+ export const SOURCE_CAPTURE_LEVELS = PINNED_CONSOLE_LEVELS;
56
+ function capturesSource(level) {
57
+ return SOURCE_CAPTURE_LEVELS.includes(level);
58
+ }
59
+ /** Which stream a crumb's category belongs to. */
60
+ export function streamOf(category) {
61
+ if (category === "console")
62
+ return "console";
63
+ if (category === "fetch" || category === "xhr" || category === "beacon") {
64
+ return "network";
65
+ }
66
+ return "breadcrumbs";
67
+ }
68
+ function normalizeBudget(value, fallback) {
69
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
70
+ return fallback;
71
+ }
72
+ return Math.floor(value);
73
+ }
74
+ /** The default high-res clock: `performance.now()` where available (React Native provides it), else `Date.now`. */
75
+ function defaultMono() {
76
+ const perf = globalThis.performance;
77
+ return perf && typeof perf.now === "function" ? perf.now() : Date.now();
78
+ }
79
+ /** A per-buffer id factory: a short random salt + a base-36 sequence. */
80
+ function createIdFactory() {
81
+ const salt = Math.random().toString(36).slice(2, 8);
82
+ let seq = 0;
83
+ return () => `${salt}${(seq++).toString(36)}`;
84
+ }
85
+ /** The streams, in a fixed order for a deterministic merge. */
86
+ const STREAMS = ["network", "console", "breadcrumbs"];
87
+ /**
88
+ * Trim the **console** stream to its budget with eviction priority:
89
+ * `warn`/`error`/`assert` are pinned, so the oldest `log`/`info`/`debug`
90
+ * entries are dropped first; a pinned entry is evicted only when dropping every
91
+ * low-priority one still leaves the stream over budget (oldest pinned first).
92
+ */
93
+ function trimConsoleStream(held, budget) {
94
+ const over = held.length - budget;
95
+ if (over <= 0)
96
+ return held;
97
+ const drop = new Set();
98
+ for (let i = 0; i < held.length && drop.size < over; i += 1) {
99
+ if (!isPinnedConsoleLevel(held[i].crumb.level))
100
+ drop.add(i);
101
+ }
102
+ for (let i = 0; i < held.length && drop.size < over; i += 1) {
103
+ if (!drop.has(i))
104
+ drop.add(i);
105
+ }
106
+ return held.filter((_, i) => !drop.has(i));
107
+ }
108
+ /**
109
+ * Create the per-stream trace buffer: **three independent FIFO rings** —
110
+ * `network`, `console`, `breadcrumbs` — each trimmed to its own budget so a
111
+ * chatty stream never evicts another's lead-up, plus a shared age cap.
112
+ * `snapshot` merges the three streams into one `mono`-ordered array for
113
+ * transport — the wire keeps its single `trace` field.
114
+ */
115
+ export function createBreadcrumbBuffer(config = {}) {
116
+ const budgets = {
117
+ network: normalizeBudget(config.budgets?.network, DEFAULT_STREAM_BUDGETS.network),
118
+ console: normalizeBudget(config.budgets?.console, DEFAULT_STREAM_BUDGETS.console),
119
+ breadcrumbs: normalizeBudget(config.budgets?.breadcrumbs, DEFAULT_STREAM_BUDGETS.breadcrumbs),
120
+ };
121
+ const maxAgeMs = config.maxAgeMs === undefined ? DEFAULT_MAX_AGE_MS : config.maxAgeMs;
122
+ const beforeBreadcrumb = config.beforeBreadcrumb;
123
+ const now = config.now ?? Date.now;
124
+ const mono = config.mono ?? defaultMono;
125
+ const nextId = config.nextId ?? createIdFactory();
126
+ const streams = {
127
+ network: [],
128
+ console: [],
129
+ breadcrumbs: [],
130
+ };
131
+ let seq = 0;
132
+ function pruneByAge() {
133
+ if (typeof maxAgeMs !== "number" || maxAgeMs <= 0)
134
+ return;
135
+ const cutoff = now() - maxAgeMs;
136
+ for (const stream of STREAMS) {
137
+ const held = streams[stream];
138
+ if (held.length > 0) {
139
+ streams[stream] = held.filter((h) => h.crumb.timestamp >= cutoff);
140
+ }
141
+ }
142
+ }
143
+ return {
144
+ add(crumb) {
145
+ let entry = crumb;
146
+ if (beforeBreadcrumb) {
147
+ try {
148
+ entry = beforeBreadcrumb(crumb);
149
+ }
150
+ catch {
151
+ // A throwing filter must never break capture; keep the (already
152
+ // masked) crumb rather than silently erasing the trace.
153
+ entry = crumb;
154
+ }
155
+ }
156
+ if (!entry)
157
+ return;
158
+ const stamped = {
159
+ ...entry,
160
+ id: entry.id ?? nextId(),
161
+ timestamp: typeof entry.timestamp === "number" ? entry.timestamp : now(),
162
+ mono: typeof entry.mono === "number" ? entry.mono : mono(),
163
+ };
164
+ const stream = streamOf(stamped.category);
165
+ streams[stream].push({ crumb: stamped, seq: seq++ });
166
+ pruneByAge();
167
+ const held = streams[stream];
168
+ const budget = budgets[stream];
169
+ if (held.length > budget) {
170
+ streams[stream] =
171
+ stream === "console"
172
+ ? trimConsoleStream(held, budget)
173
+ : held.slice(-budget);
174
+ }
175
+ },
176
+ snapshot() {
177
+ pruneByAge();
178
+ const merged = [];
179
+ for (const stream of STREAMS)
180
+ merged.push(...streams[stream]);
181
+ merged.sort((a, b) => {
182
+ const am = typeof a.crumb.mono === "number" ? a.crumb.mono : a.crumb.timestamp;
183
+ const bm = typeof b.crumb.mono === "number" ? b.crumb.mono : b.crumb.timestamp;
184
+ return am - bm || a.seq - b.seq;
185
+ });
186
+ return merged.map((h) => h.crumb);
187
+ },
188
+ clear() {
189
+ streams.network = [];
190
+ streams.console = [];
191
+ streams.breadcrumbs = [];
192
+ seq = 0;
193
+ },
194
+ };
195
+ }
196
+ // --- Pure crumb builders -----------------------------------------------------
197
+ /** Longest crumb message kept; a huge log line is truncated, never dropped. */
198
+ const MAX_MESSAGE_LENGTH = 300;
199
+ /** Longest scrubbed URL kept on a crumb. */
200
+ export const MAX_URL_LENGTH = 2048;
201
+ /** Structured console argument caps, all applied at assembly. */
202
+ export const MAX_ARG_DEPTH = 4;
203
+ export const MAX_ARG_ITEMS = 100;
204
+ export const MAX_ARG_STRING_LENGTH = 1024;
205
+ export const MAX_CONSOLE_ARGS_BYTES = 4096;
206
+ /** Longest Error `stack` kept on a structured `error` arg. */
207
+ const MAX_ERROR_STACK_LENGTH = 2048;
208
+ function capLength(value, max) {
209
+ return value.length > max ? `${value.slice(0, max)}…` : value;
210
+ }
211
+ function stringifyArg(arg) {
212
+ if (typeof arg === "string")
213
+ return arg;
214
+ if (arg instanceof Error)
215
+ return `${arg.name}: ${arg.message}`;
216
+ if (arg === null || arg === undefined)
217
+ return String(arg);
218
+ if (typeof arg === "number" || typeof arg === "boolean")
219
+ return String(arg);
220
+ try {
221
+ return JSON.stringify(arg) ?? String(arg);
222
+ }
223
+ catch {
224
+ return "[object]";
225
+ }
226
+ }
227
+ function joinArgs(args) {
228
+ const text = args.map(stringifyArg).join(" ");
229
+ return text.length > MAX_MESSAGE_LENGTH
230
+ ? `${text.slice(0, MAX_MESSAGE_LENGTH)}…`
231
+ : text;
232
+ }
233
+ /** An Error rendered to a structured, size-capped `{ name, message, stack? }`. */
234
+ function describeErrorValue(error) {
235
+ const out = {
236
+ name: error.name || "Error",
237
+ message: capLength(String(error.message ?? ""), MAX_ARG_STRING_LENGTH),
238
+ };
239
+ if (typeof error.stack === "string" && error.stack.length > 0) {
240
+ out.stack = capLength(error.stack, MAX_ERROR_STACK_LENGTH);
241
+ }
242
+ return out;
243
+ }
244
+ /**
245
+ * Build a JSON-safe, depth-/breadth-/string-capped clone of a value for a
246
+ * `json` console argument. Circular references become `"[Circular]"`; exotic
247
+ * values are rendered to safe text — always serializable and bounded.
248
+ */
249
+ function safeCloneValue(value, depth, seen) {
250
+ if (value === null)
251
+ return null;
252
+ const type = typeof value;
253
+ if (type === "string")
254
+ return capLength(value, MAX_ARG_STRING_LENGTH);
255
+ if (type === "number")
256
+ return Number.isFinite(value) ? value : String(value);
257
+ if (type === "boolean")
258
+ return value;
259
+ if (type === "bigint")
260
+ return `${value.toString()}n`;
261
+ if (type === "symbol")
262
+ return value.toString();
263
+ if (type === "function")
264
+ return "[Function]";
265
+ if (type === "undefined")
266
+ return null;
267
+ const obj = value;
268
+ if (value instanceof Error) {
269
+ const { name, message } = describeErrorValue(value);
270
+ return { name, message };
271
+ }
272
+ if (seen.has(obj))
273
+ return "[Circular]";
274
+ if (depth <= 0)
275
+ return Array.isArray(value) ? "[Array]" : "[Object]";
276
+ seen.add(obj);
277
+ try {
278
+ if (Array.isArray(value)) {
279
+ const items = value
280
+ .slice(0, MAX_ARG_ITEMS)
281
+ .map((item) => safeCloneValue(item, depth - 1, seen));
282
+ if (value.length > MAX_ARG_ITEMS) {
283
+ items.push(`… ${value.length - MAX_ARG_ITEMS} more`);
284
+ }
285
+ return items;
286
+ }
287
+ const source = value;
288
+ const keys = Object.keys(source);
289
+ const out = {};
290
+ for (const key of keys.slice(0, MAX_ARG_ITEMS)) {
291
+ const child = source[key];
292
+ if (typeof child === "undefined" || typeof child === "function")
293
+ continue;
294
+ out[key] = safeCloneValue(child, depth - 1, seen);
295
+ }
296
+ if (keys.length > MAX_ARG_ITEMS)
297
+ out["…"] = `${keys.length - MAX_ARG_ITEMS} more`;
298
+ return out;
299
+ }
300
+ finally {
301
+ seen.delete(obj);
302
+ }
303
+ }
304
+ /** Classify one console argument into a type-tagged {@link ConsoleArg}. */
305
+ export function toConsoleArg(value) {
306
+ if (value === null || value === undefined)
307
+ return { t: "null", v: null };
308
+ const type = typeof value;
309
+ if (type === "string") {
310
+ return { t: "string", v: capLength(value, MAX_ARG_STRING_LENGTH) };
311
+ }
312
+ if (type === "number") {
313
+ return Number.isFinite(value)
314
+ ? { t: "number", v: value }
315
+ : { t: "string", v: String(value) };
316
+ }
317
+ if (type === "boolean")
318
+ return { t: "bool", v: value };
319
+ if (type === "bigint")
320
+ return { t: "string", v: `${value.toString()}n` };
321
+ if (type === "symbol")
322
+ return { t: "string", v: value.toString() };
323
+ if (type === "function")
324
+ return { t: "string", v: "[Function]" };
325
+ if (value instanceof Error)
326
+ return { t: "error", v: describeErrorValue(value) };
327
+ return { t: "json", v: safeCloneValue(value, MAX_ARG_DEPTH, new Set()) };
328
+ }
329
+ /** The serialized UTF-8 byte length of a value; `Infinity` when it cannot serialize. */
330
+ function serializedBytes(value) {
331
+ try {
332
+ const json = JSON.stringify(value) ?? "";
333
+ const Encoder = globalThis
334
+ .TextEncoder;
335
+ return Encoder ? new Encoder().encode(json).length : json.length;
336
+ }
337
+ catch {
338
+ return Number.POSITIVE_INFINITY;
339
+ }
340
+ }
341
+ /**
342
+ * Cap an entry's structured args to {@link MAX_CONSOLE_ARGS_BYTES}: drop
343
+ * trailing args until the array fits; if even a single arg is over budget, keep
344
+ * one honest placeholder rather than an unbounded value.
345
+ */
346
+ function capConsoleArgs(args) {
347
+ if (serializedBytes(args) <= MAX_CONSOLE_ARGS_BYTES)
348
+ return args;
349
+ let out = args.slice();
350
+ while (out.length > 1 && serializedBytes(out) > MAX_CONSOLE_ARGS_BYTES) {
351
+ out = out.slice(0, -1);
352
+ }
353
+ if (out.length === 1 && serializedBytes(out) > MAX_CONSOLE_ARGS_BYTES) {
354
+ return [{ t: "string", v: "[trace: console argument omitted (too large)]" }];
355
+ }
356
+ return out;
357
+ }
358
+ /**
359
+ * A `console` crumb from a captured call's level and arguments. The `message`
360
+ * is the one-line preview; `args` preserves each argument as a structured,
361
+ * type-tagged, size-capped value; `source` is the best-effort `file:line`.
362
+ */
363
+ export function consoleCrumb(level, args, timestamp, source) {
364
+ const structured = capConsoleArgs(args.map(toConsoleArg));
365
+ const crumb = {
366
+ category: "console",
367
+ level,
368
+ message: joinArgs(args),
369
+ timestamp,
370
+ ...(structured.length > 0 ? { args: structured } : {}),
371
+ ...(source ? { source } : {}),
372
+ };
373
+ return crumb;
374
+ }
375
+ /**
376
+ * A `navigation` crumb; both sides are scrubbed and length-capped as the crumb
377
+ * is built. On mobile this is fed by `trackScreen` (spec 0004 §D) — the values
378
+ * are screen URLs derived from the configured origin.
379
+ */
380
+ export function navigationCrumb(from, to, timestamp) {
381
+ const fromUrl = capLength(scrubUrl(from), MAX_URL_LENGTH);
382
+ const toUrl = capLength(scrubUrl(to), MAX_URL_LENGTH);
383
+ return {
384
+ category: "navigation",
385
+ message: `${fromUrl} → ${toUrl}`,
386
+ timestamp,
387
+ data: { from: fromUrl, to: toUrl },
388
+ };
389
+ }
390
+ /** Classify an HTTP status into a {@link NetworkOutcome}. */
391
+ export function outcomeFromStatus(status) {
392
+ if (typeof status !== "number" || status <= 0)
393
+ return "network-error";
394
+ if (status >= 500)
395
+ return "http-5xx";
396
+ if (status >= 400)
397
+ return "http-4xx";
398
+ return "ok";
399
+ }
400
+ /**
401
+ * The trivially-known byte size of a request body — a string's UTF-8 length, a
402
+ * `Blob`'s `.size`, or an `ArrayBuffer`/typed-array `.byteLength`. Anything
403
+ * that would require **reading** the body returns `undefined`.
404
+ */
405
+ export function trivialBodySize(body) {
406
+ if (typeof body === "string")
407
+ return utf8ByteLength(body);
408
+ const BlobCtor = globalThis.Blob;
409
+ if (BlobCtor && body instanceof BlobCtor)
410
+ return body.size;
411
+ if (typeof ArrayBuffer !== "undefined") {
412
+ if (body instanceof ArrayBuffer)
413
+ return body.byteLength;
414
+ if (ArrayBuffer.isView(body))
415
+ return body.byteLength;
416
+ }
417
+ return undefined;
418
+ }
419
+ function utf8ByteLength(value) {
420
+ try {
421
+ const Encoder = globalThis
422
+ .TextEncoder;
423
+ return Encoder ? new Encoder().encode(value).length : value.length;
424
+ }
425
+ catch {
426
+ return value.length;
427
+ }
428
+ }
429
+ /** Parse a `content-length` header into a non-negative byte count, or `undefined`. */
430
+ export function parseContentLength(value) {
431
+ if (typeof value !== "string")
432
+ return undefined;
433
+ const bytes = Number.parseInt(value, 10);
434
+ return Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
435
+ }
436
+ /** The media type from a `content-type` header (before any `;` parameters). */
437
+ export function contentTypeOf(value) {
438
+ if (typeof value !== "string" || value.length === 0)
439
+ return undefined;
440
+ const media = value.split(";")[0]?.trim();
441
+ return media && media.length > 0 ? media : undefined;
442
+ }
443
+ /**
444
+ * A network crumb with the rich, metadata-only fields the Network tab renders.
445
+ * The URL is scrubbed and length-capped at assembly. The shape has **no field
446
+ * for a request/response body or an arbitrary header**.
447
+ */
448
+ export function networkCrumb(input, timestamp) {
449
+ const url = capLength(scrubUrl(input.url), MAX_URL_LENGTH);
450
+ const hasStatus = typeof input.status === "number" && input.status > 0;
451
+ const statusPart = hasStatus ? ` → ${input.status}` : "";
452
+ const outcomePart = input.outcome !== "ok" ? ` (${input.outcome})` : "";
453
+ return {
454
+ category: input.api,
455
+ api: input.api,
456
+ method: input.method,
457
+ url,
458
+ message: `${input.method} ${url}${statusPart}${outcomePart}`,
459
+ timestamp,
460
+ ...(hasStatus ? { status: input.status } : {}),
461
+ ...(input.statusText ? { statusText: input.statusText } : {}),
462
+ ...(typeof input.durationMs === "number" ? { durationMs: input.durationMs } : {}),
463
+ ...(typeof input.reqSize === "number" ? { reqSize: input.reqSize } : {}),
464
+ ...(typeof input.respSize === "number" ? { respSize: input.respSize } : {}),
465
+ ...(input.contentType ? { contentType: input.contentType } : {}),
466
+ outcome: input.outcome,
467
+ };
468
+ }
469
+ /** A thin `xhr` crumb from method + URL + status (outcome derived from the status). */
470
+ export function xhrCrumb(method, url, status, timestamp) {
471
+ return networkCrumb({ api: "xhr", method, url, status, outcome: outcomeFromStatus(status) }, timestamp);
472
+ }
473
+ function describeError(error) {
474
+ if (error instanceof Error) {
475
+ return { name: error.name || "Error", message: error.message };
476
+ }
477
+ if (typeof error === "string")
478
+ return { name: "Error", message: error };
479
+ return { name: "Error", message: stringifyArg(error) };
480
+ }
481
+ /**
482
+ * An `error` crumb for the failing exception that ends the trace. When
483
+ * `causedBy` is given (an auto-captured error), it rides on the crumb as the
484
+ * causal pointer to the ids of the entries immediately preceding the throw.
485
+ */
486
+ export function errorCrumb(error, timestamp, causedBy) {
487
+ const { name, message } = describeError(error);
488
+ const crumb = {
489
+ category: "error",
490
+ level: "error",
491
+ message: message ? `${name}: ${message}` : name,
492
+ timestamp,
493
+ data: { errorType: name },
494
+ };
495
+ return causedBy && causedBy.length > 0 ? { ...crumb, causedBy } : crumb;
496
+ }
497
+ /** React Native's global `console`, when present. */
498
+ export function globalConsole() {
499
+ const g = globalThis;
500
+ return g.console && typeof g.console === "object" ? g.console : undefined;
501
+ }
502
+ function noop() {
503
+ /* nothing installed */
504
+ }
505
+ /**
506
+ * Parse a single stack line into a `{ file, line }` location, or `undefined`.
507
+ * Handles V8/Hermes (`at fn (file:line:col)` / `at file:line:col`) and
508
+ * JSC (`fn@file:line:col`) frames; a query string on the asset URL is dropped.
509
+ */
510
+ function parseStackFrame(line) {
511
+ let loc;
512
+ const v8Named = line.match(/^at\s+.+?\s+\((.+)\)$/);
513
+ if (v8Named) {
514
+ loc = v8Named[1];
515
+ }
516
+ else {
517
+ const v8Bare = line.match(/^at\s+(.+)$/);
518
+ if (v8Bare) {
519
+ loc = v8Bare[1];
520
+ }
521
+ else {
522
+ const at = line.indexOf("@");
523
+ if (at >= 0)
524
+ loc = line.slice(at + 1);
525
+ }
526
+ }
527
+ if (!loc)
528
+ return undefined;
529
+ loc = loc.replace(/\?[^:]*/, "");
530
+ const m = loc.match(/^(.*):(\d+):\d+$/) ?? loc.match(/^(.*):(\d+)$/);
531
+ if (!m)
532
+ return undefined;
533
+ const file = m[1] ?? "";
534
+ const lineNo = Number(m[2]);
535
+ if (!file || !Number.isFinite(lineNo))
536
+ return undefined;
537
+ return { file, line: lineNo };
538
+ }
539
+ /**
540
+ * The best-effort `file:line` a console call was made from. Parses the frames
541
+ * of a stack, skipping `skipFrames` leading (SDK-internal) frames so the
542
+ * source points at the host code that called `console.*`.
543
+ */
544
+ export function sourceFromStack(stack, skipFrames = 0) {
545
+ if (typeof stack !== "string" || stack.length === 0)
546
+ return undefined;
547
+ const frames = [];
548
+ for (const raw of stack.split("\n")) {
549
+ const frame = parseStackFrame(raw.trim());
550
+ if (frame)
551
+ frames.push(frame);
552
+ }
553
+ return frames[skipFrames];
554
+ }
555
+ /** Frames between `new Error()` (inside the console wrapper) and the host caller. */
556
+ const CONSOLE_SOURCE_SKIP_FRAMES = 1;
557
+ /** Wrap `console` methods so calls at the captured levels become crumbs. */
558
+ export function instrumentConsole(buffer, consoleObj, levels, now) {
559
+ const restores = [];
560
+ for (const level of levels) {
561
+ const original = consoleObj[level];
562
+ if (typeof original !== "function")
563
+ continue;
564
+ const withSource = capturesSource(level);
565
+ const wrapper = (...args) => {
566
+ let source;
567
+ if (withSource) {
568
+ try {
569
+ source = sourceFromStack(new Error().stack, CONSOLE_SOURCE_SKIP_FRAMES);
570
+ }
571
+ catch {
572
+ source = undefined;
573
+ }
574
+ }
575
+ try {
576
+ if (level === "assert") {
577
+ // console.assert records only when the asserted condition is falsy.
578
+ if (!args[0]) {
579
+ buffer.add(consoleCrumb("assert", args.slice(1), now(), source));
580
+ }
581
+ }
582
+ else {
583
+ buffer.add(consoleCrumb(level, args, now(), source));
584
+ }
585
+ }
586
+ catch {
587
+ // Capture must never throw into the host app.
588
+ }
589
+ return original.apply(consoleObj, args);
590
+ };
591
+ consoleObj[level] = wrapper;
592
+ restores.push(() => {
593
+ consoleObj[level] = original;
594
+ });
595
+ }
596
+ return () => {
597
+ for (const restore of restores)
598
+ restore();
599
+ };
600
+ }
601
+ /** React Native's global `XMLHttpRequest`, when present. */
602
+ export function globalXhr() {
603
+ const g = globalThis;
604
+ return typeof g.XMLHttpRequest === "function" ? g.XMLHttpRequest : undefined;
605
+ }
606
+ function safeXhrHeader(xhr, name) {
607
+ try {
608
+ return typeof xhr.getResponseHeader === "function"
609
+ ? xhr.getResponseHeader(name)
610
+ : null;
611
+ }
612
+ catch {
613
+ return null;
614
+ }
615
+ }
616
+ function safeXhrStatusText(xhr) {
617
+ try {
618
+ return xhr.statusText || undefined;
619
+ }
620
+ catch {
621
+ return undefined;
622
+ }
623
+ }
624
+ /**
625
+ * Patch `XMLHttpRequest` to record a rich network crumb when a request settles.
626
+ * On React Native this is the **single** network hook: the built-in `fetch` is
627
+ * a polyfill over XHR, so its traffic is captured here too (as `xhr` crumbs)
628
+ * and `fetch` itself is deliberately not wrapped — wrapping both would record
629
+ * every request twice (spec 0004 §D). The `send` body argument is **never read
630
+ * for content** — only its trivially-known size.
631
+ */
632
+ export function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = defaultMono) {
633
+ if (typeof ctor !== "function")
634
+ return noop;
635
+ const proto = ctor.prototype;
636
+ const originalOpen = proto.open;
637
+ const originalSend = proto.send;
638
+ proto.open = function (method, url, ...rest) {
639
+ this.__fixbackMeta = { method: String(method).toUpperCase(), url: String(url) };
640
+ return originalOpen.call(this, method, url, ...rest);
641
+ };
642
+ proto.send = function (body) {
643
+ const meta = this.__fixbackMeta;
644
+ if (meta && !ignoreUrl(meta.url)) {
645
+ const started = mono();
646
+ const reqSize = trivialBodySize(body);
647
+ // The terminal event names the outcome; `loadend` always follows exactly
648
+ // one of load/error/timeout/abort, so by record time `settled` is set.
649
+ let settled;
650
+ const onLoad = () => {
651
+ settled = outcomeFromStatus(this.status || undefined);
652
+ };
653
+ const onError = () => {
654
+ settled = "network-error";
655
+ };
656
+ const onTimeout = () => {
657
+ settled = "timeout";
658
+ };
659
+ const onAbort = () => {
660
+ settled = "aborted";
661
+ };
662
+ const onDone = () => {
663
+ try {
664
+ buffer.add(networkCrumb({
665
+ api: "xhr",
666
+ method: meta.method,
667
+ url: meta.url,
668
+ status: this.status || undefined,
669
+ statusText: safeXhrStatusText(this),
670
+ durationMs: Math.max(0, mono() - started),
671
+ reqSize,
672
+ respSize: parseContentLength(safeXhrHeader(this, "content-length")),
673
+ contentType: contentTypeOf(safeXhrHeader(this, "content-type")),
674
+ outcome: settled ?? outcomeFromStatus(this.status || undefined),
675
+ }, now()));
676
+ }
677
+ catch {
678
+ /* never throw into the host app */
679
+ }
680
+ this.removeEventListener("load", onLoad);
681
+ this.removeEventListener("error", onError);
682
+ this.removeEventListener("timeout", onTimeout);
683
+ this.removeEventListener("abort", onAbort);
684
+ this.removeEventListener("loadend", onDone);
685
+ };
686
+ this.addEventListener("load", onLoad);
687
+ this.addEventListener("error", onError);
688
+ this.addEventListener("timeout", onTimeout);
689
+ this.addEventListener("abort", onAbort);
690
+ this.addEventListener("loadend", onDone);
691
+ }
692
+ return originalSend.call(this, body);
693
+ };
694
+ return () => {
695
+ proto.open = originalOpen;
696
+ proto.send = originalSend;
697
+ };
698
+ }
699
+ /**
700
+ * Install the React Native capture hooks — the console wrapper and the XHR
701
+ * patch — and return a single teardown that removes them all. Each hook is
702
+ * independent and defensive: a failure in one never blocks the other, and none
703
+ * can throw into the host app. An off stream is never instrumented at all.
704
+ */
705
+ export function instrumentBreadcrumbs(buffer, options = {}) {
706
+ const consoleObj = options.consoleObj === null ? undefined : options.consoleObj ?? globalConsole();
707
+ const xhr = options.xhr === null ? undefined : options.xhr ?? globalXhr();
708
+ const levels = options.consoleLevels ?? DEFAULT_CONSOLE_LEVELS;
709
+ const ignoreUrl = options.ignoreUrl ?? (() => false);
710
+ const now = options.now ?? Date.now;
711
+ const mono = options.mono ?? defaultMono;
712
+ const captureConsole = options.captureConsole !== false;
713
+ const captureNetwork = options.captureNetwork !== false;
714
+ const teardowns = [];
715
+ if (consoleObj && captureConsole) {
716
+ teardowns.push(instrumentConsole(buffer, consoleObj, levels, now));
717
+ }
718
+ if (captureNetwork) {
719
+ teardowns.push(instrumentXhr(buffer, xhr, ignoreUrl, now, mono));
720
+ }
721
+ return () => {
722
+ for (const teardown of teardowns) {
723
+ try {
724
+ teardown();
725
+ }
726
+ catch {
727
+ /* teardown is best-effort */
728
+ }
729
+ }
730
+ };
731
+ }