Skip to content

Commit 0595f6d

Browse files
bdibonclaude
andcommitted
✅ Test WebSocket collection before init() end to end
WebSocket early data collection had unit coverage on both sides of the buffer boundary, but nothing exercised the pre-start → post-start strategy swap in a browser. The headline behaviour — a socket opened before DD_RUM.init() still reports — was unverified end to end. Two scenarios open a socket in the pre-init window: one exchanges a message and stays open until the test closes it, the other also closes before init() so the entire connection lifecycle exists only in the buffer. Both assert the message counts and that the timings are measured from the real constructor call rather than from init(). Verified red: both fail against the collection code from before the buffered source was consumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 179104c commit 0595f6d

2 files changed

Lines changed: 161 additions & 2 deletions

File tree

test/e2e/lib/pages/webSocketPage.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ export function expectedWsEchoMessage(out = DEFAULT_WS_OUT_MESSAGE) {
1717
return `echo: ${out}`
1818
}
1919

20+
/** In-page expression evaluating to the /ws-echo URL for the current origin. */
21+
const WS_ECHO_URL = `(function () {
22+
var url = new URL('/ws-echo', location.href)
23+
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
24+
return url.toString()
25+
})()`
26+
27+
/** Handle exposed by {@link preInitWebSocketScript} to drive the socket it opened. */
28+
interface PreInitWebSocketHandle {
29+
closed: boolean
30+
close: () => void
31+
}
32+
33+
declare global {
34+
interface Window {
35+
preInitWebSocket?: PreInitWebSocketHandle
36+
}
37+
}
38+
2039
export class WebSocketPage {
2140
readonly wsOpenButton: Locator
2241
readonly wsStatusParagraph: Locator
@@ -114,3 +133,47 @@ export class WebSocketPage {
114133
await expect(this.wsLastMessageParagraph).toHaveText(text)
115134
}
116135
}
136+
137+
/**
138+
* Script for `createTest().withPreInitScript()`: opens a socket to /ws-echo and exchanges a
139+
* message before `init()` runs. It returns a promise, so the exchange — and, with
140+
* `closeBeforeInit`, the close as well — is guaranteed to complete while the SDK is only
141+
* buffering. The socket is left open otherwise, and can be closed later with
142+
* {@link closePreInitWebSocket}.
143+
*
144+
* It has no DOM dependency: it runs in the page `<head>`, before the body exists.
145+
*/
146+
export function preInitWebSocketScript({ closeBeforeInit = false } = {}) {
147+
return `
148+
var socket = new WebSocket(${WS_ECHO_URL})
149+
var handle = {
150+
closed: false,
151+
close: function () {
152+
socket.close()
153+
},
154+
}
155+
window.preInitWebSocket = handle
156+
return new Promise(function (resolve) {
157+
socket.addEventListener('open', function () {
158+
socket.send(${JSON.stringify(DEFAULT_WS_OUT_MESSAGE)})
159+
})
160+
socket.addEventListener('message', function () {
161+
${closeBeforeInit ? 'socket.close()' : 'resolve()'}
162+
})
163+
// Also covers a failed handshake: an 'error' is always followed by a 'close', so init() is
164+
// never held back forever.
165+
socket.addEventListener('close', function () {
166+
handle.closed = true
167+
resolve()
168+
})
169+
})
170+
`
171+
}
172+
173+
export async function closePreInitWebSocket(page: Page) {
174+
// page.goto() only waits for the load event, which can happen before the SDK bundle is
175+
// evaluated (async setup) and therefore before the pre-init script has run.
176+
await page.waitForFunction(() => window.preInitWebSocket !== undefined)
177+
await page.evaluate(() => window.preInitWebSocket!.close())
178+
await page.waitForFunction(() => window.preInitWebSocket!.closed)
179+
}

test/e2e/scenario/rum/websockets.scenario.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import type { RumResourceEvent } from '@datadog/browser-rum'
2-
import type { RawRumEvent } from '@datadog/browser-rum-core'
2+
import type { RawRumEvent, RumInitConfiguration } from '@datadog/browser-rum-core'
3+
import type { Page } from '@playwright/test'
34
import { expect, test } from '@playwright/test'
45
import { createTest } from '../../lib/framework'
56
import { expireSession, renewSession } from '../../lib/helpers/session'
6-
import { DEFAULT_WS_OUT_MESSAGE, expectedWsEchoMessage, WebSocketPage } from '../../lib/pages/webSocketPage'
7+
import {
8+
closePreInitWebSocket,
9+
DEFAULT_WS_OUT_MESSAGE,
10+
expectedWsEchoMessage,
11+
preInitWebSocketScript,
12+
WebSocketPage,
13+
} from '../../lib/pages/webSocketPage'
14+
15+
declare global {
16+
interface Window {
17+
RUM_INIT_TIME?: number
18+
}
19+
}
720

