Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/browser-rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ import {
startGlobalContext,
startUserContext,
startTabContext,
isExperimentalFeatureEnabled,
ExperimentalFeature,
} from '@datadog/browser-core'
import { createDOMMutationObservable } from '../browser/domMutationObservable'
import { createWindowOpenObservable } from '../browser/windowOpenObservable'
import { startInternalContext } from '../domain/contexts/internalContext'
import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle'
import { startViewHistory } from '../domain/contexts/viewHistory'
import { startRequestCollection } from '../domain/requestCollection'
import { startWebSocketCollection } from '../domain/webSocketCollection'
import { startActionCollection } from '../domain/action/actionCollection'
import { startErrorCollection } from '../domain/error/errorCollection'
import { startResourceCollection } from '../domain/resource/resourceCollection'
Expand Down Expand Up @@ -229,6 +232,16 @@ export function startRumEventCollection(

const vitalCollection = startVitalCollection(lifeCycle, pageStateHistory)

if (configuration.trackWebSockets && isExperimentalFeatureEnabled(ExperimentalFeature.TRACK_WEB_SOCKETS)) {
const webSocketCollection = startWebSocketCollection(
lifeCycle,
configuration,
viewHistory,
vitalCollection.addDurationVital
)
cleanupTasks.push(webSocketCollection.stop)
}

const internalContext = startInternalContext(
configuration.applicationId,
sessionManager,
Expand Down
4 changes: 4 additions & 0 deletions packages/browser-rum-core/src/domain/lifeCycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AbstractLifeCycle } from '@datadog/browser-core'
import type { RumEventDomainContext } from '../domainContext.types'
import type { RawRumEvent, AssembledRumEvent } from '../rawRumEvent.types'
import type { RequestCompleteEvent, RequestStartEvent } from './requestCollection'
import type { WebSocketCompleteEvent } from './webSocketCollection'
import type { AutoAction } from './action/actionCollection'
import type { ViewEvent, ViewCreatedEvent, ViewEndedEvent, BeforeViewUpdateEvent } from './view/trackViews'
import type { DurationVitalStart } from './vital/vitalCollection'
Expand All @@ -21,6 +22,7 @@ export const enum LifeCycleEventType {
AFTER_VIEW_ENDED,
REQUEST_STARTED,
REQUEST_COMPLETED,
WEBSOCKET_COMPLETED,

// The SESSION_EXPIRED lifecycle event has been introduced to represent when a session has expired
// and trigger cleanup tasks related to this, prior to renewing the session. Its implementation is
Expand Down Expand Up @@ -66,6 +68,7 @@ declare const LifeCycleEventTypeAsConst: {
AFTER_VIEW_ENDED: LifeCycleEventType.AFTER_VIEW_ENDED
REQUEST_STARTED: LifeCycleEventType.REQUEST_STARTED
REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED
WEBSOCKET_COMPLETED: LifeCycleEventType.WEBSOCKET_COMPLETED
SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED
SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED
PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT
Expand All @@ -88,6 +91,7 @@ export interface LifeCycleEventMap {
[LifeCycleEventTypeAsConst.AFTER_VIEW_ENDED]: ViewEndedEvent
[LifeCycleEventTypeAsConst.REQUEST_STARTED]: RequestStartEvent
[LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent
[LifeCycleEventTypeAsConst.WEBSOCKET_COMPLETED]: WebSocketCompleteEvent
[LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void
[LifeCycleEventTypeAsConst.SESSION_RENEWED]: void
[LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import { addDuration } from '@datadog/browser-core'
import type { RumPerformanceResourceTiming } from '../../browser/performanceObservable'
import type { RequestCompleteEvent } from '../requestCollection'
import { hasValidResourceEntryDuration, hasValidResourceEntryTimings } from './resourceUtils'

interface Timing {
startTime: RelativeTime
duration: Duration
}

const alreadyMatchedEntries = new WeakSet<PerformanceEntry>()

/**
* Look for corresponding timing in resource timing buffer
*
* Observations:
* - Timing (start, end) are nested inside the request (start, end)
* - Some timing can be not exactly nested, being off by < 1 ms
*
* Strategy:
* - from valid nested entries (with 1 ms error margin)
* - filter out timing that were already matched to a request
* - then, if a single timing match, return the timing
* - otherwise we can't decide, return undefined
*/
export function matchRequestResourceEntry(request: RequestCompleteEvent) {
if (!performance || !('getEntriesByName' in performance)) {
return
}
const sameNameEntries = performance.getEntriesByName(request.url, 'resource') as RumPerformanceResourceTiming[]

if (!sameNameEntries.length || !('toJSON' in sameNameEntries[0])) {
return
}

const candidates = sameNameEntries
.filter((entry) => !alreadyMatchedEntries.has(entry))
.filter((entry) => hasValidResourceEntryDuration(entry) && hasValidResourceEntryTimings(entry))
.filter((entry) =>
isBetween(
entry,
request.startClocks.relative,
endTime({ startTime: request.startClocks.relative, duration: request.duration })
)
)

if (candidates.length === 1) {
alreadyMatchedEntries.add(candidates[0])

return candidates[0].toJSON() as RumPerformanceResourceTiming
}

return
}

function endTime(timing: Timing) {
return addDuration(timing.startTime, timing.duration)
}

function isBetween(timing: Timing, start: RelativeTime, end: RelativeTime) {
const errorMargin = 1 as Duration
return timing.startTime >= start - errorMargin && endTime(timing) <= addDuration(end, errorMargin)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle'
import type { RequestCompleteEvent } from '../requestCollection'
import { getDocumentTraceId } from '../tracing/getDocumentTraceId'
import { createSpanIdentifier, createTraceIdentifier } from '../tracing/identifier'
import type { WebSocketCompleteEvent } from '../webSocketCollection'
import { REQUEST_MATCHING_DELAY, startResourceCollection } from './resourceCollection'

function buildMatchHeadersForAllUrls(headerNames: MatchOption[]): MatchHeader[] {
Expand Down Expand Up @@ -1278,6 +1279,129 @@ describe('resourceCollection', () => {
})
})

describe('websocket', () => {
const wsUrl = 'wss://example.com/socket'
const ONE_MILLISECOND_IN_NANOSECONDS = 1e6

function toServerDurationFromMs(durationInMilliseconds: number): ServerDuration {
return (durationInMilliseconds * ONE_MILLISECOND_IN_NANOSECONDS) as ServerDuration
}

function getRawWebsocketResourceEvent(index = 0): RawRumResourceEvent {
return rawRumEvents[index].rawRumEvent as RawRumResourceEvent
}

function getWebsocketResource(index = 0) {
return getRawWebsocketResourceEvent(index).resource
}

function notifyWebSocket(overrides: Partial<WebSocketCompleteEvent> = {}) {
const defaultStartTime = 1_700_000_000_000 as TimeStamp
const defaultStartRelativeTime = 200 as RelativeTime
const defaultEndTime = 1_700_000_005_000 as TimeStamp
const defaultEndRelativeTime = 5_200 as RelativeTime
const defaultMessagesIn = { count: 3, size: 300 }
const defaultMessagesOut = { count: 2, size: 200 }
const defaultCloseCode = 1000

const event: WebSocketCompleteEvent = {
connectionId: 'connection-uuid',
url: wsUrl,
startClocks: { relative: defaultStartRelativeTime, timeStamp: defaultStartTime },
endClocks: { relative: defaultEndRelativeTime, timeStamp: defaultEndTime },
messagesIn: defaultMessagesIn,
messagesOut: defaultMessagesOut,
longestSilence: 0 as Duration,
bufferedAmountMax: 0,
handshakeSucceeded: false,
trackingEndReason: 'close_event',
closeCode: defaultCloseCode,
closeReason: 'bye',
wasClean: true,
...overrides,
}
lifeCycle.notify(LifeCycleEventType.WEBSOCKET_COMPLETED, event)
runTasks()
return event
}

it('emits a resource event with type=websocket on close', () => {
setupResourceCollection()

const protocol = 'chat.v1'
const viewId = 'view-1'
const timeToFirstMessageIn = 10 as Duration
const timeToFirstMessageOut = 25 as Duration
const lastMessageAt = 1_700_000_004_000 as TimeStamp
const longestSilence = 200 as Duration
const bufferedAmountMax = 1024
const idleDurationBeforeClose = 1000 as Duration
const setupDuration = 42 as Duration

const event = notifyWebSocket({
protocol,
startViewId: viewId,
endViewId: viewId,
firstMessageInOffset: timeToFirstMessageIn,
firstMessageOutOffset: timeToFirstMessageOut,
lastMessageAt,
longestSilence,
bufferedAmountMax,
idleDurationBeforeClose,
setupDuration,
handshakeSucceeded: true,
})

const expectedEventCount = 1
const expectedResourceDuration = toServerDurationFromMs(event.endClocks.relative - event.startClocks.relative)

expect(rawRumEvents.length).toBe(expectedEventCount)

const rawEvent = getRawWebsocketResourceEvent()
expect(rawEvent.resource.type).toBe(ResourceType.WEBSOCKET)
expect(rawEvent.resource.status_code).toBeUndefined()
expect(rawEvent.resource.url).toBe(wsUrl)
expect(rawEvent.resource.duration).toBe(expectedResourceDuration)
expect(rawEvent.date).toBe(event.startClocks.timeStamp)
expect(rawEvent.resource.websocket).toEqual({
connection_id: event.connectionId,
handshake_succeeded: true,
start_time: event.startClocks.timeStamp,
end_time: event.endClocks.timeStamp,
start_view_id: viewId,
end_view_id: viewId,
tracking_end_reason: 'close_event',
close_code: event.closeCode,
close_reason: 'bye',
was_clean: true,
messages_in: event.messagesIn,
messages_out: event.messagesOut,
time_to_first_message_in: timeToFirstMessageIn,
time_to_first_message_out: timeToFirstMessageOut,
last_message_at: lastMessageAt,
longest_silence: longestSilence,
idle_duration_before_close: idleDurationBeforeClose,
buffered_amount_max: bufferedAmountMax,
protocol,
setup_duration: setupDuration,
})
})

it('emits an event spanning two views', () => {
setupResourceCollection()
const startViewId = 'view-a'
const endViewId = 'view-b'
notifyWebSocket({ startViewId, endViewId })

const expectedEventCount = 1
expect(rawRumEvents.length).toBe(expectedEventCount)

const websocket = getWebsocketResource().websocket!
expect(websocket.start_view_id).toBe(startViewId)
expect(websocket.end_view_id).toBe(endViewId)
})
})

function runTasks() {
// Request-type entries are queued through a `setTimeout(…, REQUEST_MATCHING_DELAY)` before
// they reach the task queue — advance past it so they get pushed.
Expand Down
Loading
Loading