@obsrviq/tracker 0.4.1 → 0.5.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.4.1",
3
+ "version": "0.5.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.0"
30
+ "@obsrviq/types": "0.4.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "tsup": "^8.3.5",
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import { Transport } from './transport.js';
4
4
  import { instrumentDom } from './recorder.js';
5
5
  import { instrumentConsole } from './console.js';
6
6
  import { instrumentNetwork } from './network.js';
7
+ import { instrumentWebSocket } from './websocket.js';
8
+ import { setCustomMaskKeys, markMaskConfigReady } from './mask.js';
7
9
  import { instrumentErrors } from './errors.js';
8
10
  import { instrumentVitals } from './vitals.js';
9
11
  import { instrumentInteractions } from './interactions.js';
@@ -97,6 +99,14 @@ class ObsrviqClient {
97
99
  // askPermission = "show a built-in consent prompt"; it implies a consent gate.
98
100
  if (this.config.askPermission) this.config.requireConsent = true;
99
101
 
102
+ // Custom masking keys: apply any static ones NOW, then fetch the per-site list
103
+ // (GET /v1/config) and merge. Fired before the network instrumenter is installed
104
+ // so this request is never self-recorded; the transport also re-masks custom
105
+ // keys at send time so nothing captured before the fetch resolves can leak.
106
+ const staticMaskKeys = Array.isArray(config.maskKeys) ? config.maskKeys : [];
107
+ setCustomMaskKeys(staticMaskKeys);
108
+ void this.loadRemoteMaskKeys(staticMaskKeys);
109
+
100
110
  this.startSession();
101
111
 
102
112
  // Init-time identity (rarely known this early — usually set later via identify()).
@@ -124,6 +134,39 @@ class ObsrviqClient {
124
134
  }
125
135
  }
126
136
 
