@fixback/expo 0.1.0 → 0.3.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/README.md +72 -8
- package/dist/FeedbackModal.d.ts +10 -2
- package/dist/FeedbackModal.js +84 -36
- package/dist/FeedbackModal.js.map +1 -0
- package/dist/FixbackProvider.d.ts +12 -0
- package/dist/FixbackProvider.js +46 -23
- package/dist/FixbackProvider.js.map +1 -0
- package/dist/adapters.js +73 -23
- package/dist/adapters.js.map +1 -0
- package/dist/breadcrumbs.d.ts +21 -222
- package/dist/breadcrumbs.js +45 -605
- package/dist/breadcrumbs.js.map +1 -0
- package/dist/client.d.ts +65 -67
- package/dist/client.js +288 -151
- package/dist/client.js.map +1 -0
- package/dist/connect.d.ts +60 -0
- package/dist/connect.js +63 -0
- package/dist/connect.js.map +1 -0
- package/dist/error-capture.d.ts +29 -79
- package/dist/error-capture.js +77 -295
- package/dist/error-capture.js.map +1 -0
- package/dist/http.d.ts +10 -25
- package/dist/http.js +15 -12
- package/dist/http.js.map +1 -0
- package/dist/identity.d.ts +11 -11
- package/dist/identity.js +23 -28
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +22 -5
- package/dist/index.js.map +1 -0
- package/dist/origin.d.ts +21 -0
- package/dist/origin.js +45 -0
- package/dist/origin.js.map +1 -0
- package/dist/package.json +3 -0
- package/dist/report.d.ts +28 -63
- package/dist/report.js +33 -25
- package/dist/report.js.map +1 -0
- package/dist/reporter-session-store.d.ts +27 -0
- package/dist/reporter-session-store.js +53 -0
- package/dist/reporter-session-store.js.map +1 -0
- package/dist/shake.d.ts +26 -0
- package/dist/shake.js +32 -7
- package/dist/shake.js.map +1 -0
- package/dist/submit.d.ts +24 -15
- package/dist/submit.js +30 -47
- package/dist/submit.js.map +1 -0
- package/dist/tokens.js +5 -1
- package/dist/tokens.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +5 -1
- package/dist/version.js.map +1 -0
- package/package.json +14 -7
- package/dist/auto-report-backoff.d.ts +0 -42
- package/dist/auto-report-backoff.js +0 -67
- package/dist/boot.d.ts +0 -65
- package/dist/boot.js +0 -60
- package/dist/scrub.d.ts +0 -55
- package/dist/scrub.js +0 -184
package/dist/breadcrumbs.js
CHANGED
|
@@ -1,608 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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.
|
|
3
|
+
* The Expo SDK's **React Native capture instrumentation** — the runtime seams, and
|
|
4
|
+
* nothing else.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
6
|
+
* The trace buffer, the crumb builders, the caps and eviction rules, and the
|
|
7
|
+
* `console` wrapper are all shared with the browser SDK through
|
|
8
|
+
* `@fixback/sdk-core` (ADR-0028, replacing the file-parallel port ADR-0021
|
|
9
|
+
* described), so the server and dashboard cannot tell a mobile trace from a web one.
|
|
10
|
+
* What is left here is genuinely mobile: how to find React Native's globals, and
|
|
11
|
+
* the `XMLHttpRequest` patch.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
13
|
+
* Mobile mappings (spec 0004 §D): network capture patches `XMLHttpRequest` **only**
|
|
14
|
+
* — React Native's `fetch` is a polyfill over XHR, so wrapping both would
|
|
15
|
+
* double-record; navigation crumbs come from the host app's `trackScreen` calls
|
|
16
|
+
* (there is no `history` to patch); the masked `ui.*` streams have no mobile source
|
|
17
|
+
* yet and simply never occur.
|
|
18
|
+
*
|
|
19
|
+
* Everything private is kept out at the source: network crumbs carry method + URL +
|
|
20
|
+
* status metadata only, **never** bodies, and URLs are scrubbed as the crumb is
|
|
21
|
+
* built. The SDK must never throw into the host app, so every hook is wrapped: a
|
|
22
|
+
* capture failure is swallowed and the original behaviour always runs.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.globalConsole = globalConsole;
|
|
26
|
+
exports.globalXhr = globalXhr;
|
|
27
|
+
exports.instrumentXhr = instrumentXhr;
|
|
28
|
+
exports.instrumentBreadcrumbs = instrumentBreadcrumbs;
|
|
29
|
+
const sdk_core_1 = require("@fixback/sdk-core");
|
|
30
|
+
function noop() {
|
|
31
|
+
/* nothing installed */
|
|
496
32
|
}
|
|
497
33
|
/** React Native's global `console`, when present. */
|
|
498
|
-
|
|
34
|
+
function globalConsole() {
|
|
499
35
|
const g = globalThis;
|
|
500
36
|
return g.console && typeof g.console === "object" ? g.console : undefined;
|
|
501
37
|
}
|
|
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
38
|
/** React Native's global `XMLHttpRequest`, when present. */
|
|
602
|
-
|
|
39
|
+
function globalXhr() {
|
|
603
40
|
const g = globalThis;
|
|
604
41
|
return typeof g.XMLHttpRequest === "function" ? g.XMLHttpRequest : undefined;
|
|
605
42
|
}
|
|
43
|
+
/** Read one XHR response header defensively — `null` when unavailable or on any throw. */
|
|
606
44
|
function safeXhrHeader(xhr, name) {
|
|
607
45
|
try {
|
|
608
46
|
return typeof xhr.getResponseHeader === "function"
|
|
@@ -613,6 +51,7 @@ function safeXhrHeader(xhr, name) {
|
|
|
613
51
|
return null;
|
|
614
52
|
}
|
|
615
53
|
}
|
|
54
|
+
/** Read an XHR's `statusText` defensively — `undefined` when empty or on any throw. */
|
|
616
55
|
function safeXhrStatusText(xhr) {
|
|
617
56
|
try {
|
|
618
57
|
return xhr.statusText || undefined;
|
|
@@ -629,7 +68,7 @@ function safeXhrStatusText(xhr) {
|
|
|
629
68
|
* every request twice (spec 0004 §D). The `send` body argument is **never read
|
|
630
69
|
* for content** — only its trivially-known size.
|
|
631
70
|
*/
|
|
632
|
-
|
|
71
|
+
function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = sdk_core_1.nowMono) {
|
|
633
72
|
if (typeof ctor !== "function")
|
|
634
73
|
return noop;
|
|
635
74
|
const proto = ctor.prototype;
|
|
@@ -643,12 +82,12 @@ export function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = defaultMono)
|
|
|
643
82
|
const meta = this.__fixbackMeta;
|
|
644
83
|
if (meta && !ignoreUrl(meta.url)) {
|
|
645
84
|
const started = mono();
|
|
646
|
-
const reqSize = trivialBodySize(body);
|
|
85
|
+
const reqSize = (0, sdk_core_1.trivialBodySize)(body);
|
|
647
86
|
// The terminal event names the outcome; `loadend` always follows exactly
|
|
648
87
|
// one of load/error/timeout/abort, so by record time `settled` is set.
|
|
649
88
|
let settled;
|
|
650
89
|
const onLoad = () => {
|
|
651
|
-
settled = outcomeFromStatus(this.status || undefined);
|
|
90
|
+
settled = (0, sdk_core_1.outcomeFromStatus)(this.status || undefined);
|
|
652
91
|
};
|
|
653
92
|
const onError = () => {
|
|
654
93
|
settled = "network-error";
|
|
@@ -661,7 +100,7 @@ export function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = defaultMono)
|
|
|
661
100
|
};
|
|
662
101
|
const onDone = () => {
|
|
663
102
|
try {
|
|
664
|
-
buffer.add(networkCrumb({
|
|
103
|
+
buffer.add((0, sdk_core_1.networkCrumb)({
|
|
665
104
|
api: "xhr",
|
|
666
105
|
method: meta.method,
|
|
667
106
|
url: meta.url,
|
|
@@ -669,9 +108,9 @@ export function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = defaultMono)
|
|
|
669
108
|
statusText: safeXhrStatusText(this),
|
|
670
109
|
durationMs: Math.max(0, mono() - started),
|
|
671
110
|
reqSize,
|
|
672
|
-
respSize: parseContentLength(safeXhrHeader(this, "content-length")),
|
|
673
|
-
contentType: contentTypeOf(safeXhrHeader(this, "content-type")),
|
|
674
|
-
outcome: settled ?? outcomeFromStatus(this.status || undefined),
|
|
111
|
+
respSize: (0, sdk_core_1.parseContentLength)(safeXhrHeader(this, "content-length")),
|
|
112
|
+
contentType: (0, sdk_core_1.contentTypeOf)(safeXhrHeader(this, "content-type")),
|
|
113
|
+
outcome: settled ?? (0, sdk_core_1.outcomeFromStatus)(this.status || undefined),
|
|
675
114
|
}, now()));
|
|
676
115
|
}
|
|
677
116
|
catch {
|
|
@@ -697,23 +136,23 @@ export function instrumentXhr(buffer, ctor, ignoreUrl, now, mono = defaultMono)
|
|
|
697
136
|
};
|
|
698
137
|
}
|
|
699
138
|
/**
|
|
700
|
-
* Install the React Native capture hooks — the console wrapper and the XHR
|
|
139
|
+
* Install the React Native capture hooks — the shared console wrapper and the XHR
|
|
701
140
|
* patch — and return a single teardown that removes them all. Each hook is
|
|
702
141
|
* independent and defensive: a failure in one never blocks the other, and none
|
|
703
142
|
* can throw into the host app. An off stream is never instrumented at all.
|
|
704
143
|
*/
|
|
705
|
-
|
|
144
|
+
function instrumentBreadcrumbs(buffer, options = {}) {
|
|
706
145
|
const consoleObj = options.consoleObj === null ? undefined : options.consoleObj ?? globalConsole();
|
|
707
146
|
const xhr = options.xhr === null ? undefined : options.xhr ?? globalXhr();
|
|
708
|
-
const levels = options.consoleLevels ?? DEFAULT_CONSOLE_LEVELS;
|
|
147
|
+
const levels = options.consoleLevels ?? sdk_core_1.DEFAULT_CONSOLE_LEVELS;
|
|
709
148
|
const ignoreUrl = options.ignoreUrl ?? (() => false);
|
|
710
149
|
const now = options.now ?? Date.now;
|
|
711
|
-
const mono = options.mono ??
|
|
150
|
+
const mono = options.mono ?? sdk_core_1.nowMono;
|
|
712
151
|
const captureConsole = options.captureConsole !== false;
|
|
713
152
|
const captureNetwork = options.captureNetwork !== false;
|
|
714
153
|
const teardowns = [];
|
|
715
154
|
if (consoleObj && captureConsole) {
|
|
716
|
-
teardowns.push(instrumentConsole(buffer, consoleObj, levels, now));
|
|
155
|
+
teardowns.push((0, sdk_core_1.instrumentConsole)(buffer, consoleObj, levels, now));
|
|
717
156
|
}
|
|
718
157
|
if (captureNetwork) {
|
|
719
158
|
teardowns.push(instrumentXhr(buffer, xhr, ignoreUrl, now, mono));
|
|
@@ -729,3 +168,4 @@ export function instrumentBreadcrumbs(buffer, options = {}) {
|
|
|
729
168
|
}
|
|
730
169
|
};
|
|
731
170
|
}
|
|
171
|
+
//# sourceMappingURL=breadcrumbs.js.map
|