@obsrviq/tracker 0.4.2 → 0.6.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.
@@ -0,0 +1,143 @@
1
+ import type { WsEvent } from '@obsrviq/types';
2
+ import type { InstrumentCtx, Teardown } from './context.js';
3
+ import { maskString } from './mask.js';
4
+ import { tokenBucket } from './ratelimit.js';
5
+ import { uuid } from './util.js';
6
+
7
+ const FRAME_CAP = 4 * 1024; // 4 KB text-frame preview cap (mirrors network bodies)
8
+ const MSG_RATE = 20; // sustained message-frame events/sec per socket
9
+ const MSG_BURST = 40; // burst capacity
10
+
11
+ function redactUrl(raw: string): string {
12
+ try {
13
+ const u = new URL(raw, location.href);
14
+ if (u.search) u.search = '?…';
15
+ return u.toString();
16
+ } catch {
17
+ return raw.split('?')[0] ?? raw;
18
+ }
19
+ }
20
+
21
+ function byteLen(s: string): number {
22
+ try {
23
+ return new TextEncoder().encode(s).length;
24
+ } catch {
25
+ return s.length;
26
+ }
27
+ }
28
+
29
+ type FrameInfo = Pick<WsEvent, 'encoding' | 'size' | 'body'>;
30
+
31
+ /** Derive encoding + byte size (+ opt-in masked preview) from a frame payload.
32
+ * Never reads binary payload bytes — only their length. */
33
+ function frameInfo(data: unknown, capture: boolean): FrameInfo {
34
+ if (typeof data === 'string') {
35
+ const info: FrameInfo = { encoding: 'text', size: byteLen(data) };
36
+ if (capture) {
37
+ // Pre-cut giant frames (2× slack) so masking never regex-scans a multi-MB payload.
38
+ const cut = data.length > FRAME_CAP * 2 ? data.slice(0, FRAME_CAP * 2) : data;
39
+ const masked = maskString(cut);
40
+ const truncated = cut !== data || masked.length > FRAME_CAP;
41
+ info.body = {
42
+ preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
43
+ truncated,
44
+ redacted: masked !== cut,
45
+ };
46
+ }
47
+ return info;
48
+ }
49
+ let size = 0;
50
+ if (typeof Blob !== 'undefined' && data instanceof Blob) size = data.size;
51
+ else if (data instanceof ArrayBuffer) size = data.byteLength;
52
+ else if (ArrayBuffer.isView(data)) size = (data as ArrayBufferView).byteLength;
53
+ return { encoding: 'binary', size };
54
+ }
55
+
56
+ /** Wrap the WebSocket constructor to capture connection lifecycle + frames.
57
+ * Best-effort: never throws into the app; a capture failure never breaks send/recv. */
58
+ export function instrumentWebSocket(ctx: InstrumentCtx): Teardown {
59
+ const Orig = typeof window !== 'undefined' ? window.WebSocket : undefined;
60
+ if (!Orig) return () => {};
61
+
62
+ const emit = (partial: Omit<WsEvent, 'id' | 'sessionId' | 'type' | 't' | 'ts'>) => {
63
+ try {
64
+ const ev: WsEvent = {
65
+ id: uuid(),
66
+ sessionId: ctx.sessionId,
67
+ type: 'ws',
68
+ t: ctx.clock.now(),
69
+ ts: ctx.clock.wall(),
70
+ ...partial,
71
+ };
72
+ ctx.emit(ev);
73
+ } catch {
74
+ /* swallow — capture must never affect the socket */
75
+ }
76
+ };
77
+
78
+ class WrappedWebSocket extends Orig {
79
+ constructor(url: string | URL, protocols?: string | string[]) {
80
+ super(url as string, protocols as string | string[] | undefined);
81
+ // The real socket is already live; capture setup must never throw into the app.
82
+ try {
83
+ const connId = uuid();
84
+ const redUrl = redactUrl(String(url));
85
+ const protos = protocols ? (Array.isArray(protocols) ? protocols : [protocols]) : undefined;
86
+ const capture = ctx.config.captureNetworkBodies;
87
+
88
+ // Per-socket frame throttle — a chatty socket (100s of msg/s) must not
89
+ // flood the buffer. Dropped frames are counted and reported on the next
90
+ // captured frame (or the close event). Lifecycle events (open/close/error)
91
+ // are never throttled.
92
+ const bucket = tokenBucket(MSG_RATE, MSG_BURST);
93
+ let droppedFrames = 0;
94
+ const emitFrame = (dir: 'send' | 'recv', data: unknown) => {
95
+ ctx.markActivity(); // dropped frames still count as app activity
96
+ if (!bucket.take()) {
97
+ droppedFrames++;
98
+ return;
99
+ }
100
+ const info = frameInfo(data, capture);
101
+ const dropped = droppedFrames;
102
+ droppedFrames = 0;
103
+ emit({ connId, url: redUrl, kind: 'message', dir, ...info, droppedFrames: dropped || undefined });
104
+ };
105
+
106
+ this.addEventListener('open', () => emit({ connId, url: redUrl, kind: 'open', protocols: protos }));
107
+ this.addEventListener('close', (e) =>
108
+ emit({
109
+ connId,
110
+ url: redUrl,
111
+ kind: 'close',
112
+ code: e.code,
113
+ reason: e.reason || undefined,
114
+ wasClean: e.wasClean,
115
+ droppedFrames: droppedFrames || undefined,
116
+ }),
117
+ );
118
+ this.addEventListener('error', () => emit({ connId, url: redUrl, kind: 'error' }));
119
+ this.addEventListener('message', (e: MessageEvent) => emitFrame('recv', e.data));
120
+
121
+ // Capture outgoing frames by shadowing send() on this instance. Call the
122
+ // real send FIRST — if it throws (e.g. send() while CONNECTING) the error
123
+ // propagates to the app and no phantom "send" frame is recorded.
124
+ const origSend = super.send.bind(this);
125
+ this.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
126
+ origSend(data);
127
+ try {
128
+ emitFrame('send', data);
129
+ } catch {
130
+ /* ignore capture errors */
131
+ }
132
+ };
133
+ } catch {
134
+ /* capture setup failed — the real socket still works normally. */
135
+ }
136
+ }
137
+ }
138
+
139
+ window.WebSocket = WrappedWebSocket as unknown as typeof WebSocket;
140
+ return () => {
141
+ window.WebSocket = Orig;
142
+ };
143
+ }