137
+ /** Fetch the per-site masking config (GET /v1/config) and merge its keys with any
138
+ * static ones. Best-effort: on failure the built-in masking still applies. */
139
+ private async loadRemoteMaskKeys(staticKeys: string[]): Promise<void> {
140
+ const cfg = this.config;
141
+ if (!cfg || typeof fetch === 'undefined') {
142
+ markMaskConfigReady();
143
+ return;
144
+ }
145
+ try {
146
+ const ctrl = new AbortController();
147
+ const timer = setTimeout(() => ctrl.abort(), 2000);
148
+ try {
149
+ const url = `${cfg.endpoint.replace(/\/$/, '')}/v1/config?key=${encodeURIComponent(cfg.siteKey)}`;
150
+ const res = await fetch(url, { credentials: 'omit', mode: 'cors', signal: ctrl.signal });
151
+ if (res.ok) {
152
+ const data = (await res.json()) as { maskKeys?: unknown };
153
+ const remote = Array.isArray(data.maskKeys)
154
+ ? data.maskKeys.filter((k): k is string => typeof k === 'string')
155
+ : [];
156
+ setCustomMaskKeys([...staticKeys, ...remote]);
157
+ }
158
+ } finally {
159
+ clearTimeout(timer);
160
+ }
161
+ } catch {
162
+ /* offline / blocked / timeout / bad JSON — built-in masking still protects. */
163
+ } finally {
164
+ // Always release the transport's hold — a failed/slow fetch must never block
165
+ // sending forever (built-in + static-key masking still applies).
166
+ markMaskConfigReady();
167
+ }
168
+ }
169
+
127
170
  /** (Re)create the session id, clock, meta, and transport. Used by init() and
128
171
  * reset(). Does not start recording — callers decide via maybeStart().
129
172
  * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
@@ -237,6 +280,7 @@ class ObsrviqClient {
237
280
  // before app code runs more requests; DOM last so its snapshot is current.
238
281
  this.teardowns.push(instrumentConsole(ctx));
239
282
  this.teardowns.push(instrumentNetwork(ctx));
283
+ this.teardowns.push(instrumentWebSocket(ctx));
240
284
  this.teardowns.push(instrumentErrors(ctx));
241
285
  this.teardowns.push(instrumentVitals(ctx));
242
286
  this.teardowns.push(instrumentInteractions(ctx));
package/src/mask.ts CHANGED
@@ -2,14 +2,34 @@
2
2
  // anything leaves the page. Heuristics catch the obvious PII; tenants tighten
3
3
  // with maskTextSelector / beforeSend.
4
4
 
5
+ import type { ObsrviqEvent } from '@obsrviq/types';
6
+
5
7
  const EMAIL = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
6
8
  const CC = /\b(?:\d[ -]*?){13,19}\b/g; // candidate card numbers
7
9
  const SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
8
10
  const LONG_DIGITS = /\b\d{9,}\b/g; // phone-ish / account-ish
9
11
 
10
- // Keys whose values we always redact regardless of content.
11
- const SENSITIVE_KEYS =
12
- /(pass(word)?|secret|token|api[-_]?key|authorization|cookie|ssn|card|cvv|pin|otp)/i;
12
+ // Keys whose values we always redact, regardless of content. Two tiers so we
13
+ // catch real secrets without over-redacting innocent fields:
14
+ // STRONG — long/unambiguous tokens, safe to match as a substring of the key
15
+ // name (e.g. "accessToken", "x-api-key", "clientSecret").
16
+ // WEAK — short/ambiguous tokens that we only honour when they stand alone as
17
+ // a key SEGMENT, so "shipping"(pin) / "discard"(card) / "author"(auth)
18
+ // aren't caught but "pin" / "cardNumber" / "authToken" are.
19
+ const SENSITIVE_STRONG =
20
+ /(password|passwd|passphrase|secret|token|authoriz|apikey|api[_-]key|creditcard|cardnumber|cardholder|cookie|cvv|cvc)/i;
21
+ const SENSITIVE_WEAK = new Set(['pass', 'pwd', 'auth', 'card', 'pin', 'otp', 'ssn', 'cvv', 'cvc']);
22
+
23
+ /** Split a key name into lowercase word segments on delimiters AND camelCase
24
+ * boundaries: "accessToken" → [access, token], "api_key" → [api, key]. */
25
+ function keySegments(key: string): string[] {
26
+ return key
27
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
28
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
29
+ .split(/[^A-Za-z0-9]+/)
30
+ .filter(Boolean)
31
+ .map((s) => s.toLowerCase());
32
+ }
13
33
 
