@obsrviq/tracker 0.4.2 → 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.2",
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
@@ -270,7 +270,8 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
270
270
  window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
271
271
  const rawUrl = input instanceof Request ? input.url : String(input);
272
272
  // Never record our own telemetry round-trips to the ingest endpoint.
273
- 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);
274
275
  const startT = ctx.clock.now();
275
276
  const perfStart = perfNow();
276
277
  const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
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,
@@ -0,0 +1,118 @@
1
+ import type { WsEvent } from '@obsrviq/types';
2
+ import type { InstrumentCtx, Teardown } from './context.js';
3
+ import { maskString } from './mask.js';
4
+ import { uuid } from './util.js';
5
+
6
+ const FRAME_CAP = 4 * 1024; // 4 KB text-frame preview cap (mirrors network bodies)
7
+
8
+ function redactUrl(raw: string): string {
9
+ try {
10
+ const u = new URL(raw, location.href);
11
+ if (u.search) u.search = '?…';
12
+ return u.toString();
13
+ } catch {
14
+ return raw.split('?')[0] ?? raw;
15
+ }
16
+ }
17
+
18
+ function byteLen(s: string): number {
19
+ try {
20
+ return new TextEncoder().encode(s).length;
21
+ } catch {
22
+ return s.length;
23
+ }
24
+ }
25
+
26
+ type FrameInfo = Pick<WsEvent, 'encoding' | 'size' | 'body'>;
27
+
28
+ /** Derive encoding + byte size (+ opt-in masked preview) from a frame payload.
29
+ * Never reads binary payload bytes — only their length. */
30
+ function frameInfo(data: unknown, capture: boolean): FrameInfo {
31
+ if (typeof data === 'string') {
32
+ const info: FrameInfo = { encoding: 'text', size: byteLen(data) };
33
+ if (capture) {
34
+ const masked = maskString(data);
35
+ const truncated = masked.length > FRAME_CAP;
36
+ info.body = {
37
+ preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
38
+ truncated,
39
+ redacted: masked !== data,
40
+ };
41
+ }
42
+ return info;
43
+ }
44
+ let size = 0;
45
+ if (typeof Blob !== 'undefined' && data instanceof Blob) size = data.size;
46
+ else if (data instanceof ArrayBuffer) size = data.byteLength;
47
+ else if (ArrayBuffer.isView(data)) size = (data as ArrayBufferView).byteLength;
48
+ return { encoding: 'binary', size };
49
+ }
50
+
51
+ /** Wrap the WebSocket constructor to capture connection lifecycle + frames.
52
+ * Best-effort: never throws into the app; a capture failure never breaks send/recv. */
53
+ export function instrumentWebSocket(ctx: InstrumentCtx): Teardown {
54
+ const Orig = typeof window !== 'undefined' ? window.WebSocket : undefined;
55
+ if (!Orig) return () => {};
56
+
57
+ const emit = (partial: Omit<WsEvent, 'id' | 'sessionId' | 'type' | 't' | 'ts'>) => {
58
+ try {
59
+ const ev: WsEvent = {
60
+ id: uuid(),
61
+ sessionId: ctx.sessionId,
62
+ type: 'ws',
63
+ t: ctx.clock.now(),
64
+ ts: ctx.clock.wall(),
65
+ ...partial,
66
+ };
67
+ ctx.emit(ev);
68
+ } catch {
69
+ /* swallow — capture must never affect the socket */
70
+ }
71
+ };
72
+
73
+ class WrappedWebSocket extends Orig {
74
+ constructor(url: string | URL, protocols?: string | string[]) {
75
+ super(url as string, protocols as string | string[] | undefined);
76
+ // The real socket is already live; capture setup must never throw into the app.
77
+ try {
78
+ const connId = uuid();
79
+ const redUrl = redactUrl(String(url));
80
+ const protos = protocols ? (Array.isArray(protocols) ? protocols : [protocols]) : undefined;
81
+ const capture = ctx.config.captureNetworkBodies;
82
+
83
+ this.addEventListener('open', () => emit({ connId, url: redUrl, kind: 'open', protocols: protos }));
84
+ this.addEventListener('close', (e) =>
85
+ emit({ connId, url: redUrl, kind: 'close', code: e.code, reason: e.reason || undefined, wasClean: e.wasClean }),
86
+ );
87
+ this.addEventListener('error', () => emit({ connId, url: redUrl, kind: 'error' }));
88
+ this.addEventListener('message', (e: MessageEvent) => {
89
+ const info = frameInfo(e.data, capture);
90
+ emit({ connId, url: redUrl, kind: 'message', dir: 'recv', ...info });
91
+ ctx.markActivity();
92
+ });
93
+
94
+ // Capture outgoing frames by shadowing send() on this instance. Call the
95
+ // real send FIRST — if it throws (e.g. send() while CONNECTING) the error
96
+ // propagates to the app and no phantom "send" frame is recorded.
97
+ const origSend = super.send.bind(this);
98
+ this.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
99
+ origSend(data);
100
+ try {
101
+ const info = frameInfo(data, capture);
102
+ emit({ connId, url: redUrl, kind: 'message', dir: 'send', ...info });
103
+ ctx.markActivity();
104
+ } catch {
105
+ /* ignore capture errors */
106
+ }
107
+ };
108
+ } catch {
109
+ /* capture setup failed — the real socket still works normally. */
110
+ }
111
+ }
112
+ }
113
+
114
+ window.WebSocket = WrappedWebSocket as unknown as typeof WebSocket;
115
+ return () => {
116
+ window.WebSocket = Orig;
117
+ };
118
+ }