@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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/FeedbackModal.d.ts +23 -0
- package/dist/FeedbackModal.js +201 -0
- package/dist/FixbackProvider.d.ts +35 -0
- package/dist/FixbackProvider.js +102 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +143 -0
- package/dist/auto-report-backoff.d.ts +42 -0
- package/dist/auto-report-backoff.js +67 -0
- package/dist/boot.d.ts +65 -0
- package/dist/boot.js +60 -0
- package/dist/breadcrumbs.d.ts +276 -0
- package/dist/breadcrumbs.js +731 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +351 -0
- package/dist/error-capture.d.ts +168 -0
- package/dist/error-capture.js +395 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.js +20 -0
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +10 -0
- package/dist/report.d.ts +93 -0
- package/dist/report.js +67 -0
- package/dist/scrub.d.ts +55 -0
- package/dist/scrub.js +184 -0
- package/dist/shake.d.ts +51 -0
- package/dist/shake.js +79 -0
- package/dist/submit.d.ts +98 -0
- package/dist/submit.js +154 -0
- package/dist/tokens.d.ts +41 -0
- package/dist/tokens.js +46 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +63 -0
|
@@ -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
|
+
}
|