14
34
  export function maskString(input: string): string {
15
35
  return input
@@ -24,8 +44,109 @@ function luhnish(s: string): boolean {
24
44
  return digits.length >= 13 && digits.length <= 19;
25
45
  }
26
46
 
27
- export function isSensitiveKey(key: string): boolean {
28
- return SENSITIVE_KEYS.test(key);
47
+ // Customer-defined keys to mask (case-insensitive exact match), delivered via the
48
+ // per-site config (GET /v1/config) and/or the init `maskKeys` option. Additive to
49
+ // the built-in list above — you can only add protections, never remove them.
50
+ let customKeys = new Set<string>();
51
+ export function setCustomMaskKeys(keys: string[]): void {
52
+ customKeys = new Set(keys.map((k) => String(k).trim().toLowerCase()).filter(Boolean));
53
+ }
54
+
55
+ // Whether the per-site mask config (GET /v1/config) has settled. Until it does,
56
+ // remote-only custom keys aren't known yet, so the transport holds non-unload
57
+ // sends (below) to avoid leaking a custom-keyed value in the brief init window.
58
+ let maskConfigSettled = false;
59
+ export function markMaskConfigReady(): void {
60
+ maskConfigSettled = true;
61
+ }
62
+ export function isMaskConfigPending(): boolean {
63
+ return !maskConfigSettled;
64
+ }
65
+ export function hasCustomMaskKeys(): boolean {
66
+ return customKeys.size > 0;
67
+ }
68
+ function isCustomKey(key: string): boolean {
69
+ return customKeys.has(key.toLowerCase());
70
+ }
71
+
72
+ /** Key-based sensitivity test: built-in STRONG/WEAK tiers PLUS any customer-
73
+ * configured keys (exact, case-insensitive). Boundary-aware so innocent fields
74
+ * like "shipping" / "discard" / "author" aren't redacted. */
75
+ export function isSensitiveKeyName(key: string): boolean {
76
+ if (isCustomKey(key)) return true;
77
+ if (SENSITIVE_STRONG.test(key)) return true;
78
+ return keySegments(key).some((seg) => SENSITIVE_WEAK.has(seg));
79
+ }
80
+
81
+ /** Redact sensitive-key values in a parsed object (built-in list + custom keys).
82
+ * The send-time safety net that guarantees a `{"password": "…"}` style body can
83
+ * never leave the page unmasked, even for non-DOM network/WS payloads (whose
84
+ * capture-time masking is PII-pattern only, not key-aware). */
85
+ function redactSensitiveInValue(value: unknown, depth = 0): unknown {
86
+ if (depth > 24 || value === null || typeof value !== 'object') return value;
87
+ if (Array.isArray(value)) return value.map((v) => redactSensitiveInValue(v, depth + 1));
88
+ const out: Record<string, unknown> = {};
89
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
90
+ out[k] = isSensitiveKeyName(k) ? '«redacted»' : redactSensitiveInValue(v, depth + 1);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** String-level key redaction for payloads that don't parse as JSON — non-JSON
96
+ * bodies (form-urlencoded / query strings) AND truncated JSON, where the
97
+ * structured path bails. Captures each `"key": value` (JSON-ish, tolerant of a
98
+ * cut-off tail) and `key=value` (form / query) key name and redacts the value
99
+ * when the key is sensitive. */
100
+ function redactSensitiveInRawText(text: string): string {
101
+ let out = text.replace(
102
+ /"([A-Za-z0-9_.\-]+)"\s*:\s*("(?:[^"\\]|\\.)*"|[^,}\]\s]+)/g,
103
+ (m, key: string) => (isSensitiveKeyName(key) ? '"' + key + '": "«redacted»"' : m),
104
+ );
105
+ out = out.replace(
106
+ /(^|[?&])([A-Za-z0-9_.\-[\]]+)=([^&\s]*)/g,
107
+ (m, pre: string, key: string) => (isSensitiveKeyName(key) ? pre + key + '=«redacted»' : m),
108
+ );
109
+ return out;
110
+ }
111
+
112
+ /** Redact sensitive-key values in a stored body/arg string. Tries structured
113
+ * JSON first; falls back to a string scan for non-JSON or truncated payloads so a
114
+ * sensitive key can't slip through in `email=a&password=b` or a cut-off body. */
115
+ function redactSensitiveInText(text: string | undefined): string | undefined {
116
+ if (!text) return text;
117
+ const trimmed = text.trim();
118
+ if (trimmed[0] === '{' || trimmed[0] === '[') {
119
+ try {
120
+ return JSON.stringify(redactSensitiveInValue(JSON.parse(trimmed)));
121
+ } catch {
122
+ /* truncated / invalid JSON — fall through to the string scan */
123
+ }
124
+ }
125
+ return redactSensitiveInRawText(text);
126
+ }
127
+
128
+ /** Send-time pass: mask sensitive keys (built-in list + custom keys) across a
129
+ * batch's bodies / frames / console args / custom props (mutates in place).
130
+ * Always runs — the built-in list applies with no per-site config needed. */
131
+ export function redactBatchSensitiveKeys(events: ObsrviqEvent[]): void {
132
+ for (const e of events) {
133
+ if (e.type === 'network' || e.type === 'ws') {
134
+ const anyE = e as unknown as {
135
+ requestBody?: { preview?: string };
136
+ responseBody?: { preview?: string };
137
+ body?: { preview?: string };
138
+ };
139
+ if (anyE.requestBody?.preview) anyE.requestBody.preview = redactSensitiveInText(anyE.requestBody.preview);
140
+ if (anyE.responseBody?.preview) anyE.responseBody.preview = redactSensitiveInText(anyE.responseBody.preview);
141
+ if (anyE.body?.preview) anyE.body.preview = redactSensitiveInText(anyE.body.preview);
142
+ } else if (e.type === 'custom') {
143
+ const c = e as unknown as { props?: Record<string, unknown> };
144
+ if (c.props) c.props = redactSensitiveInValue(c.props) as Record<string, unknown>;
145
+ } else if (e.type === 'console') {
146
+ const args = (e as unknown as { args?: Array<{ kind: string; json?: string }> }).args;
147
+ if (args) for (const a of args) if (a.kind === 'object' && a.json) a.json = redactSensitiveInText(a.json);
148
+ }
149
+ }
29
150
  }
30
151
 
31
152
  /** Recursively mask an object's string values and sensitive keys. */
@@ -36,7 +157,7 @@ export function maskValue(value: unknown, depth = 0): unknown {
36
157
  if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
37
158
  const out: Record<string, unknown> = {};
38
159
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
39
- out[k] = isSensitiveKey(k) ? '«redacted»' : maskValue(v, depth + 1);
160
+ out[k] = isSensitiveKeyName(k) ? '«redacted»' : maskValue(v, depth + 1);
40
161
  }
41
162
  return out;
42
163
  }
package/src/network.ts CHANGED
@@ -66,47 +66,214 @@ function bodyPreview(
66
66
  };
67
67
  }
