@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/dist/index.js +105 -0
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +39 -39
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +4 -0
- package/src/index.ts +2 -0
- package/src/network.ts +38 -0
- package/src/trace.ts +130 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/tracker",
|
|
3
|
-
"version": "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.
|
|
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
|
+
}
|