@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/dist/index.d.ts +3 -0
- package/dist/index.js +411 -91
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +40 -40
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +44 -0
- package/src/mask.ts +127 -6
- package/src/network.ts +242 -64
- package/src/transport.ts +21 -0
- package/src/websocket.ts +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/tracker",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|
|
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
|
|
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
|
|
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
|
|
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,
|