68
68
 
69
- function enrichTiming(ctx: InstrumentCtx, url: string, base: NetworkTiming): NetworkTiming {
69
+ function perfNow(): number {
70
+ return typeof performance !== 'undefined' ? performance.now() : 0;
71
+ }
72
+
73
+ /** Resource Timing `entry.name` is always absolute; requests may be issued with a
74
+ * relative URL. Absolutize (query preserved) so the two can be matched. */
75
+ function absUrl(raw: string): string {
70
76
  try {
71
- const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
72
- // newest matching entry by URL
73
- const match = entries.filter((e) => e.name === url).pop();
74
- if (!match) return base;
75
- const t: NetworkTiming = { ...base };
76
- if (match.domainLookupEnd && match.domainLookupStart)
77
- t.dns = Math.max(0, match.domainLookupEnd - match.domainLookupStart);
78
- if (match.connectEnd && match.connectStart)
79
- t.tcp = Math.max(0, match.connectEnd - match.connectStart);
80
- if (match.secureConnectionStart)
81
- t.tls = Math.max(0, match.connectEnd - match.secureConnectionStart);
82
- if (match.responseStart && match.requestStart)
83
- t.ttfb = Math.max(0, match.responseStart - match.requestStart);
84
- if (match.responseEnd && match.responseStart)
85
- t.download = Math.max(0, match.responseEnd - match.responseStart);
86
- return t;
77
+ return new URL(raw, typeof location !== 'undefined' ? location.href : undefined).href;
87
78
  } catch {
88
- return base;
79
+ return raw;
80
+ }
81
+ }
82
+
83
+ /** Extract the DNS/TCP/TLS/TTFB/Download breakdown from a matched Resource Timing
84
+ * entry, layered onto the SDK-measured `base` (startT/endT/duration). Phases that
85
+ * are zero — a reused keep-alive connection, or a cross-origin resource without a
86
+ * `Timing-Allow-Origin` header (browser zeroes them for privacy) — are left
87
+ * undefined so the player shows "—" rather than a misleading 0. */
88
+ function phasesFrom(m: PerformanceResourceTiming, base: NetworkTiming): NetworkTiming {
89
+ const t: NetworkTiming = { ...base };
90
+ const r = (n: number) => Math.round(n);
91
+ // Every phase is guarded on its START marker being > 0. A cross-origin resource
92
+ // without `Timing-Allow-Origin` has its start markers (domainLookupStart,
93
+ // connectStart, secureConnectionStart, requestStart, responseStart) zeroed by the
94
+ // browser while responseEnd stays real — so a bare `end > start` check would
95
+ // compute a garbage phase (e.g. download = responseEnd − 0). Requiring start > 0
96
+ // omits those phases instead.
97
+ if (m.domainLookupStart > 0 && m.domainLookupEnd > m.domainLookupStart)
98
+ t.dns = r(m.domainLookupEnd - m.domainLookupStart);
99
+ if (m.connectStart > 0 && m.connectEnd > m.connectStart) t.tcp = r(m.connectEnd - m.connectStart);
100
+ if (m.secureConnectionStart > 0 && m.connectEnd > m.secureConnectionStart)
101
+ t.tls = r(m.connectEnd - m.secureConnectionStart);
102
+ if (m.requestStart > 0 && m.responseStart > m.requestStart)
103
+ t.ttfb = r(m.responseStart - m.requestStart);
104
+ if (m.responseStart > 0 && m.responseEnd > m.responseStart)
105
+ t.download = r(m.responseEnd - m.responseStart);
106
+ return t;
107
+ }
108
+
109
+ /** Pick the Resource Timing entry that best corresponds to a request that started
110
+ * at `perfStart` (performance-timeline ms): same URL, started at ~the same time.
111
+ * Guards against mis-attributing timing when the same URL is hit repeatedly. */
112
+ function matchEntry(
113
+ name: string,
114
+ perfStart: number,
115
+ entries: PerformanceResourceTiming[],
116
+ ): PerformanceResourceTiming | undefined {
117
+ let best: PerformanceResourceTiming | undefined;
118
+ let bestDelta = Infinity;
119
+ for (const e of entries) {
120
+ if (e.name !== name) continue;
121
+ if (e.startTime < perfStart - 4) continue; // started before our request → not ours
122
+ const d = e.startTime - perfStart;
123
+ if (d < bestDelta) {
124
+ bestDelta = d;
125
+ best = e;
126
+ }
89
127
  }
128
+ return best;
129
+ }
130
+
131
+ interface PendingTiming {
132
+ name: string; // absolute URL, to match against PerformanceResourceTiming.name
133
+ perfStart: number;
134
+ base: NetworkTiming;
135
+ resolve: (t: NetworkTiming) => void;
136
+ timer: ReturnType<typeof setTimeout>;
137
+ }
138
+
139
+ /** How long to wait for a Resource Timing entry to be published before giving up
140
+ * and emitting with duration-only timing. The browser adds the entry to the
141
+ * performance buffer *slightly after* fetch/XHR completion, so a synchronous
142
+ * lookup at completion almost always misses it — hence this short async wait. */
143
+ const TIMING_WAIT_MS = 300;
144
+
145
+ interface TimingResolver {
146
+ resolve(rawUrl: string, perfStart: number, base: NetworkTiming): Promise<NetworkTiming>;
147
+ flushPending(): void;
148
+ teardown(): void;
149
+ }
150
+
151
+ /** Resolves the phase breakdown for a fetch/XHR asynchronously. A single shared
152
+ * PerformanceObserver watches for resource entries as they land and settles the
153
+ * matching pending request; unmatched requests settle on a short timeout. */
154
+ function createTimingResolver(): TimingResolver {
155
+ const pending: PendingTiming[] = [];
156
+ let obs: PerformanceObserver | undefined;
157
+
158
+ const stopObs = () => {
159
+ if (obs) {
160
+ try {
161
+ obs.disconnect();
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ obs = undefined;
166
+ }
167
+ };
168
+
169
+ const settle = (p: PendingTiming, t: NetworkTiming) => {
170
+ const i = pending.indexOf(p);
171
+ if (i === -1) return; // already settled
172
+ pending.splice(i, 1);
173
+ clearTimeout(p.timer);
174
+ p.resolve(t);
175
+ if (pending.length === 0) stopObs();
176
+ };
177
+
178
+ const consume = (entries: PerformanceEntryList) => {
179
+ for (const e of entries as PerformanceResourceTiming[]) {
180
+ let best: PendingTiming | undefined;
181
+ let bestDelta = Infinity;
182
+ for (const p of pending) {
183
+ if (e.name !== p.name || e.startTime < p.perfStart - 4) continue;
184
+ const d = e.startTime - p.perfStart;
185
+ if (d < bestDelta) {
186
+ bestDelta = d;
187
+ best = p;
188
+ }
189
+ }
190
+ if (best) settle(best, phasesFrom(e, best.base));
191
+ }
192
+ };
193
+
194
+ const ensureObserver = () => {
195
+ if (obs || typeof PerformanceObserver === 'undefined') return;
196
+ try {
197
+ obs = new PerformanceObserver((list) => consume(list.getEntries()));
198
+ obs.observe({ type: 'resource', buffered: false });
199
+ } catch {
200
+ obs = undefined;
201
+ }
202
+ };
203
+
204
+ return {
205
+ resolve(rawUrl, perfStart, base) {
206
+ if (typeof performance === 'undefined') return Promise.resolve(base);
207
+ const name = absUrl(rawUrl);
208
+ // Fast path: the entry may already be buffered (cached / same-tick resources).
209
+ try {
210
+ const now = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
211
+ const immediate = matchEntry(name, perfStart, now);
212
+ if (immediate) return Promise.resolve(phasesFrom(immediate, base));
213
+ } catch {
214
+ /* ignore */
215
+ }
216
+ if (typeof PerformanceObserver === 'undefined') return Promise.resolve(base);
217
+ // Slow path: wait for the entry to be published (or time out).
218
+ return new Promise<NetworkTiming>((resolve) => {
219
+ const p: PendingTiming = {
220
+ name,
221
+ perfStart,
222
+ base,
223
+ resolve,
224
+ timer: setTimeout(() => settle(p, base), TIMING_WAIT_MS),
225
+ };
226
+ pending.push(p);
227
+ ensureObserver();
228
+ });
229
+ },
230
+ // Force-settle everything still in flight (with duration-only timing) so their
231
+ // events are emitted into the transport buffer before an unload beacon fires.
232
+ flushPending() {
233
+ for (const p of [...pending]) settle(p, p.base);
234
+ },
235
+ teardown() {
236
+ this.flushPending();
237
+ stopObs();
238
+ },
239
+ };
90
240
  }
91
241
 
92
242
  export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
243
+ const timing = createTimingResolver();
244
+ // On page hide, settle any request whose Resource Timing hasn't arrived yet so
245
+ // its event lands in the transport buffer before the unload flush.
246
+ const onPageHide = () => timing.flushPending();
247
+ const onVisibility = () => {
248
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') timing.flushPending();
249
+ };
250
+ if (typeof window !== 'undefined') window.addEventListener('pagehide', onPageHide);
251
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
252
+
93
253
  const teardowns: Teardown[] = [];
94
- teardowns.push(wrapFetch(ctx));
95
- teardowns.push(wrapXhr(ctx));
254
+ teardowns.push(wrapFetch(ctx, timing));
255
+ teardowns.push(wrapXhr(ctx, timing));
96
256
  teardowns.push(wrapBeacon(ctx));
97
257
  teardowns.push(observeResources(ctx));
258
+ teardowns.push(() => {
259
+ if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide);
260
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
261
+ timing.teardown();
262
+ });
98
263
  return () => teardowns.forEach((t) => t());
99
264
  }
