Skip to content

Commit ba4b635

Browse files
bdibonclaude
andcommitted
✨ Collect WebSocket connections opened before init()
WebSocket collection now consumes the buffered data observable instead of subscribing to the WebSocket observable directly, so connections opened before init() are replayed and reported as complete resource events. The opt-in gate (trackResources plus betaTrackWebSockets or the TRACK_WEBSOCKETS experimental feature) moves into the collection entry point, since it is not knowable until init(). RUM startup now calls it unconditionally; the returned stop handle is a no-op when the gate is closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 29abcf7 commit ba4b635

4 files changed

Lines changed: 236 additions & 98 deletions

File tree

packages/browser-rum-core/src/boot/startRum.spec.ts

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
import { ONE_SECOND, toServerDuration, relativeNow, relativeToClocks } from '@datadog/js-core/time'
22
import type { Duration } from '@datadog/js-core/time'
33
import type { BufferedData, SessionManager } from '@datadog/browser-core'
4-
import {
5-
Observable,
6-
findLast,
7-
noop,
8-
createIdentityEncoder,
9-
BufferedObservable,
10-
addExperimentalFeatures,
11-
ExperimentalFeature,
12-
} from '@datadog/browser-core'
4+
import { Observable, findLast, noop, createIdentityEncoder, BufferedObservable } from '@datadog/browser-core'
135
import type { Clock, SessionManagerMock } from '@datadog/browser-core/test'
146
import {
157
createNewEvent,
@@ -268,72 +260,3 @@ describe('view events', () => {
268260
expect(lastRumViewEvent._dd.sdk_name).toBe('rum')
269261
})
270262
})
271-
272-
describe('WebSocket resource collection activation', () => {
273-
it('starts when betaTrackWebSockets is enabled and resources are tracked', () => {
274-
const originalWebSocket = window.WebSocket
275-
const { stop } = startRumStub(
276-
new LifeCycle(),
277-
mockRumConfiguration({ trackResources: true, betaTrackWebSockets: true }),
278-
createSessionManagerMock(),
279-
noop
280-
)
281-
registerCleanupTask(stop)
282-
283-
expect(window.WebSocket).not.toBe(originalWebSocket)
284-
})
285-
286-
it('starts when the experimental flag is enabled and resources are tracked', () => {
287-
addExperimentalFeatures([ExperimentalFeature.TRACK_WEBSOCKETS])
288-
const originalWebSocket = window.WebSocket
289-
const { stop } = startRumStub(
290-
new LifeCycle(),
291-
mockRumConfiguration({ trackResources: true, betaTrackWebSockets: false }),
292-
createSessionManagerMock(),
293-
noop
294-
)
295-
registerCleanupTask(stop)
296-
297-
expect(window.WebSocket).not.toBe(originalWebSocket)
298-
})
299-
300-
it('does not start when neither enablement mechanism is active', () => {
301-
const originalWebSocket = window.WebSocket
302-
const { stop } = startRumStub(
303-
new LifeCycle(),
304-
mockRumConfiguration({ trackResources: true, betaTrackWebSockets: false }),
305-
createSessionManagerMock(),
306-
noop
307-
)
308-
registerCleanupTask(stop)
309-
310-
expect(window.WebSocket).toBe(originalWebSocket)
311-
})
312-
313-
it('does not start from the beta option when resource tracking is disabled', () => {
314-
const originalWebSocket = window.WebSocket
315-
const { stop } = startRumStub(
316-
new LifeCycle(),
317-
mockRumConfiguration({ trackResources: false, betaTrackWebSockets: true }),
318-
createSessionManagerMock(),
319-
noop
320-
)
321-
registerCleanupTask(stop)
322-
323-
expect(window.WebSocket).toBe(originalWebSocket)
324-
})
325-
326-
it('does not start from the experimental flag when resource tracking is disabled', () => {
327-
addExperimentalFeatures([ExperimentalFeature.TRACK_WEBSOCKETS])
328-
const originalWebSocket = window.WebSocket
329-
const { stop } = startRumStub(
330-
new LifeCycle(),
331-
mockRumConfiguration({ trackResources: false, betaTrackWebSockets: false }),
332-
createSessionManagerMock(),
333-
noop
334-
)
335-
registerCleanupTask(stop)
336-
337-
expect(window.WebSocket).toBe(originalWebSocket)
338-
})
339-
})

