Skip to content

Commit fe7597d

Browse files
bdiboncursoragent
andauthored
⚗️ Collect WebSocket resource events (#4718)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 413d568 commit fe7597d

30 files changed

Lines changed: 1944 additions & 48 deletions

File tree

developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const LOG_STATUS_COLOR = {
5454
const RESOURCE_TYPE_LABELS: Record<string, string | undefined> = {
5555
xhr: 'XHR',
5656
fetch: 'Fetch',
57+
websocket: 'WebSocket',
5758
document: 'Document',
5859
beacon: 'Beacon',
5960
css: 'CSS',
@@ -435,9 +436,12 @@ function ErrorDescription({ event }: { event: RumErrorEvent }) {
435436
)
436437
}
437438

439+
// TODO: remove this once we introduce websockets on rum-events-format
440+
type RumResourceEventTypeWithWebSocket = RumResourceEvent['resource']['type'] | 'websocket'
441+
438442
function ResourceDescription({ event }: { event: RumResourceEvent }) {
439-
const resourceType = event.resource.type
440-
const isAsset = resourceType !== 'xhr' && resourceType !== 'fetch'
443+
const resourceType = event.resource.type as RumResourceEventTypeWithWebSocket
444+
const isAsset = resourceType !== 'xhr' && resourceType !== 'fetch' && resourceType !== 'websocket'
441445

442446
if (isAsset) {
443447
return (
@@ -450,7 +454,8 @@ function ResourceDescription({ event }: { event: RumResourceEvent }) {
450454

451455
return (
452456
<>
453-
{RESOURCE_TYPE_LABELS[resourceType]} request <Emphasis>{event.resource.url}</Emphasis>
457+
{`${RESOURCE_TYPE_LABELS[resourceType]} ${resourceType === 'websocket' ? 'connection' : 'request'} `}
458+
<Emphasis>{event.resource.url}</Emphasis>
454459
</>
455460
)
456461
}

packages/browser-core/src/browser/addEventListener.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ type EventMapFor<T> = T extends Window
8080
? WorkerEventMap
8181
: T extends CookieStore
8282
? CookieStoreEventMap
83-
: Record<never, never>
83+
: T extends WebSocket
84+
? WebSocketEventMap
85+
: Record<never, never>
8486

8587
/**
8688
* Add an event listener to an event target object (Window, Element, mock object...). This provides
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { registerCleanupTask } from '../../test'
2+
import type { Subscription } from '../tools/observable'
3+
import { setAllowUntrustedEvents } from './addEventListener'
4+
import type { WebSocketContext } from './webSocketObservable'
5+
import { initWebSocketObservable, resetWebSocketObservable } from './webSocketObservable'
6+
7+
// A minimal stand-in for the native `WebSocket` constructor. We do not connect to a real server in
8+
// unit tests; instead we expose helpers to simulate the browser dispatching events on the instance.
9+
class FakeWebSocket extends EventTarget {
10+
static readonly CONNECTING = 0
11+
static readonly OPEN = 1
12+
static readonly CLOSING = 2
13+
static readonly CLOSED = 3
14+
15+
url: string
16+
protocol = ''
17+
bufferedAmount = 0
18+
readyState: number = FakeWebSocket.CONNECTING
19+
onmessage: ((event: MessageEvent) => void) | null = null
20+
onopen: ((event: Event) => void) | null = null
21+
onclose: ((event: CloseEvent) => void) | null = null
22+
23+
constructor(url: string | URL, protocols?: string | string[]) {
24+
super()
25+
this.url = resolveWebSocketUrl(String(url))
26+
if (typeof protocols === 'string') {
27+
this.protocol = protocols
28+
}
29+
}
30+
31+
send(_data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
32+
// no-op; tests will set `bufferedAmount` before calling send to verify it is sampled.
33+
}
34+
35+
close(_code?: number, _reason?: string): void {
36+
this.readyState = FakeWebSocket.CLOSED
37+
}
38+
39+
simulateOpen() {
40+
this.readyState = FakeWebSocket.OPEN
41+
const event = new Event('open')
42+
this.dispatchEvent(event)
43+
this.onopen?.(event)
44+
}
45+
46+
simulateMessage(data: unknown) {
47+
const event = new MessageEvent('message', { data })
48+
this.dispatchEvent(event)
49+
this.onmessage?.(event)
50+
}
51+
52+
simulateClose(code: number, reason: string, wasClean: boolean) {
53+
this.readyState = FakeWebSocket.CLOSED
54+
// CloseEvent is not always constructable in test environments; use a plain Event with assigned fields.
55+
const event = Object.assign(new Event('close'), { code, reason, wasClean }) as CloseEvent
56+
this.dispatchEvent(event)
57+
this.onclose?.(event)
58+
}
59+
}
60+
61+
// Mimics how a real browser resolves the URL passed to the `WebSocket` constructor: relative URLs
62+
// are resolved against the document location, and `http(s)` schemes are translated to `ws(s)`.
63+
function resolveWebSocketUrl(url: string): string {
64+
const resolved = new URL(url, location.href)
65+
if (resolved.protocol === 'http:') {
66+
resolved.protocol = 'ws:'
67+
} else if (resolved.protocol === 'https:') {
68+
resolved.protocol = 'wss:'
69+
}
70+
return resolved.href
71+
}
72+
73+
type FakeWebSocketConstructor = typeof FakeWebSocket
74+
75+
const windowAsWebSocketHost = window as unknown as { WebSocket: FakeWebSocketConstructor }
76+
77+
describe('webSocketObservable', () => {
78+
let originalWebSocket: FakeWebSocketConstructor
79+
let contexts: WebSocketContext[]
80+
let subscription: Subscription | undefined
81+
82+
beforeEach(() => {
83+
originalWebSocket = windowAsWebSocketHost.WebSocket
84+
windowAsWebSocketHost.WebSocket = FakeWebSocket
85+
contexts = []
86+
87+
registerCleanupTask(() => {
88+
subscription?.unsubscribe()
89+
subscription = undefined
90+
resetWebSocketObservable()
91+
windowAsWebSocketHost.WebSocket = originalWebSocket
92+
})
93+
})
94+
95+
function startTracking() {
96+
setAllowUntrustedEvents(true)
97+
subscription = initWebSocketObservable().subscribe((context) => {
98+
contexts.push(context)
99+
})
100+
}
101+
102+
function getContexts<T extends WebSocketContext['state']>(state: T) {
103+
return contexts.filter((context): context is Extract<WebSocketContext, { state: T }> => context.state === state)
104+
}
105+
106+
describe('when tracking is started', () => {
107+
beforeEach(() => {
108+
startTracking()
109+
})
110+
111+
describe('connecting context', () => {
112+
it('emits a "connecting" context when a WebSocket is constructed', () => {
113+
const url = 'wss://example.com/socket'
114+
const ws = new windowAsWebSocketHost.WebSocket(url)
115+
116+
const connectingContexts = getContexts('connecting')
117+
expect(connectingContexts.length).toBe(1)
118+
expect(connectingContexts[0].url).toBe(url)
119+
expect(connectingContexts[0].instance).toBe(ws as unknown as WebSocket)
120+
expect(connectingContexts[0].startClocks.timeStamp).toEqual(jasmine.any(Number))
121+
})
122+
123+
it('reports the resolved instance.url rather than the raw constructor argument', () => {
124+
const ws = new windowAsWebSocketHost.WebSocket('/socket')
125+
126+
const connectingContext = getContexts('connecting')[0]
127+
expect(connectingContext.url).not.toBe('/socket')
128+
expect(connectingContext.url).toBe(ws.url)
129+
})
130+
131+
it('does not include protocols in the "connecting" context when omitted', () => {
132+
new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
133+
134+
expect(getContexts('connecting')[0].protocols).toBeUndefined()
135+
})
136+
137+
it('includes string protocols in the "connecting" context', () => {
138+
const url = 'wss://example.com/socket'
139+
const protocols = 'chat.v1'
140+
new windowAsWebSocketHost.WebSocket(url, protocols)
141+
142+
expect(getContexts('connecting')[0].protocols).toBe(protocols)
143+
})
144+
145+
it('includes array protocols in the "connecting" context', () => {
146+
const url = 'wss://example.com/socket'
147+
const protocols = ['chat.v1', 'json']
148+
new windowAsWebSocketHost.WebSocket(url, protocols)
149+
150+
expect(getContexts('connecting')[0].protocols).toEqual(protocols)
151+
})
152+
})
153+
154+
describe('preservation of native behavior', () => {
155+
it('does not clobber a customer-set onmessage handler', () => {
156+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
157+
const customerHandler = jasmine.createSpy()
158+
ws.onmessage = customerHandler
159+
160+
ws.simulateMessage('hello')
161+
162+
expect(customerHandler).toHaveBeenCalledTimes(1)
163+
expect(getContexts('message-in').length).toBe(1)
164+
})
165+
166+
it('does not clobber a customer-set onopen handler', () => {
167+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
168+
const customerHandler = jasmine.createSpy()
169+
ws.onopen = customerHandler
170+
171+
ws.simulateOpen()
172+
173+
expect(customerHandler).toHaveBeenCalledTimes(1)
174+
expect(getContexts('open').length).toBe(1)
175+
})
176+
177+
it('does not clobber a customer-set onclose handler', () => {
178+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
179+
const customerHandler = jasmine.createSpy()
180+
ws.onclose = customerHandler
181+
182+
ws.simulateClose(1000, 'bye', true)
183+
184+
expect(customerHandler).toHaveBeenCalledTimes(1)
185+
expect(getContexts('closed').length).toBe(1)
186+
})
187+
})
188+
189+
describe('open context', () => {
190+
it('emits an "open" context when the WebSocket opens', () => {
191+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
192+
const negotiatedProtocol = 'chat.v1'
193+
ws.protocol = negotiatedProtocol
194+
ws.simulateOpen()
195+
196+
const openContexts = getContexts('open')
197+
expect(openContexts.length).toBe(1)
198+
expect(openContexts[0].protocol).toBe(negotiatedProtocol)
199+
expect(openContexts[0].instance).toBe(ws as unknown as WebSocket)
200+
expect(openContexts[0].openClocks.timeStamp).toEqual(jasmine.any(Number))
201+
})
202+
203+
it('emits an "open" context with empty protocol when no sub-protocol negotiated', () => {
204+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
205+
ws.simulateOpen()
206+
207+
const openContexts = getContexts('open')
208+
expect(openContexts.length).toBe(1)
209+
expect(openContexts[0].protocol).toBe('')
210+
})
211+
})
212+
213+
describe('message-in context', () => {
214+
it('emits "message-in" with byte-length size for string payloads', () => {
215+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
216+
ws.simulateOpen()
217+
const payload = 'hello world'
218+
ws.simulateMessage(payload)
219+
220+
const messageInContexts = getContexts('message-in')
221+
expect(messageInContexts.length).toBe(1)
222+
expect(messageInContexts[0].size).toBe(payload.length)
223+
})
224+
225+
it('emits "message-in" with UTF-8 byte length for multi-byte strings', () => {
226+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
227+
ws.simulateOpen()
228+
// 'é' is 2 bytes in UTF-8 and 'あ' is 3 bytes; total is 5 bytes for 2 chars
229+
const payload = 'éあ'
230+
ws.simulateMessage(payload)
231+
232+
expect(getContexts('message-in')[0].size).toBe(new TextEncoder().encode(payload).byteLength)
233+
})
234+
235+
it('emits "message-in" with byteLength for ArrayBuffer payloads', () => {
236+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
237+
ws.simulateOpen()
238+
const byteLength = 16
239+
ws.simulateMessage(new ArrayBuffer(byteLength))
240+
241+
expect(getContexts('message-in')[0].size).toBe(byteLength)
242+
})
243+
244+
it('emits "message-in" with byteLength for ArrayBufferView payloads', () => {
245+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
246+
ws.simulateOpen()
247+
const viewByteLength = 12
248+
ws.simulateMessage(new Uint8Array(new ArrayBuffer(32), 4, viewByteLength))
249+
250+
expect(getContexts('message-in')[0].size).toBe(viewByteLength)
251+
})
252+
253+
it('emits "message-in" with size for Blob payloads', () => {
254+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
255+
ws.simulateOpen()
256+
const blob = new Blob(['hello'])
257+
ws.simulateMessage(blob)
258+
259+
expect(getContexts('message-in')[0].size).toBe(blob.size)
260+
})
261+
})
262+
263+
describe('message-out context', () => {
264+
it('emits "message-out" with size and bufferedAmountPreSend for string payloads', () => {
265+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
266+
const bufferedAmountPreSend = 42
267+
ws.bufferedAmount = bufferedAmountPreSend
268+
const payload = 'hello'
269+
ws.send(payload)
270+
271+
const messageOutContexts = getContexts('message-out')
272+
expect(messageOutContexts.length).toBe(1)
273+
expect(messageOutContexts[0].size).toBe(payload.length)
274+
expect(messageOutContexts[0].bufferedAmountPreSend).toBe(bufferedAmountPreSend)
275+
expect(messageOutContexts[0].at.timeStamp).toEqual(jasmine.any(Number))
276+
})
277+
278+
it('emits "message-out" with byteLength for ArrayBuffer payloads', () => {
279+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
280+
const byteLength = 8
281+
ws.send(new ArrayBuffer(byteLength))
282+
283+
expect(getContexts('message-out')[0].size).toBe(byteLength)
284+
})
285+
286+
it('emits "message-out" with byteLength for ArrayBufferView payloads', () => {
287+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
288+
const viewByteLength = 10
289+
ws.send(new Uint8Array(new ArrayBuffer(20), 2, viewByteLength))
290+
291+
expect(getContexts('message-out')[0].size).toBe(viewByteLength)
292+
})
293+
294+
it('emits "message-out" with size for Blob payloads', () => {
295+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
296+
const blob = new Blob(['hello world'])
297+
ws.send(blob)
298+
299+
expect(getContexts('message-out')[0].size).toBe(blob.size)
300+
})
301+
})
302+
303+
describe('closed context', () => {
304+
it('emits a "closed" context with code, reason, and wasClean', () => {
305+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
306+
const closeCode = 1000
307+
const closeReason = 'bye'
308+
const wasClean = true
309+
ws.simulateClose(closeCode, closeReason, wasClean)
310+
311+
const closeContexts = getContexts('closed')
312+
expect(closeContexts.length).toBe(1)
313+
expect(closeContexts[0].code).toBe(closeCode)
314+
expect(closeContexts[0].reason).toBe(closeReason)
315+
expect(closeContexts[0].wasClean).toBe(wasClean)
316+
expect(closeContexts[0].at.timeStamp).toEqual(jasmine.any(Number))
317+
})
318+
})
319+
320+
describe('subscription lifecycle', () => {
321+
it('restores the native WebSocket constructor when all subscribers unsubscribe', () => {
322+
subscription?.unsubscribe()
323+
subscription = undefined
324+
325+
expect(windowAsWebSocketHost.WebSocket).toBe(FakeWebSocket)
326+
})
327+
328+
it('does not emit any further events after all subscribers unsubscribe', () => {
329+
subscription?.unsubscribe()
330+
subscription = undefined
331+
332+
const ws = new windowAsWebSocketHost.WebSocket('wss://example.com/socket')
333+
ws.simulateOpen()
334+
ws.send('hello')
335+
336+
expect(contexts.length).toBe(0)
337+
})
338+
})
339+
})
340+
})

0 commit comments

Comments
 (0)