100
265
 
101
266
  // ───────────────────────────── fetch ────────────────────────────────
102
- function wrapFetch(ctx: InstrumentCtx): Teardown {
267
+ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
103
268
  const orig = window.fetch;
104
269
  if (!orig) return () => {};
105
270
  window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
106
271
  const rawUrl = input instanceof Request ? input.url : String(input);
107
272
  // Never record our own telemetry round-trips to the ingest endpoint.
108
- if (rawUrl.includes('/v1/batch')) return orig.call(window, input as RequestInfo, init);
273
+ if (rawUrl.includes('/v1/batch') || rawUrl.includes('/v1/config'))
274
+ return orig.call(window, input as RequestInfo, init);
109
275
  const startT = ctx.clock.now();
276
+ const perfStart = perfNow();
110
277
  const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
111
278
  const url = redactUrl(rawUrl);
112
279
  const reqHeaders = pickHeaders(
@@ -116,25 +283,29 @@ function wrapFetch(ctx: InstrumentCtx): Teardown {
116
283
 
117
284
  const emit = (partial: Partial<NetworkEvent>) => {
118
285
  const endT = ctx.clock.now();
119
- const timing = enrichTiming(ctx, rawUrl, { startT, endT, duration: endT - startT });
120
- const ev: NetworkEvent = {
121
- id: uuid(),
122
- sessionId: ctx.sessionId,
123
- type: 'network',
124
- t: startT,
125
- ts: ctx.clock.wall(),
126
- initiator: 'fetch',
127
- method,
128
- url,
129
- status: 0,
130
- ok: false,
131
- resourceType: 'fetch',
132
- requestHeaders: reqHeaders,
133
- timing,
134
- ...partial,
135
- };
136
- ctx.emit(ev);
286
+ const base: NetworkTiming = { startT, endT, duration: endT - startT };
137
287
  ctx.markActivity();
288
+ // Resolve the phase breakdown asynchronously — the Resource Timing entry is
289
+ // published just after completion, so a synchronous read would miss it.
290
+ void tr.resolve(rawUrl, perfStart, base).then((timing) => {
291
+ const ev: NetworkEvent = {
292
+ id: uuid(),
293
+ sessionId: ctx.sessionId,
294
+ type: 'network',
295
+ t: startT,
296
+ ts: ctx.clock.wall(),
297
+ initiator: 'fetch',
298
+ method,
299
+ url,
300
+ status: 0,
301
+ ok: false,
302
+ resourceType: 'fetch',
303
+ requestHeaders: reqHeaders,
304
+ timing,
305
+ ...partial,
306
+ };
307
+ ctx.emit(ev);
308
+ });
138
309
  };
139
310
 
140
311
  try {
@@ -188,11 +359,12 @@ interface XhrMeta {
188
359
  url: string;
189
360
  rawUrl: string;
190
361
  startT: number;
362
+ perfStart: number;
191
363
  reqHeaders: Record<string, string>;
192
364
  body?: string;
193
365
  }
194
366
 
195
- function wrapXhr(ctx: InstrumentCtx): Teardown {
367
+ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
196
368
  const XHR = XMLHttpRequest.prototype;
197
369
  const origOpen = XHR.open;
198
370
  const origSend = XHR.send;
@@ -206,6 +378,7 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
206
378
  rawUrl,
207
379
  url: redactUrl(rawUrl),
208
380
  startT: 0,
381
+ perfStart: 0,
209
382
  reqHeaders: {},
210
383
  });
211
384
  return origOpen.apply(this, [method, url, ...rest] as never);
@@ -226,14 +399,15 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
226
399
  const meta = META.get(this);
227
400
  if (meta) {
228
401
  meta.startT = ctx.clock.now();
402
+ meta.perfStart = perfNow();
229
403
  if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
230
404
  const finish = (error?: string) => {
231
405
  const endT = ctx.clock.now();
232
- const timing = enrichTiming(ctx, meta.rawUrl, {
406
+ const base: NetworkTiming = {
233
407
  startT: meta.startT,
234
408
  endT,
235
409
  duration: endT - meta.startT,
236
- });
410
+ };
237
411
  const status = this.status;
238
412
  const respHeaders = parseRawHeaders(ctx, this.getAllResponseHeaders());
239
413
  // Response body (texty + readable responseType only), parallel to fetch.
@@ -247,28 +421,32 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
247
421
  /* opaque/cross-origin response — skip the body */
248
422
  }
249
423
  }
250
- const ev: NetworkEvent = {
251
- id: uuid(),
252
- sessionId: ctx.sessionId,
253
- type: 'network',
254
- t: meta.startT,
255
- ts: ctx.clock.wall(),
256
- initiator: 'xhr',
257
- method: meta.method,
258
- url: meta.url,
259
- status,
260
- ok: status >= 200 && status < 400,
261
- resourceType: 'xhr',
262
- requestHeaders: meta.reqHeaders,
263
- responseHeaders: respHeaders,
264
- responseSize: safeLen(this),
265
- requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
266
- responseBody,
267
- timing,
268
- error: error || classifyHttp(status),
269
- };
270
- ctx.emit(ev);
424
+ const responseSize = safeLen(this);
271
425
  ctx.markActivity();
426
+ // Resolve the phase breakdown asynchronously (see wrapFetch).
427
+ void tr.resolve(meta.rawUrl, meta.perfStart, base).then((timing) => {
428
+ const ev: NetworkEvent = {
429
+ id: uuid(),
430
+ sessionId: ctx.sessionId,
431
+ type: 'network',
432
+ t: meta.startT,
433
+ ts: ctx.clock.wall(),
434
+ initiator: 'xhr',
435
+ method: meta.method,
436
+ url: meta.url,
437
+ status,
438
+ ok: status >= 200 && status < 400,
439
+ resourceType: 'xhr',
440
+ requestHeaders: meta.reqHeaders,
441
+ responseHeaders: respHeaders,
442
+ responseSize,
443
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
444
+ responseBody,
445
+ timing,
446
+ error: error || classifyHttp(status),
447
+ };
448
+ ctx.emit(ev);
449
+ });
272
450
  };
273
451
  this.addEventListener('loadend', () => finish());
274
452
  this.addEventListener('error', () => finish('network'));
package/src/transport.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { gzipSync } from 'fflate';
2
2
  import type { IngestBatch, IngestSessionMeta, ObsrviqEvent } from '@obsrviq/types';
3
+ import { redactBatchSensitiveKeys, isMaskConfigPending } from './mask.js';
3
4
 
4
5
  export interface TransportOptions {
5
6
  endpoint: string;
@@ -55,10 +56,30 @@ export class Transport {
55
56
  /** Build, compress, and send the current buffer. Returns true on success. */
56
57
  async flush(reason: 'timer' | 'hidden' | 'pagehide' | 'manual' | 'backpressure'): Promise<boolean> {
57
58
  if (this.sending || this.buffer.length === 0) return true;
59
+
60
+ // Hold non-unload sends until the per-site mask config has loaded, so a
61
+ // remote-only custom key can't leak in the brief init fetch window. The fetch
62
+ // is bounded (index.ts marks config ready within ~2s even on failure/timeout),
63
+ // so this delays at most the first batch — it never blocks data indefinitely.
64
+ // Unload flushes (pagehide/hidden) can't wait, so they proceed best-effort.
65
+ const unloading = reason === 'pagehide' || reason === 'hidden';
66
+ if (!unloading && isMaskConfigPending()) {
67
+ if (!this.stopped) setTimeout(() => void this.flush('timer'), 200);
68
+ return true;
69
+ }
70
+
58
71
  this.sending = true;
59
72
  const events = this.buffer;
60
73
  this.buffer = [];
61
74
 
75
+ // Send-time safety net: mask sensitive keys across the batch — the built-in
76
+ // list (password/token/secret/…) PLUS any customer-defined keys — so a
77
+ // `{"password":"…"}` style body can't leave the page unmasked. Network/WS
78
+ // bodies get PII-pattern masking at capture but not key-based masking, so this
79
+ // is where key redaction lands for them; it also covers custom keys captured
80
+ // before the per-site config arrived.
81
+ redactBatchSensitiveKeys(events);
82
+
62
83
  let batch: IngestBatch | null = {
63
84
  siteKey: this.opts.siteKey,
64
85
  sessionId: this.opts.sessionId,