packages/browser-rum-core/src/boot/startRum.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import {
1717
startUserContext,
1818
startTabContext,
1919
ErrorSource,
20-
isExperimentalFeatureEnabled,
21-
ExperimentalFeature,
2220
} from '@datadog/browser-core'
2321
import { clocksNow } from '@datadog/js-core/time'
2422
import { createDOMMutationObservable } from '../browser/domMutationObservable'
@@ -226,13 +224,14 @@ export function startRumEventCollection(
226224

227225
const vitalCollection = startVitalCollection(lifeCycle, pageStateHistory)
228226

229-
if (
230-
configuration.trackResources &&
231-
(configuration.betaTrackWebSockets || isExperimentalFeatureEnabled(ExperimentalFeature.TRACK_WEBSOCKETS))
232-
) {
233-
const webSocketCollection = startWebSocketCollection(lifeCycle, viewHistory, vitalCollection.addDurationVital)
234-
cleanupTasks.push(webSocketCollection.stop)
235-
}
227+
const webSocketCollection = startWebSocketCollection(
228+
lifeCycle,
229+
configuration,
230+
viewHistory,
231+
vitalCollection.addDurationVital,
232+
bufferedDataObservable
233+
)
234+
cleanupTasks.push(webSocketCollection.stop)
236235

237236
const internalContext = startInternalContext(
238237
configuration.applicationId,

packages/browser-rum-core/src/domain/resource/webSocketCollection.spec.ts

Lines changed: 190 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { initWebSocketObservable, resetAllowUntrustedEvents, setAllowUntrustedEvents } from '@datadog/browser-core'
1+
import type { BufferedData } from '@datadog/browser-core'
22
import {
3+
addExperimentalFeatures,
4+
BufferedDataType,
5+
ExperimentalFeature,
6+
initWebSocketObservable,
7+
Observable,
8+
resetAllowUntrustedEvents,
9+
setAllowUntrustedEvents,
10+
startBufferingData,
11+
} from '@datadog/browser-core'
12+
import {
13+
collectAsyncCalls,
314
createMockWebSocket,
415
mockClock,
516
mockWebSocket,
@@ -9,7 +20,7 @@ import {
920
} from '@datadog/browser-core/test'
1021
import type { Duration, RelativeTime } from '@datadog/js-core/time'
1122
import { elapsed, relativeToClocks } from '@datadog/js-core/time'
12-
import { mockViewHistory } from '../../../test'
23+
import { mockRumConfiguration, mockViewHistory } from '../../../test'
1324
import { VitalType } from '../../rawRumEvent.types'
1425
import type { ViewHistoryEntry } from '../contexts/viewHistory'
1526
import { LifeCycle, LifeCycleEventType } from '../lifeCycle'
@@ -45,11 +56,23 @@ describe('webSocketCollection', () => {
4556
lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, { endClocks })
4657
}
4758

59+
// Feeds WebSocket instrumentation into a plain `BufferedData` observable, so that specs keep
60+
// driving real sockets while observing the events synchronously. `startBufferingData` is used
61+
// instead where the asynchronous buffer replay is what is under test.
62+
function createWebSocketDataObservable() {
63+
const observable = new Observable<BufferedData>()
64+
const subscription = initWebSocketObservable().subscribe((data) =>
65+
observable.notify({ type: BufferedDataType.WEB_SOCKET, data })
66+
)
67+
registerCleanupTask(() => subscription.unsubscribe())
68+
return observable
69+
}
70+
4871
function startTracking(
4972
viewHistory = mockViewHistory(),
5073
addDurationVital: (vital: DurationVital) => void = jasmine.createSpy()
5174
) {
52-
const tracker = trackWebSocket(lifeCycle, initWebSocketObservable(), viewHistory, addDurationVital)
75+
const tracker = trackWebSocket(lifeCycle, createWebSocketDataObservable(), viewHistory, addDurationVital)
5376
registerCleanupTask(tracker.stop)
5477
return tracker
5578
}
@@ -502,12 +525,174 @@ describe('webSocketCollection', () => {
502525
})
503526

504527
describe('startWebSocketCollection', () => {
505-
function startCollection() {
506-
const collection = startWebSocketCollection(lifeCycle, mockViewHistory(), jasmine.createSpy())
528+
function startCollection(
529+
configuration = mockRumConfiguration({ betaTrackWebSockets: true }),
530+
bufferedDataObservable = createWebSocketDataObservable()
531+
) {
532+
const collection = startWebSocketCollection(
533+
lifeCycle,
534+
configuration,
535+
mockViewHistory(),
536+
jasmine.createSpy(),
537+
bufferedDataObservable
538+
)
507539
registerCleanupTask(() => collection.stop())
508540
return collection
509541
}
510542

543+
describe('opt-in gate', () => {
544+
;(
545+
[
546+
{ trackResources: true, betaTrackWebSockets: true, experimentalFeature: false, collects: true },
547+
{ trackResources: true, betaTrackWebSockets: false, experimentalFeature: true, collects: true },
548+
{ trackResources: true, betaTrackWebSockets: false, experimentalFeature: false, collects: false },
549+
{ trackResources: false, betaTrackWebSockets: true, experimentalFeature: false, collects: false },
550+
{ trackResources: false, betaTrackWebSockets: false, experimentalFeature: true, collects: false },
551+
] as const
552+
).forEach(({ trackResources, betaTrackWebSockets, experimentalFeature, collects }) => {
553+
it(`${collects ? 'collects' : 'does not collect'} with trackResources=${trackResources}, betaTrackWebSockets=${betaTrackWebSockets}, TRACK_WEBSOCKETS=${experimentalFeature}`, () => {
554+
if (experimentalFeature) {
555+
addExperimentalFeatures([ExperimentalFeature.TRACK_WEBSOCKETS])
556+
}
557+
558+
startCollection(mockRumConfiguration({ trackResources, betaTrackWebSockets }))
559+
const socket = notifyConnecting()
560+
notifyOpen(socket, 10)
561+
notifyClosed(socket, 20, 1000, 'bye', true)
562+
563+
expect(webSocketCompleteEvents.length).toBe(collects ? 1 : 0)
564+
})
565+
})
566+
567+
it('does not subscribe to the buffered data observable when the gate is closed', () => {
568+
const bufferedDataObservable = createWebSocketDataObservable()
569+
const subscribeSpy = spyOn(bufferedDataObservable, 'subscribe').and.callThrough()
570+
571+
startCollection(
572+
mockRumConfiguration({ trackResources: true, betaTrackWebSockets: false }),
573+
bufferedDataObservable
574+
)
575+
576+
expect(subscribeSpy).not.toHaveBeenCalled()
577+
})
578+
})
579+
580+
// WebSocket activity is instrumented and buffered from SDK load; collection only subscribes at
581+
// init(), and receives everything that happened before as a replayed burst.
582+
describe('connections started before collection subscribed', () => {
583+
let bufferedDataObservable: Observable<BufferedData>
584+
let completeSpy: jasmine.Spy<(event: WebSocketCompleteEvent) => void>
585+
586+
beforeEach(() => {
587+
const buffering = startBufferingData()
588+
bufferedDataObservable = buffering.observable
589+
registerCleanupTask(buffering.stop)
590+
591+
completeSpy = jasmine.createSpy()
592+
lifeCycle.subscribe(LifeCycleEventType.WEBSOCKET_COMPLETED, completeSpy)
593+
})
594+
595+
function subscribeCollection(addDurationVital: (vital: DurationVital) => void = jasmine.createSpy()) {
596+
const collection = startWebSocketCollection(
597+
lifeCycle,
598+
mockRumConfiguration({ betaTrackWebSockets: true }),
599+
mockViewHistory(),
600+
addDurationVital,
601+
bufferedDataObservable
602+
)
603+
registerCleanupTask(() => collection.stop())
604+
}
605+
606+
it('reports a connection that also completed before collection subscribed', async () => {
607+
const socket = notifyConnecting(0)
608+
notifyOpen(socket, 10)
609+
notifyMessageIn(socket, 20, 30)
610+
notifyClosed(socket, 40, 1000, 'bye', true)
611+
612+
subscribeCollection()
613+
await collectAsyncCalls(completeSpy)
614+
615+
expect(webSocketCompleteEvents.length).toBe(1)
616+
const webSocket = webSocketCompleteEvents[0]
617+
expect(webSocket.messagesIn).toEqual({ count: 1, size: 30 })
618+
// measured from the real constructor call, not from the subscription
619+
expect(webSocket.setupDuration).toBe(10 as Duration)
620+
})
621+
622+
it('reports a connection spanning the subscription exactly once', async () => {
623+
const socket = notifyConnecting(0)
624+
notifyOpen(socket, 10)
625+
notifyMessageIn(socket, 20, 30)
626+
627+
// a connection that completed early gives a deterministic signal that the replay is over
628+
const replayedSocket = notifyConnecting(21, 'wss://example.com/replayed')
629+
notifyClosed(replayedSocket, 22, 1000, 'bye', true)
630+
const replaySpy = jasmine.createSpy<(event: WebSocketCompleteEvent) => void>()
631+
const replaySubscription = lifeCycle.subscribe(LifeCycleEventType.WEBSOCKET_COMPLETED, replaySpy)
632+
633+
subscribeCollection()
634+
await collectAsyncCalls(replaySpy)
635+
replaySubscription.unsubscribe()
636+
637+
notifyMessageIn(socket, 50, 5)
638+
notifyClosed(socket, 60, 1000, 'bye', true)
639+
640+
// a single event, merging what was replayed with what came in live
641+
expect(webSocketCompleteEvents.length).toBe(2)
642+
const webSocket = webSocketCompleteEvents[1]
643+
expect(webSocket.messagesIn).toEqual({ count: 2, size: 35 })
644+
expect(webSocket.setupDuration).toBe(10 as Duration)
645+
})
646+
647+
it('keeps a distinct connection id per early connection and emits both vitals', async () => {
648+
const addDurationVital = jasmine.createSpy<(vital: DurationVital) => void>()
649+
const firstSocket = notifyConnecting(0, 'wss://example.com/socket-a')
650+
const secondSocket = notifyConnecting(5, 'wss://example.com/socket-b')
651+
notifyClosed(firstSocket, 10, 1000, 'bye-a', true)
652+
notifyClosed(secondSocket, 20, 1000, 'bye-b', true)
653+
654+
subscribeCollection(addDurationVital)
655+
await collectAsyncCalls(completeSpy, 2)
656+
657+
const [first, second] = webSocketCompleteEvents
658+
expect(first.connectionId).not.toBe(second.connectionId)
659+
660+
const vitalNames = addDurationVital.calls.all().map((call) => call.args[0].name)
661+
expect(vitalNames).toEqual([
662+
WEBSOCKET_CONNECTING_VITAL_NAME,
663+
WEBSOCKET_CONNECTING_VITAL_NAME,
664+
WEBSOCKET_CLOSED_VITAL_NAME,
665+
WEBSOCKET_CLOSED_VITAL_NAME,
666+
])
667+
})
668+
})
669+
670+
it('leaves application-set handlers and exchanged payloads untouched', () => {
671+
const openHandler = jasmine.createSpy<(event: Event) => void>()
672+
const messageHandler = jasmine.createSpy<(event: MessageEvent) => void>()
673+
const closeHandler = jasmine.createSpy<(event: CloseEvent) => void>()
674+
// spied before instrumentation is installed, so that the instrumented `send` delegates to it
675+
const sendSpy = spyOn(window.WebSocket.prototype, 'send').and.callThrough()
676+
677+
startCollection()
678+
const socket = notifyConnecting()
679+
socket.onopen = openHandler
680+
socket.onmessage = messageHandler
681+
socket.onclose = closeHandler
682+
683+
notifyOpen(socket, 10)
684+
setClock(20)
685+
socket.simulateMessage('hello')
686+
setClock(30)
687+
socket.send('world')
688+
notifyClosed(socket, 40, 1000, 'bye', true)
689+
690+
expect(openHandler).toHaveBeenCalledTimes(1)
691+
expect(messageHandler.calls.mostRecent().args[0].data).toBe('hello')
692+
expect(closeHandler.calls.mostRecent().args[0].code).toBe(1000)
693+
expect(sendSpy).toHaveBeenCalledOnceWith('world')
694+
})
695+
511696
it('finalizes open connections with tracking_end_reason="session_end" when the session expires', () => {
512697
const endClocks = relativeToClocks(clock.relative(40))
513698
startCollection()

0 commit comments

Comments
 (0)