@obsrviq/tracker 0.5.0 → 0.7.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/dist/index.js +513 -100
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +42 -42
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +4 -0
- package/src/index.ts +7 -1
- package/src/network.ts +111 -16
- package/src/ratelimit.ts +24 -0
- package/src/recorder.ts +20 -1
- package/src/serialize.ts +63 -7
- package/src/trace.ts +100 -0
- package/src/transport.ts +340 -68
- package/src/util.ts +15 -1
- package/src/websocket.ts +37 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@rrweb/types": "2.0.1",
|
|
28
28
|
"fflate": "^0.8.2",
|
|
29
29
|
"rrweb": "2.0.1",
|
|
30
|
-
"@obsrviq/types": "0.
|
|
30
|
+
"@obsrviq/types": "0.6.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"tsup": "^8.3.5",
|
package/src/context.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface ResolvedConfig {
|
|
|
13
13
|
noPrivacy: boolean;
|
|
14
14
|
captureNetworkBodies: boolean;
|
|
15
15
|
redactHeaders: string[];
|
|
16
|
+
/** Propagate a W3C `traceparent` on first-party outbound requests (App Monitor join). */
|
|
17
|
+
tracePropagation: boolean;
|
|
18
|
+
/** First-party hosts (besides same-origin) allowed to receive `traceparent`. */
|
|
19
|
+
traceHosts: string[];
|
|
16
20
|
/** Opt-in: resume the same session for a returning visit within the window. */
|
|
17
21
|
continueSession: boolean;
|
|
18
22
|
/** Inactivity window (ms) for continueSession. */
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IngestSessionMeta, ObsrviqEvent, ObsrviqInitConfig } from '@obsrviq/types';
|
|
2
2
|
import type { InstrumentCtx, ResolvedConfig, Teardown } from './context.js';
|
|
3
3
|
import { Transport } from './transport.js';
|
|
4
|
-
import { instrumentDom } from './recorder.js';
|
|
4
|
+
import { instrumentDom, takeFullSnapshot } from './recorder.js';
|
|
5
5
|
import { instrumentConsole } from './console.js';
|
|
6
6
|
import { instrumentNetwork } from './network.js';
|
|
7
7
|
import { instrumentWebSocket } from './websocket.js';
|
|
@@ -22,6 +22,8 @@ const DEFAULTS = {
|
|
|
22
22
|
noPrivacy: false,
|
|
23
23
|
captureNetworkBodies: false,
|
|
24
24
|
redactHeaders: ['authorization', 'cookie', 'set-cookie'],
|
|
25
|
+
tracePropagation: false,
|
|
26
|
+
traceHosts: [] as string[],
|
|
25
27
|
sampleRate: 1.0,
|
|
26
28
|
recordCanvas: false,
|
|
27
29
|
endpoint: 'https://in.lumera.app',
|
|
@@ -215,6 +217,10 @@ class ObsrviqClient {
|
|
|
215
217
|
this.nextSeq = next;
|
|
216
218
|
this.persistSession();
|
|
217
219
|
},
|
|
220
|
+
// Backpressure dropped DOM deltas → re-anchor the rrweb stream immediately
|
|
221
|
+
// so replay stays coherent (a delta stream with a hole is corrupt until
|
|
222
|
+
// the next 30s checkout).
|
|
223
|
+
onDomDrop: () => takeFullSnapshot(),
|
|
218
224
|
onFlush: ({ bytes, events, ok }) => {
|
|
219
225
|
if (ok) {
|
|
220
226
|
this.diag.flushes++;
|
package/src/network.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { NetworkEvent, NetworkResourceType, NetworkTiming } from '@obsrviq/types';
|
|
2
2
|
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
3
|
import { maskString } from './mask.js';
|
|
4
|
+
import { tokenBucket } from './ratelimit.js';
|
|
5
|
+
import { injectFetchTrace, newTrace, traceTarget } from './trace.js';
|
|
4
6
|
import { uuid } from './util.js';
|
|
5
7
|
|
|
6
8
|
const BODY_CAP = 4 * 1024; // 4 KB body preview cap
|
|
9
|
+
const NET_RATE = 50; // sustained network events/sec per page
|
|
10
|
+
const NET_BURST = 100; // burst capacity
|
|
7
11
|
// Response/request header allowlist — everything else is dropped.
|
|
8
12
|
const HEADER_ALLOW = new Set([
|
|
9
13
|
'content-type',
|
|
@@ -16,6 +20,7 @@ const HEADER_ALLOW = new Set([
|
|
|
16
20
|
'content-encoding',
|
|
17
21
|
'x-request-id',
|
|
18
22
|
'x-correlation-id',
|
|
23
|
+
'traceparent',
|
|
19
24
|
'server',
|
|
20
25
|
'vary',
|
|
21
26
|
]);
|
|
@@ -56,16 +61,56 @@ function bodyPreview(
|
|
|
56
61
|
contentType: string | undefined,
|
|
57
62
|
): NetworkEvent['requestBody'] {
|
|
58
63
|
if (text == null) return undefined;
|
|
59
|
-
|
|
60
|
-
const
|
|
64
|
+
// Pre-cut giant bodies (2× slack) so masking never regex-scans a multi-MB string.
|
|
65
|
+
const cut = text.length > BODY_CAP * 2 ? text.slice(0, BODY_CAP * 2) : text;
|
|
66
|
+
const masked = maskString(cut);
|
|
67
|
+
const truncated = cut !== text || masked.length > BODY_CAP;
|
|
61
68
|
return {
|
|
62
69
|
contentType,
|
|
63
70
|
preview: truncated ? masked.slice(0, BODY_CAP) : masked,
|
|
64
71
|
truncated,
|
|
65
|
-
redacted: masked !==
|
|
72
|
+
redacted: masked !== cut,
|
|
66
73
|
};
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
/** Read at most `cap`+1 bytes of a Response body via its stream reader (then
|
|
77
|
+
* cancel), so a multi-MB response is never fully buffered just to keep a 4 KB
|
|
78
|
+
* preview. `overflow` = the body had more bytes than `cap`. Falls back to
|
|
79
|
+
* text() where a stream reader isn't available (older fetch polyfills). */
|
|
80
|
+
async function readTextCapped(res: Response, cap: number): Promise<{ text: string; overflow: boolean }> {
|
|
81
|
+
const body = res.body;
|
|
82
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
83
|
+
return { text: await res.text(), overflow: false };
|
|
84
|
+
}
|
|
85
|
+
const reader = body.getReader();
|
|
86
|
+
const decoder = new TextDecoder(); // Response.text() decodes UTF-8 — match it
|
|
87
|
+
let text = '';
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
let overflow = false;
|
|
90
|
+
try {
|
|
91
|
+
for (;;) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done || !value) {
|
|
94
|
+
text += decoder.decode(); // flush any trailing multi-byte sequence
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
const remaining = cap + 1 - bytes;
|
|
98
|
+
if (value.byteLength >= remaining) {
|
|
99
|
+
// Keep only up to cap+1 bytes total — the +1 detects truncation.
|
|
100
|
+
text += decoder.decode(value.subarray(0, remaining), { stream: true });
|
|
101
|
+
overflow = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
text += decoder.decode(value, { stream: true });
|
|
105
|
+
bytes += value.byteLength;
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
// Release the tee'd clone branch so it doesn't buffer the rest of the response.
|
|
109
|
+
void reader.cancel().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
return { text, overflow };
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
function perfNow(): number {
|
|
70
115
|
return typeof performance !== 'undefined' ? performance.now() : 0;
|
|
71
116
|
}
|
|
@@ -239,8 +284,26 @@ function createTimingResolver(): TimingResolver {
|
|
|
239
284
|
};
|
|
240
285
|
}
|
|
241
286
|
|
|
287
|
+
/** A rate-gated NetworkEvent emitter shared by all capture paths on the page. */
|
|
288
|
+
type EmitNet = (ev: NetworkEvent) => void;
|
|
289
|
+
|
|
242
290
|
export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
243
291
|
const timing = createTimingResolver();
|
|
292
|
+
// Per-page event throttle — a polling loop / request storm must not flood the
|
|
293
|
+
// buffer. Dropped events are counted and reported on the next captured one.
|
|
294
|
+
const bucket = tokenBucket(NET_RATE, NET_BURST);
|
|
295
|
+
let dropped = 0;
|
|
296
|
+
const emitNet: EmitNet = (ev) => {
|
|
297
|
+
if (!bucket.take()) {
|
|
298
|
+
dropped++;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (dropped > 0) {
|
|
302
|
+
ev.droppedEvents = dropped;
|
|
303
|
+
dropped = 0;
|
|
304
|
+
}
|
|
305
|
+
ctx.emit(ev);
|
|
306
|
+
};
|
|
244
307
|
// On page hide, settle any request whose Resource Timing hasn't arrived yet so
|
|
245
308
|
// its event lands in the transport buffer before the unload flush.
|
|
246
309
|
const onPageHide = () => timing.flushPending();
|
|
@@ -251,10 +314,10 @@ export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
|
251
314
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
|
|
252
315
|
|
|
253
316
|
const teardowns: Teardown[] = [];
|
|
254
|
-
teardowns.push(wrapFetch(ctx, timing));
|
|
255
|
-
teardowns.push(wrapXhr(ctx, timing));
|
|
256
|
-
teardowns.push(wrapBeacon(ctx));
|
|
257
|
-
teardowns.push(observeResources(ctx));
|
|
317
|
+
teardowns.push(wrapFetch(ctx, timing, emitNet));
|
|
318
|
+
teardowns.push(wrapXhr(ctx, timing, emitNet));
|
|
319
|
+
teardowns.push(wrapBeacon(ctx, emitNet));
|
|
320
|
+
teardowns.push(observeResources(ctx, emitNet));
|
|
258
321
|
teardowns.push(() => {
|
|
259
322
|
if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide);
|
|
260
323
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
|
|
@@ -264,7 +327,7 @@ export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
|
264
327
|
}
|
|
265
328
|
|
|
266
329
|
// ───────────────────────────── fetch ────────────────────────────────
|
|
267
|
-
function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
330
|
+
function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
|
|
268
331
|
const orig = window.fetch;
|
|
269
332
|
if (!orig) return () => {};
|
|
270
333
|
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
|
|
@@ -276,6 +339,21 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
276
339
|
const perfStart = perfNow();
|
|
277
340
|
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
278
341
|
const url = redactUrl(rawUrl);
|
|
342
|
+
// Trace-context propagation (best-effort, scoped to first-party hosts). Inject
|
|
343
|
+
// BEFORE reading reqHeaders so the traceparent shows in the captured headers,
|
|
344
|
+
// and stamp the trace id onto the event so replay ↔ backend trace can join.
|
|
345
|
+
let traceId: string | undefined;
|
|
346
|
+
if (traceTarget(rawUrl, ctx.config)) {
|
|
347
|
+
try {
|
|
348
|
+
const tr = newTrace();
|
|
349
|
+
const inj = injectFetchTrace(input, init, tr.header);
|
|
350
|
+
input = inj.input;
|
|
351
|
+
init = inj.init;
|
|
352
|
+
traceId = tr.traceId;
|
|
353
|
+
} catch {
|
|
354
|
+
/* never let propagation break the request */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
279
357
|
const reqHeaders = pickHeaders(
|
|
280
358
|
ctx.config.redactHeaders,
|
|
281
359
|
headerEntries(init?.headers ?? (input instanceof Request ? input.headers : undefined)),
|
|
@@ -301,10 +379,11 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
301
379
|
ok: false,
|
|
302
380
|
resourceType: 'fetch',
|
|
303
381
|
requestHeaders: reqHeaders,
|
|
382
|
+
traceId,
|
|
304
383
|
timing,
|
|
305
384
|
...partial,
|
|
306
385
|
};
|
|
307
|
-
|
|
386
|
+
emitNet(ev);
|
|
308
387
|
});
|
|
309
388
|
};
|
|
310
389
|
|
|
@@ -316,8 +395,10 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
316
395
|
let responseBody: NetworkEvent['responseBody'];
|
|
317
396
|
if (ctx.config.captureNetworkBodies && isTexty(res.headers.get('content-type'))) {
|
|
318
397
|
try {
|
|
319
|
-
|
|
398
|
+
// Stream-read the clone up to the preview cap — never buffer a huge body.
|
|
399
|
+
const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
|
|
320
400
|
responseBody = bodyPreview(text, res.headers.get('content-type') || undefined);
|
|
401
|
+
if (responseBody && overflow) responseBody.truncated = true;
|
|
321
402
|
} catch {
|
|
322
403
|
/* ignore */
|
|
323
404
|
}
|
|
@@ -362,9 +443,10 @@ interface XhrMeta {
|
|
|
362
443
|
perfStart: number;
|
|
363
444
|
reqHeaders: Record<string, string>;
|
|
364
445
|
body?: string;
|
|
446
|
+
traceId?: string;
|
|
365
447
|
}
|
|
366
448
|
|
|
367
|
-
function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
449
|
+
function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
|
|
368
450
|
const XHR = XMLHttpRequest.prototype;
|
|
369
451
|
const origOpen = XHR.open;
|
|
370
452
|
const origSend = XHR.send;
|
|
@@ -401,6 +483,18 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
401
483
|
meta.startT = ctx.clock.now();
|
|
402
484
|
meta.perfStart = perfNow();
|
|
403
485
|
if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
|
|
486
|
+
// Trace-context propagation (first-party only). setRequestHeader is valid
|
|
487
|
+
// here — after open(), before the real send() below. Our own setRequestHeader
|
|
488
|
+
// patch records it into meta.reqHeaders (traceparent is allowlisted).
|
|
489
|
+
if (traceTarget(meta.rawUrl, ctx.config)) {
|
|
490
|
+
try {
|
|
491
|
+
const tr = newTrace();
|
|
492
|
+
this.setRequestHeader('traceparent', tr.header);
|
|
493
|
+
meta.traceId = tr.traceId;
|
|
494
|
+
} catch {
|
|
495
|
+
/* header rejected (e.g. send already dispatched) — skip, never throw */
|
|
496
|
+
}
|
|
497
|
+
}
|
|
404
498
|
const finish = (error?: string) => {
|
|
405
499
|
const endT = ctx.clock.now();
|
|
406
500
|
const base: NetworkTiming = {
|
|
@@ -438,6 +532,7 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
438
532
|
ok: status >= 200 && status < 400,
|
|
439
533
|
resourceType: 'xhr',
|
|
440
534
|
requestHeaders: meta.reqHeaders,
|
|
535
|
+
traceId: meta.traceId,
|
|
441
536
|
responseHeaders: respHeaders,
|
|
442
537
|
responseSize,
|
|
443
538
|
requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
|
|
@@ -445,7 +540,7 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
445
540
|
timing,
|
|
446
541
|
error: error || classifyHttp(status),
|
|
447
542
|
};
|
|
448
|
-
|
|
543
|
+
emitNet(ev);
|
|
449
544
|
});
|
|
450
545
|
};
|
|
451
546
|
this.addEventListener('loadend', () => finish());
|
|
@@ -484,7 +579,7 @@ function parseRawHeaders(ctx: InstrumentCtx, raw: string): Record<string, string
|
|
|
484
579
|
}
|
|
485
580
|
|
|
486
581
|
// ──────────────────────────── sendBeacon ────────────────────────────
|
|
487
|
-
function wrapBeacon(ctx: InstrumentCtx): Teardown {
|
|
582
|
+
function wrapBeacon(ctx: InstrumentCtx, emitNet: EmitNet): Teardown {
|
|
488
583
|
const orig = navigator.sendBeacon;
|
|
489
584
|
if (!orig) return () => {};
|
|
490
585
|
navigator.sendBeacon = function (url: string | URL, data?: BodyInit | null): boolean {
|
|
@@ -511,7 +606,7 @@ function wrapBeacon(ctx: InstrumentCtx): Teardown {
|
|
|
511
606
|
timing: { startT, endT: ctx.clock.now(), duration: 0 },
|
|
512
607
|
error: ok ? undefined : 'network',
|
|
513
608
|
};
|
|
514
|
-
|
|
609
|
+
emitNet(ev);
|
|
515
610
|
}
|
|
516
611
|
}
|
|
517
612
|
return ok;
|
|
@@ -534,7 +629,7 @@ const RESOURCE_TYPE_MAP: Record<string, NetworkResourceType> = {
|
|
|
534
629
|
track: 'media',
|
|
535
630
|
};
|
|
536
631
|
|
|
537
|
-
function observeResources(ctx: InstrumentCtx): Teardown {
|
|
632
|
+
function observeResources(ctx: InstrumentCtx, emitNet: EmitNet): Teardown {
|
|
538
633
|
if (typeof PerformanceObserver === 'undefined') return () => {};
|
|
539
634
|
let obs: PerformanceObserver | undefined;
|
|
540
635
|
try {
|
|
@@ -571,7 +666,7 @@ function observeResources(ctx: InstrumentCtx): Teardown {
|
|
|
571
666
|
responseSize: entry.transferSize || entry.encodedBodySize || undefined,
|
|
572
667
|
timing,
|
|
573
668
|
};
|
|
574
|
-
|
|
669
|
+
emitNet(ev);
|
|
575
670
|
}
|
|
576
671
|
});
|
|
577
672
|
obs.observe({ type: 'resource', buffered: true });
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared token-bucket rate limiter for capture paths (network / WebSocket
|
|
2
|
+
// event floods). Kept tiny on purpose — callers count their own drops.
|
|
3
|
+
|
|
4
|
+
export interface TokenBucket {
|
|
5
|
+
/** Spend one token if available; false = the caller should drop the event. */
|
|
6
|
+
take(): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A token bucket: refills at `rate` tokens/sec (sustained), holds at most
|
|
10
|
+
* `burst` tokens (burst capacity). */
|
|
11
|
+
export function tokenBucket(rate: number, burst: number): TokenBucket {
|
|
12
|
+
let tokens = burst;
|
|
13
|
+
let last = Date.now();
|
|
14
|
+
return {
|
|
15
|
+
take() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
tokens = Math.min(burst, tokens + ((now - last) / 1000) * rate);
|
|
18
|
+
last = now;
|
|
19
|
+
if (tokens < 1) return false;
|
|
20
|
+
tokens -= 1;
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -10,11 +10,18 @@ const INCREMENTAL = 3;
|
|
|
10
10
|
export function instrumentDom(ctx: InstrumentCtx): Teardown {
|
|
11
11
|
const stop = record({
|
|
12
12
|
emit(event: eventWithTime) {
|
|
13
|
+
const t = ctx.clock.fromPerf(performanceNow());
|
|
14
|
+
// ONE CLOCK: rrweb stamps events with Date.now(), while everything else in
|
|
15
|
+
// the session runs on the monotonic perf clock (`t`). Rewrite the rrweb
|
|
16
|
+
// timestamp onto that same clock (startedAt + t) so an NTP step / manual
|
|
17
|
+
// clock change mid-session can't desync replay from the console/network
|
|
18
|
+
// lanes — the player only ever uses timestamp DELTAS, so this is safe.
|
|
19
|
+
event.timestamp = ctx.clock.startedAt + t;
|
|
13
20
|
const dom: DomEvent = {
|
|
14
21
|
id: uuid(),
|
|
15
22
|
sessionId: ctx.sessionId,
|
|
16
23
|
type: 'dom',
|
|
17
|
-
t
|
|
24
|
+
t,
|
|
18
25
|
ts: ctx.clock.wall(),
|
|
19
26
|
data: event,
|
|
20
27
|
};
|
|
@@ -48,6 +55,18 @@ export function instrumentDom(ctx: InstrumentCtx): Teardown {
|
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** Force a fresh FullSnapshot checkpoint NOW. Used after transport backpressure
|
|
59
|
+
* had to drop buffered DOM deltas — the new snapshot re-anchors the stream so
|
|
60
|
+
* replay stays coherent instead of playing broken deltas until the next 30s
|
|
61
|
+
* checkout. Safe no-op when recording isn't active. */
|
|
62
|
+
export function takeFullSnapshot(): void {
|
|
63
|
+
try {
|
|
64
|
+
record.takeFullSnapshot(true);
|
|
65
|
+
} catch {
|
|
66
|
+
/* not recording */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
function performanceNow(): number {
|
|
52
71
|
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
53
72
|
}
|
package/src/serialize.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SerializedArg } from '@obsrviq/types';
|
|
2
|
-
import { maskString,
|
|
2
|
+
import { maskString, isSensitiveKeyName } from './mask.js';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_MAX_BYTES = 8 * 1024; // 8 KB string cap (§6.1)
|
|
5
|
+
const MAX_WALK_NODES = 2000; // deep-walk node-visit cap (§6.1)
|
|
5
6
|
|
|
6
7
|
function byteLen(s: string): number {
|
|
7
8
|
return new Blob([s]).size;
|
|
@@ -24,9 +25,12 @@ export function serializeArg(arg: unknown, maxBytes = DEFAULT_MAX_BYTES): Serial
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
if (type === 'string') {
|
|
27
|
-
|
|
28
|
+
// Pre-cut giant strings (2× slack) so masking never regex-scans a multi-MB arg.
|
|
29
|
+
const s = arg as string;
|
|
30
|
+
const cut = s.length > maxBytes * 2 ? s.slice(0, maxBytes * 2) : s;
|
|
31
|
+
const masked = maskString(cut);
|
|
28
32
|
const { value, truncated } = truncate(masked, maxBytes);
|
|
29
|
-
return { kind: 'string', value, truncated };
|
|
33
|
+
return { kind: 'string', value, truncated: truncated || cut !== s };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
if (arg instanceof Error) {
|
|
@@ -43,24 +47,76 @@ export function serializeArg(arg: unknown, maxBytes = DEFAULT_MAX_BYTES): Serial
|
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
if (type === 'object' || type === 'function' || type === 'symbol' || type === 'bigint') {
|
|
46
|
-
|
|
50
|
+
// Budgets are enforced DURING the walk (node-visit count + ~2× the byte cap)
|
|
51
|
+
// so a huge object never burns CPU/memory before the final truncate applies.
|
|
52
|
+
const budget: WalkBudget = { nodes: MAX_WALK_NODES, bytes: maxBytes * 2, exhausted: false };
|
|
53
|
+
const masked = maskValueBudgeted(arg, budget);
|
|
47
54
|
let json: string | undefined;
|
|
48
|
-
let truncated =
|
|
55
|
+
let truncated = budget.exhausted;
|
|
49
56
|
try {
|
|
50
57
|
const full = JSON.stringify(masked, safeReplacer());
|
|
51
58
|
const cut = truncate(full ?? 'undefined', maxBytes);
|
|
52
59
|
json = cut.value;
|
|
53
|
-
truncated = cut.truncated;
|
|
60
|
+
truncated = truncated || cut.truncated;
|
|
54
61
|
} catch {
|
|
55
62
|
json = undefined;
|
|
56
63
|
}
|
|
57
|
-
return { kind: 'object', preview: previewOf(
|
|
64
|
+
return { kind: 'object', preview: previewOf(arg), json, truncated };
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// undefined / fallback
|
|
61
68
|
return { kind: 'primitive', value: String(arg) };
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
interface WalkBudget {
|
|
72
|
+
nodes: number; // node visits left
|
|
73
|
+
bytes: number; // approximate serialized bytes left
|
|
74
|
+
exhausted: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `maskValue` with hard budgets enforced DURING the walk — a node-visit cap and a
|
|
78
|
+
* running approximate byte budget — so a huge arg (e.g. a multi-MB store) is never
|
|
79
|
+
* fully cloned/stringified just to be truncated afterwards. When a budget runs out
|
|
80
|
+
* we stop descending and substitute a '«truncated»' placeholder. Byte-identical to
|
|
81
|
+
* the unbudgeted walk for args that fit the budget. */
|
|
82
|
+
function maskValueBudgeted(value: unknown, budget: WalkBudget, depth = 0): unknown {
|
|
83
|
+
if (budget.exhausted) return '«truncated»';
|
|
84
|
+
if (depth > 6) return '«depth»';
|
|
85
|
+
if (--budget.nodes <= 0) budget.exhausted = true;
|
|
86
|
+
if (typeof value === 'string') {
|
|
87
|
+
// Pre-cut giant strings so masking never regex-scans more than the budget.
|
|
88
|
+
const cut = value.length > budget.bytes ? value.slice(0, Math.max(0, budget.bytes)) : value;
|
|
89
|
+
if (cut !== value) budget.exhausted = true;
|
|
90
|
+
const masked = maskString(cut);
|
|
91
|
+
if ((budget.bytes -= masked.length + 2) <= 0) budget.exhausted = true;
|
|
92
|
+
return masked;
|
|
93
|
+
}
|
|
94
|
+
if (value === null || typeof value !== 'object') return value;
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
const out: unknown[] = [];
|
|
97
|
+
for (const v of value) {
|
|
98
|
+
if (budget.exhausted) {
|
|
99
|
+
out.push('«truncated»');
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
out.push(maskValueBudgeted(v, budget, depth + 1));
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const out: Record<string, unknown> = {};
|
|
107
|
+
for (const k of Object.keys(value)) {
|
|
108
|
+
if (budget.exhausted) {
|
|
109
|
+
out['…'] = '«truncated»';
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if ((budget.bytes -= k.length + 4) <= 0) budget.exhausted = true;
|
|
113
|
+
out[k] = isSensitiveKeyName(k)
|
|
114
|
+
? '«redacted»'
|
|
115
|
+
: maskValueBudgeted((value as Record<string, unknown>)[k], budget, depth + 1);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
64
120
|
function safeReplacer() {
|
|
65
121
|
const seen = new WeakSet();
|
|
66
122
|
return (_key: string, value: unknown) => {
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// W3C Trace Context propagation — stamp a `traceparent` header on FIRST-PARTY
|
|
2
|
+
// outbound fetch/XHR so the backend's OpenTelemetry agent continues the same
|
|
3
|
+
// trace, letting a session replay link to its server-side spans (App Monitor).
|
|
4
|
+
//
|
|
5
|
+
// CORS is the sharp edge: a custom request header on a CROSS-origin call promotes
|
|
6
|
+
// it to a preflighted request, and the browser BLOCKS it unless the server echoes
|
|
7
|
+
// the header in `Access-Control-Allow-Headers`. So we only ever propagate to:
|
|
8
|
+
// • the page's own origin (same-origin → no CORS at all), and
|
|
9
|
+
// • hosts the site explicitly allowlists via `traceHosts` (they've confirmed the
|
|
10
|
+
// server allows `traceparent`).
|
|
11
|
+
// Anything else is left untouched — propagation is strictly best-effort and must
|
|
12
|
+
// never break or block a request.
|
|
13
|
+
|
|
14
|
+
export interface TraceIds {
|
|
15
|
+
/** 32-hex W3C trace id (== the backend span's trace_id). */
|
|
16
|
+
traceId: string;
|
|
17
|
+
/** 16-hex parent/span id for this outbound request. */
|
|
18
|
+
spanId: string;
|
|
19
|
+
/** the ready-to-send `traceparent` header value. */
|
|
20
|
+
header: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function randomHex(bytes: number): string {
|
|
24
|
+
const arr = new Uint8Array(bytes);
|
|
25
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
26
|
+
crypto.getRandomValues(arr);
|
|
27
|
+
} else {
|
|
28
|
+
for (let i = 0; i < bytes; i++) arr[i] = (Math.random() * 256) | 0;
|
|
29
|
+
}
|
|
30
|
+
let s = '';
|
|
31
|
+
for (let i = 0; i < bytes; i++) s += arr[i]!.toString(16).padStart(2, '0');
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mint a fresh W3C trace context for one outbound request (sampled flag = 01). */
|
|
36
|
+
export function newTrace(): TraceIds {
|
|
37
|
+
const traceId = randomHex(16); // 128-bit
|
|
38
|
+
const spanId = randomHex(8); // 64-bit
|
|
39
|
+
return { traceId, spanId, header: `00-${traceId}-${spanId}-01` };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TraceCfg {
|
|
43
|
+
tracePropagation: boolean;
|
|
44
|
+
traceHosts: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Should we propagate to this URL? Same-origin always (safe); cross-origin only
|
|
48
|
+
* if the host is allowlisted. Non-http(s) and unparseable URLs never match. */
|
|
49
|
+
export function traceTarget(rawUrl: string, cfg: TraceCfg): boolean {
|
|
50
|
+
if (!cfg.tracePropagation) return false;
|
|
51
|
+
let u: URL;
|
|
52
|
+
try {
|
|
53
|
+
u = new URL(rawUrl, typeof location !== 'undefined' ? location.href : undefined);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
58
|
+
if (typeof location !== 'undefined' && u.origin === location.origin) return true;
|
|
59
|
+
return hostAllowed(u.hostname, cfg.traceHosts);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hostAllowed(hostname: string, hosts: string[]): boolean {
|
|
63
|
+
const h = hostname.toLowerCase();
|
|
64
|
+
for (const raw of hosts) {
|
|
65
|
+
let entry = String(raw).trim().toLowerCase();
|
|
66
|
+
if (!entry) continue;
|
|
67
|
+
if (entry.includes('://')) {
|
|
68
|
+
try {
|
|
69
|
+
entry = new URL(entry).hostname;
|
|
70
|
+
} catch {
|
|
71
|
+
/* keep the raw string */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
entry = entry.replace(/^\*?\./, ''); // "*.example.com" / ".example.com" → "example.com"
|
|
75
|
+
if (h === entry || h.endsWith('.' + entry)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Return the fetch args to call `orig` with, carrying `traceparent`. Never
|
|
81
|
+
* throws upward is the caller's job — but we keep this total and side-effect-free
|
|
82
|
+
* so a rebuild failure can fall back to the originals. */
|
|
83
|
+
export function injectFetchTrace(
|
|
84
|
+
input: RequestInfo | URL,
|
|
85
|
+
init: RequestInit | undefined,
|
|
86
|
+
header: string,
|
|
87
|
+
): { input: RequestInfo | URL; init: RequestInit | undefined } {
|
|
88
|
+
const hasInitHeaders = !!init && 'headers' in init && init.headers != null;
|
|
89
|
+
// fetch(Request) with no override headers → rebuild the Request so we don't drop
|
|
90
|
+
// its own headers. When init.headers IS given, fetch uses THOSE (spec: they
|
|
91
|
+
// replace the Request's), so we inject there instead.
|
|
92
|
+
if (input instanceof Request && !hasInitHeaders) {
|
|
93
|
+
const h = new Headers(input.headers);
|
|
94
|
+
if (!h.has('traceparent')) h.set('traceparent', header);
|
|
95
|
+
return { input: new Request(input, { headers: h }), init };
|
|
96
|
+
}
|
|
97
|
+
const h = new Headers((init?.headers as HeadersInit | undefined) ?? undefined);
|
|
98
|
+
if (!h.has('traceparent')) h.set('traceparent', header);
|
|
99
|
+
return { input, init: { ...(init || {}), headers: h } };
|
|
100
|
+
}
|