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.
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +24 -0
- package/dist/cjs/capture.js +378 -0
- package/dist/cjs/index.js +184 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/redact.js +351 -0
- package/dist/cjs/sink.js +276 -0
- package/dist/esm/capture.d.ts +49 -0
- package/dist/esm/capture.js +371 -0
- package/dist/esm/index.d.ts +61 -0
- package/dist/esm/index.js +181 -0
- package/dist/esm/redact.d.ts +18 -0
- package/dist/esm/redact.js +341 -0
- package/dist/esm/sink.d.ts +71 -0
- package/dist/esm/sink.js +271 -0
- package/package.json +48 -0
package/dist/cjs/sink.js
ADDED
|
@@ -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 };
|