drengr-js 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,276 @@
1
+ "use strict";
2
+ /**
3
+ * Batches captured signals and ships them to the Drengr ingest endpoint,
4
+ * authenticated by a publishable key. Port of the proven Dart IngestSink,
5
+ * carrying both device-run lessons from birth:
6
+ * - delivery uses the PRE-PATCH fetch (structurally invisible to capture —
7
+ * the self-capture loop cannot exist);
8
+ * - the persistence scheduler uses the writer-loops-until-clean pattern
9
+ * (an overlap marks dirty and returns; never reschedules a microtask,
10
+ * which starved the event loop and froze the Flutter demo).
11
+ *
12
+ * Best-effort and non-blocking: never throws into the app, drops oldest on
13
+ * overflow, retries with exponential backoff + full jitter, persists the
14
+ * queue through a pluggable storage adapter (localStorage on web,
15
+ * AsyncStorage-compatible on React Native, in-memory fallback anywhere).
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.IngestSink = void 0;
19
+ exports.defaultStorage = defaultStorage;
20
+ const capture_js_1 = require("./capture.js");
21
+ const redact_js_1 = require("./redact.js");
22
+ const QUEUE_KEY = 'drengr_queue_v1';
23
+ const BASE_BACKOFF_MS = 2000;
24
+ const MAX_BACKOFF_MS = 5 * 60000;
25
+ function defaultStorage() {
26
+ try {
27
+ const ls = globalThis.localStorage;
28
+ if (ls) {
29
+ const probe = '__drengr_probe__';
30
+ ls.setItem(probe, '1');
31
+ ls.removeItem(probe);
32
+ return ls;
33
+ }
34
+ }
35
+ catch { /* private mode / RN / node — fall through */ }
36
+ const mem = new Map();
37
+ return {
38
+ getItem: (k) => mem.get(k) ?? null,
39
+ setItem: (k, v) => void mem.set(k, v),
40
+ removeItem: (k) => void mem.delete(k),
41
+ };
42
+ }
43
+ class IngestSink {
44
+ constructor(opts) {
45
+ this.queue = [];
46
+ this.timer = null;
47
+ this.sending = false;
48
+ this.retries = 0;
49
+ this.persistScheduled = false;
50
+ this.persisting = false;
51
+ this.persistDirty = false;
52
+ this.experiments = {};
53
+ /** Map a captured exchange to an ingest event and enqueue it. */
54
+ this.addNetwork = (e) => {
55
+ try {
56
+ this.enqueue(this.toNet(e));
57
+ }
58
+ catch { /* never throw into the app */ }
59
+ };
60
+ /** Sets the session's external_id (attached to every event hereafter) and emits
61
+ * one identify event. traits go through the same redact+project pipeline as
62
+ * bodies. Fail-open: invalid externalId is a no-op. */
63
+ this.identify = (externalId, traits) => {
64
+ if (typeof externalId !== 'string' || externalId.length === 0)
65
+ return;
66
+ let redactedTraits = null;
67
+ try {
68
+ if (traits)
69
+ redactedTraits = (0, capture_js_1.projectBody)((0, redact_js_1.redactBody)(JSON.stringify(traits)));
70
+ }
71
+ catch { /* bad traits: ship identify without them */ }
72
+ try {
73
+ this.externalId = externalId;
74
+ this.enqueue({
75
+ kind: 'identify',
76
+ event_id: randomId(),
77
+ ts_ms: Date.now(),
78
+ external_id: externalId,
79
+ ...(redactedTraits != null ? { traits: redactedTraits } : {}),
80
+ });
81
+ }
82
+ catch { /* never throw into the app */ }
83
+ };
84
+ /** Sets/clears a session-scoped experiment variant (attached to every event
85
+ * hereafter as `experiments`). variant null/empty clears the key. Fail-open. */
86
+ this.setExperiment = (key, variant) => {
87
+ try {
88
+ if (typeof key !== 'string' || key.length === 0)
89
+ return;
90
+ if (!variant)
91
+ delete this.experiments[key];
92
+ else
93
+ this.experiments[key] = variant;
94
+ }
95
+ catch { /* never throw into the app */ }
96
+ };
97
+ this.url = opts.url;
98
+ this.key = opts.publishableKey;
99
+ this.context = opts.context;
100
+ this.storage = opts.storage ?? defaultStorage();
101
+ this.maxBatch = opts.maxBatch ?? 50;
102
+ this.maxQueue = opts.maxQueue ?? 500;
103
+ this.flushIntervalMs = opts.flushIntervalMs ?? 10000;
104
+ void this.restore();
105
+ }
106
+ toNet(e) {
107
+ const status = e.statusCode ?? 0;
108
+ const failed = e.errorText != null || status >= 400;
109
+ const reqBody = (0, capture_js_1.projectBody)(e.requestBody);
110
+ const respBody = (0, capture_js_1.projectBody)(e.responseBody);
111
+ return {
112
+ kind: failed ? 'net_fail' : 'net',
113
+ event_id: randomId(),
114
+ ts_ms: e.timestampMs,
115
+ method: e.method,
116
+ url: e.url, // already redacted by the capture layer
117
+ status,
118
+ error_kind: failed
119
+ ? e.errorText != null
120
+ ? 'transport'
121
+ : status >= 500
122
+ ? 'server'
123
+ : 'client'
124
+ : '',
125
+ duration_ms: e.durationMs,
126
+ req_bytes: e.requestBodyBytes,
127
+ resp_bytes: e.responseBodyBytes,
128
+ ...(reqBody != null ? { req_body: reqBody } : {}),
129
+ ...(respBody != null ? { body: respBody } : {}),
130
+ };
131
+ }
132
+ enqueue(ev) {
133
+ this.queue.push(ev);
134
+ while (this.queue.length > this.maxQueue) {
135
+ this.queue.shift(); // drop oldest on overflow — never block
136
+ }
137
+ this.schedulePersist();
138
+ if (this.retries > 0)
139
+ return; // backoff timer drives the flush
140
+ if (this.queue.length >= this.maxBatch) {
141
+ void this.flush();
142
+ }
143
+ else if (this.timer == null) {
144
+ this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
145
+ }
146
+ }
147
+ async flush() {
148
+ if (this.timer != null) {
149
+ clearTimeout(this.timer);
150
+ this.timer = null;
151
+ }
152
+ if (this.sending || this.queue.length === 0)
153
+ return;
154
+ this.sending = true;
155
+ const batch = this.queue.splice(0, 1000);
156
+ // sent_at_ms = device clock AT SEND: the server derives clock error from it
157
+ // and corrects timeline placement exactly. Set per attempt.
158
+ const envelope = { ...this.context, sent_at_ms: Date.now(), events: batch };
159
+ if (this.externalId)
160
+ envelope.external_id = this.externalId;
161
+ if (Object.keys(this.experiments).length > 0)
162
+ envelope.experiments = { ...this.experiments };
163
+ let acked = false;
164
+ let permanent = false;
165
+ try {
166
+ // nativeFetch: the pre-patch fetch — delivery is invisible to capture.
167
+ const resp = await (0, capture_js_1.nativeFetch)()(this.url, {
168
+ method: 'POST',
169
+ headers: {
170
+ authorization: `Bearer ${this.key}`,
171
+ 'content-type': 'application/json',
172
+ },
173
+ body: JSON.stringify(envelope),
174
+ keepalive: batch.length < 30, // survive page unload for small batches
175
+ });
176
+ acked = resp.status >= 200 && resp.status < 300;
177
+ // A non-retriable 4xx (revoked key 401, bad batch 400/413) will never
178
+ // succeed — retrying it forever head-of-line-blocks the queue and drops all
179
+ // newer events. Drop it. 429/408 are transient and still retry.
180
+ permanent = resp.status >= 400 && resp.status < 500 && resp.status !== 429 && resp.status !== 408;
181
+ }
182
+ catch {
183
+ acked = false; // best-effort: never throw into the app
184
+ }
185
+ finally {
186
+ this.sending = false;
187
+ if (acked || permanent) {
188
+ this.retries = 0; // batch consumed (delivered or dropped as permanent)
189
+ this.schedulePersist();
190
+ if (this.queue.length > 0 && this.timer == null) {
191
+ this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
192
+ }
193
+ }
194
+ else {
195
+ // Keep the batch: requeue at the front, shed newest on overflow.
196
+ this.queue.unshift(...batch);
197
+ while (this.queue.length > this.maxQueue)
198
+ this.queue.pop();
199
+ this.schedulePersist();
200
+ this.armBackoff();
201
+ }
202
+ }
203
+ }
204
+ armBackoff() {
205
+ if (this.timer != null)
206
+ clearTimeout(this.timer);
207
+ const exp = BASE_BACKOFF_MS * 2 ** Math.min(this.retries, 20);
208
+ const capped = Math.min(exp, MAX_BACKOFF_MS);
209
+ const delay = BASE_BACKOFF_MS + Math.floor(Math.random() * capped);
210
+ this.retries++;
211
+ this.timer = setTimeout(() => void this.flush(), delay);
212
+ }
213
+ // --- persistence (writer-loops-until-clean; overlap marks dirty) ---
214
+ schedulePersist() {
215
+ if (this.persistScheduled)
216
+ return;
217
+ this.persistScheduled = true;
218
+ queueMicrotask(() => void this.persist());
219
+ }
220
+ async persist() {
221
+ this.persistScheduled = false;
222
+ if (this.persisting) {
223
+ this.persistDirty = true; // the in-flight writer re-snapshots
224
+ return;
225
+ }
226
+ this.persisting = true;
227
+ try {
228
+ do {
229
+ this.persistDirty = false;
230
+ if (this.queue.length === 0) {
231
+ await this.storage.removeItem(QUEUE_KEY);
232
+ }
233
+ else {
234
+ await this.storage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
235
+ }
236
+ } while (this.persistDirty);
237
+ }
238
+ catch { /* best-effort */ }
239
+ finally {
240
+ this.persisting = false;
241
+ }
242
+ }
243
+ async restore() {
244
+ try {
245
+ const raw = await this.storage.getItem(QUEUE_KEY);
246
+ if (!raw)
247
+ return;
248
+ const parsed = JSON.parse(raw);
249
+ if (Array.isArray(parsed)) {
250
+ for (const ev of parsed) {
251
+ if (ev && typeof ev === 'object')
252
+ this.queue.push(ev);
253
+ }
254
+ while (this.queue.length > this.maxQueue)
255
+ this.queue.shift();
256
+ if (this.queue.length > 0 && this.timer == null) {
257
+ this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
258
+ }
259
+ }
260
+ }
261
+ catch { /* corrupt/missing store: start empty */ }
262
+ }
263
+ }
264
+ exports.IngestSink = IngestSink;
265
+ function randomId() {
266
+ try {
267
+ const c = globalThis.crypto;
268
+ if (c?.randomUUID)
269
+ return c.randomUUID().replace(/-/g, '');
270
+ }
271
+ catch { /* fall through */ }
272
+ let out = '';
273
+ for (let i = 0; i < 32; i++)
274
+ out += Math.floor(Math.random() * 16).toString(16);
275
+ return out;
276
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Zero-code network capture for JS runtimes (Web, React Native, Electron
3
+ * renderer/main): patches `fetch` and `XMLHttpRequest`, emits one redacted
4
+ * NetworkEvent per completed exchange. Fail-open by design — capture errors
5
+ * never propagate into the app, and the app's response objects are never
6
+ * consumed (fetch bodies are read from a clone; XHR is observed post-loadend).
7
+ *
8
+ * The pre-patch `fetch` is preserved and exported for the sink, so delivery
9
+ * traffic is STRUCTURALLY invisible to capture (lesson from the Flutter SDK's
10
+ * self-capture loop — no ignoreHosts wiring, no recursion possible).
11
+ */
12
+ import { projectBody } from './redact.js';
13
+ export interface NetworkEvent {
14
+ method: string;
15
+ /** Already redacted. */
16
+ url: string;
17
+ statusCode: number | null;
18
+ durationMs: number;
19
+ requestBodyBytes: number;
20
+ responseBodyBytes: number;
21
+ requestHeaders: Record<string, string>;
22
+ responseHeaders: Record<string, string>;
23
+ /** Redacted request body text (textual bodies only, capped). */
24
+ requestBody: string | null;
25
+ /** Redacted response body text (textual bodies only, capped). */
26
+ responseBody: string | null;
27
+ errorText: string | null;
28
+ timestampMs: number;
29
+ }
30
+ export interface CaptureOptions {
31
+ maxBodyBytes?: number;
32
+ /** Return false to skip a request entirely (sampling / allow-listing). */
33
+ captureWhen?: (url: string) => boolean;
34
+ /** Hosts to skip (exact or subdomain match). */
35
+ ignoreHosts?: Set<string>;
36
+ /** Extra header names (lowercase) to mask on top of the built-in set. */
37
+ redactHeaderNames?: Set<string>;
38
+ onEvent: (e: NetworkEvent) => void;
39
+ }
40
+ type FetchFn = typeof globalThis.fetch;
41
+ /** The runtime's fetch as it was BEFORE capture installed. Sink delivery uses
42
+ * this so its own requests can never be captured. */
43
+ export declare function nativeFetch(): FetchFn;
44
+ export declare function setEnabled(v: boolean): void;
45
+ export declare function isInstalled(): boolean;
46
+ export declare function install(options: CaptureOptions): void;
47
+ export declare function uninstall(): void;
48
+ /** Exported for the projection step in the sink. */
49
+ export { projectBody };
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Zero-code network capture for JS runtimes (Web, React Native, Electron
3
+ * renderer/main): patches `fetch` and `XMLHttpRequest`, emits one redacted
4
+ * NetworkEvent per completed exchange. Fail-open by design — capture errors
5
+ * never propagate into the app, and the app's response objects are never
6
+ * consumed (fetch bodies are read from a clone; XHR is observed post-loadend).
7
+ *
8
+ * The pre-patch `fetch` is preserved and exported for the sink, so delivery
9
+ * traffic is STRUCTURALLY invisible to capture (lesson from the Flutter SDK's
10
+ * self-capture loop — no ignoreHosts wiring, no recursion possible).
11
+ */
12
+ import { projectBody, redactBody, redactHeaders, redactUrl } from './redact.js';
13
+ const g = globalThis;
14
+ let installed = false;
15
+ let enabled = true;
16
+ let opts = {
17
+ maxBodyBytes: 64 * 1024,
18
+ onEvent: () => { },
19
+ };
20
+ let originalFetch = null;
21
+ let originalXhrOpen = null;
22
+ let originalXhrSend = null;
23
+ /** The runtime's fetch as it was BEFORE capture installed. Sink delivery uses
24
+ * this so its own requests can never be captured. */
25
+ export function nativeFetch() {
26
+ return originalFetch ?? g.fetch;
27
+ }
28
+ export function setEnabled(v) {
29
+ enabled = v;
30
+ }
31
+ export function isInstalled() {
32
+ return installed;
33
+ }
34
+ // React Native implements fetch ON TOP of XMLHttpRequest. Detecting it lets us
35
+ // avoid patching both layers (which would double-capture every fetch and let the
36
+ // sink's own XHR-backed deliveries recurse).
37
+ function isReactNative() {
38
+ try {
39
+ const nav = g.navigator;
40
+ return nav?.product === 'ReactNative';
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ function ignored(url) {
47
+ try {
48
+ if (!enabled)
49
+ return true;
50
+ if (opts.captureWhen && !opts.captureWhen(url))
51
+ return true;
52
+ if (opts.ignoreHosts && opts.ignoreHosts.size > 0) {
53
+ const host = new URL(url, 'http://localhost').host;
54
+ for (const h of opts.ignoreHosts) {
55
+ if (host === h || host.endsWith(`.${h}`))
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ function isTextual(contentType) {
66
+ if (!contentType)
67
+ return false;
68
+ const ct = contentType.toLowerCase();
69
+ return (ct.startsWith('text/') ||
70
+ ct.includes('json') ||
71
+ ct.includes('xml') ||
72
+ ct.includes('x-www-form-urlencoded') ||
73
+ ct.includes('graphql'));
74
+ }
75
+ function headerObj(h) {
76
+ const out = {};
77
+ try {
78
+ h?.forEach((v, k) => {
79
+ out[k] = v;
80
+ });
81
+ }
82
+ catch { /* fail-open */ }
83
+ return out;
84
+ }
85
+ function cap(s, max) {
86
+ return s.length > max ? s.slice(0, max) : s;
87
+ }
88
+ // Read a response body with a HARD byte cap: pull chunks until `max` is exceeded,
89
+ // then cancel — so an infinite SSE/streaming or multi-MB body never buffers
90
+ // unbounded. Over the cap → { text: null } (size-only). Never throws.
91
+ async function readCapped(resp, max) {
92
+ const body = resp.body;
93
+ if (!body) {
94
+ try {
95
+ const t = await resp.text();
96
+ return { text: t.length <= max ? t : null, bytes: t.length };
97
+ }
98
+ catch {
99
+ return { text: null, bytes: 0 };
100
+ }
101
+ }
102
+ const reader = body.getReader();
103
+ const chunks = [];
104
+ let total = 0;
105
+ let over = false;
106
+ try {
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ total += value.byteLength;
112
+ if (total > max) {
113
+ over = true;
114
+ break;
115
+ }
116
+ chunks.push(value);
117
+ }
118
+ }
119
+ catch {
120
+ return { text: null, bytes: total };
121
+ }
122
+ finally {
123
+ // Fire-and-forget cancel: it aborts the underlying network read (so an
124
+ // infinite SSE stops), but AWAITING it can hang forever (observed in
125
+ // undici on a cloned body) — which would swallow the event entirely.
126
+ try {
127
+ void reader.cancel();
128
+ }
129
+ catch { /* ignore */ }
130
+ }
131
+ if (over)
132
+ return { text: null, bytes: total }; // over cap → size-only
133
+ const buf = new Uint8Array(total);
134
+ let off = 0;
135
+ for (const c of chunks) {
136
+ buf.set(c, off);
137
+ off += c.byteLength;
138
+ }
139
+ try {
140
+ return { text: new TextDecoder().decode(buf), bytes: total };
141
+ }
142
+ catch {
143
+ return { text: null, bytes: total };
144
+ }
145
+ }
146
+ function emit(e) {
147
+ try {
148
+ opts.onEvent(e);
149
+ }
150
+ catch { /* the app must never see capture errors */ }
151
+ }
152
+ // ---------------------------------------------------------------- fetch ----
153
+ function patchFetch() {
154
+ const orig = g.fetch;
155
+ if (typeof orig !== 'function')
156
+ return;
157
+ originalFetch = orig.bind(globalThis);
158
+ const wrapped = async (input, init) => {
159
+ const start = Date.now();
160
+ let url = '';
161
+ let method = 'GET';
162
+ let reqBodyText = null;
163
+ let reqHeaders = {};
164
+ try {
165
+ if (typeof input === 'string')
166
+ url = input;
167
+ else if (input instanceof URL)
168
+ url = input.toString();
169
+ else {
170
+ url = input.url;
171
+ method = input.method || 'GET';
172
+ reqHeaders = headerObj(input.headers);
173
+ }
174
+ if (init?.method)
175
+ method = init.method;
176
+ if (init?.headers) {
177
+ reqHeaders = { ...reqHeaders, ...headerObj(new Headers(init.headers)) };
178
+ }
179
+ const b = init?.body;
180
+ if (typeof b === 'string')
181
+ reqBodyText = b;
182
+ else if (b instanceof URLSearchParams)
183
+ reqBodyText = b.toString();
184
+ // Streams / FormData / binary: recorded by absence (size unknown), never read.
185
+ }
186
+ catch { /* fail-open */ }
187
+ const skip = ignored(url);
188
+ try {
189
+ const resp = await originalFetch(input, init);
190
+ if (!skip) {
191
+ void captureFetchResponse(resp, url, method.toUpperCase(), reqHeaders, reqBodyText, start);
192
+ }
193
+ return resp;
194
+ }
195
+ catch (err) {
196
+ if (!skip) {
197
+ emit({
198
+ method: method.toUpperCase(),
199
+ url: redactUrl(url),
200
+ statusCode: null,
201
+ durationMs: Date.now() - start,
202
+ requestBodyBytes: reqBodyText?.length ?? 0,
203
+ responseBodyBytes: 0,
204
+ requestHeaders: redactHeaders(reqHeaders, opts.redactHeaderNames),
205
+ responseHeaders: {},
206
+ requestBody: reqBodyText ? redactBody(cap(reqBodyText, opts.maxBodyBytes)) : null,
207
+ responseBody: null,
208
+ errorText: String(err),
209
+ timestampMs: start,
210
+ });
211
+ }
212
+ throw err; // the app sees exactly what it would have seen
213
+ }
214
+ };
215
+ try {
216
+ g.fetch = wrapped;
217
+ }
218
+ catch { /* frozen global: capture unavailable, app unharmed */ }
219
+ }
220
+ async function captureFetchResponse(resp, url, method, reqHeaders, reqBodyText, start) {
221
+ try {
222
+ const respHeaders = headerObj(resp.headers);
223
+ const ct = resp.headers.get('content-type');
224
+ let respBody = null;
225
+ let respBytes = 0;
226
+ const lenHeader = Number(resp.headers.get('content-length'));
227
+ if (Number.isFinite(lenHeader) && lenHeader > 0)
228
+ respBytes = lenHeader;
229
+ if (isTextual(ct)) {
230
+ // Read a CLONE with a hard cap so an SSE / streaming / huge textual body
231
+ // can never buffer unbounded (was `resp.clone().text()` → OOM on an
232
+ // infinite stream). Over the cap → size-only. The app's own stream is
233
+ // untouched (we read the clone) and we cancel the clone's reader.
234
+ const capped = await readCapped(resp.clone(), opts.maxBodyBytes);
235
+ if (capped.bytes > 0)
236
+ respBytes = capped.bytes;
237
+ respBody = capped.text; // null when over cap or unreadable
238
+ }
239
+ emit({
240
+ method,
241
+ url: redactUrl(url),
242
+ statusCode: resp.status,
243
+ durationMs: Date.now() - start,
244
+ requestBodyBytes: reqBodyText?.length ?? 0,
245
+ responseBodyBytes: respBytes,
246
+ requestHeaders: redactHeaders(reqHeaders, opts.redactHeaderNames),
247
+ responseHeaders: redactHeaders(respHeaders, opts.redactHeaderNames),
248
+ requestBody: reqBodyText ? redactBody(cap(reqBodyText, opts.maxBodyBytes)) : null,
249
+ responseBody: respBody ? redactBody(respBody) : null,
250
+ errorText: null,
251
+ timestampMs: start,
252
+ });
253
+ }
254
+ catch { /* fail-open */ }
255
+ }
256
+ const xhrMeta = new WeakMap();
257
+ function patchXhr() {
258
+ const X = g.XMLHttpRequest;
259
+ if (typeof X !== 'function')
260
+ return;
261
+ originalXhrOpen = X.prototype.open;
262
+ originalXhrSend = X.prototype.send;
263
+ const origSetHeader = X.prototype.setRequestHeader;
264
+ X.prototype.open = function (method, url, ...rest) {
265
+ try {
266
+ xhrMeta.set(this, {
267
+ method: String(method).toUpperCase(),
268
+ url: String(url),
269
+ start: 0,
270
+ reqBody: null,
271
+ reqHeaders: {},
272
+ });
273
+ }
274
+ catch { /* fail-open */ }
275
+ // @ts-expect-error — pass through the runtime's own signature verbatim
276
+ return originalXhrOpen.call(this, method, url, ...rest);
277
+ };
278
+ X.prototype.setRequestHeader = function (name, value) {
279
+ try {
280
+ const m = xhrMeta.get(this);
281
+ if (m)
282
+ m.reqHeaders[name] = value;
283
+ }
284
+ catch { /* fail-open */ }
285
+ return origSetHeader.call(this, name, value);
286
+ };
287
+ X.prototype.send = function (body) {
288
+ const m = xhrMeta.get(this);
289
+ if (m && !ignored(m.url)) {
290
+ m.start = Date.now();
291
+ if (typeof body === 'string')
292
+ m.reqBody = body;
293
+ else if (body instanceof URLSearchParams)
294
+ m.reqBody = body.toString();
295
+ this.addEventListener('loadend', () => {
296
+ try {
297
+ const status = this.status;
298
+ let respBody = null;
299
+ let respBytes = 0;
300
+ if (this.responseType === '' || this.responseType === 'text') {
301
+ const text = this.responseText ?? '';
302
+ respBytes = text.length;
303
+ if (text.length > 0 &&
304
+ text.length <= opts.maxBodyBytes &&
305
+ isTextual(this.getResponseHeader('content-type'))) {
306
+ respBody = text;
307
+ }
308
+ }
309
+ const respHeaders = {};
310
+ for (const line of (this.getAllResponseHeaders() || '').split('\r\n')) {
311
+ const i = line.indexOf(': ');
312
+ if (i > 0)
313
+ respHeaders[line.slice(0, i)] = line.slice(i + 2);
314
+ }
315
+ emit({
316
+ method: m.method,
317
+ url: redactUrl(m.url),
318
+ statusCode: status === 0 ? null : status,
319
+ durationMs: Date.now() - m.start,
320
+ requestBodyBytes: m.reqBody?.length ?? 0,
321
+ responseBodyBytes: respBytes,
322
+ requestHeaders: redactHeaders(m.reqHeaders, opts.redactHeaderNames),
323
+ responseHeaders: redactHeaders(respHeaders, opts.redactHeaderNames),
324
+ requestBody: m.reqBody ? redactBody(cap(m.reqBody, opts.maxBodyBytes)) : null,
325
+ responseBody: respBody ? redactBody(respBody) : null,
326
+ errorText: status === 0 ? 'network_error' : null,
327
+ timestampMs: m.start,
328
+ });
329
+ }
330
+ catch { /* fail-open */ }
331
+ });
332
+ }
333
+ return originalXhrSend.call(this, body);
334
+ };
335
+ }
336
+ // -------------------------------------------------------------- install ----
337
+ export function install(options) {
338
+ if (installed)
339
+ return;
340
+ // maxBodyBytes AFTER the spread with a nullish default: the documented minimal
341
+ // call passes `maxBodyBytes: undefined`, and a spread of an explicit-undefined
342
+ // key would clobber the default — silently dropping every response body and
343
+ // uncapping request bodies. Coerce so undefined can never win.
344
+ opts = { ...options, maxBodyBytes: options.maxBodyBytes ?? 64 * 1024 };
345
+ // On React Native, fetch is XHR-backed: patching both layers would capture every
346
+ // fetch() TWICE and let the sink's own deliveries recurse. Patch ONLY XHR there —
347
+ // the single substrate every request funnels through. Native fetch elsewhere is
348
+ // independent, so patch both. (Self-capture is also blocked by ignoring the
349
+ // ingest host — see index.ts — which is what makes the RN sink path safe.)
350
+ if (!isReactNative())
351
+ patchFetch();
352
+ patchXhr();
353
+ installed = true;
354
+ }
355
+ export function uninstall() {
356
+ if (!installed)
357
+ return;
358
+ try {
359
+ if (originalFetch)
360
+ g.fetch = originalFetch;
361
+ const X = g.XMLHttpRequest;
362
+ if (X && originalXhrOpen)
363
+ X.prototype.open = originalXhrOpen;
364
+ if (X && originalXhrSend)
365
+ X.prototype.send = originalXhrSend;
366
+ }
367
+ catch { /* best-effort */ }
368
+ installed = false;
369
+ }
370
+ /** Exported for the projection step in the sink. */
371
+ export { projectBody };