@obsrviq/tracker 0.6.0 → 0.7.1

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.1",
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 { applyFetchTrace, newTrace, traceIdOf, 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,20 @@ 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 inj = applyFetchTrace(input, init);
349
+ input = inj.input;
350
+ init = inj.init;
351
+ traceId = inj.traceId || undefined;
352
+ } catch {
353
+ /* never let propagation break the request */
354
+ }
355
+ }
340
356
  const reqHeaders = pickHeaders(
341
357
  ctx.config.redactHeaders,
342
358
  headerEntries(init?.headers ?? (input instanceof Request ? input.headers : undefined)),
@@ -362,6 +378,7 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Te
362
378
  ok: false,
363
379
  resourceType: 'fetch',
364
380
  requestHeaders: reqHeaders,
381
+ traceId,
365
382
  timing,
366
383
  ...partial,
367
384
  };
@@ -425,6 +442,7 @@ interface XhrMeta {
425
442
  perfStart: number;
426
443
  reqHeaders: Record<string, string>;
427
444
  body?: string;
445
+ traceId?: string;
428
446
  }
429
447
 
430
448
  function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
@@ -464,6 +482,25 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Tear
464
482
  meta.startT = ctx.clock.now();
465
483
  meta.perfStart = perfNow();
466
484
  if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
485
+ // Trace-context propagation (first-party only). setRequestHeader is valid
486
+ // here — after open(), before the real send() below. Our own setRequestHeader
487
+ // patch records it into meta.reqHeaders (traceparent is allowlisted), so a
488
+ // traceparent the APP already set is visible here: respect it (record its id,
489
+ // don't re-set — XHR COMBINES duplicate headers into an invalid "v1, v2").
490
+ if (traceTarget(meta.rawUrl, ctx.config)) {
491
+ try {
492
+ const appSet = meta.reqHeaders['traceparent'];
493
+ if (appSet) {
494
+ meta.traceId = traceIdOf(appSet) ?? undefined;
495
+ } else {
496
+ const tr = newTrace();
497
+ this.setRequestHeader('traceparent', tr.header);
498
+ meta.traceId = tr.traceId;
499
+ }
500
+ } catch {
501
+ /* header rejected (e.g. send already dispatched) — skip, never throw */
502
+ }
503
+ }
467
504
  const finish = (error?: string) => {
468
505
  const endT = ctx.clock.now();
469
506
  const base: NetworkTiming = {
@@ -501,6 +538,7 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Tear
501
538
  ok: status >= 200 && status < 400,
502
539
  resourceType: 'xhr',
503
540
  requestHeaders: meta.reqHeaders,
541
+ traceId: meta.traceId,
504
542
  responseHeaders: respHeaders,
505
543
  responseSize,
506
544
  requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
package/src/trace.ts ADDED
@@ -0,0 +1,130 @@
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
+ /** The 32-hex trace id inside a `traceparent` value, or null if malformed. */
81
+ export function traceIdOf(header: string | null | undefined): string | null {
82
+ if (!header) return null;
83
+ const parts = header.split('-');
84
+ return parts.length >= 2 && /^[0-9a-f]{32}$/.test(parts[1]!) ? parts[1]! : null;
85
+ }
86
+
87
+ /** The traceparent already on this fetch call, honoring fetch's header semantics:
88
+ * init.headers (when present) REPLACE a Request's headers, so they win. */
89
+ function existingFetchTraceparent(input: RequestInfo | URL, init: RequestInit | undefined): string | null {
90
+ const hasInitHeaders = !!init && 'headers' in init && init.headers != null;
91
+ const src = hasInitHeaders
92
+ ? (init!.headers as HeadersInit)
93
+ : input instanceof Request
94
+ ? input.headers
95
+ : undefined;
96
+ if (!src) return null;
97
+ try {
98
+ return new Headers(src).get('traceparent');
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /** Ensure a fetch call carries a `traceparent`, and report the trace id that will
105
+ * actually be ON THE WIRE. If the app already set one, we DON'T overwrite it (and
106
+ * return ITS id, so the recorded event matches what the backend sees); otherwise
107
+ * we mint + inject ours. Total + side-effect-free so a caller can fall back to the
108
+ * originals if it throws. */
109
+ export function applyFetchTrace(
110
+ input: RequestInfo | URL,
111
+ init: RequestInit | undefined,
112
+ ): { input: RequestInfo | URL; init: RequestInit | undefined; traceId: string } {
113
+ const existing = existingFetchTraceparent(input, init);
114
+ if (existing) {
115
+ // Respect the app's own trace context — record its id, change nothing.
116
+ return { input, init, traceId: traceIdOf(existing) ?? '' };
117
+ }
118
+ const tr = newTrace();
119
+ const hasInitHeaders = !!init && 'headers' in init && init.headers != null;
120
+ // fetch(Request) with no override headers → rebuild the Request so we don't drop
121
+ // its own headers. When init.headers IS given, fetch uses THOSE, so inject there.
122
+ if (input instanceof Request && !hasInitHeaders) {
123
+ const h = new Headers(input.headers);
124
+ h.set('traceparent', tr.header);
125
+ return { input: new Request(input, { headers: h }), init, traceId: tr.traceId };
126
+ }
127
+ const h = new Headers((init?.headers as HeadersInit | undefined) ?? undefined);
128
+ h.set('traceparent', tr.header);
129
+ return { input, init: { ...(init || {}), headers: h }, traceId: tr.traceId };
130
+ }