New source found from dndbeyond.com

This commit is contained in:
2026-07-08 01:00:09 -07:00
parent 9a983a6d7b
commit dcefa0d2f2
2865 changed files with 222467 additions and 49053 deletions
@@ -0,0 +1,14 @@
/**
* Test for Browser features used while recording
*/
export function isBrowserSupported() {
return (
// Array.from is a bit less supported by browsers than CSSSupportsRule, but has higher chances
// to be polyfilled. Test for both to be more confident. We could add more things if we find out
// this test is not sufficient.
typeof Array.from === 'function' &&
typeof CSSSupportsRule === 'function' &&
typeof URL.createObjectURL === 'function' &&
'forEach' in NodeList.prototype
)
}
@@ -0,0 +1,20 @@
import { mockable } from '@datadog/browser-core'
import { reportScriptLoadingError } from '../domain/scriptLoadingError'
import type { createRumProfiler } from '../domain/profiling/profiler'
export async function lazyLoadProfiler(): Promise<typeof createRumProfiler | undefined> {
try {
return await mockable(importProfiler)()
} catch (error: unknown) {
reportScriptLoadingError({
error,
source: 'Profiler',
scriptType: 'module',
})
}
}
export async function importProfiler() {
const module = await import(/* webpackChunkName: "profiler" */ '../domain/profiling/profiler')
return module.createRumProfiler
}
@@ -0,0 +1,20 @@
import { mockable } from '@datadog/browser-core'
import { reportScriptLoadingError } from '../domain/scriptLoadingError'
import type { startRecording } from './startRecording'
export async function lazyLoadRecorder(): Promise<typeof startRecording | undefined> {
try {
return await mockable(importRecorder)()
} catch (error: unknown) {
reportScriptLoadingError({
error,
source: 'Recorder',
scriptType: 'module',
})
}
}
export async function importRecorder() {
const module = await import(/* webpackChunkName: "recorder" */ './startRecording')
return module.startRecording
}
@@ -0,0 +1,176 @@
import type {
LifeCycle,
RumConfiguration,
RumSessionManager,
StartRecordingOptions,
ViewHistory,
RumSession,
} from '@datadog/browser-rum-core'
import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core'
import type { Telemetry, DeflateEncoder } from '@datadog/browser-core'
import { asyncRunOnReadyState, monitorError, Observable } from '@datadog/browser-core'
import { getSessionReplayLink } from '../domain/getSessionReplayLink'
import { startRecorderInitTelemetry } from '../domain/startRecorderInitTelemetry'
import type { startRecording } from './startRecording'
export type StartRecording = typeof startRecording
export const enum RecorderStatus {
// The recorder is stopped.
Stopped,
// The user started the recording while it wasn't possible yet. The recorder should start as soon
// as possible.
IntentToStart,
// The recorder is starting. It does not record anything yet.
Starting,
// The recorder is started, it records the session.
Started,
}
export type RecorderInitEvent =
| { type: 'start'; forced: boolean }
| { type: 'document-ready' }
| { type: 'recorder-settled' }
| { type: 'aborted' }
| { type: 'deflate-encoder-load-failed' }
| { type: 'recorder-load-failed' }
| { type: 'succeeded' }
export interface Strategy {
start: (options?: StartRecordingOptions) => void
stop: () => void
isRecording: () => boolean
getSessionReplayLink: () => string | undefined
}
export function createPostStartStrategy(
configuration: RumConfiguration,
lifeCycle: LifeCycle,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
loadRecorder: () => Promise<StartRecording | undefined>,
getOrCreateDeflateEncoder: () => DeflateEncoder | undefined,
telemetry: Telemetry
): Strategy {
let status = RecorderStatus.Stopped
let stopRecording: () => void
lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => {
if (status === RecorderStatus.Starting || status === RecorderStatus.Started) {
stop()
status = RecorderStatus.IntentToStart
}
})
lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => {
if (status === RecorderStatus.IntentToStart) {
start()
}
})
const observable = new Observable<RecorderInitEvent>()
startRecorderInitTelemetry(telemetry, observable)
const doStart = async (forced: boolean) => {
observable.notify({ type: 'start', forced })
const [startRecordingImpl] = await Promise.all([
notifyWhenSettled(observable, { type: 'recorder-settled' }, loadRecorder()),
notifyWhenSettled(observable, { type: 'document-ready' }, asyncRunOnReadyState(configuration, 'interactive')),
])
if (status !== RecorderStatus.Starting) {
observable.notify({ type: 'aborted' })
return
}
if (!startRecordingImpl) {
status = RecorderStatus.Stopped
observable.notify({ type: 'recorder-load-failed' })
return
}
const deflateEncoder = getOrCreateDeflateEncoder()
if (!deflateEncoder) {
status = RecorderStatus.Stopped
observable.notify({ type: 'deflate-encoder-load-failed' })
return
}
;({ stop: stopRecording } = startRecordingImpl(
lifeCycle,
configuration,
sessionManager,
viewHistory,
deflateEncoder,
telemetry
))
status = RecorderStatus.Started
observable.notify({ type: 'succeeded' })
}
function start(options?: StartRecordingOptions) {
const session = sessionManager.findTrackedSession()
if (canStartRecording(session, options)) {
status = RecorderStatus.IntentToStart
return
}
if (isRecordingInProgress(status)) {
return
}
status = RecorderStatus.Starting
const forced = shouldForceReplay(session!, options) || false
// Intentionally not awaiting doStart() to keep it asynchronous
doStart(forced).catch(monitorError)
if (forced) {
sessionManager.setForcedReplay()
}
}
function stop() {
if (status === RecorderStatus.Started) {
stopRecording?.()
}
status = RecorderStatus.Stopped
}
return {
start,
stop,
getSessionReplayLink() {
return getSessionReplayLink(configuration, sessionManager, viewHistory, status !== RecorderStatus.Stopped)
},
isRecording: () => status === RecorderStatus.Started,
}
}
function canStartRecording(session: RumSession | undefined, options?: StartRecordingOptions) {
return !session || (session.sessionReplay === SessionReplayState.OFF && (!options || !options.force))
}
function isRecordingInProgress(status: RecorderStatus) {
return status === RecorderStatus.Starting || status === RecorderStatus.Started
}
function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) {
return options && options.force && session.sessionReplay === SessionReplayState.OFF
}
async function notifyWhenSettled<Event, Result>(
observable: Observable<Event>,
event: Event,
promise: Promise<Result>
): Promise<Result> {
try {
return await promise
} finally {
observable.notify(event)
}
}
@@ -0,0 +1,34 @@
import { noop } from '@datadog/browser-core'
import type { RumConfiguration } from '@datadog/browser-rum-core'
import type { Strategy } from './postStartStrategy'
const enum PreStartRecorderStatus {
None,
HadManualStart,
HadManualStop,
}
export function createPreStartStrategy(): {
strategy: Strategy
shouldStartImmediately: (configuration: RumConfiguration) => boolean
} {
let status = PreStartRecorderStatus.None
return {
strategy: {
start() {
status = PreStartRecorderStatus.HadManualStart
},
stop() {
status = PreStartRecorderStatus.HadManualStop
},
isRecording: () => false,
getSessionReplayLink: noop as () => string | undefined,
},
shouldStartImmediately(configuration) {
return (
status === PreStartRecorderStatus.HadManualStart ||
(status === PreStartRecorderStatus.None && !configuration.startSessionReplayRecordingManually)
)
},
}
}
+82
View File
@@ -0,0 +1,82 @@
import type {
LifeCycle,
ViewHistory,
RumSessionManager,
RumConfiguration,
ProfilerApi,
Hooks,
} from '@datadog/browser-rum-core'
import type { DeflateEncoderStreamId, Encoder } from '@datadog/browser-core'
import { isSampled } from '@datadog/browser-rum-core'
import { monitorError } from '@datadog/browser-core'
import type { RUMProfiler } from '../domain/profiling/types'
import { isProfilingSupported } from '../domain/profiling/profilingSupported'
import { startProfilingContext } from '../domain/profiling/profilingContext'
import { lazyLoadProfiler } from './lazyLoadProfiler'
export function makeProfilerApi(): ProfilerApi {
let profiler: RUMProfiler | undefined
function onRumStart(
lifeCycle: LifeCycle,
hooks: Hooks,
configuration: RumConfiguration,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder
) {
const session = sessionManager.findTrackedSession() // Check if the session is tracked.
if (!session) {
// No session tracked, no profiling.
// Note: No Profiling context is set at this stage.
return
}
// Sampling (sticky sampling based on session id)
if (!isSampled(session.id, configuration.profilingSampleRate)) {
// No sampling, no profiling.
// Note: No Profiling context is set at this stage.
return
}
// Listen to events and add the profiling context to them.
const profilingContextManager = startProfilingContext(hooks)
// Browser support check
if (!isProfilingSupported()) {
profilingContextManager.set({
status: 'error',
error_reason: 'not-supported-by-browser',
})
return
}
lazyLoadProfiler()
.then((createRumProfiler) => {
if (!createRumProfiler) {
profilingContextManager.set({ status: 'error', error_reason: 'failed-to-lazy-load' })
return
}
profiler = createRumProfiler(
configuration,
lifeCycle,
sessionManager,
profilingContextManager,
createEncoder,
viewHistory,
undefined
)
profiler.start()
})
.catch(monitorError)
}
return {
onRumStart,
stop: () => {
profiler?.stop()
},
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { DeflateEncoder, DeflateWorker, Telemetry } from '@datadog/browser-core'
import {
canUseEventBridge,
noop,
BridgeCapability,
bridgeSupports,
DeflateEncoderStreamId,
} from '@datadog/browser-core'
import type {
LifeCycle,
ViewHistory,
RumSessionManager,
RecorderApi,
RumConfiguration,
StartRecordingOptions,
} from '@datadog/browser-rum-core'
import { getReplayStats as getReplayStatsImpl } from '../domain/replayStats'
import {
createDeflateEncoder,
DeflateWorkerStatus,
getDeflateWorkerStatus,
startDeflateWorker,
} from '../domain/deflate'
import { isBrowserSupported } from './isBrowserSupported'
import type { StartRecording } from './postStartStrategy'
import { createPostStartStrategy } from './postStartStrategy'
import { createPreStartStrategy } from './preStartStrategy'
export function makeRecorderApi(loadRecorder: () => Promise<StartRecording | undefined>): RecorderApi {
if ((canUseEventBridge() && !bridgeSupports(BridgeCapability.RECORDS)) || !isBrowserSupported()) {
return {
start: noop,
stop: noop,
getReplayStats: () => undefined,
onRumStart: noop,
isRecording: () => false,
getSessionReplayLink: () => undefined,
}
}
// eslint-disable-next-line prefer-const
let { strategy, shouldStartImmediately } = createPreStartStrategy()
return {
start: (options?: StartRecordingOptions) => strategy.start(options),
stop: () => strategy.stop(),
getSessionReplayLink: () => strategy.getSessionReplayLink(),
onRumStart,
isRecording: () =>
// The worker is started optimistically, meaning we could have started to record but its
// initialization fails a bit later. This could happen when:
// * the worker URL (blob or plain URL) is blocked by CSP in Firefox only (Chromium and Safari
// throw an exception when instantiating the worker, and IE doesn't care about CSP)
// * the browser fails to load the worker in case the workerUrl is used
// * an unexpected error occurs in the Worker before initialization, ex:
// * a runtime exception collected by monitor()
// * a syntax error notified by the browser via an error event
// * the worker is unresponsive for some reason and timeouts
//
// It is not expected to happen often. Nonetheless, the "replayable" status on RUM events is
// an important part of the Datadog App:
// * If we have a false positive (we set has_replay: true even if no replay data is present),
// we might display broken links to the Session Replay player.
// * If we have a false negative (we don't set has_replay: true even if replay data is
// available), it is less noticeable because no link will be displayed.
//
// Thus, it is better to have false negative, so let's make sure the worker is correctly
// initialized before advertizing that we are recording.
//
// In the future, when the compression worker will also be used for RUM data, this will be
// less important since no RUM event will be sent when the worker fails to initialize.
getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized && strategy.isRecording(),
getReplayStats: (viewId) =>
getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized ? getReplayStatsImpl(viewId) : undefined,
}
function onRumStart(
lifeCycle: LifeCycle,
configuration: RumConfiguration,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
worker: DeflateWorker | undefined,
telemetry: Telemetry
) {
let cachedDeflateEncoder: DeflateEncoder | undefined
function getOrCreateDeflateEncoder() {
if (!cachedDeflateEncoder) {
worker ??= startDeflateWorker(configuration, 'Datadog Session Replay', () => strategy.stop())
if (worker) {
cachedDeflateEncoder = createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY)
}
}
return cachedDeflateEncoder
}
strategy = createPostStartStrategy(
configuration,
lifeCycle,
sessionManager,
viewHistory,
loadRecorder,
getOrCreateDeflateEncoder,
telemetry
)
if (shouldStartImmediately(configuration)) {
strategy.start()
}
}
}
@@ -0,0 +1,144 @@
import type {
DeflateWorkerResponse,
DeflateEncoder,
DeflateEncoderStreamId,
DeflateWorker,
EncoderResult,
Uint8ArrayBuffer,
} from '@datadog/browser-core'
import { addEventListener, concatBuffers } from '@datadog/browser-core'
import type { RumConfiguration } from '@datadog/browser-rum-core'
export function createDeflateEncoder(
configuration: RumConfiguration,
worker: DeflateWorker,
streamId: DeflateEncoderStreamId
): DeflateEncoder {
let rawBytesCount = 0
let compressedData: Uint8ArrayBuffer[] = []
let compressedDataTrailer: Uint8ArrayBuffer
let isEmpty = true
let nextWriteActionId = 0
const pendingWriteActions: Array<{
writeCallback?: (additionalEncodedBytesCount: number) => void
finishCallback?: () => void
id: number
data: string
}> = []
const { stop: removeMessageListener } = addEventListener(
configuration,
worker,
'message',
({ data: workerResponse }: MessageEvent<DeflateWorkerResponse>) => {
if (workerResponse.type !== 'wrote' || (workerResponse.streamId as DeflateEncoderStreamId) !== streamId) {
return
}
const nextPendingAction = pendingWriteActions[0]
if (nextPendingAction) {
if (nextPendingAction.id === workerResponse.id) {
pendingWriteActions.shift()
rawBytesCount += workerResponse.additionalBytesCount
compressedData.push(workerResponse.result)
compressedDataTrailer = workerResponse.trailer
if (nextPendingAction.writeCallback) {
nextPendingAction.writeCallback(workerResponse.result.byteLength)
} else if (nextPendingAction.finishCallback) {
nextPendingAction.finishCallback()
}
} else if (nextPendingAction.id < workerResponse.id) {
// Worker responses received out of order
removeMessageListener()
}
}
}
)
function consumeResult(): EncoderResult<Uint8ArrayBuffer> {
const output =
compressedData.length === 0 ? new Uint8Array(0) : concatBuffers(compressedData.concat(compressedDataTrailer))
const result: EncoderResult<Uint8ArrayBuffer> = {
rawBytesCount,
output,
outputBytesCount: output.byteLength,
encoding: 'deflate',
}
rawBytesCount = 0
compressedData = []
return result
}
function sendResetIfNeeded() {
if (!isEmpty) {
worker.postMessage({
action: 'reset',
streamId,
})
isEmpty = true
}
}
return {
isAsync: true,
get isEmpty() {
return isEmpty
},
write(data, callback) {
worker.postMessage({
action: 'write',
id: nextWriteActionId,
data,
streamId,
})
pendingWriteActions.push({
id: nextWriteActionId,
writeCallback: callback,
data,
})
isEmpty = false
nextWriteActionId += 1
},
finish(callback) {
sendResetIfNeeded()
if (!pendingWriteActions.length) {
callback(consumeResult())
} else {
// Make sure we do not call any write callback
pendingWriteActions.forEach((pendingWriteAction) => {
delete pendingWriteAction.writeCallback
})
// Wait for the last action to finish before calling the finish callback
pendingWriteActions[pendingWriteActions.length - 1].finishCallback = () => callback(consumeResult())
}
},
finishSync() {
sendResetIfNeeded()
const pendingData = pendingWriteActions.map((pendingWriteAction) => pendingWriteAction.data).join('')
// Ignore all pending write actions responses from the worker
pendingWriteActions.length = 0
return { ...consumeResult(), pendingData }
},
estimateEncodedBytesCount(data) {
// This is a rough estimation of the data size once it'll be encoded by deflate. We observed
// that if it's the first chunk of data pushed to the stream, the ratio is lower (3-4), but
// after that the ratio is greater (10+). We chose 8 here, which (on average) seems to produce
// requests of the expected size.
return data.length / 8
},
stop() {
removeMessageListener()
},
}
}
@@ -0,0 +1,152 @@
import type { DeflateWorker, DeflateWorkerResponse } from '@datadog/browser-core'
import { addTelemetryError, display, addEventListener, setTimeout, ONE_SECOND, mockable } from '@datadog/browser-core'
import type { RumConfiguration } from '@datadog/browser-rum-core'
import { reportScriptLoadingError } from '../scriptLoadingError'
export const INITIALIZATION_TIME_OUT_DELAY = 30 * ONE_SECOND
declare const __BUILD_ENV__WORKER_STRING__: string
/**
* In order to be sure that the worker is correctly working, we need a round trip of
* initialization messages, making the creation asynchronous.
* These worker lifecycle states handle this case.
*/
export const enum DeflateWorkerStatus {
Nil,
Loading,
Error,
Initialized,
}
type DeflateWorkerState =
| {
status: DeflateWorkerStatus.Nil
}
| {
status: DeflateWorkerStatus.Loading
worker: DeflateWorker
stop: () => void
initializationFailureCallbacks: Array<() => void>
}
| {
status: DeflateWorkerStatus.Error
}
| {
status: DeflateWorkerStatus.Initialized
worker: DeflateWorker
stop: () => void
version: string
}
export type CreateDeflateWorker = typeof createDeflateWorker
export function createDeflateWorker(configuration: RumConfiguration): DeflateWorker {
return new Worker(configuration.workerUrl || URL.createObjectURL(new Blob([__BUILD_ENV__WORKER_STRING__])))
}
let state: DeflateWorkerState = { status: DeflateWorkerStatus.Nil }
export function startDeflateWorker(
configuration: RumConfiguration,
source: string,
onInitializationFailure: () => void
) {
if (state.status === DeflateWorkerStatus.Nil) {
// doStartDeflateWorker updates the state to "loading" or "error"
doStartDeflateWorker(configuration, source)
}
switch (state.status) {
case DeflateWorkerStatus.Loading:
state.initializationFailureCallbacks.push(onInitializationFailure)
return state.worker
case DeflateWorkerStatus.Initialized:
return state.worker
}
}
export function resetDeflateWorkerState() {
if (state.status === DeflateWorkerStatus.Initialized || state.status === DeflateWorkerStatus.Loading) {
state.stop()
}
state = { status: DeflateWorkerStatus.Nil }
}
export function getDeflateWorkerStatus() {
return state.status
}
/**
* Starts the deflate worker and handle messages and errors
*
* The spec allow browsers to handle worker errors differently:
* - Chromium throws an exception
* - Firefox fires an error event
*
* more details: https://bugzilla.mozilla.org/show_bug.cgi?id=1736865#c2
*/
export function doStartDeflateWorker(configuration: RumConfiguration, source: string) {
try {
const worker = mockable(createDeflateWorker)(configuration)
const { stop: removeErrorListener } = addEventListener(configuration, worker, 'error', (error) => {
onError(configuration, source, error)
})
const { stop: removeMessageListener } = addEventListener(
configuration,
worker,
'message',
({ data }: MessageEvent<DeflateWorkerResponse>) => {
if (data.type === 'errored') {
onError(configuration, source, data.error, data.streamId)
} else if (data.type === 'initialized') {
onInitialized(data.version)
}
}
)
worker.postMessage({ action: 'init' })
setTimeout(() => onTimeout(source), INITIALIZATION_TIME_OUT_DELAY)
const stop = () => {
removeErrorListener()
removeMessageListener()
}
state = { status: DeflateWorkerStatus.Loading, worker, stop, initializationFailureCallbacks: [] }
} catch (error) {
onError(configuration, source, error)
}
}
function onTimeout(source: string) {
if (state.status === DeflateWorkerStatus.Loading) {
display.error(`${source} failed to start: a timeout occurred while initializing the Worker`)
state.initializationFailureCallbacks.forEach((callback) => callback())
state = { status: DeflateWorkerStatus.Error }
}
}
function onInitialized(version: string) {
if (state.status === DeflateWorkerStatus.Loading) {
state = { status: DeflateWorkerStatus.Initialized, worker: state.worker, stop: state.stop, version }
}
}
function onError(configuration: RumConfiguration, source: string, error: unknown, streamId?: number) {
if (state.status === DeflateWorkerStatus.Loading || state.status === DeflateWorkerStatus.Nil) {
reportScriptLoadingError({
configuredUrl: configuration.workerUrl,
error,
source,
scriptType: 'worker',
})
if (state.status === DeflateWorkerStatus.Loading) {
state.initializationFailureCallbacks.forEach((callback) => callback())
}
state = { status: DeflateWorkerStatus.Error }
} else {
addTelemetryError(error, {
worker_version: state.status === DeflateWorkerStatus.Initialized && state.version,
stream_id: streamId,
})
}
}
@@ -0,0 +1,40 @@
import type { RumConfiguration, RumSessionManager, ViewHistory, RumSession } from '@datadog/browser-rum-core'
import { getSessionReplayUrl, SessionReplayState } from '@datadog/browser-rum-core'
import { isBrowserSupported } from '../boot/isBrowserSupported'
export function getSessionReplayLink(
configuration: RumConfiguration,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
isRecordingStarted: boolean
): string | undefined {
const session = sessionManager.findTrackedSession()
const errorType = getErrorType(session, isRecordingStarted)
const viewContext = viewHistory.findView()
return getSessionReplayUrl(configuration, {
viewContext,
errorType,
session,
})
}
function getErrorType(session: RumSession | undefined, isRecordingStarted: boolean) {
if (!isBrowserSupported()) {
return 'browser-not-supported'
}
if (!session) {
// possibilities:
// - rum sampled out
// - session expired (edge case)
return 'rum-not-tracked'
}
if (session.sessionReplay === SessionReplayState.OFF) {
// possibilities
// - replay sampled out
return 'incorrect-session-plan'
}
if (!isRecordingStarted) {
return 'replay-not-started'
}
}
@@ -0,0 +1,39 @@
import { HookNames, SKIPPED } from '@datadog/browser-core'
import type { Hooks, ProfilingInternalContextSchema } from '@datadog/browser-rum-core'
import { RumEventType } from '@datadog/browser-rum-core'
export interface ProfilingContextManager {
set: (next: ProfilingInternalContextSchema) => void
get: () => ProfilingInternalContextSchema | undefined
}
export function startProfilingContext(hooks: Hooks): ProfilingContextManager {
let currentContext: ProfilingInternalContextSchema = {
status: 'starting',
}
hooks.register(HookNames.Assemble, ({ eventType }) => {
if (
eventType !== RumEventType.VIEW &&
eventType !== RumEventType.LONG_TASK &&
eventType !== RumEventType.ACTION &&
eventType !== RumEventType.VITAL
) {
return SKIPPED
}
return {
type: eventType,
_dd: {
profiling: currentContext,
},
}
})
return {
get: () => currentContext,
set: (newContext: ProfilingInternalContextSchema) => {
currentContext = newContext
},
}
}
@@ -0,0 +1,10 @@
import { getGlobalObject } from '@datadog/browser-core'
import type { Profiler } from './types'
export function isProfilingSupported(): boolean {
const globalThis = getGlobalObject()
// This API might be unavailable in some browsers
const globalThisProfiler: Profiler | undefined = (globalThis as any).Profiler
return globalThisProfiler !== undefined
}
+61
View File
@@ -0,0 +1,61 @@
import type { ReplayStats } from '@datadog/browser-rum-core'
export const MAX_STATS_HISTORY = 1000
let statsPerView: Map<string, ReplayStats> | undefined
export function getSegmentsCount(viewId: string) {
return getOrCreateReplayStats(viewId).segments_count
}
export function addSegment(viewId: string) {
getOrCreateReplayStats(viewId).segments_count += 1
}
export function addRecord(viewId: string) {
getOrCreateReplayStats(viewId).records_count += 1
}
export function addWroteData(viewId: string, additionalBytesCount: number) {
getOrCreateReplayStats(viewId).segments_total_raw_size += additionalBytesCount
}
export function getReplayStats(viewId: string) {
return statsPerView?.get(viewId)
}
export function resetReplayStats() {
statsPerView = undefined
}
function getOrCreateReplayStats(viewId: string) {
if (!statsPerView) {
statsPerView = new Map()
}
let replayStats: ReplayStats
if (statsPerView.has(viewId)) {
replayStats = statsPerView.get(viewId)!
} else {
replayStats = {
records_count: 0,
segments_count: 0,
segments_total_raw_size: 0,
}
statsPerView.set(viewId, replayStats)
if (statsPerView.size > MAX_STATS_HISTORY) {
deleteOldestStats()
}
}
return replayStats
}
function deleteOldestStats() {
if (!statsPerView) {
return
}
const toDelete = statsPerView.keys().next().value
if (toDelete) {
statsPerView.delete(toDelete)
}
}
@@ -0,0 +1,36 @@
import { addTelemetryError, display, DOCS_ORIGIN } from '@datadog/browser-core'
export function reportScriptLoadingError({
configuredUrl,
error,
source,
scriptType,
}: {
configuredUrl?: string | undefined
error: unknown
source: string
scriptType: 'module' | 'worker'
}) {
display.error(`${source} failed to start: an error occurred while initializing the ${scriptType}:`, error)
if (error instanceof Event || (error instanceof Error && isMessageCspRelated(error.message))) {
let baseMessage
if (configuredUrl) {
baseMessage = `Please make sure the ${scriptType} URL ${configuredUrl} is correct and CSP is correctly configured.`
} else {
baseMessage = 'Please make sure CSP is correctly configured.'
}
display.error(
`${baseMessage} See documentation at ${DOCS_ORIGIN}/integrations/content_security_policy_logs/#use-csp-with-real-user-monitoring-and-session-replay`
)
} else if (scriptType === 'worker') {
addTelemetryError(error)
}
}
function isMessageCspRelated(message: string) {
return (
message.includes('Content Security Policy') ||
// Related to `require-trusted-types-for` CSP: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/require-trusted-types-for
message.includes("requires 'TrustedScriptURL'")
)
}
@@ -0,0 +1,90 @@
import type { Context, Duration, Telemetry, Observable, TimeStamp } from '@datadog/browser-core'
import { TelemetryMetrics, addTelemetryMetrics, noop, timeStampNow, elapsed } from '@datadog/browser-core'
import type { RecorderInitEvent } from '../boot/postStartStrategy'
type RecorderInitResult = 'aborted' | 'deflate-encoder-load-failed' | 'recorder-load-failed' | 'succeeded'
export interface RecorderInitMetrics extends Context {
forced: boolean
loadRecorderModuleDuration: number | undefined
recorderInitDuration: number
result: RecorderInitResult
waitForDocReadyDuration: number | undefined
}
export function startRecorderInitTelemetry(telemetry: Telemetry, observable: Observable<RecorderInitEvent>) {
if (!telemetry.metricsEnabled) {
return { stop: noop }
}
let startContext:
| {
forced: boolean
timestamp: TimeStamp
}
| undefined
let documentReadyDuration: Duration | undefined
let recorderSettledDuration: Duration | undefined
const { unsubscribe } = observable.subscribe((event) => {
switch (event.type) {
case 'start':
startContext = { forced: event.forced, timestamp: timeStampNow() }
documentReadyDuration = undefined
recorderSettledDuration = undefined
break
case 'document-ready':
if (startContext) {
documentReadyDuration = elapsed(startContext.timestamp, timeStampNow())
}
break
case 'recorder-settled':
if (startContext) {
recorderSettledDuration = elapsed(startContext.timestamp, timeStampNow())
}
break
case 'aborted':
case 'deflate-encoder-load-failed':
case 'recorder-load-failed':
case 'succeeded':
// Only send metrics for the first attempt at starting the recorder.
unsubscribe()
if (startContext) {
// monitor-until: 2026-07-01
addTelemetryMetrics(TelemetryMetrics.RECORDER_INIT_METRICS_TELEMETRY_NAME, {
metrics: createRecorderInitMetrics(
startContext.forced,
recorderSettledDuration,
elapsed(startContext.timestamp, timeStampNow()),
event.type,
documentReadyDuration
),
})
}
break
}
})
return { stop: unsubscribe }
}
function createRecorderInitMetrics(
forced: boolean,
loadRecorderModuleDuration: Duration | undefined,
recorderInitDuration: Duration,
result: RecorderInitResult,
waitForDocReadyDuration: Duration | undefined
): RecorderInitMetrics {
return {
forced,
loadRecorderModuleDuration,
recorderInitDuration,
result,
waitForDocReadyDuration,
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Datadog Browser RUM SDK - Full version with Session Replay and Real User Profiling capabilities.
* Use this package to monitor your web application's performance and user experience.
*
* @packageDocumentation
* @see [RUM Browser Monitoring Setup](https://docs.datadoghq.com/real_user_monitoring/browser/)
*/
// Keep the following in sync with packages/rum-slim/src/entries/main.ts
import { defineGlobal, getGlobalObject } from '@datadog/browser-core'
import type { RumPublicApi } from '@datadog/browser-rum-core'
import { makeRumPublicApi } from '@datadog/browser-rum-core'
import { makeRecorderApi } from '../boot/recorderApi'
import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate'
import { lazyLoadRecorder } from '../boot/lazyLoadRecorder'
import { makeProfilerApi } from '../boot/profilerApi'
export type {
User,
Account,
TraceContextInjection,
SessionPersistence,
TrackingConsent,
MatchOption,
ProxyFn,
Site,
Context,
ContextValue,
ContextArray,
RumInternalContext,
} from '@datadog/browser-core'
export { DefaultPrivacyLevel } from '@datadog/browser-core'
/**
* @deprecated Use {@link DatadogRum} instead
*/
export type RumGlobal = RumPublicApi
export type {
RumPublicApi as DatadogRum,
RumInitConfiguration,
RumBeforeSend,
ViewOptions,
StartRecordingOptions,
AddDurationVitalOptions,
DurationVitalOptions,
DurationVitalReference,
TracingOption,
RumPlugin,
OnRumStartOptions,
PropagatorType,
FeatureFlagsForEvents,
// Events
CommonProperties,
RumEvent,
RumActionEvent,
RumErrorEvent,
RumLongTaskEvent,
RumResourceEvent,
RumViewEvent,
RumVitalEvent,
// Events context
RumEventDomainContext,
RumViewEventDomainContext,
RumErrorEventDomainContext,
RumActionEventDomainContext,
RumVitalEventDomainContext,
RumFetchResourceEventDomainContext,
RumXhrResourceEventDomainContext,
RumOtherResourceEventDomainContext,
RumLongTaskEventDomainContext,
} from '@datadog/browser-rum-core'
const recorderApi = makeRecorderApi(lazyLoadRecorder)
const profilerApi = makeProfilerApi()
/**
* The global RUM instance. Use this to call RUM methods.
*
* @category Main
* @see {@link DatadogRum}
* @see [RUM Browser Monitoring Setup](https://docs.datadoghq.com/real_user_monitoring/browser/)
*/
export const datadogRum = makeRumPublicApi(recorderApi, profilerApi, {
startDeflateWorker,
createDeflateEncoder,
sdkName: 'rum',
})
interface BrowserWindow extends Window {
DD_RUM?: RumPublicApi
}
defineGlobal(getGlobalObject<BrowserWindow>(), 'DD_RUM', datadogRum)