@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsrviq/tracker",
3
- "version": "0.5.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.4.1"
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
- const masked = maskString(text);
60
- const truncated = masked.length > BODY_CAP;
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 !== text,
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
- ctx.emit(ev);
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
- const text = await res.clone().text();
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
- ctx.emit(ev);
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
- ctx.emit(ev);
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
- ctx.emit(ev);
669
+ emitNet(ev);
575
670
  }
576
671
  });
577
672
  obs.observe({ type: 'resource', buffered: true });
@@ -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: ctx.clock.fromPerf(performanceNow()),
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, maskValue } from './mask.js';
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
- const masked = maskString(arg as string);
28
+ // Pre-cut giant strings ( 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
- const masked = maskValue(arg);
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 = false;
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(masked), json, truncated };
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
+ }