@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/dist/index.d.ts +3 -0
- package/dist/index.js +233 -29
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +24 -24
- 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 +2 -1
- 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
|
@@ -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')
|
|
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,
|
package/src/websocket.ts
ADDED
|
@@ -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
|
+
}
|