821
type RawRumResource = Extract<RawRumEvent, { type: 'resource' }>
922
type WebSocketResourceProperties = NonNullable<RawRumResource['resource']['websocket']>
@@ -195,6 +208,70 @@ test.describe('rum websockets', () => {
195208
expect(wsResources).toHaveLength(0)
196209
})
197210

211+
createTest('collects a websocket opened and used before init()')
212+
.withRum({ enableExperimentalFeatures: ['track_websockets'] })
213+
.withRumInit(recordInitTime)
214+
.withPreInitScript(preInitWebSocketScript())
215+
.run(async ({ intakeRegistry, flushEvents, page }) => {
216+
await closePreInitWebSocket(page)
217+
218+
// Read before flushing: flushEvents() navigates away and drops the page state.
219+
const initTime = await getInitTime(page)
220+
221+
await flushEvents()
222+
223+
const rumEvent = getLastRumResourceEventWithWebSocket(intakeRegistry.rumResourceEvents)
224+
expect(rumEvent).toBeDefined()
225+
226+
const { websocket } = rumEvent!.resource
227+
228+
// The message was exchanged before init(): it is only counted if the SDK buffered it.
229+
expect(websocket.messages_out.count).toBe(1)
230+
expect(websocket.messages_out.size).toBe(DEFAULT_WS_OUT_MESSAGE.length)
231+
expect(websocket.messages_in.count).toBe(1)
232+
expect(websocket.messages_in.size).toBe(expectedWsEchoMessage().length)
233+
234+
// Timings come from the constructor call, not from init().
235+
expect(websocket.start_time).toBeLessThan(initTime)
236+
expect(websocket.handshake_succeeded).toBe(true)
237+
expect(websocket.start_time + websocket.setup_duration / NANOSECONDS_PER_MILLISECOND).toBeLessThanOrEqual(
238+
initTime
239+
)
240+
241+
const connectingVital = intakeRegistry.rumVitalEvents.find((e) => e.vital.name === 'websocket-connecting')
242+
expect(connectingVital).toBeDefined()
243+
expect(connectingVital!.context!.connection_id).toBe(websocket.connection_id)
244+
expect(connectingVital!.date).toBeLessThan(initTime)
245+
})
246+
247+
createTest('collects a websocket whose whole lifecycle happened before init()')
248+
.withRum({ enableExperimentalFeatures: ['track_websockets'] })
249+
.withRumInit(recordInitTime)
250+
.withPreInitScript(preInitWebSocketScript({ closeBeforeInit: true }))
251+
.run(async ({ intakeRegistry, flushEvents, page }) => {
252+
// Read before flushing: flushEvents() navigates away and drops the page state.
253+
const initTime = await getInitTime(page)
254+
255+
await flushEvents()
256+
257+
const rumEvent = getLastRumResourceEventWithWebSocket(intakeRegistry.rumResourceEvents)
258+
expect(rumEvent).toBeDefined()
259+
260+
const { websocket } = rumEvent!.resource
261+
262+
expect(websocket.tracking_end_reason).toBe('close_event')
263+
expect(websocket.messages_out.count).toBe(1)
264+
expect(websocket.messages_in.count).toBe(1)
265+
266+
// The connection was opened, used and closed while the SDK was only buffering.
267+
expect(websocket.start_time).toBeLessThan(initTime)
268+
expect(websocket.end_time).toBeLessThanOrEqual(initTime)
269+
270+
const closedVital = intakeRegistry.rumVitalEvents.find((e) => e.vital.name === 'websocket-closed')
271+
expect(closedVital).toBeDefined()
272+
expect(closedVital!.context!.connection_id).toBe(websocket.connection_id)
273+
})
274+
198275
createTest('websocket resource records different start and end views when it spanned multiple views')
199276
.withRum({ enableExperimentalFeatures: ['track_websockets'] })
200277
.withBody(WebSocketPage.testBody())
@@ -223,6 +300,25 @@ test.describe('rum websockets', () => {
223300
})
224301
})
225302

303+
/**
304+
* Serialized into the page, so it must stay self-contained. Marks the moment init() runs, to
305+
* assert that pre-init timings are measured from the real WebSocket constructor call.
306+
*/
307+
function recordInitTime(configuration: RumInitConfiguration) {
308+
window.RUM_INIT_TIME = Date.now()
309+
window.DD_RUM!.init(configuration)
310+
}
311+
312+
/**
313+
* init() is held back until the pre-init WebSocket exchange completes, which can outlast the load
314+
* event page.goto() waits for — so wait for the marker rather than assuming it is already set.
315+
*/
316+
async function getInitTime(page: Page) {
317+
await page.waitForFunction(() => window.RUM_INIT_TIME !== undefined)
318+
const initTime = await page.evaluate(() => window.RUM_INIT_TIME)
319+
return initTime!
320+
}
321+
226322
function isWebSocketResource(event: RumResourceEvent): event is RumResourceEventWithWebSocket {
227323
// Public RumResourceEvent.resource omits `websocket` until rum-events-format is updated.
228324
const resource = event.resource as unknown as {

0 commit comments

Comments
 (0)