@obsrviq/tracker 0.6.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.6.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.5.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
@@ -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',
package/src/network.ts CHANGED
@@ -2,6 +2,7 @@ import type { NetworkEvent, NetworkResourceType, NetworkTiming } from '@obsrviq/
2
2
  import type { InstrumentCtx, Teardown } from './context.js';
3
3
  import { maskString } from './mask.js';
4
4
  import { tokenBucket } from './ratelimit.js';
5
+ import { injectFetchTrace, newTrace, traceTarget } from './trace.js';
5
6
  import { uuid } from './util.js';
6
7
 
7
8
  const BODY_CAP = 4 * 1024; // 4 KB body preview cap
@@ -19,6 +20,7 @@ const HEADER_ALLOW = new Set([
19
20
  'content-encoding',
20
21
  'x-request-id',
21
22
  'x-correlation-id',
23
+ 'traceparent',
22
24
  'server',
23
25
  'vary',
24
26
  ]);
@@ -337,6 +339,21 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Te
337
339
  const perfStart = perfNow();
338
340
  const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
339
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
+ }
340
357
  const reqHeaders = pickHeaders(
341
358
  ctx.config.redactHeaders,
342
359
  headerEntries(init?.headers ?? (input instanceof Request ? input.headers : undefined)),
@@ -362,6 +379,7 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Te
362
379
  ok: false,
363
380
  resourceType: 'fetch',
364
381
  requestHeaders: reqHeaders,
382
+ traceId,
365
383
  timing,
366
384
  ...partial,
367
385
  };
@@ -425,6 +443,7 @@ interface XhrMeta {
425
443
  perfStart: number;
426
444
  reqHeaders: Record<string, string>;
427
445
  body?: string;
446
+ traceId?: string;
428
447
  }
429
448
 
430
449
  function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
@@ -464,6 +483,18 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Tear
464
483
  meta.startT = ctx.clock.now();
465
484
  meta.perfStart = perfNow();
466
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
+ }
467
498
  const finish = (error?: string) => {
468
499
  const endT = ctx.clock.now();
469
500
  const base: NetworkTiming = {
@@ -501,6 +532,7 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Tear
501
532
  ok: status >= 200 && status < 400,
502
533
  resourceType: 'xhr',
503
534
  requestHeaders: meta.reqHeaders,
535
+ traceId: meta.traceId,
504
536
  responseHeaders: respHeaders,
505
537
  responseSize,
506
538
  requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
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
+ }