Skip to content

Commit adaf9f3

Browse files
bdiboncursoragent
andcommitted
✨ Collect WebSocket resource events
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1afcc3a commit adaf9f3

10 files changed

Lines changed: 992 additions & 22 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ import {
1717
startGlobalContext,
1818
startUserContext,
1919
startTabContext,
20+
isExperimentalFeatureEnabled,
21+
ExperimentalFeature,
2022
} from '@datadog/browser-core'
2123
import { createDOMMutationObservable } from '../browser/domMutationObservable'
2224
import { createWindowOpenObservable } from '../browser/windowOpenObservable'
2325
import { startInternalContext } from '../domain/contexts/internalContext'
2426
import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle'
2527
import { startViewHistory } from '../domain/contexts/viewHistory'
2628
import { startRequestCollection } from '../domain/requestCollection'
29+
import { startWebSocketCollection } from '../domain/webSocketCollection'
2730
import { startActionCollection } from '../domain/action/actionCollection'
2831
import { startErrorCollection } from '../domain/error/errorCollection'
2932
import { startResourceCollection } from '../domain/resource/resourceCollection'
@@ -229,6 +232,16 @@ export function startRumEventCollection(
229232

230233
const vitalCollection = startVitalCollection(lifeCycle, pageStateHistory)
231234

235+
if (configuration.trackWebSockets && isExperimentalFeatureEnabled(ExperimentalFeature.TRACK_WEB_SOCKETS)) {
236+
const webSocketCollection = startWebSocketCollection(
237+
lifeCycle,
238+
configuration,
239+
viewHistory,
240+
vitalCollection.addDurationVital
241+
)
242+
cleanupTasks.push(webSocketCollection.stop)
243+
}
244+
232245
const internalContext = startInternalContext(
233246
configuration.applicationId,
234247
sessionManager,

packages/browser-rum-core/src/domain/lifeCycle.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AbstractLifeCycle } from '@datadog/browser-core'
33
import type { RumEventDomainContext } from '../domainContext.types'
44
import type { RawRumEvent, AssembledRumEvent } from '../rawRumEvent.types'
55
import type { RequestCompleteEvent, RequestStartEvent } from './requestCollection'
6+
import type { WebSocketCompleteEvent } from './webSocketCollection'
67
import type { AutoAction } from './action/actionCollection'
78
import type { ViewEvent, ViewCreatedEvent, ViewEndedEvent, BeforeViewUpdateEvent } from './view/trackViews'
89
import type { DurationVitalStart } from './vital/vitalCollection'
@@ -21,6 +22,7 @@ export const enum LifeCycleEventType {
2122
AFTER_VIEW_ENDED,
2223
REQUEST_STARTED,
2324
REQUEST_COMPLETED,
25+
WEBSOCKET_COMPLETED,
2426

2527
// The SESSION_EXPIRED lifecycle event has been introduced to represent when a session has expired
2628
// and trigger cleanup tasks related to this, prior to renewing the session. Its implementation is
@@ -66,6 +68,7 @@ declare const LifeCycleEventTypeAsConst: {
6668
AFTER_VIEW_ENDED: LifeCycleEventType.AFTER_VIEW_ENDED
6769
REQUEST_STARTED: LifeCycleEventType.REQUEST_STARTED
6870
REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED
71+
WEBSOCKET_COMPLETED: LifeCycleEventType.WEBSOCKET_COMPLETED
6972
SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED
7073
SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED
7174
PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT
@@ -88,6 +91,7 @@ export interface LifeCycleEventMap {
8891
[LifeCycleEventTypeAsConst.AFTER_VIEW_ENDED]: ViewEndedEvent
8992
[LifeCycleEventTypeAsConst.REQUEST_STARTED]: RequestStartEvent
9093
[LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent
94+
[LifeCycleEventTypeAsConst.WEBSOCKET_COMPLETED]: WebSocketCompleteEvent
9195
[LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void
9296
[LifeCycleEventTypeAsConst.SESSION_RENEWED]: void
9397
[LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Duration, RelativeTime } from '@datadog/browser-core'
2+
import { addDuration } from '@datadog/browser-core'
3+
import type { RumPerformanceResourceTiming } from '../../browser/performanceObservable'
4+
import type { RequestCompleteEvent } from '../requestCollection'
5+
import { hasValidResourceEntryDuration, hasValidResourceEntryTimings } from './resourceUtils'
6+
7+
interface Timing {
8+
startTime: RelativeTime
9+
duration: Duration
10+
}
11+
12+
const alreadyMatchedEntries = new WeakSet<PerformanceEntry>()
13+
14+
/**
15+
* Look for corresponding timing in resource timing buffer
16+
*
17+
* Observations:
18+
* - Timing (start, end) are nested inside the request (start, end)
19+
* - Some timing can be not exactly nested, being off by < 1 ms
20+
*
21+
* Strategy:
22+
* - from valid nested entries (with 1 ms error margin)
23+
* - filter out timing that were already matched to a request
24+
* - then, if a single timing match, return the timing
25+
* - otherwise we can't decide, return undefined
26+
*/
27+
export function matchRequestResourceEntry(request: RequestCompleteEvent) {
28+
if (!performance || !('getEntriesByName' in performance)) {
29+
return
30+
}
31+
const sameNameEntries = performance.getEntriesByName(request.url, 'resource') as RumPerformanceResourceTiming[]
32+
33+
if (!sameNameEntries.length || !('toJSON' in sameNameEntries[0])) {
34+
return
35+
}
36+
37+
const candidates = sameNameEntries
38+
.filter((entry) => !alreadyMatchedEntries.has(entry))
39+
.filter((entry) => hasValidResourceEntryDuration(entry) && hasValidResourceEntryTimings(entry))
40+
.filter((entry) =>
41+
isBetween(
42+
entry,
43+
request.startClocks.relative,
44+
endTime({ startTime: request.startClocks.relative, duration: request.duration })
45+
)
46+
)
47+
48+
if (candidates.length === 1) {
49+
alreadyMatchedEntries.add(candidates[0])
50+
51+
return candidates[0].toJSON() as RumPerformanceResourceTiming
52+
}
53+
54+
return
55+
}
56+
57+
function endTime(timing: Timing) {
58+
return addDuration(timing.startTime, timing.duration)
59+
}
60+
61+
function isBetween(timing: Timing, start: RelativeTime, end: RelativeTime) {
62+
const errorMargin = 1 as Duration
63+
return timing.startTime >= start - errorMargin && endTime(timing) <= addDuration(end, errorMargin)
64+
}

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle'
2222
import type { RequestCompleteEvent } from '../requestCollection'
2323
import { getDocumentTraceId } from '../tracing/getDocumentTraceId'
2424
import { createSpanIdentifier, createTraceIdentifier } from '../tracing/identifier'
25+
import type { WebSocketCompleteEvent } from '../webSocketCollection'
2526
import { REQUEST_MATCHING_DELAY, startResourceCollection } from './resourceCollection'
2627

2728
function buildMatchHeadersForAllUrls(headerNames: MatchOption[]): MatchHeader[] {
@@ -1278,6 +1279,129 @@ describe('resourceCollection', () => {
12781279
})
12791280
})
12801281

1282+
describe('websocket', () => {
1283+
const wsUrl = 'wss://example.com/socket'
1284+
const ONE_MILLISECOND_IN_NANOSECONDS = 1e6
1285+
1286+
function toServerDurationFromMs(durationInMilliseconds: number): ServerDuration {
1287+
return (durationInMilliseconds * ONE_MILLISECOND_IN_NANOSECONDS) as ServerDuration
1288+
}
1289+
1290+
function getRawWebsocketResourceEvent(index = 0): RawRumResourceEvent {
1291+
return rawRumEvents[index].rawRumEvent as RawRumResourceEvent
1292+
}
1293+
1294+
function getWebsocketResource(index = 0) {
1295+
return getRawWebsocketResourceEvent(index).resource
1296+
}
1297+
1298+
function notifyWebSocket(overrides: Partial<WebSocketCompleteEvent> = {}) {
1299+
const defaultStartTime = 1_700_000_000_000 as TimeStamp
1300+
const defaultStartRelativeTime = 200 as RelativeTime
1301+
const defaultEndTime = 1_700_000_005_000 as TimeStamp
1302+
const defaultEndRelativeTime = 5_200 as RelativeTime
1303+
const defaultMessagesIn = { count: 3, size: 300 }
1304+
const defaultMessagesOut = { count: 2, size: 200 }
1305+
const defaultCloseCode = 1000
1306+
1307+
const event: WebSocketCompleteEvent = {
1308+
connectionId: 'connection-uuid',
1309+
url: wsUrl,
1310+
startClocks: { relative: defaultStartRelativeTime, timeStamp: defaultStartTime },
1311+
endClocks: { relative: defaultEndRelativeTime, timeStamp: defaultEndTime },
1312+
messagesIn: defaultMessagesIn,
1313+
messagesOut: defaultMessagesOut,
1314+
longestSilence: 0 as Duration,
1315+
bufferedAmountMax: 0,
1316+
handshakeSucceeded: false,
1317+
trackingEndReason: 'close_event',
1318+
closeCode: defaultCloseCode,
1319+
closeReason: 'bye',
1320+
wasClean: true,
1321+
...overrides,
1322+
}
1323+
lifeCycle.notify(LifeCycleEventType.WEBSOCKET_COMPLETED, event)
1324+
runTasks()
1325+
return event
1326+
}
1327+
1328+
it('emits a resource event with type=websocket on close', () => {
1329+
setupResourceCollection()
1330+
1331+
const protocol = 'chat.v1'
1332+
const viewId = 'view-1'
1333+
const timeToFirstMessageIn = 10 as Duration
1334+
const timeToFirstMessageOut = 25 as Duration
1335+
const lastMessageAt = 1_700_000_004_000 as TimeStamp
1336+
const longestSilence = 200 as Duration
1337+
const bufferedAmountMax = 1024
1338+
const idleDurationBeforeClose = 1000 as Duration
1339+
const setupDuration = 42 as Duration
1340+
1341+
const event = notifyWebSocket({
1342+
protocol,
1343+
startViewId: viewId,
1344+
endViewId: viewId,
1345+
firstMessageInOffset: timeToFirstMessageIn,
1346+
firstMessageOutOffset: timeToFirstMessageOut,
1347+
lastMessageAt,
1348+
longestSilence,
1349+
bufferedAmountMax,
1350+
idleDurationBeforeClose,
1351+
setupDuration,
1352+
handshakeSucceeded: true,
1353+
})
1354+
1355+
const expectedEventCount = 1
1356+
const expectedResourceDuration = toServerDurationFromMs(event.endClocks.relative - event.startClocks.relative)
1357+
1358+
expect(rawRumEvents.length).toBe(expectedEventCount)
1359+
1360+
const rawEvent = getRawWebsocketResourceEvent()
1361+
expect(rawEvent.resource.type).toBe(ResourceType.WEBSOCKET)
1362+
expect(rawEvent.resource.status_code).toBeUndefined()
1363+
expect(rawEvent.resource.url).toBe(wsUrl)
1364+
expect(rawEvent.resource.duration).toBe(expectedResourceDuration)
1365+
expect(rawEvent.date).toBe(event.startClocks.timeStamp)
1366+
expect(rawEvent.resource.websocket).toEqual({
1367+
connection_id: event.connectionId,
1368+
handshake_succeeded: true,
1369+
start_time: event.startClocks.timeStamp,
1370+
end_time: event.endClocks.timeStamp,
1371+
start_view_id: viewId,
1372+
end_view_id: viewId,
1373+
tracking_end_reason: 'close_event',
1374+
close_code: event.closeCode,
1375+
close_reason: 'bye',
1376+
was_clean: true,
1377+
messages_in: event.messagesIn,
1378+
messages_out: event.messagesOut,
1379+
time_to_first_message_in: timeToFirstMessageIn,
1380+
time_to_first_message_out: timeToFirstMessageOut,
1381+
last_message_at: lastMessageAt,
1382+
longest_silence: longestSilence,
1383+
idle_duration_before_close: idleDurationBeforeClose,
1384+
buffered_amount_max: bufferedAmountMax,
1385+
protocol,
1386+
setup_duration: setupDuration,
1387+
})
1388+
})
1389+
1390+
it('emits an event spanning two views', () => {
1391+
setupResourceCollection()
1392+
const startViewId = 'view-a'
1393+
const endViewId = 'view-b'
1394+
notifyWebSocket({ startViewId, endViewId })
1395+
1396+
const expectedEventCount = 1
1397+
expect(rawRumEvents.length).toBe(expectedEventCount)
1398+
1399+
const websocket = getWebsocketResource().websocket!
1400+
expect(websocket.start_view_id).toBe(startViewId)
1401+
expect(websocket.end_view_id).toBe(endViewId)
1402+
})
1403+
})
1404+
12811405
function runTasks() {
12821406
// Request-type entries are queued through a `setTimeout(…, REQUEST_MATCHING_DELAY)` before
12831407
// they reach the task queue — advance past it so they get pushed.

0 commit comments

Comments
 (0)