1- import { initWebSocketObservable , resetAllowUntrustedEvents , setAllowUntrustedEvents } from '@datadog/browser-core'
1+ import type { BufferedData } from '@datadog/browser-core'
22import {
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'
1021import type { Duration , RelativeTime } from '@datadog/js-core/time'
1122import { elapsed , relativeToClocks } from '@datadog/js-core/time'
12- import { mockViewHistory } from '../../../test'
23+ import { mockRumConfiguration , mockViewHistory } from '../../../test'
1324import { VitalType } from '../../rawRumEvent.types'
1425import type { ViewHistoryEntry } from '../contexts/viewHistory'
1526import { 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