@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/dist/index.js +81 -0
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +41 -41
- 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 +32 -0
- package/src/trace.ts +100 -0
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
|
@@ -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
|
+
}
|