New source found from dndbeyond.com
This commit is contained in:
+14
@@ -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
|
||||
)
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+176
@@ -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)
|
||||
}
|
||||
}
|
||||
+34
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
+152
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
+40
@@ -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'
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+39
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+10
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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'")
|
||||
)
|
||||
}
|
||||
Generated
Vendored
+90
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user