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,8 @@
import type { InitConfiguration } from '../domain/configuration'
import { display } from '../tools/display'
export function displayAlreadyInitializedError(sdkName: 'DD_RUM' | 'DD_LOGS', initConfiguration: InitConfiguration) {
if (!initConfiguration.silentMultipleInit) {
display.error(`${sdkName} is already initialized.`)
}
}
+55
View File
@@ -0,0 +1,55 @@
import { catchUserErrors } from '../tools/catchUserErrors'
import { setDebugMode } from '../tools/monitor'
import { display } from '../tools/display'
// replaced at build time
declare const __BUILD_ENV__SDK_VERSION__: string
export interface PublicApi {
/**
* Version of the Logs browser SDK
*/
version: string
/**
* For CDN async setup: Early RUM API calls must be wrapped in the `window.DD_RUM.onReady()` callback. This ensures the code only gets executed once the SDK is properly loaded.
*
* See [CDN async setup](https://docs.datadoghq.com/real_user_monitoring/browser/#cdn-async) for further information.
*/
onReady: (callback: () => void) => void
}
export function makePublicApi<T extends PublicApi>(stub: Omit<T, keyof PublicApi>): T {
const publicApi = {
version: __BUILD_ENV__SDK_VERSION__,
// This API method is intentionally not monitored, since the only thing executed is the
// user-provided 'callback'. All SDK usages executed in the callback should be monitored, and
// we don't want to interfere with the user uncaught exceptions.
onReady(callback: () => void) {
callback()
},
...stub,
}
// Add a "hidden" property to set debug mode. We define it that way to hide it
// as much as possible but of course it's not a real protection.
Object.defineProperty(publicApi, '_setDebug', {
get() {
return setDebugMode
},
enumerable: false,
})
return publicApi as T
}
export function defineGlobal<Global, Name extends keyof Global>(global: Global, name: Name, api: Global[Name]) {
const existingGlobalVariable = global[name] as { q?: Array<() => void>; version?: string } | undefined
if (existingGlobalVariable && !existingGlobalVariable.q && existingGlobalVariable.version) {
display.warn('SDK is loaded more than once. This is unsupported and might have unexpected behavior.')
}
global[name] = api
if (existingGlobalVariable && existingGlobalVariable.q) {
existingGlobalVariable.q.forEach((fn) => catchUserErrors(fn, 'onReady callback threw an error:')())
}
}
@@ -0,0 +1,145 @@
import { monitor } from '../tools/monitor'
import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue'
import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './browser.types'
export type TrustableEvent<E extends Event = Event> = E & { __ddIsTrusted?: boolean }
export const enum DOM_EVENT {
BEFORE_UNLOAD = 'beforeunload',
CLICK = 'click',
DBL_CLICK = 'dblclick',
KEY_DOWN = 'keydown',
LOAD = 'load',
POP_STATE = 'popstate',
SCROLL = 'scroll',
TOUCH_START = 'touchstart',
TOUCH_END = 'touchend',
TOUCH_MOVE = 'touchmove',
VISIBILITY_CHANGE = 'visibilitychange',
PAGE_SHOW = 'pageshow',
FREEZE = 'freeze',
RESUME = 'resume',
DOM_CONTENT_LOADED = 'DOMContentLoaded',
POINTER_DOWN = 'pointerdown',
POINTER_UP = 'pointerup',
POINTER_CANCEL = 'pointercancel',
HASH_CHANGE = 'hashchange',
PAGE_HIDE = 'pagehide',
MOUSE_DOWN = 'mousedown',
MOUSE_UP = 'mouseup',
MOUSE_MOVE = 'mousemove',
FOCUS = 'focus',
BLUR = 'blur',
CONTEXT_MENU = 'contextmenu',
RESIZE = 'resize',
CHANGE = 'change',
INPUT = 'input',
PLAY = 'play',
PAUSE = 'pause',
SECURITY_POLICY_VIOLATION = 'securitypolicyviolation',
SELECTION_CHANGE = 'selectionchange',
STORAGE = 'storage',
}
interface AddEventListenerOptions {
once?: boolean
capture?: boolean
passive?: boolean
}
type EventMapFor<T> = T extends Window
? WindowEventMap & {
// TS 4.9.5 does not support `freeze` and `resume` events yet
freeze: Event
resume: Event
// TS 4.9.5 does not define `visibilitychange` on Window (only Document)
visibilitychange: Event
}
: T extends Document
? DocumentEventMap
: T extends HTMLElement
? HTMLElementEventMap
: T extends VisualViewport
? VisualViewportEventMap
: T extends ShadowRoot
? // ShadowRootEventMap is not yet defined in our supported TS version. Instead, use
// GlobalEventHandlersEventMap which is more than enough as we only need to listen for events bubbling
// through the ShadowRoot like "change" or "input"
GlobalEventHandlersEventMap
: T extends XMLHttpRequest
? XMLHttpRequestEventMap
: T extends Performance
? PerformanceEventMap
: T extends Worker
? WorkerEventMap
: T extends CookieStore
? CookieStoreEventMap
: Record<never, never>
/**
* Add an event listener to an event target object (Window, Element, mock object...). This provides
* a few conveniences compared to using `element.addEventListener` directly:
*
* * supports IE11 by: using an option object only if needed and emulating the `once` option
*
* * wraps the listener with a `monitor` function
*
* * returns a `stop` function to remove the listener
*/
export function addEventListener<Target extends EventTarget, EventName extends keyof EventMapFor<Target> & string>(
configuration: { allowUntrustedEvents?: boolean | undefined },
eventTarget: Target,
eventName: EventName,
listener: (event: EventMapFor<Target>[EventName] & { type: EventName }) => void,
options?: AddEventListenerOptions
) {
return addEventListeners(configuration, eventTarget, [eventName], listener, options)
}
/**
* Add event listeners to an event target object (Window, Element, mock object...). This provides
* a few conveniences compared to using `element.addEventListener` directly:
*
* * supports IE11 by: using an option object only if needed and emulating the `once` option
*
* * wraps the listener with a `monitor` function
*
* * returns a `stop` function to remove the listener
*
* * with `once: true`, the listener will be called at most once, even if different events are listened
*/
export function addEventListeners<Target extends EventTarget, EventName extends keyof EventMapFor<Target> & string>(
configuration: { allowUntrustedEvents?: boolean | undefined },
eventTarget: Target,
eventNames: EventName[],
listener: (event: EventMapFor<Target>[EventName] & { type: EventName }) => void,
{ once, capture, passive }: AddEventListenerOptions = {}
) {
const listenerWithMonitor = monitor((event: TrustableEvent) => {
if (!event.isTrusted && !event.__ddIsTrusted && !configuration.allowUntrustedEvents) {
return
}
if (once) {
stop()
}
listener(event as unknown as EventMapFor<Target>[EventName] & { type: EventName })
})
const options = passive ? { capture, passive } : capture
// Use the window.EventTarget.prototype when possible to avoid wrong overrides (e.g: https://github.com/salesforce/lwc/issues/1824)
const listenerTarget =
window.EventTarget && eventTarget instanceof EventTarget ? window.EventTarget.prototype : eventTarget
const add = getZoneJsOriginalValue(listenerTarget, 'addEventListener')
eventNames.forEach((eventName) => add.call(eventTarget, eventName, listenerWithMonitor, options))
function stop() {
const remove = getZoneJsOriginalValue(listenerTarget, 'removeEventListener')
eventNames.forEach((eventName) => remove.call(eventTarget, eventName, listenerWithMonitor, options))
}
return {
stop,
}
}
+125
View File
@@ -0,0 +1,125 @@
import { display } from '../tools/display'
import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
import {
findAllCommaSeparatedValues,
findCommaSeparatedValue,
findCommaSeparatedValues,
generateUUID,
} from '../tools/utils/stringUtils'
import { buildUrl } from '../tools/utils/urlPolyfill'
export interface CookieOptions {
secure?: boolean
crossSite?: boolean
partitioned?: boolean
domain?: string
}
export function setCookie(name: string, value: string, expireDelay: number = 0, options?: CookieOptions) {
const date = new Date()
date.setTime(date.getTime() + expireDelay)
const expires = `expires=${date.toUTCString()}`
const sameSite = options && options.crossSite ? 'none' : 'strict'
const domain = options && options.domain ? `;domain=${options.domain}` : ''
const secure = options && options.secure ? ';secure' : ''
const partitioned = options && options.partitioned ? ';partitioned' : ''
document.cookie = `${name}=${value};${expires};path=/;samesite=${sameSite}${domain}${secure}${partitioned}`
}
/**
* Returns the value of the cookie with the given name
* If there are multiple cookies with the same name, returns the first one
*/
export function getCookie(name: string) {
return findCommaSeparatedValue(document.cookie, name)
}
/**
* Returns all the values of the cookies with the given name
*/
export function getCookies(name: string): string[] {
return findAllCommaSeparatedValues(document.cookie).get(name) || []
}
let initCookieParsed: Map<string, string> | undefined
/**
* Returns a cached value of the cookie. Use this during SDK initialization (and whenever possible)
* to avoid accessing document.cookie multiple times.
*
* ⚠️ If there are multiple cookies with the same name, returns the LAST one (unlike `getCookie()`)
*/
export function getInitCookie(name: string) {
if (!initCookieParsed) {
initCookieParsed = findCommaSeparatedValues(document.cookie)
}
return initCookieParsed.get(name)
}
export function resetInitCookies() {
initCookieParsed = undefined
}
export function deleteCookie(name: string, options?: CookieOptions) {
setCookie(name, '', 0, options)
}
export function areCookiesAuthorized(options: CookieOptions): boolean {
if (document.cookie === undefined || document.cookie === null) {
return false
}
try {
// Use a unique cookie name to avoid issues when the SDK is initialized multiple times during
// the test cookie lifetime
const testCookieName = `dd_cookie_test_${generateUUID()}`
const testCookieValue = 'test'
setCookie(testCookieName, testCookieValue, ONE_MINUTE, options)
const isCookieCorrectlySet = getCookie(testCookieName) === testCookieValue
deleteCookie(testCookieName, options)
return isCookieCorrectlySet
} catch (error) {
display.error(error)
return false
}
}
/**
* No API to retrieve it, number of levels for subdomain and suffix are unknown
* strategy: find the minimal domain on which cookies are allowed to be set
* https://web.dev/same-site-same-origin/#site
*/
let getCurrentSiteCache: string | undefined
export function getCurrentSite(hostname = location.hostname, referrer = document.referrer): string | undefined {
if (getCurrentSiteCache === undefined) {
const defaultHostName = getCookieDefaultHostName(hostname, referrer)
if (defaultHostName) {
// Use a unique cookie name to avoid issues when the SDK is initialized multiple times during
// the test cookie lifetime
const testCookieName = `dd_site_test_${generateUUID()}`
const testCookieValue = 'test'
const domainLevels = defaultHostName.split('.')
let candidateDomain = domainLevels.pop()!
while (domainLevels.length && !getCookie(testCookieName)) {
candidateDomain = `${domainLevels.pop()!}.${candidateDomain}`
setCookie(testCookieName, testCookieValue, ONE_SECOND, { domain: candidateDomain })
}
deleteCookie(testCookieName, { domain: candidateDomain })
getCurrentSiteCache = candidateDomain
}
}
return getCurrentSiteCache
}
function getCookieDefaultHostName(hostname: string, referrer: string) {
try {
return hostname || buildUrl(referrer).hostname
} catch {
// Ignore
}
}
export function resetGetCurrentSite() {
getCurrentSiteCache = undefined
}
+14
View File
@@ -0,0 +1,14 @@
import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue'
import { getGlobalObject } from '../tools/globalObject'
/**
* Make a fetch request using the native implementation, bypassing Zone.js patching.
* This prevents unnecessary Angular change detection cycles.
*
* @param input - The resource to fetch (URL or Request object)
* @param init - Optional fetch options
* @returns A Promise that resolves to the Response
*/
export function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return getZoneJsOriginalValue(getGlobalObject(), 'fetch')(input, init)
}
@@ -0,0 +1,170 @@
import type { InstrumentedMethodCall } from '../tools/instrumentMethod'
import { instrumentMethod } from '../tools/instrumentMethod'
import { monitorError } from '../tools/monitor'
import { Observable } from '../tools/observable'
import type { ClocksState } from '../tools/utils/timeUtils'
import { clocksNow } from '../tools/utils/timeUtils'
import { normalizeUrl } from '../tools/utils/urlPolyfill'
import type { GlobalObject } from '../tools/globalObject'
import { globalObject } from '../tools/globalObject'
import { readBytesFromStream } from '../tools/readBytesFromStream'
import { tryToClone } from '../tools/utils/responseUtils'
interface FetchContextBase {
method: string
startClocks: ClocksState
input: unknown
init?: RequestInit
url: string
handlingStack?: string
isAbortedOnStart: boolean
}
export interface FetchStartContext extends FetchContextBase {
state: 'start'
}
export interface FetchResolveContext extends FetchContextBase {
state: 'resolve'
status: number
response?: Response
responseBody?: string
responseType?: string
isAborted: boolean
error?: Error
}
export type FetchContext = FetchStartContext | FetchResolveContext
type ResponseBodyActionGetter = (context: FetchResolveContext) => ResponseBodyAction
/**
* Action to take with the response body of a fetch request.
* Values are ordered by priority: higher values take precedence when multiple actions are requested.
*/
export const enum ResponseBodyAction {
IGNORE = 0,
// TODO(next-major): Remove the "WAIT" action when `trackEarlyRequests` is removed, as the
// duration of fetch requests will always come from PerformanceResourceTiming
WAIT = 1,
COLLECT = 2,
}
let fetchObservable: Observable<FetchContext> | undefined
const responseBodyActionGetters: ResponseBodyActionGetter[] = []
export function initFetchObservable({ responseBodyAction }: { responseBodyAction?: ResponseBodyActionGetter } = {}) {
if (responseBodyAction) {
responseBodyActionGetters.push(responseBodyAction)
}
if (!fetchObservable) {
fetchObservable = createFetchObservable()
}
return fetchObservable
}
export function resetFetchObservable() {
fetchObservable = undefined
responseBodyActionGetters.length = 0
}
function createFetchObservable() {
return new Observable<FetchContext>((observable) => {
// eslint-disable-next-line local-rules/disallow-zone-js-patched-values
if (!globalObject.fetch) {
return
}
const { stop } = instrumentMethod(globalObject, 'fetch', (call) => beforeSend(call, observable), {
computeHandlingStack: true,
})
return stop
})
}
function beforeSend(
{ parameters, onPostCall, handlingStack }: InstrumentedMethodCall<GlobalObject, 'fetch'>,
observable: Observable<FetchContext>
) {
const [input, init] = parameters
let methodFromParams = init && init.method
if (methodFromParams === undefined && input instanceof Request) {
methodFromParams = input.method
}
const method = methodFromParams !== undefined ? String(methodFromParams).toUpperCase() : 'GET'
const url = input instanceof Request ? input.url : normalizeUrl(String(input))
const startClocks = clocksNow()
const context: FetchStartContext = {
state: 'start',
init,
input,
method,
startClocks,
url,
handlingStack,
isAbortedOnStart: (input instanceof Request && input.signal?.aborted) || init?.signal?.aborted || false,
}
observable.notify(context)
// Those properties can be changed by observable subscribers
parameters[0] = context.input as RequestInfo | URL
parameters[1] = context.init
onPostCall((responsePromise) => {
afterSend(observable, responsePromise, context).catch(monitorError)
})
}
async function afterSend(
observable: Observable<FetchContext>,
responsePromise: Promise<Response>,
startContext: FetchStartContext
) {
const context = startContext as unknown as FetchResolveContext
context.state = 'resolve'
let response: Response
try {
response = await responsePromise
} catch (error) {
context.status = 0
context.isAborted =
context.init?.signal?.aborted || (error instanceof DOMException && error.code === DOMException.ABORT_ERR)
context.error = error as Error
observable.notify(context)
return
}
context.response = response
context.status = response.status
context.responseType = response.type
context.isAborted = false
const responseBodyCondition = responseBodyActionGetters.reduce(
(action, getter) => Math.max(action, getter(context)),
ResponseBodyAction.IGNORE
) as ResponseBodyAction
if (responseBodyCondition !== ResponseBodyAction.IGNORE) {
const clonedResponse = tryToClone(response)
if (clonedResponse && clonedResponse.body) {
try {
const bytes = await readBytesFromStream(clonedResponse.body, {
collectStreamBody: responseBodyCondition === ResponseBodyAction.COLLECT,
})
context.responseBody = bytes && new TextDecoder().decode(bytes)
} catch {
// Ignore errors when reading the response body (e.g., stream aborted, network errors)
// This is not critical and should not be reported as an SDK error
}
}
}
observable.notify(context)
}
@@ -0,0 +1,61 @@
import { Observable } from '../tools/observable'
import { objectValues } from '../tools/utils/polyfills'
import type { Configuration } from '../domain/configuration'
import { isWorkerEnvironment } from '../tools/globalObject'
import { addEventListeners, addEventListener, DOM_EVENT } from './addEventListener'
export const PageExitReason = {
HIDDEN: 'visibility_hidden',
UNLOADING: 'before_unload',
PAGEHIDE: 'page_hide',
FROZEN: 'page_frozen',
} as const
export type PageExitReason = (typeof PageExitReason)[keyof typeof PageExitReason]
export interface PageMayExitEvent {
reason: PageExitReason
}
export function createPageMayExitObservable(configuration: Configuration): Observable<PageMayExitEvent> {
return new Observable<PageMayExitEvent>((observable) => {
if (isWorkerEnvironment) {
// Page exit is not observable in worker environments (no window/document events)
return
}
const { stop: stopListeners } = addEventListeners(
configuration,
window,
[DOM_EVENT.VISIBILITY_CHANGE, DOM_EVENT.FREEZE],
(event) => {
if (event.type === DOM_EVENT.VISIBILITY_CHANGE && document.visibilityState === 'hidden') {
/**
* Only event that guarantee to fire on mobile devices when the page transitions to background state
* (e.g. when user switches to a different application, goes to homescreen, etc), or is being unloaded.
*/
observable.notify({ reason: PageExitReason.HIDDEN })
} else if (event.type === DOM_EVENT.FREEZE) {
/**
* After transitioning in background a tab can be freezed to preserve resources. (cf: https://developer.chrome.com/blog/page-lifecycle-api)
* Allow to collect events happening between hidden and frozen state.
*/
observable.notify({ reason: PageExitReason.FROZEN })
}
},
{ capture: true }
)
const stopBeforeUnloadListener = addEventListener(configuration, window, DOM_EVENT.BEFORE_UNLOAD, () => {
observable.notify({ reason: PageExitReason.UNLOADING })
}).stop
return () => {
stopListeners()
stopBeforeUnloadListener()
}
})
}
export function isPageExitReason(reason: string): reason is PageExitReason {
return objectValues(PageExitReason).includes(reason as PageExitReason)
}
@@ -0,0 +1,25 @@
import type { Configuration } from '../domain/configuration'
import { noop } from '../tools/utils/functionUtils'
import { DOM_EVENT, addEventListener } from './addEventListener'
export function runOnReadyState(
configuration: Configuration,
expectedReadyState: 'complete' | 'interactive',
callback: () => void
): { stop: () => void } {
if (document.readyState === expectedReadyState || document.readyState === 'complete') {
callback()
return { stop: noop }
}
const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED
return addEventListener(configuration, window, eventName, callback, { once: true })
}
export function asyncRunOnReadyState(
configuration: Configuration,
expectedReadyState: 'complete' | 'interactive'
): Promise<void> {
return new Promise((resolve) => {
runOnReadyState(configuration, expectedReadyState, resolve)
})
}
@@ -0,0 +1,143 @@
import type { InstrumentedMethodCall } from '../tools/instrumentMethod'
import { instrumentMethod } from '../tools/instrumentMethod'
import { Observable } from '../tools/observable'
import type { Duration, ClocksState } from '../tools/utils/timeUtils'
import { elapsed, clocksNow, timeStampNow } from '../tools/utils/timeUtils'
import { normalizeUrl } from '../tools/utils/urlPolyfill'
import { shallowClone } from '../tools/utils/objectUtils'
import type { Configuration } from '../domain/configuration'
import { addEventListener } from './addEventListener'
export interface XhrOpenContext {
state: 'open'
method: string
url: string
}
export interface XhrStartContext extends Omit<XhrOpenContext, 'state'> {
state: 'start'
startClocks: ClocksState
isAborted: boolean
xhr: XMLHttpRequest
handlingStack?: string
requestBody?: unknown
}
export interface XhrCompleteContext extends Omit<XhrStartContext, 'state'> {
state: 'complete'
duration: Duration
status: number
responseBody?: string
}
export type XhrContext = XhrOpenContext | XhrStartContext | XhrCompleteContext
let xhrObservable: Observable<XhrContext> | undefined
const xhrContexts = new WeakMap<XMLHttpRequest, XhrContext>()
export function initXhrObservable(configuration: Configuration) {
if (!xhrObservable) {
xhrObservable = createXhrObservable(configuration)
}
return xhrObservable
}
function createXhrObservable(configuration: Configuration) {
return new Observable<XhrContext>((observable) => {
const { stop: stopInstrumentingStart } = instrumentMethod(XMLHttpRequest.prototype, 'open', openXhr)
const { stop: stopInstrumentingSend } = instrumentMethod(
XMLHttpRequest.prototype,
'send',
(call) => {
sendXhr(call, configuration, observable)
},
{ computeHandlingStack: true }
)
const { stop: stopInstrumentingAbort } = instrumentMethod(XMLHttpRequest.prototype, 'abort', abortXhr)
return () => {
stopInstrumentingStart()
stopInstrumentingSend()
stopInstrumentingAbort()
}
})
}
function openXhr({ target: xhr, parameters: [method, url] }: InstrumentedMethodCall<XMLHttpRequest, 'open'>) {
xhrContexts.set(xhr, {
state: 'open',
method: String(method).toUpperCase(),
url: normalizeUrl(String(url)),
})
}
function sendXhr(
{ target: xhr, parameters: [body], handlingStack }: InstrumentedMethodCall<XMLHttpRequest, 'send'>,
configuration: Configuration,
observable: Observable<XhrContext>
) {
const context = xhrContexts.get(xhr)
if (!context) {
return
}
const startContext = context as XhrStartContext
startContext.state = 'start'
startContext.startClocks = clocksNow()
startContext.isAborted = false
startContext.xhr = xhr
startContext.handlingStack = handlingStack
startContext.requestBody = body
let hasBeenReported = false
const { stop: stopInstrumentingOnReadyStateChange } = instrumentMethod(xhr, 'onreadystatechange', () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
// Try to report the XHR as soon as possible, because the XHR may be mutated by the
// application during a future event. For example, Angular is calling .abort() on
// completed requests during an onreadystatechange event, so the status becomes '0'
// before the request is collected.
onEnd()
}
})
const onEnd = () => {
unsubscribeLoadEndListener()
stopInstrumentingOnReadyStateChange()
if (hasBeenReported) {
return
}
hasBeenReported = true
const completeContext = context as XhrCompleteContext
completeContext.state = 'complete'
completeContext.duration = elapsed(startContext.startClocks.timeStamp, timeStampNow())
completeContext.status = xhr.status
if (typeof xhr.response === 'string') {
completeContext.responseBody = xhr.response
}
observable.notify(shallowClone(completeContext))
}
const { stop: unsubscribeLoadEndListener } = addEventListener(configuration, xhr, 'loadend', onEnd)
observable.notify(startContext)
}
function abortXhr({ target: xhr }: InstrumentedMethodCall<XMLHttpRequest, 'abort'>) {
const context = xhrContexts.get(xhr) as XhrStartContext | undefined
if (context) {
context.isAborted = true
}
}
/**
* Reset the XHR observable global state. This is useful for testing to ensure clean state between tests.
*
* @internal
*/
export function resetXhrObservable() {
xhrObservable = undefined
}
@@ -0,0 +1,30 @@
import { display } from '../tools/display'
import { getGlobalObject } from '../tools/globalObject'
import { matchList } from '../tools/matchOption'
import { mockable } from '../tools/mockable'
import type { InitConfiguration } from './configuration'
import { isUnsupportedExtensionEnvironment } from './extension/extensionUtils'
export const ERROR_DOES_NOT_HAVE_ALLOWED_TRACKING_ORIGIN =
'Running the Browser SDK in a Web extension content script is forbidden unless the `allowedTrackingOrigins` option is provided.'
export const ERROR_NOT_ALLOWED_TRACKING_ORIGIN = 'SDK initialized on a non-allowed domain.'
export function isAllowedTrackingOrigins(configuration: InitConfiguration, errorStack: string): boolean {
const location = mockable(getGlobalObject().location)
const windowOrigin = location ? location.origin : ''
const allowedTrackingOrigins = configuration.allowedTrackingOrigins
if (!allowedTrackingOrigins) {
if (isUnsupportedExtensionEnvironment(windowOrigin, errorStack)) {
display.error(ERROR_DOES_NOT_HAVE_ALLOWED_TRACKING_ORIGIN)
return false
}
return true
}
const isAllowed = matchList(allowedTrackingOrigins, windowOrigin)
if (!isAllowed) {
display.error(ERROR_NOT_ALLOWED_TRACKING_ORIGIN)
}
return isAllowed
}
+33
View File
@@ -0,0 +1,33 @@
import { BufferedObservable } from '../tools/observable'
import { mockable } from '../tools/mockable'
import type { RawError } from './error/error.types'
import { trackRuntimeError } from './error/trackRuntimeError'
const BUFFER_LIMIT = 500
export const enum BufferedDataType {
RUNTIME_ERROR,
}
export interface BufferedData {
type: BufferedDataType.RUNTIME_ERROR
error: RawError
}
export function startBufferingData() {
const observable = new BufferedObservable<BufferedData>(BUFFER_LIMIT)
const runtimeErrorSubscription = mockable(trackRuntimeError)().subscribe((error) => {
observable.notify({
type: BufferedDataType.RUNTIME_ERROR,
error,
})
})
return {
observable,
stop: () => {
runtimeErrorSubscription.unsubscribe()
},
}
}
@@ -0,0 +1,456 @@
import { catchUserErrors } from '../../tools/catchUserErrors'
import { DOCS_ORIGIN, MORE_DETAILS, display } from '../../tools/display'
import type { RawTelemetryConfiguration } from '../telemetry'
import { isPercentage } from '../../tools/utils/numberUtils'
import { objectHasValue } from '../../tools/utils/objectUtils'
import { selectSessionStoreStrategyType } from '../session/sessionStore'
import type { SessionStoreStrategyType } from '../session/storeStrategies/sessionStoreStrategy'
import { TrackingConsent } from '../trackingConsent'
import type { SessionPersistence } from '../session/sessionConstants'
import type { MatchOption } from '../../tools/matchOption'
import { isAllowedTrackingOrigins } from '../allowedTrackingOrigins'
import type { Site } from '../intakeSites'
import { isWorkerEnvironment } from '../../tools/globalObject'
import type { TransportConfiguration } from './transportConfiguration'
import { computeTransportConfiguration } from './transportConfiguration'
/**
* Default privacy level for the browser SDK.
*
* [Replay Privacy Options](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/privacy_options) for further information.
*/
export const DefaultPrivacyLevel = {
ALLOW: 'allow',
MASK: 'mask',
MASK_USER_INPUT: 'mask-user-input',
MASK_UNLESS_ALLOWLISTED: 'mask-unless-allowlisted',
} as const
export type DefaultPrivacyLevel = (typeof DefaultPrivacyLevel)[keyof typeof DefaultPrivacyLevel]
/**
* Trace context injection option.
*
* See [Connect RUM and Traces](https://docs.datadoghq.com/real_user_monitoring/platform/connect_rum_and_traces/?tab=browserrum) for further information.
*/
export const TraceContextInjection = {
ALL: 'all',
SAMPLED: 'sampled',
} as const
/**
* Trace context injection option.
*
* See [Connect RUM and Traces](https://docs.datadoghq.com/real_user_monitoring/platform/connect_rum_and_traces/?tab=browserrum) for further information.
*
*/
export type TraceContextInjection = (typeof TraceContextInjection)[keyof typeof TraceContextInjection]
export interface InitConfiguration {
/**
* The client token for Datadog. Required for authenticating your application with Datadog.
*
* @category Authentication
*/
clientToken: string
/**
* A callback function that can be used to modify events before they are sent to Datadog.
*
* @category Data Collection
*/
beforeSend?: GenericBeforeSendCallback | undefined
/**
* The percentage of sessions tracked. A value between 0 and 100.
*
* @category Data Collection
* @defaultValue 100
*/
sessionSampleRate?: number | undefined
/**
* The percentage of telemetry events sent. A value between 0 and 100.
*
* @category Data Collection
* @defaultValue 20
*/
telemetrySampleRate?: number | undefined
/**
* Initialization fails silently if the RUM Browser SDK is already initialized on the page.
*
* @defaultValue false
*/
silentMultipleInit?: boolean | undefined
/**
* Which storage strategy to use for persisting sessions. Can be 'cookie', 'local-storage', or 'memory'.
* When an array is provided, the SDK will attempt each persistence type in the order specified.
*
* Important: If you are using the RUM and Logs Browser SDKs, this option must be configured with identical values
*
* Note: 'memory' option is only for use with single-page applications. All page loads will start a new session, likely resulting in an increase in total number of RUM sessions
*
* @category Session Persistence
* @defaultValue "cookie"
*/
sessionPersistence?: SessionPersistence | SessionPersistence[] | undefined
/**
* Allows the use of localStorage when cookies cannot be set. This enables the RUM Browser SDK to run in environments that do not provide cookie support.
*
* Important: If you are using the RUM and Logs Browser SDKs, this option must be configured with identical values
* See [Monitor Electron Applications Using the Browser SDK](https://docs.datadoghq.com/real_user_monitoring/guide/monitor-electron-applications-using-browser-sdk) for further information.
*
* @category Session Persistence
* @deprecated use `sessionPersistence: local-storage` where you want to use localStorage instead
*/
allowFallbackToLocalStorage?: boolean | undefined
/**
* Allow listening to DOM events dispatched programmatically ([untrusted events](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted)). Enabling this option can be useful if you heavily rely on programmatic events, such as in an automated UI test environment.
*
* @defaultValue false
*/
allowUntrustedEvents?: boolean | undefined
/**
* Store global context and user context in localStorage to preserve them along the user navigation.
* See [Contexts life cycle](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#contexts-life-cycle) for further information.
*
* @defaultValue false
*/
storeContextsAcrossPages?: boolean | undefined
/**
* Set the initial user tracking consent state.
* See [User tracking consent](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#user-tracking-consent) for further information.
*
* @category Privacy
* @defaultValue granted
*/
trackingConsent?: TrackingConsent | undefined
/**
* List of origins where the SDK is allowed to run when used in a browser extension context.
* Matches urls against the extensions origin.
* If not provided and the SDK is running in a browser extension, the SDK will not run.
*/
allowedTrackingOrigins?: MatchOption[] | undefined
// transport options
/**
* Optional proxy URL, for example: https://www.proxy.com/path.
* See [Proxy Your Browser RUM Data](https://docs.datadoghq.com/real_user_monitoring/guide/proxy-rum-data) for further information.
*
* @category Transport
*/
proxy?: string | ProxyFn | undefined
/**
* The Datadog [site](https://docs.datadoghq.com/getting_started/site) parameter of your organization.
*
* @category Transport
* @defaultValue datadoghq.com
*/
site?: Site | undefined
// tag and context options
/**
* The service name for your application. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags).
*
* @category Data Collection
*/
service?: string | undefined | null
/**
* The applications environment, for example: prod, pre-prod, and staging. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags).
*
* @category Data Collection
*/
env?: string | undefined | null
/**
* The applications version, for example: 1.2.3, 6c44da20, and 2020.02.13. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags).
*
* @category Data Collection
*/
version?: string | undefined | null
// cookie options
/**
* Use a partitioned secure cross-site session cookie. This allows the RUM Browser SDK to run when the site is loaded from another one (iframe). Implies `useSecureSessionCookie`.
*
* Important: If you are using the RUM and Logs Browser SDKs, this option must be configured with identical values
*
* @category Session Persistence
* @defaultValue false
*/
usePartitionedCrossSiteSessionCookie?: boolean | undefined
/**
* Use a secure session cookie. This disables RUM events sent on insecure (non-HTTPS) connections.
*
* Important: If you are using the RUM and Logs Browser SDKs, this option must be configured with identical values
*
* @category Session Persistence
* @defaultValue false
*/
useSecureSessionCookie?: boolean | undefined
/**
* Preserve the session across subdomains for the same site.
*
* Important: If you are using the RUM and Logs Browser SDKs, this option must be configured with identical values
*
* @category Session Persistence
* @defaultValue false
*/
trackSessionAcrossSubdomains?: boolean | undefined
/**
* Track anonymous user for the same site and extend cookie expiration date
*
* @category Data Collection
* @defaultValue true
*/
trackAnonymousUser?: boolean | undefined
/**
* Encode cookie options in the cookie value. This can be used as a mitigation for microssession issues.
* ⚠️ This is a beta feature and may be changed or removed in the future.
*
* @category Beta
* @defaultValue false
*/
betaEncodeCookieOptions?: boolean | undefined
// internal options
/**
* [Internal option] Enable experimental features
*
* @internal
*/
enableExperimentalFeatures?: string[] | undefined
/**
* [Internal option] Configure the dual shipping to another datacenter
*
* @internal
*/
replica?: ReplicaUserConfiguration | undefined
/**
* [Internal option] Set the datacenter from where the data is dual shipped
*
* @internal
*/
datacenter?: string
/**
* [Internal option] Datadog internal analytics subdomain
*
* @internal
*/
// TODO next major: remove this option and replace usages by proxyFn
internalAnalyticsSubdomain?: string
/**
* [Internal option] The percentage of telemetry configuration sent. A value between 0 and 100.
*
* @internal
* @defaultValue 5
*/
telemetryConfigurationSampleRate?: number
/**
* [Internal option] The percentage of telemetry usage sent. A value between 0 and 100.
*
* @internal
* @defaultValue 5
*/
telemetryUsageSampleRate?: number
/**
* [Internal option] Additional configuration for the SDK.
*
* @internal
*/
source?: 'browser' | 'flutter' | 'unity' | undefined
/**
* [Internal option] Additional configuration for the SDK.
*
* @internal
*/
sdkVersion?: string | undefined
/**
* [Internal option] Additional configuration for the SDK.
*
* @internal
*/
variant?: string | undefined
}
// This type is only used to build the core configuration. Logs and RUM SDKs are using a proper type
// for this option.
type GenericBeforeSendCallback = (event: any, context?: any) => unknown
/**
* path: /api/vX/product
* parameters: xxx=yyy&zzz=aaa
*/
export type ProxyFn = (options: { path: string; parameters: string }) => string
/**
* @internal
*/
export interface ReplicaUserConfiguration {
applicationId?: string
clientToken: string
}
export interface Configuration extends TransportConfiguration {
// Built from init configuration
beforeSend: GenericBeforeSendCallback | undefined
sessionStoreStrategyType: SessionStoreStrategyType | undefined
sessionSampleRate: number
telemetrySampleRate: number
telemetryConfigurationSampleRate: number
telemetryUsageSampleRate: number
service?: string | undefined
version?: string | undefined
env?: string | undefined
silentMultipleInit: boolean
allowUntrustedEvents: boolean
trackingConsent: TrackingConsent
storeContextsAcrossPages: boolean
trackAnonymousUser?: boolean
betaEncodeCookieOptions: boolean
// internal
sdkVersion: string | undefined
source: 'browser' | 'flutter' | 'unity'
variant: string | undefined
}
function isString(tag: unknown, tagName: string): tag is string | undefined | null {
if (tag !== undefined && tag !== null && typeof tag !== 'string') {
display.error(`${tagName} must be defined as a string`)
return false
}
return true
}
function isDatadogSite(site: unknown) {
if (site && typeof site === 'string' && !/(datadog|ddog|datad0g|dd0g)/.test(site)) {
display.error(`Site should be a valid Datadog site. ${MORE_DETAILS} ${DOCS_ORIGIN}/getting_started/site/.`)
return false
}
return true
}
export function isSampleRate(sampleRate: unknown, name: string) {
if (sampleRate !== undefined && !isPercentage(sampleRate)) {
display.error(`${name} Sample Rate should be a number between 0 and 100`)
return false
}
return true
}
export function validateAndBuildConfiguration(
initConfiguration: InitConfiguration,
errorStack?: string
): Configuration | undefined {
if (!initConfiguration || !initConfiguration.clientToken) {
display.error('Client Token is not configured, we will not send any data.')
return
}
if (
initConfiguration.allowedTrackingOrigins !== undefined &&
!Array.isArray(initConfiguration.allowedTrackingOrigins)
) {
display.error('Allowed Tracking Origins must be an array')
return
}
if (
!isDatadogSite(initConfiguration.site) ||
!isSampleRate(initConfiguration.sessionSampleRate, 'Session') ||
!isSampleRate(initConfiguration.telemetrySampleRate, 'Telemetry') ||
!isSampleRate(initConfiguration.telemetryConfigurationSampleRate, 'Telemetry Configuration') ||
!isSampleRate(initConfiguration.telemetryUsageSampleRate, 'Telemetry Usage') ||
!isString(initConfiguration.version, 'Version') ||
!isString(initConfiguration.env, 'Env') ||
!isString(initConfiguration.service, 'Service') ||
!isAllowedTrackingOrigins(initConfiguration, errorStack ?? '')
) {
return
}
if (
initConfiguration.trackingConsent !== undefined &&
!objectHasValue(TrackingConsent, initConfiguration.trackingConsent)
) {
display.error('Tracking Consent should be either "granted" or "not-granted"')
return
}
return {
beforeSend:
initConfiguration.beforeSend && catchUserErrors(initConfiguration.beforeSend, 'beforeSend threw an error:'),
sessionStoreStrategyType: isWorkerEnvironment ? undefined : selectSessionStoreStrategyType(initConfiguration),
sessionSampleRate: initConfiguration.sessionSampleRate ?? 100,
telemetrySampleRate: initConfiguration.telemetrySampleRate ?? 20,
telemetryConfigurationSampleRate: initConfiguration.telemetryConfigurationSampleRate ?? 5,
telemetryUsageSampleRate: initConfiguration.telemetryUsageSampleRate ?? 5,
service: initConfiguration.service ?? undefined,
env: initConfiguration.env ?? undefined,
version: initConfiguration.version ?? undefined,
datacenter: initConfiguration.datacenter ?? undefined,
silentMultipleInit: !!initConfiguration.silentMultipleInit,
allowUntrustedEvents: !!initConfiguration.allowUntrustedEvents,
trackingConsent: initConfiguration.trackingConsent ?? TrackingConsent.GRANTED,
trackAnonymousUser: initConfiguration.trackAnonymousUser ?? true,
storeContextsAcrossPages: !!initConfiguration.storeContextsAcrossPages,
betaEncodeCookieOptions: !!initConfiguration.betaEncodeCookieOptions,
/**
* The source of the SDK, used for support plugins purposes.
*/
variant: initConfiguration.variant,
sdkVersion: initConfiguration.sdkVersion,
...computeTransportConfiguration(initConfiguration),
}
}
export function serializeConfiguration(initConfiguration: InitConfiguration) {
return {
session_sample_rate: initConfiguration.sessionSampleRate,
telemetry_sample_rate: initConfiguration.telemetrySampleRate,
telemetry_configuration_sample_rate: initConfiguration.telemetryConfigurationSampleRate,
telemetry_usage_sample_rate: initConfiguration.telemetryUsageSampleRate,
use_before_send: !!initConfiguration.beforeSend,
use_partitioned_cross_site_session_cookie: initConfiguration.usePartitionedCrossSiteSessionCookie,
use_secure_session_cookie: initConfiguration.useSecureSessionCookie,
use_proxy: !!initConfiguration.proxy,
silent_multiple_init: initConfiguration.silentMultipleInit,
track_session_across_subdomains: initConfiguration.trackSessionAcrossSubdomains,
track_anonymous_user: initConfiguration.trackAnonymousUser,
session_persistence: Array.isArray(initConfiguration.sessionPersistence)
? initConfiguration.sessionPersistence[0]
: initConfiguration.sessionPersistence,
allow_fallback_to_local_storage: !!initConfiguration.allowFallbackToLocalStorage,
store_contexts_across_pages: !!initConfiguration.storeContextsAcrossPages,
allow_untrusted_events: !!initConfiguration.allowUntrustedEvents,
tracking_consent: initConfiguration.trackingConsent,
use_allowed_tracking_origins: Array.isArray(initConfiguration.allowedTrackingOrigins),
beta_encode_cookie_options: initConfiguration.betaEncodeCookieOptions,
source: initConfiguration.source,
sdk_version: initConfiguration.sdkVersion,
variant: initConfiguration.variant,
} satisfies RawTelemetryConfiguration
}
@@ -0,0 +1,118 @@
import type { Payload } from '../../transport'
import { timeStampNow } from '../../tools/utils/timeUtils'
import { normalizeUrl } from '../../tools/utils/urlPolyfill'
import { generateUUID } from '../../tools/utils/stringUtils'
import { INTAKE_SITE_FED_STAGING, INTAKE_SITE_US1, PCI_INTAKE_HOST_US1 } from '../intakeSites'
import type { InitConfiguration } from './configuration'
// replaced at build time
declare const __BUILD_ENV__SDK_VERSION__: string
export type TrackType = 'logs' | 'rum' | 'replay' | 'profile' | 'exposures' | 'flagevaluation'
export type ApiType =
| 'fetch'
| 'beacon'
// 'manual' reflects that the request have been sent manually, outside of the SDK (ex: via curl or
// a Node.js script).
| 'manual'
export type EndpointBuilder = ReturnType<typeof createEndpointBuilder>
export function createEndpointBuilder(
initConfiguration: InitConfiguration,
trackType: TrackType,
extraParameters?: string[]
) {
const buildUrlWithParameters = createEndpointUrlWithParametersBuilder(initConfiguration, trackType)
return {
build(api: ApiType, payload: Payload) {
const parameters = buildEndpointParameters(initConfiguration, trackType, api, payload, extraParameters)
return buildUrlWithParameters(parameters)
},
trackType,
}
}
/**
* Create a function used to build a full endpoint url from provided parameters. The goal of this
* function is to pre-compute some parts of the URL to avoid re-computing everything on every
* request, as only parameters are changing.
*/
function createEndpointUrlWithParametersBuilder(
initConfiguration: InitConfiguration,
trackType: TrackType
): (parameters: string) => string {
const path = `/api/v2/${trackType}`
const proxy = initConfiguration.proxy
if (typeof proxy === 'string') {
const normalizedProxyUrl = normalizeUrl(proxy)
return (parameters) => `${normalizedProxyUrl}?ddforward=${encodeURIComponent(`${path}?${parameters}`)}`
}
if (typeof proxy === 'function') {
return (parameters) => proxy({ path, parameters })
}
const host = buildEndpointHost(trackType, initConfiguration)
return (parameters) => `https://${host}${path}?${parameters}`
}
export function buildEndpointHost(
trackType: TrackType,
initConfiguration: InitConfiguration & { usePciIntake?: boolean }
) {
const { site = INTAKE_SITE_US1, internalAnalyticsSubdomain } = initConfiguration
if (trackType === 'logs' && initConfiguration.usePciIntake && site === INTAKE_SITE_US1) {
return PCI_INTAKE_HOST_US1
}
if (internalAnalyticsSubdomain && site === INTAKE_SITE_US1) {
return `${internalAnalyticsSubdomain}.${INTAKE_SITE_US1}`
}
if (site === INTAKE_SITE_FED_STAGING) {
return `http-intake.logs.${site}`
}
const domainParts = site.split('.')
const extension = domainParts.pop()
return `browser-intake-${domainParts.join('-')}.${extension!}`
}
/**
* Build parameters to be used for an intake request. Parameters should be re-built for each
* request, as they change randomly.
*/
function buildEndpointParameters(
{ clientToken, internalAnalyticsSubdomain, source = 'browser' }: InitConfiguration,
trackType: TrackType,
api: ApiType,
{ retry, encoding }: Payload,
extraParameters: string[] = []
) {
const parameters = [
`ddsource=${source}`,
`dd-api-key=${clientToken}`,
`dd-evp-origin-version=${encodeURIComponent(__BUILD_ENV__SDK_VERSION__)}`,
'dd-evp-origin=browser',
`dd-request-id=${generateUUID()}`,
].concat(extraParameters)
if (encoding) {
parameters.push(`dd-evp-encoding=${encoding}`)
}
if (trackType === 'rum') {
parameters.push(`batch_time=${timeStampNow()}`, `_dd.api=${api}`)
if (retry) {
parameters.push(`_dd.retry_count=${retry.count}`, `_dd.retry_after=${retry.lastFailureStatus}`)
}
}
if (internalAnalyticsSubdomain) {
parameters.reverse()
}
return parameters.join('&')
}
@@ -0,0 +1,80 @@
import type { Site } from '../intakeSites'
import { INTAKE_SITE_US1, INTAKE_URL_PARAMETERS } from '../intakeSites'
import type { InitConfiguration } from './configuration'
import type { EndpointBuilder } from './endpointBuilder'
import { createEndpointBuilder } from './endpointBuilder'
export interface TransportConfiguration {
logsEndpointBuilder: EndpointBuilder
rumEndpointBuilder: EndpointBuilder
sessionReplayEndpointBuilder: EndpointBuilder
profilingEndpointBuilder: EndpointBuilder
exposuresEndpointBuilder: EndpointBuilder
flagEvaluationEndpointBuilder: EndpointBuilder
datacenter?: string | undefined
replica?: ReplicaConfiguration
site: Site
source: 'browser' | 'flutter' | 'unity'
}
export interface ReplicaConfiguration {
logsEndpointBuilder: EndpointBuilder
rumEndpointBuilder: EndpointBuilder
}
export function computeTransportConfiguration(initConfiguration: InitConfiguration): TransportConfiguration {
const site = initConfiguration.site || INTAKE_SITE_US1
const source = validateSource(initConfiguration.source)
const endpointBuilders = computeEndpointBuilders({ ...initConfiguration, site, source })
const replicaConfiguration = computeReplicaConfiguration({ ...initConfiguration, site, source })
return {
replica: replicaConfiguration,
site,
source,
...endpointBuilders,
}
}
function validateSource(source: string | undefined) {
if (source === 'flutter' || source === 'unity') {
return source
}
return 'browser'
}
function computeEndpointBuilders(initConfiguration: InitConfiguration) {
return {
logsEndpointBuilder: createEndpointBuilder(initConfiguration, 'logs'),
rumEndpointBuilder: createEndpointBuilder(initConfiguration, 'rum'),
profilingEndpointBuilder: createEndpointBuilder(initConfiguration, 'profile'),
sessionReplayEndpointBuilder: createEndpointBuilder(initConfiguration, 'replay'),
exposuresEndpointBuilder: createEndpointBuilder(initConfiguration, 'exposures'),
flagEvaluationEndpointBuilder: createEndpointBuilder(initConfiguration, 'flagevaluation'),
}
}
function computeReplicaConfiguration(initConfiguration: InitConfiguration): ReplicaConfiguration | undefined {
if (!initConfiguration.replica) {
return
}
const replicaConfiguration: InitConfiguration = {
...initConfiguration,
site: INTAKE_SITE_US1,
clientToken: initConfiguration.replica.clientToken,
}
return {
logsEndpointBuilder: createEndpointBuilder(replicaConfiguration, 'logs'),
rumEndpointBuilder: createEndpointBuilder(replicaConfiguration, 'rum', [
`application.id=${initConfiguration.replica.applicationId}`,
]),
}
}
export function isIntakeUrl(url: string): boolean {
// check if tags is present in the query string
return INTAKE_URL_PARAMETERS.every((param) => url.includes(param))
}
@@ -0,0 +1,31 @@
import { globalObject } from '../../tools/globalObject'
export type NetworkInterface = 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown'
export type EffectiveType = 'slow-2g' | '2g' | '3g' | '4g'
interface BrowserNavigator extends Navigator {
connection?: NetworkInformation
}
export interface NetworkInformation {
type?: NetworkInterface
effectiveType?: EffectiveType
saveData: boolean
}
export interface Connectivity {
status: 'connected' | 'not_connected'
interfaces?: NetworkInterface[]
effective_type?: EffectiveType
[key: string]: unknown
}
export function getConnectivity(): Connectivity {
const navigator = globalObject.navigator as BrowserNavigator
return {
status: navigator.onLine ? 'connected' : 'not_connected',
interfaces: navigator.connection && navigator.connection.type ? [navigator.connection.type] : undefined,
effective_type: navigator.connection?.effectiveType,
}
}
@@ -0,0 +1,118 @@
import { isError, computeRawError } from '../error/error'
import { mergeObservables, Observable } from '../../tools/observable'
import { ConsoleApiName, globalConsole } from '../../tools/display'
import { callMonitored } from '../../tools/monitor'
import { sanitize } from '../../tools/serialisation/sanitize'
import { jsonStringify } from '../../tools/serialisation/jsonStringify'
import type { RawError } from '../error/error.types'
import { ErrorHandling, ErrorSource, NonErrorPrefix } from '../error/error.types'
import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace'
import { createHandlingStack, formatErrorMessage } from '../../tools/stackTrace/handlingStack'
import { clocksNow } from '../../tools/utils/timeUtils'
export type ConsoleLog = NonErrorConsoleLog | ErrorConsoleLog
interface NonErrorConsoleLog extends ConsoleLogBase {
api: Exclude<ConsoleApiName, typeof ConsoleApiName.error>
error: undefined
}
export interface ErrorConsoleLog extends ConsoleLogBase {
api: typeof ConsoleApiName.error
error: RawError
}
interface ConsoleLogBase {
message: string
api: ConsoleApiName
handlingStack: string
}
type ConsoleLogForApi<T extends ConsoleApiName> = T extends typeof ConsoleApiName.error
? ErrorConsoleLog
: NonErrorConsoleLog
let consoleObservablesByApi: { [K in ConsoleApiName]?: Observable<ConsoleLogForApi<K>> } = {}
export function initConsoleObservable<T extends ConsoleApiName[]>(apis: T): Observable<ConsoleLogForApi<T[number]>> {
const consoleObservables = apis.map((api) => {
if (!consoleObservablesByApi[api]) {
consoleObservablesByApi[api] = createConsoleObservable(api) as any // we are sure that the observable created for this api will yield the expected ConsoleLog type
}
return consoleObservablesByApi[api] as unknown as Observable<ConsoleLogForApi<T[number]>>
})
return mergeObservables(...consoleObservables)
}
export function resetConsoleObservable() {
consoleObservablesByApi = {}
}
function createConsoleObservable(api: ConsoleApiName) {
return new Observable<ConsoleLog>((observable) => {
const originalConsoleApi = globalConsole[api]
globalConsole[api] = (...params: unknown[]) => {
originalConsoleApi.apply(console, params)
const handlingStack = createHandlingStack('console error')
callMonitored(() => {
observable.notify(buildConsoleLog(params, api, handlingStack))
})
}
return () => {
globalConsole[api] = originalConsoleApi
}
})
}
function buildConsoleLog(params: unknown[], api: ConsoleApiName, handlingStack: string): ConsoleLog {
const message = params.map((param) => formatConsoleParameters(param)).join(' ')
if (api === ConsoleApiName.error) {
const firstErrorParam = params.find(isError)
const rawError = computeRawError({
originalError: firstErrorParam,
handlingStack,
startClocks: clocksNow(),
source: ErrorSource.CONSOLE,
handling: ErrorHandling.HANDLED,
nonErrorPrefix: NonErrorPrefix.PROVIDED,
// if no good stack is computed from the error, let's not use the fallback stack message
// advising the user to use an instance of Error, as console.error is commonly used without an
// Error instance.
useFallbackStack: false,
})
// Use the full log message as the error message instead of just the error instance message.
rawError.message = message
return {
api,
message,
handlingStack,
error: rawError,
}
}
return {
api,
message,
error: undefined,
handlingStack,
}
}
function formatConsoleParameters(param: unknown) {
if (typeof param === 'string') {
return sanitize(param)
}
if (isError(param)) {
return formatErrorMessage(computeStackTrace(param))
}
return jsonStringify(sanitize(param), undefined, 2)
}
@@ -0,0 +1,27 @@
export const enum CustomerDataType {
FeatureFlag,
User,
GlobalContext,
View,
Account,
}
// Use a const instead of const enum to avoid inlining the enum values in the bundle and save bytes
export const CustomerContextKey = {
userContext: 'userContext',
globalContext: 'globalContext',
accountContext: 'accountContext',
} as const
export type CustomerContextKey = (typeof CustomerContextKey)[keyof typeof CustomerContextKey]
// Use a const instead of const enum to avoid inlining the enum values in the bundle and save bytes
export const ContextManagerMethod = {
getContext: 'getContext',
setContext: 'setContext',
setContextProperty: 'setContextProperty',
removeContextProperty: 'removeContextProperty',
clearContext: 'clearContext',
} as const
export type ContextManagerMethod = (typeof ContextManagerMethod)[keyof typeof ContextManagerMethod]
@@ -0,0 +1,85 @@
import { deepClone } from '../../tools/mergeInto'
import { sanitize } from '../../tools/serialisation/sanitize'
import type { Context } from '../../tools/serialisation/context'
import { Observable } from '../../tools/observable'
import { display } from '../../tools/display'
import { checkContext } from './contextUtils'
export type ContextManager = ReturnType<typeof createContextManager>
export interface PropertiesConfig {
[key: string]: {
required?: boolean
type?: 'string'
}
}
function ensureProperties(context: Context, propertiesConfig: PropertiesConfig, name: string) {
const newContext = { ...context }
for (const [key, { required, type }] of Object.entries(propertiesConfig)) {
/**
* Ensure specified properties are strings as defined here:
* https://docs.datadoghq.com/logs/log_configuration/attributes_naming_convention/#user-related-attributes
*/
if (type === 'string' && !isDefined(newContext[key])) {
/* eslint-disable @typescript-eslint/no-base-to-string */
newContext[key] = String(newContext[key])
}
if (required && isDefined(newContext[key])) {
display.warn(`The property ${key} of ${name} is required; context will not be sent to the intake.`)
}
}
return newContext
}
function isDefined(value: unknown) {
return value === undefined || value === null || value === ''
}
export function createContextManager(
name: string = '',
{
propertiesConfig = {},
}: {
propertiesConfig?: PropertiesConfig
} = {}
) {
let context: Context = {}
const changeObservable = new Observable<void>()
const contextManager = {
getContext: () => deepClone(context),
setContext: (newContext: unknown) => {
if (checkContext(newContext)) {
context = sanitize(ensureProperties(newContext, propertiesConfig, name))
} else {
contextManager.clearContext()
}
changeObservable.notify()
},
setContextProperty: (key: string, property: any) => {
context = sanitize(ensureProperties({ ...context, [key]: property }, propertiesConfig, name))
changeObservable.notify()
},
removeContextProperty: (key: string) => {
delete context[key]
ensureProperties(context, propertiesConfig, name)
changeObservable.notify()
},
clearContext: () => {
context = {}
changeObservable.notify()
},
changeObservable,
}
return contextManager
}
@@ -0,0 +1,14 @@
import type { Context } from '../../tools/serialisation/context'
import { display } from '../../tools/display'
import { getType } from '../../tools/utils/typeUtils'
/**
* Simple check to ensure an object is a valid context
*/
export function checkContext(maybeContext: unknown): maybeContext is Context {
const isValid = getType(maybeContext) === 'object'
if (!isValid) {
display.error('Unsupported context:', maybeContext)
}
return isValid
}
@@ -0,0 +1,31 @@
import type { RawTelemetryUsage, RawTelemetryUsageFeature } from '../telemetry'
import { addTelemetryUsage } from '../telemetry'
import { monitor } from '../../tools/monitor'
import type { BoundedBuffer } from '../../tools/boundedBuffer'
import type { ContextManager } from './contextManager'
import type { ContextManagerMethod, CustomerContextKey } from './contextConstants'
export function defineContextMethod<MethodName extends ContextManagerMethod, Key extends CustomerContextKey>(
getStrategy: () => Record<Key, ContextManager>,
contextName: Key,
methodName: MethodName,
usage?: RawTelemetryUsageFeature
): ContextManager[MethodName] {
return monitor((...args: any[]) => {
if (usage) {
addTelemetryUsage({ feature: usage } as RawTelemetryUsage)
}
return (getStrategy()[contextName][methodName] as (...args: unknown[]) => unknown)(...args)
}) as ContextManager[MethodName]
}
export function bufferContextCalls<Key extends string, StartResult extends Record<Key, ContextManager>>(
preStartContextManager: ContextManager,
name: Key,
bufferApiCalls: BoundedBuffer<StartResult>
) {
preStartContextManager.changeObservable.subscribe(() => {
const context = preStartContextManager.getContext()
bufferApiCalls.add((startResult) => startResult[name].setContext(context))
})
}
@@ -0,0 +1,55 @@
import { addEventListener, DOM_EVENT } from '../../browser/addEventListener'
import type { Context } from '../../tools/serialisation/context'
import type { Configuration } from '../configuration'
import { combine } from '../../tools/mergeInto'
import { isEmptyObject } from '../../tools/utils/objectUtils'
import type { ContextManager } from './contextManager'
import type { CustomerDataType } from './contextConstants'
const CONTEXT_STORE_KEY_PREFIX = '_dd_c'
const storageListeners: Array<{ stop: () => void }> = []
export function storeContextManager(
configuration: Configuration,
contextManager: ContextManager,
productKey: string,
customerDataType: CustomerDataType
) {
const storageKey = buildStorageKey(productKey, customerDataType)
storageListeners.push(
addEventListener(configuration, window, DOM_EVENT.STORAGE, ({ key }) => {
if (storageKey === key) {
synchronizeWithStorage()
}
})
)
contextManager.changeObservable.subscribe(dumpToStorage)
const contextFromStorage = combine(getFromStorage(), contextManager.getContext())
if (!isEmptyObject(contextFromStorage)) {
contextManager.setContext(contextFromStorage)
}
function synchronizeWithStorage() {
contextManager.setContext(getFromStorage())
}
function dumpToStorage() {
localStorage.setItem(storageKey, JSON.stringify(contextManager.getContext()))
}
function getFromStorage() {
const rawContext = localStorage.getItem(storageKey)
return rawContext ? (JSON.parse(rawContext) as Context) : {}
}
}
export function buildStorageKey(productKey: string, customerDataType: CustomerDataType) {
return `${CONTEXT_STORE_KEY_PREFIX}_${productKey}_${customerDataType}`
}
export function removeStorageListeners() {
storageListeners.map((listener) => listener.stop())
}
@@ -0,0 +1,47 @@
import type { Configuration } from '../configuration'
import { CustomerDataType } from '../context/contextConstants'
import { storeContextManager } from '../context/storeContextManager'
import { HookNames, SKIPPED } from '../../tools/abstractHooks'
import type { AbstractHooks } from '../../tools/abstractHooks'
import { isEmptyObject } from '../../tools/utils/objectUtils'
import { createContextManager } from '../context/contextManager'
/**
* Account information for the browser SDK.
*/
export interface Account {
id: string
name?: string | undefined
[key: string]: unknown
}
export function startAccountContext(hooks: AbstractHooks, configuration: Configuration, productKey: string) {
const accountContextManager = buildAccountContextManager()
if (configuration.storeContextsAcrossPages) {
storeContextManager(configuration, accountContextManager, productKey, CustomerDataType.Account)
}
hooks.register(HookNames.Assemble, () => {
const account = accountContextManager.getContext() as Account
if (isEmptyObject(account) || !account.id) {
return SKIPPED
}
return {
account,
}
})
return accountContextManager
}
export function buildAccountContextManager() {
return createContextManager('account', {
propertiesConfig: {
id: { type: 'string', required: true },
name: { type: 'string' },
},
})
}
@@ -0,0 +1,30 @@
import type { AbstractHooks } from '../../tools/abstractHooks'
import { CustomerDataType } from '../context/contextConstants'
import { storeContextManager } from '../context/storeContextManager'
import { HookNames } from '../../tools/abstractHooks'
import { createContextManager } from '../context/contextManager'
import type { Configuration } from '../configuration'
export function startGlobalContext(
hooks: AbstractHooks,
configuration: Configuration,
productKey: string,
useContextNamespace: boolean
) {
const globalContextManager = buildGlobalContextManager()
if (configuration.storeContextsAcrossPages) {
storeContextManager(configuration, globalContextManager, productKey, CustomerDataType.GlobalContext)
}
hooks.register(HookNames.Assemble, () => {
const context = globalContextManager.getContext()
return useContextNamespace ? { context } : context
})
return globalContextManager
}
export function buildGlobalContextManager() {
return createContextManager('global context')
}
@@ -0,0 +1,64 @@
import type { AbstractHooks } from '../../tools/abstractHooks'
import { CustomerDataType } from '../context/contextConstants'
import { storeContextManager } from '../context/storeContextManager'
import { HookNames, SKIPPED } from '../../tools/abstractHooks'
import { createContextManager } from '../context/contextManager'
import type { Configuration } from '../configuration'
import { isEmptyObject } from '../../tools/utils/objectUtils'
import type { RelativeTime } from '../../tools/utils/timeUtils'
export interface User {
id?: string | undefined
email?: string | undefined
name?: string | undefined
[key: string]: unknown
}
export function startUserContext(
hooks: AbstractHooks,
configuration: Configuration,
sessionManager: {
findTrackedSession: (startTime?: RelativeTime) => { anonymousId?: string } | undefined
},
productKey: string
) {
const userContextManager = buildUserContextManager()
if (configuration.storeContextsAcrossPages) {
storeContextManager(configuration, userContextManager, productKey, CustomerDataType.User)
}
hooks.register(HookNames.Assemble, ({ eventType, startTime }) => {
const user = userContextManager.getContext()
const session = sessionManager.findTrackedSession(startTime)
if (session && session.anonymousId && !user.anonymous_id && !!configuration.trackAnonymousUser) {
user.anonymous_id = session.anonymousId
}
if (isEmptyObject(user)) {
return SKIPPED
}
return {
type: eventType,
usr: user,
}
})
hooks.register(HookNames.AssembleTelemetry, ({ startTime }) => ({
anonymous_id: sessionManager.findTrackedSession(startTime)?.anonymousId,
}))
return userContextManager
}
export function buildUserContextManager() {
return createContextManager('user', {
propertiesConfig: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
})
}
+129
View File
@@ -0,0 +1,129 @@
import { sanitize } from '../../tools/serialisation/sanitize'
import type { ClocksState } from '../../tools/utils/timeUtils'
import type { Context } from '../../tools/serialisation/context'
import { jsonStringify } from '../../tools/serialisation/jsonStringify'
import type { StackTrace } from '../../tools/stackTrace/computeStackTrace'
import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace'
import { toStackTraceString } from '../../tools/stackTrace/handlingStack'
import { isIndexableObject } from '../../tools/utils/typeUtils'
import type { ErrorSource, ErrorHandling, RawError, RawErrorCause, ErrorWithCause, NonErrorPrefix } from './error.types'
export const NO_ERROR_STACK_PRESENT_MESSAGE = 'No stack, consider using an instance of Error'
interface RawErrorParams {
stackTrace?: StackTrace
originalError: unknown
handlingStack?: string
componentStack?: string
startClocks: ClocksState
nonErrorPrefix: NonErrorPrefix
useFallbackStack?: boolean
source: ErrorSource
handling: ErrorHandling
}
function computeErrorBase({
originalError,
stackTrace,
source,
useFallbackStack = true,
nonErrorPrefix,
}: {
originalError: unknown
stackTrace?: StackTrace
source: ErrorSource
useFallbackStack?: boolean
nonErrorPrefix?: NonErrorPrefix
}) {
const isErrorInstance = isError(originalError)
if (!stackTrace && isErrorInstance) {
stackTrace = computeStackTrace(originalError)
}
return {
source,
type: stackTrace ? stackTrace.name : undefined,
message: computeMessage(stackTrace, isErrorInstance, nonErrorPrefix, originalError),
stack: stackTrace ? toStackTraceString(stackTrace) : useFallbackStack ? NO_ERROR_STACK_PRESENT_MESSAGE : undefined,
}
}
export function computeRawError({
stackTrace,
originalError,
handlingStack,
componentStack,
startClocks,
nonErrorPrefix,
useFallbackStack = true,
source,
handling,
}: RawErrorParams): RawError {
const errorBase = computeErrorBase({ originalError, stackTrace, source, useFallbackStack, nonErrorPrefix })
return {
startClocks,
handling,
handlingStack,
componentStack,
originalError,
...errorBase,
causes: isError(originalError) ? flattenErrorCauses(originalError as ErrorWithCause, source) : undefined,
fingerprint: tryToGetFingerprint(originalError),
context: tryToGetErrorContext(originalError),
}
}
function computeMessage(
stackTrace: StackTrace | undefined,
isErrorInstance: boolean,
nonErrorPrefix: NonErrorPrefix | undefined,
originalError: unknown
) {
// Favor stackTrace message only if tracekit has really been able to extract something meaningful (message + name)
// TODO rework tracekit integration to avoid scattering error building logic
return stackTrace?.message && stackTrace?.name
? stackTrace.message
: !isErrorInstance
? nonErrorPrefix
? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}`
: jsonStringify(sanitize(originalError))!
: 'Empty message'
}
export function tryToGetFingerprint(originalError: unknown) {
return isError(originalError) && 'dd_fingerprint' in originalError ? String(originalError.dd_fingerprint) : undefined
}
export function tryToGetErrorContext(originalError: unknown) {
if (isIndexableObject(originalError)) {
return originalError.dd_context as Context | undefined
}
}
export function getFileFromStackTraceString(stack: string) {
return /@ (.+)/.exec(stack)?.[1]
}
export function isError(error: unknown): error is Error {
return error instanceof Error || Object.prototype.toString.call(error) === '[object Error]'
}
export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSource): RawErrorCause[] | undefined {
const causes: RawErrorCause[] = []
let currentCause = error.cause
while (currentCause !== undefined && currentCause !== null && causes.length < 10) {
const causeBase = computeErrorBase({
originalError: currentCause,
source: parentSource,
useFallbackStack: false,
})
causes.push(causeBase)
currentCause = isError(currentCause) ? (currentCause as ErrorWithCause).cause : undefined
}
return causes.length ? causes : undefined
}
@@ -0,0 +1,64 @@
import type { Context } from '../../tools/serialisation/context'
import type { ClocksState } from '../../tools/utils/timeUtils'
// TS v4.6 introduced Error.cause[1] typed as `Error`. TS v4.8 changed Error.cause to be
// `unknown`[2].
//
// Because we still support TS 3.8, we need to declare our own type. We can remove it once we drop
// support for TS v4.7 and before. The 'cause' property defined by TS needs to be omitted because
// we define it with a type `unknown` which is incompatible with TS 4.6 and 4.7.
//
// [1]: https://devblogs.microsoft.com/typescript/announcing-typescript-4-6/#target-es2022
// [2]: https://devblogs.microsoft.com/typescript/announcing-typescript-4-8/#lib-d-ts-updates
export interface ErrorWithCause extends Omit<Error, 'cause'> {
cause?: unknown
}
export interface RawErrorCause {
message: string
source: ErrorSource
type?: string
stack?: string
}
export interface Csp {
disposition: 'enforce' | 'report'
}
export interface RawError {
startClocks: ClocksState
message: string
type?: string
stack?: string
source: ErrorSource
originalError?: unknown
handling?: ErrorHandling
handlingStack?: string
componentStack?: string
causes?: RawErrorCause[]
fingerprint?: string
csp?: Csp
context?: Context
}
export const ErrorSource = {
AGENT: 'agent',
CONSOLE: 'console',
CUSTOM: 'custom',
LOGGER: 'logger',
NETWORK: 'network',
SOURCE: 'source',
REPORT: 'report',
} as const
export const enum NonErrorPrefix {
UNCAUGHT = 'Uncaught',
PROVIDED = 'Provided',
}
export const enum ErrorHandling {
HANDLED = 'handled',
UNHANDLED = 'unhandled',
}
export type ErrorSource = (typeof ErrorSource)[keyof typeof ErrorSource]
@@ -0,0 +1,50 @@
import { instrumentMethod } from '../../tools/instrumentMethod'
import { Observable } from '../../tools/observable'
import { clocksNow } from '../../tools/utils/timeUtils'
import type { StackTrace } from '../../tools/stackTrace/computeStackTrace'
import { computeStackTraceFromOnErrorMessage } from '../../tools/stackTrace/computeStackTrace'
import { getGlobalObject } from '../../tools/globalObject'
import { computeRawError, isError } from './error'
import type { RawError } from './error.types'
import { ErrorHandling, ErrorSource, NonErrorPrefix } from './error.types'
export type UnhandledErrorCallback = (originalError: unknown, stackTrace?: StackTrace) => any
export function trackRuntimeError() {
return new Observable<RawError>((observer) => {
const handleRuntimeError = (originalError: unknown, stackTrace?: StackTrace) => {
const rawError = computeRawError({
stackTrace,
originalError,
startClocks: clocksNow(),
nonErrorPrefix: NonErrorPrefix.UNCAUGHT,
source: ErrorSource.SOURCE,
handling: ErrorHandling.UNHANDLED,
})
observer.notify(rawError)
}
const { stop: stopInstrumentingOnError } = instrumentOnError(handleRuntimeError)
const { stop: stopInstrumentingOnUnhandledRejection } = instrumentUnhandledRejection(handleRuntimeError)
return () => {
stopInstrumentingOnError()
stopInstrumentingOnUnhandledRejection()
}
})
}
export function instrumentOnError(callback: UnhandledErrorCallback) {
return instrumentMethod(getGlobalObject(), 'onerror', ({ parameters: [messageObj, url, line, column, errorObj] }) => {
let stackTrace
if (!isError(errorObj)) {
stackTrace = computeStackTraceFromOnErrorMessage(messageObj, url, line, column)
}
callback(errorObj ?? messageObj, stackTrace)
})
}
export function instrumentUnhandledRejection(callback: UnhandledErrorCallback) {
return instrumentMethod(getGlobalObject(), 'onunhandledrejection', ({ parameters: [e] }) => {
callback(e.reason || 'Empty reason')
})
}
@@ -0,0 +1,49 @@
import { setTimeout } from '../../tools/timer'
import { clocksNow, ONE_MINUTE } from '../../tools/utils/timeUtils'
import type { RawError } from '../error/error.types'
import { ErrorSource } from '../error/error.types'
export type EventRateLimiter = ReturnType<typeof createEventRateLimiter>
// Limit the maximum number of actions, errors and logs per minutes
const EVENT_RATE_LIMIT = 3000
export function createEventRateLimiter(
eventType: string,
onLimitReached: (limitError: RawError) => void,
limit = EVENT_RATE_LIMIT
) {
let eventCount = 0
let allowNextEvent = false
return {
isLimitReached() {
if (eventCount === 0) {
setTimeout(() => {
eventCount = 0
}, ONE_MINUTE)
}
eventCount += 1
if (eventCount <= limit || allowNextEvent) {
allowNextEvent = false
return false
}
if (eventCount === limit + 1) {
allowNextEvent = true
try {
onLimitReached({
message: `Reached max number of ${eventType}s by minute: ${limit}`,
source: ErrorSource.AGENT,
startClocks: clocksNow(),
})
} finally {
allowNextEvent = false
}
}
return true
},
}
}
@@ -0,0 +1,28 @@
export const EXTENSION_PREFIXES = ['chrome-extension://', 'moz-extension://']
export function containsExtensionUrl(str: string): boolean {
return EXTENSION_PREFIXES.some((prefix) => str.includes(prefix))
}
/**
* Utility function to detect if the SDK is being initialized in an unsupported browser extension environment.
*
* @param windowLocation - The current window location to check
* @param stack - The error stack to check for extension URLs
* @returns true if running in an unsupported browser extension environment
*/
export function isUnsupportedExtensionEnvironment(windowLocation: string, stack: string = '') {
// If the page itself is an extension page.
if (containsExtensionUrl(windowLocation)) {
return false
}
// Since we generate the error on the init, we check the 2nd frame line.
const frameLines = stack.split('\n').filter((line) => {
const trimmedLine = line.trim()
return trimmedLine.length && /^at\s+|@/.test(trimmedLine)
})
const target = frameLines[1] || ''
return containsExtensionUrl(target)
}
+18
View File
@@ -0,0 +1,18 @@
export type Site =
| 'datadoghq.com'
| 'us3.datadoghq.com'
| 'us5.datadoghq.com'
| 'datadoghq.eu'
| 'ddog-gov.com'
| 'ap1.datadoghq.com'
| 'ap2.datadoghq.com'
| (string & {})
export const INTAKE_SITE_STAGING: Site = 'datad0g.com'
export const INTAKE_SITE_FED_STAGING: Site = 'dd0g-gov.com'
export const INTAKE_SITE_US1: Site = 'datadoghq.com'
export const INTAKE_SITE_EU1: Site = 'datadoghq.eu'
export const INTAKE_SITE_US1_FED: Site = 'ddog-gov.com'
export const PCI_INTAKE_HOST_US1 = 'pci.browser-intake-datadoghq.com'
export const INTAKE_URL_PARAMETERS = ['ddsource', 'dd-api-key', 'dd-request-id']
@@ -0,0 +1,133 @@
import { toStackTraceString } from '../../tools/stackTrace/handlingStack'
import { monitor } from '../../tools/monitor'
import { mergeObservables, Observable } from '../../tools/observable'
import { addEventListener, DOM_EVENT } from '../../browser/addEventListener'
import { safeTruncate } from '../../tools/utils/stringUtils'
import type { Configuration } from '../configuration'
import type { RawError } from '../error/error.types'
import { ErrorHandling, ErrorSource } from '../error/error.types'
import { clocksNow } from '../../tools/utils/timeUtils'
import type { ReportType, InterventionReport, DeprecationReport } from './browser.types'
export const RawReportType = {
intervention: 'intervention',
deprecation: 'deprecation',
cspViolation: 'csp_violation',
} as const
export type RawReportType = (typeof RawReportType)[keyof typeof RawReportType]
export type RawReportError = RawError & {
originalError: SecurityPolicyViolationEvent | DeprecationReport | InterventionReport
}
export function initReportObservable(configuration: Configuration, apis: RawReportType[]) {
const observables: Array<Observable<RawReportError>> = []
if (apis.includes(RawReportType.cspViolation)) {
observables.push(createCspViolationReportObservable(configuration))
}
const reportTypes = apis.filter((api: RawReportType): api is ReportType => api !== RawReportType.cspViolation)
if (reportTypes.length) {
observables.push(createReportObservable(reportTypes))
}
return mergeObservables(...observables)
}
function createReportObservable(reportTypes: ReportType[]) {
return new Observable<RawReportError>((observable) => {
if (!window.ReportingObserver) {
return
}
const handleReports = monitor((reports: Array<DeprecationReport | InterventionReport>, _: ReportingObserver) =>
reports.forEach((report) => observable.notify(buildRawReportErrorFromReport(report)))
) as ReportingObserverCallback
const observer = new window.ReportingObserver(handleReports, {
types: reportTypes,
buffered: true,
})
observer.observe()
return () => {
observer.disconnect()
}
})
}
function createCspViolationReportObservable(configuration: Configuration) {
return new Observable<RawReportError>((observable) => {
const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => {
observable.notify(buildRawReportErrorFromCspViolation(event))
})
return stop
})
}
function buildRawReportErrorFromReport(report: DeprecationReport | InterventionReport): RawReportError {
const { type, body } = report
return buildRawReportError({
type: body.id,
message: `${type}: ${body.message}`,
originalError: report,
stack: buildStack(body.id, body.message, body.sourceFile, body.lineNumber, body.columnNumber),
})
}
function buildRawReportErrorFromCspViolation(event: SecurityPolicyViolationEvent): RawReportError {
const message = `'${event.blockedURI}' blocked by '${event.effectiveDirective}' directive`
return buildRawReportError({
type: event.effectiveDirective,
message: `${RawReportType.cspViolation}: ${message}`,
originalError: event,
csp: {
disposition: event.disposition,
},
stack: buildStack(
event.effectiveDirective,
event.originalPolicy
? `${message} of the policy "${safeTruncate(event.originalPolicy, 100, '...')}"`
: 'no policy',
event.sourceFile,
event.lineNumber,
event.columnNumber
),
})
}
function buildRawReportError(partial: Omit<RawReportError, 'startClocks' | 'source' | 'handling'>): RawReportError {
return {
startClocks: clocksNow(),
source: ErrorSource.REPORT,
handling: ErrorHandling.UNHANDLED,
...partial,
}
}
function buildStack(
name: string,
message: string,
sourceFile: string | null,
lineNumber: number | null,
columnNumber: number | null
): string | undefined {
return sourceFile
? toStackTraceString({
name,
message,
stack: [
{
func: '?',
url: sourceFile,
line: lineNumber ?? undefined,
column: columnNumber ?? undefined,
},
],
})
: undefined
}
@@ -0,0 +1,21 @@
export const ResourceType = {
DOCUMENT: 'document',
XHR: 'xhr',
BEACON: 'beacon',
FETCH: 'fetch',
CSS: 'css',
JS: 'js',
IMAGE: 'image',
FONT: 'font',
MEDIA: 'media',
OTHER: 'other',
} as const
export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType]
export const RequestType = {
FETCH: ResourceType.FETCH,
XHR: ResourceType.XHR,
} as const
export type RequestType = (typeof RequestType)[keyof typeof RequestType]
@@ -0,0 +1,42 @@
import { getInitCookie } from '../../browser/cookie'
import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
import type { SessionState } from './sessionState'
import { expandSessionState, isSessionStarted } from './sessionState'
export const OLD_SESSION_COOKIE_NAME = '_dd'
export const OLD_RUM_COOKIE_NAME = '_dd_r'
export const OLD_LOGS_COOKIE_NAME = '_dd_l'
// duplicate values to avoid dependency issues
export const RUM_SESSION_KEY = 'rum'
export const LOGS_SESSION_KEY = 'logs'
/**
* This migration should remain in the codebase as long as older versions are available/live
* to allow older sdk versions to be upgraded to newer versions without compatibility issues.
*/
export function tryOldCookiesMigration(cookieStoreStrategy: SessionStoreStrategy) {
const sessionString = getInitCookie(SESSION_STORE_KEY)
if (!sessionString) {
const oldSessionId = getInitCookie(OLD_SESSION_COOKIE_NAME)
const oldRumType = getInitCookie(OLD_RUM_COOKIE_NAME)
const oldLogsType = getInitCookie(OLD_LOGS_COOKIE_NAME)
const session: SessionState = {}
if (oldSessionId) {
session.id = oldSessionId
}
if (oldLogsType && /^[01]$/.test(oldLogsType)) {
session[LOGS_SESSION_KEY] = oldLogsType
}
if (oldRumType && /^[012]$/.test(oldRumType)) {
session[RUM_SESSION_KEY] = oldRumType
}
if (isSessionStarted(session)) {
expandSessionState(session)
cookieStoreStrategy.persistSession(session)
}
}
}
@@ -0,0 +1,20 @@
import { ONE_HOUR, ONE_MINUTE, ONE_YEAR } from '../../tools/utils/timeUtils'
export const SESSION_TIME_OUT_DELAY = 4 * ONE_HOUR
export const SESSION_EXPIRATION_DELAY = 15 * ONE_MINUTE
export const SESSION_COOKIE_EXPIRATION_DELAY = ONE_YEAR
export const SESSION_NOT_TRACKED = '0'
/**
* @internal
*/
export const SessionPersistence = {
COOKIE: 'cookie',
MEMORY: 'memory',
LOCAL_STORAGE: 'local-storage',
} as const
/**
* @inline
*/
export type SessionPersistence = (typeof SessionPersistence)[keyof typeof SessionPersistence]
@@ -0,0 +1,262 @@
import { Observable } from '../../tools/observable'
import type { Context } from '../../tools/serialisation/context'
import { createValueHistory } from '../../tools/valueHistory'
import type { RelativeTime } from '../../tools/utils/timeUtils'
import { clocksOrigin, dateNow, ONE_MINUTE, relativeNow } from '../../tools/utils/timeUtils'
import { addEventListener, addEventListeners, DOM_EVENT } from '../../browser/addEventListener'
import { clearInterval, setInterval } from '../../tools/timer'
import type { Configuration } from '../configuration'
import type { TrackingConsentState } from '../trackingConsent'
import { addTelemetryDebug } from '../telemetry'
import { isSyntheticsTest } from '../synthetics/syntheticsWorkerValues'
import type { CookieStore } from '../../browser/browser.types'
import { getCurrentSite } from '../../browser/cookie'
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
import { findLast } from '../../tools/utils/polyfills'
import { monitorError } from '../../tools/monitor'
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
import { startSessionStore } from './sessionStore'
import type { SessionState } from './sessionState'
import { toSessionState } from './sessionState'
import { retrieveSessionCookie } from './storeStrategies/sessionInCookie'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage'
export interface SessionManager<TrackingType extends string> {
findSession: (
startTime?: RelativeTime,
options?: { returnInactive: boolean }
) => SessionContext<TrackingType> | undefined
renewObservable: Observable<void>
expireObservable: Observable<void>
sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }>
expire: () => void
updateSessionState: (state: Partial<SessionState>) => void
}
export interface SessionContext<TrackingType extends string> extends Context {
id: string
trackingType: TrackingType
isReplayForced: boolean
anonymousId: string | undefined
}
export const VISIBILITY_CHECK_DELAY = ONE_MINUTE
const SESSION_CONTEXT_TIMEOUT_DELAY = SESSION_TIME_OUT_DELAY
let stopCallbacks: Array<() => void> = []
export function startSessionManager<TrackingType extends string>(
configuration: Configuration,
productKey: string,
computeTrackingType: (rawTrackingType?: string) => TrackingType,
trackingConsentState: TrackingConsentState
): SessionManager<TrackingType> {
const renewObservable = new Observable<void>()
const expireObservable = new Observable<void>()
// TODO - Improve configuration type and remove assertion
const sessionStore = startSessionStore(
configuration.sessionStoreStrategyType!,
configuration,
productKey,
computeTrackingType
)
stopCallbacks.push(() => sessionStore.stop())
const sessionContextHistory = createValueHistory<SessionContext<TrackingType>>({
expireDelay: SESSION_CONTEXT_TIMEOUT_DELAY,
})
stopCallbacks.push(() => sessionContextHistory.stop())
sessionStore.renewObservable.subscribe(() => {
sessionContextHistory.add(buildSessionContext(), relativeNow())
renewObservable.notify()
})
sessionStore.expireObservable.subscribe(() => {
expireObservable.notify()
sessionContextHistory.closeActive(relativeNow())
})
// We expand/renew session unconditionally as tracking consent is always granted when the session
// manager is started.
sessionStore.expandOrRenewSession()
sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative)
if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) {
const session = sessionStore.getSession()
if (session) {
detectSessionIdChange(configuration, session)
}
}
trackingConsentState.observable.subscribe(() => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
} else {
sessionStore.expire(false)
}
})
trackActivity(configuration, () => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
}
})
trackVisibility(configuration, () => sessionStore.expandSession())
trackResume(configuration, () => sessionStore.restartSession())
function buildSessionContext() {
const session = sessionStore.getSession()
if (!session) {
reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors
return {
id: 'invalid',
trackingType: SESSION_NOT_TRACKED as TrackingType,
isReplayForced: false,
anonymousId: undefined,
}
}
return {
id: session.id!,
trackingType: session[productKey] as TrackingType,
isReplayForced: !!session.forcedReplay,
anonymousId: session.anonymousId,
}
}
return {
findSession: (startTime, options) => sessionContextHistory.find(startTime, options),
renewObservable,
expireObservable,
sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable,
expire: sessionStore.expire,
updateSessionState: sessionStore.updateSessionState,
}
}
export function stopSessionManager() {
stopCallbacks.forEach((e) => e())
stopCallbacks = []
}
function trackActivity(configuration: Configuration, expandOrRenewSession: () => void) {
const { stop } = addEventListeners(
configuration,
window,
[DOM_EVENT.CLICK, DOM_EVENT.TOUCH_START, DOM_EVENT.KEY_DOWN, DOM_EVENT.SCROLL],
expandOrRenewSession,
{ capture: true, passive: true }
)
stopCallbacks.push(stop)
}
function trackVisibility(configuration: Configuration, expandSession: () => void) {
const expandSessionWhenVisible = () => {
if (document.visibilityState === 'visible') {
expandSession()
}
}
const { stop } = addEventListener(configuration, document, DOM_EVENT.VISIBILITY_CHANGE, expandSessionWhenVisible)
stopCallbacks.push(stop)
const visibilityCheckInterval = setInterval(expandSessionWhenVisible, VISIBILITY_CHECK_DELAY)
stopCallbacks.push(() => {
clearInterval(visibilityCheckInterval)
})
}
function trackResume(configuration: Configuration, cb: () => void) {
const { stop } = addEventListener(configuration, window, DOM_EVENT.RESUME, cb, { capture: true })
stopCallbacks.push(stop)
}
async function reportUnexpectedSessionState(configuration: Configuration) {
const sessionStoreStrategyType = configuration.sessionStoreStrategyType
if (!sessionStoreStrategyType) {
return
}
let rawSession
let cookieContext
if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) {
rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions, configuration)
cookieContext = {
cookie: await getSessionCookies(),
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
}
} else {
rawSession = retrieveSessionFromLocalStorage()
}
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
addTelemetryDebug('Unexpected session state', {
sessionStoreStrategyType: sessionStoreStrategyType.type,
session: rawSession,
isSyntheticsTest: isSyntheticsTest(),
createdTimestamp: rawSession?.created,
expireTimestamp: rawSession?.expire,
...cookieContext,
})
}
function detectSessionIdChange(configuration: Configuration, initialSessionState: SessionState) {
if (!window.cookieStore || !initialSessionState.created) {
return
}
const sessionCreatedTime = Number(initialSessionState.created)
const sdkInitTime = dateNow()
const { stop } = addEventListener(configuration, cookieStore as CookieStore, DOM_EVENT.CHANGE, listener)
stopCallbacks.push(stop)
function listener(event: CookieChangeEvent) {
const changed = findLast(event.changed, (change): change is CookieListItem => change.name === SESSION_STORE_KEY)
if (!changed) {
return
}
const sessionAge = dateNow() - sessionCreatedTime
if (sessionAge > 14 * ONE_MINUTE) {
// The session might have expired just because it's too old or lack activity
stop()
} else {
const newSessionState = toSessionState(changed.value)
if (newSessionState.id && newSessionState.id !== initialSessionState.id) {
stop()
const time = dateNow() - sdkInitTime
getSessionCookies()
.then((cookie) => {
// monitor-until: 2026-04-01, after RUM-10845 investigation done
addTelemetryDebug('Session cookie changed', {
time,
session_age: sessionAge,
old: initialSessionState,
new: newSessionState,
cookie,
})
})
.catch(monitorError)
}
}
}
}
async function getSessionCookies(): Promise<{ count: number; domain: string }> {
let sessionCookies: string[] | Awaited<ReturnType<CookieStore['getAll']>>
if ('cookieStore' in window) {
sessionCookies = await (window as { cookieStore: CookieStore }).cookieStore.getAll(SESSION_STORE_KEY)
} else {
sessionCookies = document.cookie.split(/\s*;\s*/).filter((cookie) => cookie.startsWith(SESSION_STORE_KEY))
}
return {
count: sessionCookies.length,
domain: getCurrentSite() || 'undefined',
...sessionCookies,
}
}
@@ -0,0 +1,83 @@
import { isEmptyObject } from '../../tools/utils/objectUtils'
import { objectEntries } from '../../tools/utils/polyfills'
import { dateNow } from '../../tools/utils/timeUtils'
import type { Configuration } from '../configuration'
import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants'
import { isValidSessionString, SESSION_ENTRY_REGEXP, SESSION_ENTRY_SEPARATOR } from './sessionStateValidation'
export const EXPIRED = '1'
export interface SessionState {
id?: string
created?: string
expire?: string
isExpired?: typeof EXPIRED
[key: string]: string | undefined
}
export function getExpiredSessionState(
previousSessionState: SessionState | undefined,
configuration: Configuration
): SessionState {
const expiredSessionState: SessionState = {
isExpired: EXPIRED,
}
if (configuration.trackAnonymousUser && previousSessionState?.anonymousId) {
expiredSessionState.anonymousId = previousSessionState?.anonymousId
}
return expiredSessionState
}
export function isSessionInNotStartedState(session: SessionState) {
return isEmptyObject(session)
}
export function isSessionStarted(session: SessionState) {
return !isSessionInNotStartedState(session)
}
export function isSessionInExpiredState(session: SessionState) {
return session.isExpired !== undefined || !isActiveSession(session)
}
// An active session is a session in either `Tracked` or `NotTracked` state
function isActiveSession(sessionState: SessionState) {
// created and expire can be undefined for versions which was not storing them
// these checks could be removed when older versions will not be available/live anymore
return (
(sessionState.created === undefined || dateNow() - Number(sessionState.created) < SESSION_TIME_OUT_DELAY) &&
(sessionState.expire === undefined || dateNow() < Number(sessionState.expire))
)
}
export function expandSessionState(session: SessionState) {
session.expire = String(dateNow() + SESSION_EXPIRATION_DELAY)
}
export function toSessionString(session: SessionState) {
return (
objectEntries(session)
// we use `aid` as a key for anonymousId
.map(([key, value]) => (key === 'anonymousId' ? `aid=${value}` : `${key}=${value}`))
.join(SESSION_ENTRY_SEPARATOR)
)
}
export function toSessionState(sessionString: string | undefined | null) {
const session: SessionState = {}
if (isValidSessionString(sessionString)) {
sessionString.split(SESSION_ENTRY_SEPARATOR).forEach((entry) => {
const matches = SESSION_ENTRY_REGEXP.exec(entry)
if (matches !== null) {
const [, key, value] = matches
if (key === 'aid') {
// we use `aid` as a key for anonymousId
session.anonymousId = value
} else {
session[key] = value
}
}
})
}
return session
}
@@ -0,0 +1,9 @@
export const SESSION_ENTRY_REGEXP = /^([a-zA-Z]+)=([a-z0-9-]+)$/
export const SESSION_ENTRY_SEPARATOR = '&'
export function isValidSessionString(sessionString: string | undefined | null): sessionString is string {
return (
!!sessionString &&
(sessionString.indexOf(SESSION_ENTRY_SEPARATOR) !== -1 || SESSION_ENTRY_REGEXP.test(sessionString))
)
}
@@ -0,0 +1,287 @@
import { clearInterval, setInterval } from '../../tools/timer'
import { Observable } from '../../tools/observable'
import { ONE_SECOND, dateNow } from '../../tools/utils/timeUtils'
import { throttle } from '../../tools/utils/functionUtils'
import { generateUUID } from '../../tools/utils/stringUtils'
import type { InitConfiguration, Configuration } from '../configuration'
import { display } from '../../tools/display'
import { selectCookieStrategy, initCookieStrategy } from './storeStrategies/sessionInCookie'
import type { SessionStoreStrategy, SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy'
import type { SessionState } from './sessionState'
import {
getExpiredSessionState,
isSessionInExpiredState,
isSessionInNotStartedState,
isSessionStarted,
} from './sessionState'
import { initLocalStorageStrategy, selectLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage'
import { processSessionStoreOperations } from './sessionStoreOperations'
import { SESSION_NOT_TRACKED, SessionPersistence } from './sessionConstants'
import { initMemorySessionStoreStrategy, selectMemorySessionStoreStrategy } from './storeStrategies/sessionInMemory'
export interface SessionStore {
expandOrRenewSession: () => void
expandSession: () => void
getSession: () => SessionState
restartSession: () => void
renewObservable: Observable<void>
expireObservable: Observable<void>
sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }>
expire: (hasConsent?: boolean) => void
stop: () => void
updateSessionState: (state: Partial<SessionState>) => void
}
/**
* Every second, the storage will be polled to check for any change that can occur
* to the session state in another browser tab, or another window.
* This value has been determined from our previous cookie-only implementation.
*/
export const STORAGE_POLL_DELAY = ONE_SECOND
/**
* Selects the correct session store strategy type based on the configuration and storage
* availability. When an array is provided, tries each persistence type in order until one
* successfully initializes.
*/
export function selectSessionStoreStrategyType(
initConfiguration: InitConfiguration
): SessionStoreStrategyType | undefined {
const { sessionPersistence } = initConfiguration
const persistenceList = normalizePersistenceList(sessionPersistence, initConfiguration)
for (const persistence of persistenceList) {
const strategyType = selectStrategyForPersistence(persistence, initConfiguration)
if (strategyType !== undefined) {
return strategyType
}
}
return undefined
}
function normalizePersistenceList(
sessionPersistence: SessionPersistence | SessionPersistence[] | undefined,
initConfiguration: InitConfiguration
): SessionPersistence[] {
if (Array.isArray(sessionPersistence)) {
return sessionPersistence
}
if (sessionPersistence !== undefined) {
return [sessionPersistence]
}
// Legacy default behavior: cookie first, with optional localStorage fallback
return initConfiguration.allowFallbackToLocalStorage
? [SessionPersistence.COOKIE, SessionPersistence.LOCAL_STORAGE]
: [SessionPersistence.COOKIE]
}
function selectStrategyForPersistence(
persistence: SessionPersistence,
initConfiguration: InitConfiguration
): SessionStoreStrategyType | undefined {
switch (persistence) {
case SessionPersistence.COOKIE:
return selectCookieStrategy(initConfiguration)
case SessionPersistence.LOCAL_STORAGE:
return selectLocalStorageStrategy()
case SessionPersistence.MEMORY:
return selectMemorySessionStoreStrategy()
default:
display.error(`Invalid session persistence '${String(persistence)}'`)
return undefined
}
}
export function getSessionStoreStrategy(
sessionStoreStrategyType: SessionStoreStrategyType,
configuration: Configuration
) {
return sessionStoreStrategyType.type === SessionPersistence.COOKIE
? initCookieStrategy(configuration, sessionStoreStrategyType.cookieOptions)
: sessionStoreStrategyType.type === SessionPersistence.LOCAL_STORAGE
? initLocalStorageStrategy(configuration)
: initMemorySessionStoreStrategy(configuration)
}
/**
* Different session concepts:
* - tracked, the session has an id and is updated along the user navigation
* - not tracked, the session does not have an id but it is updated along the user navigation
* - inactive, no session in store or session expired, waiting for a renew session
*/
export function startSessionStore<TrackingType extends string>(
sessionStoreStrategyType: SessionStoreStrategyType,
configuration: Configuration,
productKey: string,
computeTrackingType: (rawTrackingType?: string) => TrackingType,
sessionStoreStrategy: SessionStoreStrategy = getSessionStoreStrategy(sessionStoreStrategyType, configuration)
): SessionStore {
const renewObservable = new Observable<void>()
const expireObservable = new Observable<void>()
const sessionStateUpdateObservable = new Observable<{ previousState: SessionState; newState: SessionState }>()
const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY)
let sessionCache: SessionState
startSession()
const { throttled: throttledExpandOrRenewSession, cancel: cancelExpandOrRenewSession } = throttle(() => {
processSessionStoreOperations(
{
process: (sessionState) => {
if (isSessionInNotStartedState(sessionState)) {
return
}
const synchronizedSession = synchronizeSession(sessionState)
expandOrRenewSessionState(synchronizedSession)
return synchronizedSession
},
after: (sessionState) => {
if (isSessionStarted(sessionState) && !hasSessionInCache()) {
renewSessionInCache(sessionState)
}
sessionCache = sessionState
},
},
sessionStoreStrategy
)
}, STORAGE_POLL_DELAY)
function expandSession() {
processSessionStoreOperations(
{
process: (sessionState) => (hasSessionInCache() ? synchronizeSession(sessionState) : undefined),
},
sessionStoreStrategy
)
}
/**
* allows two behaviors:
* - if the session is active, synchronize the session cache without updating the session store
* - if the session is not active, clear the session store and expire the session cache
*/
function watchSession() {
const sessionState = sessionStoreStrategy.retrieveSession()
if (isSessionInExpiredState(sessionState)) {
processSessionStoreOperations(
{
process: (sessionState: SessionState) =>
isSessionInExpiredState(sessionState) ? getExpiredSessionState(sessionState, configuration) : undefined,
after: synchronizeSession,
},
sessionStoreStrategy
)
} else {
synchronizeSession(sessionState)
}
}
function synchronizeSession(sessionState: SessionState) {
if (isSessionInExpiredState(sessionState)) {
sessionState = getExpiredSessionState(sessionState, configuration)
}
if (hasSessionInCache()) {
if (isSessionInCacheOutdated(sessionState)) {
expireSessionInCache()
} else {
sessionStateUpdateObservable.notify({ previousState: sessionCache, newState: sessionState })
sessionCache = sessionState
}
}
return sessionState
}
function startSession() {
processSessionStoreOperations(
{
process: (sessionState) => {
if (isSessionInNotStartedState(sessionState)) {
sessionState.anonymousId = generateUUID()
return getExpiredSessionState(sessionState, configuration)
}
},
after: (sessionState) => {
sessionCache = sessionState
},
},
sessionStoreStrategy
)
}
function expandOrRenewSessionState(sessionState: SessionState) {
if (isSessionInNotStartedState(sessionState)) {
return false
}
const trackingType = computeTrackingType(sessionState[productKey])
sessionState[productKey] = trackingType
delete sessionState.isExpired
if (trackingType !== SESSION_NOT_TRACKED && !sessionState.id) {
sessionState.id = generateUUID()
sessionState.created = String(dateNow())
}
if (configuration.trackAnonymousUser && !sessionState.anonymousId) {
sessionState.anonymousId = generateUUID()
}
}
function hasSessionInCache() {
return sessionCache?.[productKey] !== undefined
}
function isSessionInCacheOutdated(sessionState: SessionState) {
return sessionCache.id !== sessionState.id || sessionCache[productKey] !== sessionState[productKey]
}
function expireSessionInCache() {
sessionCache = getExpiredSessionState(sessionCache, configuration)
expireObservable.notify()
}
function renewSessionInCache(sessionState: SessionState) {
sessionCache = sessionState
renewObservable.notify()
}
function updateSessionState(partialSessionState: Partial<SessionState>) {
processSessionStoreOperations(
{
process: (sessionState) => ({ ...sessionState, ...partialSessionState }),
after: synchronizeSession,
},
sessionStoreStrategy
)
}
return {
expandOrRenewSession: throttledExpandOrRenewSession,
expandSession,
getSession: () => sessionCache,
renewObservable,
expireObservable,
sessionStateUpdateObservable,
restartSession: startSession,
expire: (hasConsent?: boolean) => {
cancelExpandOrRenewSession()
if (hasConsent === false && sessionCache) {
delete sessionCache.anonymousId
}
sessionStoreStrategy.expireSession(sessionCache)
synchronizeSession(getExpiredSessionState(sessionCache, configuration))
},
stop: () => {
clearInterval(watchSessionTimeoutId)
},
updateSessionState,
}
}
@@ -0,0 +1,131 @@
import { setTimeout } from '../../tools/timer'
import { generateUUID } from '../../tools/utils/stringUtils'
import type { TimeStamp } from '../../tools/utils/timeUtils'
import { elapsed, ONE_SECOND, timeStampNow } from '../../tools/utils/timeUtils'
import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy'
import type { SessionState } from './sessionState'
import { expandSessionState, isSessionInExpiredState } from './sessionState'
interface Operations {
process: (sessionState: SessionState) => SessionState | undefined
after?: (sessionState: SessionState) => void
}
export const LOCK_RETRY_DELAY = 10
export const LOCK_MAX_TRIES = 100
// Locks should be hold for a few milliseconds top, just the time it takes to read and write a
// cookie. Using one second should be enough in most situations.
export const LOCK_EXPIRATION_DELAY = ONE_SECOND
const LOCK_SEPARATOR = '--'
const bufferedOperations: Operations[] = []
let ongoingOperations: Operations | undefined
export function processSessionStoreOperations(
operations: Operations,
sessionStoreStrategy: SessionStoreStrategy,
numberOfRetries = 0
) {
const { isLockEnabled, persistSession, expireSession } = sessionStoreStrategy
const persistWithLock = (session: SessionState) => persistSession({ ...session, lock: currentLock })
const retrieveStore = () => {
const { lock, ...session } = sessionStoreStrategy.retrieveSession()
return {
session,
lock: lock && !isLockExpired(lock) ? lock : undefined,
}
}
if (!ongoingOperations) {
ongoingOperations = operations
}
if (operations !== ongoingOperations) {
bufferedOperations.push(operations)
return
}
if (isLockEnabled && numberOfRetries >= LOCK_MAX_TRIES) {
next(sessionStoreStrategy)
return
}
let currentLock: string
let currentStore = retrieveStore()
if (isLockEnabled) {
// if someone has lock, retry later
if (currentStore.lock) {
retryLater(operations, sessionStoreStrategy, numberOfRetries)
return
}
// acquire lock
currentLock = createLock()
persistWithLock(currentStore.session)
// if lock is not acquired, retry later
currentStore = retrieveStore()
if (currentStore.lock !== currentLock) {
retryLater(operations, sessionStoreStrategy, numberOfRetries)
return
}
}
let processedSession = operations.process(currentStore.session)
if (isLockEnabled) {
// if lock corrupted after process, retry later
currentStore = retrieveStore()
if (currentStore.lock !== currentLock!) {
retryLater(operations, sessionStoreStrategy, numberOfRetries)
return
}
}
if (processedSession) {
if (isSessionInExpiredState(processedSession)) {
expireSession(processedSession)
} else {
expandSessionState(processedSession)
if (isLockEnabled) {
persistWithLock(processedSession)
} else {
persistSession(processedSession)
}
}
}
if (isLockEnabled) {
// correctly handle lock around expiration would require to handle this case properly at several levels
// since we don't have evidence of lock issues around expiration, let's just not do the corruption check for it
if (!(processedSession && isSessionInExpiredState(processedSession))) {
// if lock corrupted after persist, retry later
currentStore = retrieveStore()
if (currentStore.lock !== currentLock!) {
retryLater(operations, sessionStoreStrategy, numberOfRetries)
return
}
persistSession(currentStore.session)
processedSession = currentStore.session
}
}
// call after even if session is not persisted in order to perform operations on
// up-to-date session state value => the value could have been modified by another tab
operations.after?.(processedSession || currentStore.session)
next(sessionStoreStrategy)
}
function retryLater(operations: Operations, sessionStore: SessionStoreStrategy, currentNumberOfRetries: number) {
setTimeout(() => {
processSessionStoreOperations(operations, sessionStore, currentNumberOfRetries + 1)
}, LOCK_RETRY_DELAY)
}
function next(sessionStore: SessionStoreStrategy) {
ongoingOperations = undefined
const nextOperations = bufferedOperations.shift()
if (nextOperations) {
processSessionStoreOperations(nextOperations, sessionStore)
}
}
export function createLock(): string {
return generateUUID() + LOCK_SEPARATOR + timeStampNow()
}
function isLockExpired(lock: string) {
const [, timeStamp] = lock.split(LOCK_SEPARATOR)
return !timeStamp || elapsed(Number(timeStamp) as TimeStamp, timeStampNow()) > LOCK_EXPIRATION_DELAY
}
@@ -0,0 +1,146 @@
import { isEmptyObject } from '../../../tools/utils/objectUtils'
import { isChromium } from '../../../tools/utils/browserDetection'
import type { CookieOptions } from '../../../browser/cookie'
import { getCurrentSite, areCookiesAuthorized, getCookies, setCookie, getCookie } from '../../../browser/cookie'
import type { InitConfiguration, Configuration } from '../../configuration'
import { tryOldCookiesMigration } from '../oldCookiesMigration'
import {
SESSION_COOKIE_EXPIRATION_DELAY,
SESSION_EXPIRATION_DELAY,
SESSION_TIME_OUT_DELAY,
SessionPersistence,
} from '../sessionConstants'
import type { SessionState } from '../sessionState'
import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState'
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'
const SESSION_COOKIE_VERSION = 0
export function selectCookieStrategy(initConfiguration: InitConfiguration): SessionStoreStrategyType | undefined {
const cookieOptions = buildCookieOptions(initConfiguration)
return cookieOptions && areCookiesAuthorized(cookieOptions)
? { type: SessionPersistence.COOKIE, cookieOptions }
: undefined
}
export function initCookieStrategy(configuration: Configuration, cookieOptions: CookieOptions): SessionStoreStrategy {
const cookieStore = {
/**
* Lock strategy allows mitigating issues due to concurrent access to cookie.
* This issue concerns only chromium browsers and enabling this on firefox increases cookie write failures.
*/
isLockEnabled: isChromium(),
persistSession: (sessionState: SessionState) =>
storeSessionCookie(cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY),
retrieveSession: () => retrieveSessionCookie(cookieOptions, configuration),
expireSession: (sessionState: SessionState) =>
storeSessionCookie(
cookieOptions,
configuration,
getExpiredSessionState(sessionState, configuration),
SESSION_TIME_OUT_DELAY
),
}
tryOldCookiesMigration(cookieStore)
return cookieStore
}
function storeSessionCookie(
options: CookieOptions,
configuration: Configuration,
sessionState: SessionState,
defaultTimeout: number
) {
let sessionStateString = toSessionString(sessionState)
if (configuration.betaEncodeCookieOptions) {
sessionStateString = toSessionString({
...sessionState,
// deleting a cookie is writing a new cookie with an empty value
// we don't want to store the cookie options in this case otherwise the cookie will not be deleted
...(!isEmptyObject(sessionState) ? { c: encodeCookieOptions(options) } : {}),
})
}
setCookie(
SESSION_STORE_KEY,
sessionStateString,
configuration.trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : defaultTimeout,
options
)
}
/**
* Retrieve the session state from the cookie that was set with the same cookie options
* If there is no match, return the first cookie, because that's how `getCookie()` works
*/
export function retrieveSessionCookie(cookieOptions: CookieOptions, configuration: Configuration): SessionState {
if (configuration.betaEncodeCookieOptions) {
return retrieveSessionCookieFromEncodedCookie(cookieOptions)
}
const sessionString = getCookie(SESSION_STORE_KEY)
const sessionState = toSessionState(sessionString)
return sessionState
}
export function buildCookieOptions(initConfiguration: InitConfiguration): CookieOptions | undefined {
const cookieOptions: CookieOptions = {}
cookieOptions.secure =
!!initConfiguration.useSecureSessionCookie || !!initConfiguration.usePartitionedCrossSiteSessionCookie
cookieOptions.crossSite = !!initConfiguration.usePartitionedCrossSiteSessionCookie
cookieOptions.partitioned = !!initConfiguration.usePartitionedCrossSiteSessionCookie
if (initConfiguration.trackSessionAcrossSubdomains) {
const currentSite = getCurrentSite()
if (!currentSite) {
return
}
cookieOptions.domain = currentSite
}
return cookieOptions
}
function encodeCookieOptions(cookieOptions: CookieOptions): string {
const domainCount = cookieOptions.domain ? cookieOptions.domain.split('.').length - 1 : 0
/* eslint-disable no-bitwise */
let byte = 0
byte |= SESSION_COOKIE_VERSION << 5 // Store version in upper 3 bits
byte |= domainCount << 1 // Store domain count in next 4 bits
byte |= cookieOptions.crossSite ? 1 : 0 // Store useCrossSiteScripting in next bit
/* eslint-enable no-bitwise */
return byte.toString(16) // Convert to hex string
}
/**
* Retrieve the session state from the cookie that was set with the same cookie options.
* If there is no match, fallback to the first cookie, (because that's how `getCookie()` works)
* and this allows to keep the current session id when we release this feature.
*/
function retrieveSessionCookieFromEncodedCookie(cookieOptions: CookieOptions): SessionState {
const cookies = getCookies(SESSION_STORE_KEY)
const opts = encodeCookieOptions(cookieOptions)
let sessionState: SessionState | undefined
// reverse the cookies so that if there is no match, the cookie returned is the first one
for (const cookie of cookies.reverse()) {
sessionState = toSessionState(cookie)
if (sessionState.c === opts) {
break
}
}
// remove the cookie options from the session state as this is not part of the session state
delete sessionState?.c
return sessionState ?? {}
}
@@ -0,0 +1,44 @@
import { generateUUID } from '../../../tools/utils/stringUtils'
import type { Configuration } from '../../configuration'
import { SessionPersistence } from '../sessionConstants'
import type { SessionState } from '../sessionState'
import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState'
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'
const LOCAL_STORAGE_TEST_KEY = '_dd_test_'
export function selectLocalStorageStrategy(): SessionStoreStrategyType | undefined {
try {
const id = generateUUID()
const testKey = `${LOCAL_STORAGE_TEST_KEY}${id}`
localStorage.setItem(testKey, id)
const retrievedId = localStorage.getItem(testKey)
localStorage.removeItem(testKey)
return id === retrievedId ? { type: SessionPersistence.LOCAL_STORAGE } : undefined
} catch {
return undefined
}
}
export function initLocalStorageStrategy(configuration: Configuration): SessionStoreStrategy {
return {
isLockEnabled: false,
persistSession: persistInLocalStorage,
retrieveSession: retrieveSessionFromLocalStorage,
expireSession: (sessionState: SessionState) => expireSessionFromLocalStorage(sessionState, configuration),
}
}
function persistInLocalStorage(sessionState: SessionState) {
localStorage.setItem(SESSION_STORE_KEY, toSessionString(sessionState))
}
export function retrieveSessionFromLocalStorage(): SessionState {
const sessionString = localStorage.getItem(SESSION_STORE_KEY)
return toSessionState(sessionString)
}
function expireSessionFromLocalStorage(previousSessionState: SessionState, configuration: Configuration) {
persistInLocalStorage(getExpiredSessionState(previousSessionState, configuration))
}
@@ -0,0 +1,47 @@
import { getGlobalObject } from '../../../tools/globalObject'
import type { Configuration } from '../../configuration'
import { SessionPersistence } from '../sessionConstants'
import type { SessionState } from '../sessionState'
import { getExpiredSessionState } from '../sessionState'
import { shallowClone } from '../../../tools/utils/objectUtils'
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
/**
* Key used to store session state in the global object.
* This allows RUM and Logs SDKs to share the same session when using memory storage.
*/
export const MEMORY_SESSION_STORE_KEY = '_DD_SESSION'
interface GlobalObjectWithSession {
[MEMORY_SESSION_STORE_KEY]?: SessionState
}
export function selectMemorySessionStoreStrategy(): SessionStoreStrategyType {
return { type: SessionPersistence.MEMORY }
}
export function initMemorySessionStoreStrategy(configuration: Configuration): SessionStoreStrategy {
return {
expireSession: (sessionState: SessionState) => expireSessionFromMemory(sessionState, configuration),
isLockEnabled: false,
persistSession: persistInMemory,
retrieveSession: retrieveFromMemory,
}
}
function retrieveFromMemory(): SessionState {
const globalObject = getGlobalObject<GlobalObjectWithSession>()
if (!globalObject[MEMORY_SESSION_STORE_KEY]) {
globalObject[MEMORY_SESSION_STORE_KEY] = {}
}
return shallowClone(globalObject[MEMORY_SESSION_STORE_KEY])
}
function persistInMemory(state: SessionState): void {
const globalObject = getGlobalObject<GlobalObjectWithSession>()
globalObject[MEMORY_SESSION_STORE_KEY] = shallowClone(state)
}
function expireSessionFromMemory(previousSessionState: SessionState, configuration: Configuration) {
persistInMemory(getExpiredSessionState(previousSessionState, configuration))
}
@@ -0,0 +1,17 @@
import type { CookieOptions } from '../../../browser/cookie'
import type { SessionPersistence } from '../sessionConstants'
import type { SessionState } from '../sessionState'
export const SESSION_STORE_KEY = '_dd_s'
export type SessionStoreStrategyType =
| { type: typeof SessionPersistence.COOKIE; cookieOptions: CookieOptions }
| { type: typeof SessionPersistence.LOCAL_STORAGE }
| { type: typeof SessionPersistence.MEMORY }
export interface SessionStoreStrategy {
isLockEnabled: boolean
persistSession: (session: SessionState) => void
retrieveSession: () => SessionState
expireSession: (previousSessionState: SessionState) => void
}
@@ -0,0 +1,69 @@
import { getInitCookie } from '../../browser/cookie'
import { globalObject, isWorkerEnvironment } from '../../tools/globalObject'
import { tryJsonParse } from '../../tools/utils/objectUtils'
const cookieNamePrefix = 'datadog-synthetics-'
export const SYNTHETICS_TEST_ID_COOKIE_NAME = `${cookieNamePrefix}public-id`
export const SYNTHETICS_RESULT_ID_COOKIE_NAME = `${cookieNamePrefix}result-id`
export const SYNTHETICS_INJECTS_RUM_COOKIE_NAME = `${cookieNamePrefix}injects-rum`
export const SYNTHETICS_CONTEXT_COOKIE_NAME = `${cookieNamePrefix}rum-context`
export interface BrowserWindow extends Window {
_DATADOG_SYNTHETICS_PUBLIC_ID?: unknown
_DATADOG_SYNTHETICS_RESULT_ID?: unknown
_DATADOG_SYNTHETICS_INJECTS_RUM?: unknown
_DATADOG_SYNTHETICS_RUM_CONTEXT?: unknown
}
export interface SyntheticsContext {
test_id: string
result_id: string
[key: string]: unknown
}
export function willSyntheticsInjectRum(): boolean {
if (isWorkerEnvironment) {
// We don't expect to run synthetics tests in a worker environment
return false
}
return Boolean(
(globalObject as BrowserWindow)._DATADOG_SYNTHETICS_INJECTS_RUM || getInitCookie(SYNTHETICS_INJECTS_RUM_COOKIE_NAME)
)
}
export function getSyntheticsContext(): SyntheticsContext | undefined {
const raw = getRawSyntheticsContext()
return isValidSyntheticsContext(raw) ? raw : undefined
}
export function isSyntheticsTest(): boolean {
return Boolean(getSyntheticsContext())
}
function getRawSyntheticsContext(): unknown {
const rawGlobal = (globalObject as BrowserWindow)._DATADOG_SYNTHETICS_RUM_CONTEXT
if (rawGlobal) {
return rawGlobal
}
const rawCookie = getInitCookie(SYNTHETICS_CONTEXT_COOKIE_NAME)
if (rawCookie) {
return tryJsonParse(decodeURIComponent(rawCookie))
}
return {
test_id: (window as BrowserWindow)._DATADOG_SYNTHETICS_PUBLIC_ID || getInitCookie(SYNTHETICS_TEST_ID_COOKIE_NAME),
result_id:
(window as BrowserWindow)._DATADOG_SYNTHETICS_RESULT_ID || getInitCookie(SYNTHETICS_RESULT_ID_COOKIE_NAME),
}
}
function isValidSyntheticsContext(value: unknown): value is SyntheticsContext {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).test_id === 'string' &&
typeof (value as Record<string, unknown>).result_id === 'string'
)
}
+76
View File
@@ -0,0 +1,76 @@
import { DOCS_ORIGIN, MORE_DETAILS, display } from '../tools/display'
import type { Configuration } from './configuration'
export const TAG_SIZE_LIMIT = 200
// replaced at build time
declare const __BUILD_ENV__SDK_VERSION__: string
export function buildTags(configuration: Configuration): string[] {
const { env, service, version, datacenter, sdkVersion, variant } = configuration
const tags = [buildTag('sdk_version', sdkVersion ?? __BUILD_ENV__SDK_VERSION__)]
if (env) {
tags.push(buildTag('env', env))
}
if (service) {
tags.push(buildTag('service', service))
}
if (version) {
tags.push(buildTag('version', version))
}
if (datacenter) {
tags.push(buildTag('datacenter', datacenter))
}
if (variant) {
tags.push(buildTag('variant', variant))
}
return tags
}
export function buildTag(key: string, rawValue?: string) {
// See https://docs.datadoghq.com/getting_started/tagging/#defining-tags for tags syntax. Note
// that the backend may not follow the exact same rules, so we only want to display an informal
// warning.
const tag = rawValue ? `${key}:${rawValue}` : key
if (tag.length > TAG_SIZE_LIMIT || hasForbiddenCharacters(tag)) {
display.warn(
`Tag ${tag} doesn't meet tag requirements and will be sanitized. ${MORE_DETAILS} ${DOCS_ORIGIN}/getting_started/tagging/#defining-tags`
)
}
// Let the backend do most of the sanitization, but still make sure multiple tags can't be crafted
// by forging a value containing commas.
return sanitizeTag(tag)
}
export function sanitizeTag(tag: string) {
return tag.replace(/,/g, '_')
}
function hasForbiddenCharacters(rawValue: string) {
// Unicode property escapes is not supported in all browsers, so we use a try/catch.
// Todo: Remove the try/catch when dropping support for Chrome 63 and Firefox 67
// see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape#browser_compatibility
if (!supportUnicodePropertyEscapes()) {
return false
}
// We use the Unicode property escapes to match any character that is a letter including other languages like Chinese, Japanese, etc.
// p{Ll} matches a lowercase letter.
// p{Lo} matches a letter that is neither uppercase nor lowercase (ex: Japanese characters).
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape#unicode_property_escapes_vs._character_classes
return new RegExp('[^\\p{Ll}\\p{Lo}0-9_:./-]', 'u').test(rawValue)
}
export function supportUnicodePropertyEscapes() {
try {
new RegExp('[\\p{Ll}]', 'u')
return true
} catch {
return false
}
}
@@ -0,0 +1,22 @@
import type { TelemetryEvent, TelemetryConfigurationEvent, TelemetryUsageEvent } from './telemetryEvent.types'
export const TelemetryType = {
LOG: 'log',
CONFIGURATION: 'configuration',
USAGE: 'usage',
} as const
export const enum StatusType {
debug = 'debug',
error = 'error',
}
export interface RuntimeEnvInfo {
is_local_file: boolean
is_worker: boolean
}
export type RawTelemetryEvent = TelemetryEvent['telemetry']
export type RawTelemetryConfiguration = TelemetryConfigurationEvent['telemetry']['configuration']
export type RawTelemetryUsage = TelemetryUsageEvent['telemetry']['usage']
export type RawTelemetryUsageFeature = TelemetryUsageEvent['telemetry']['usage']['feature']
@@ -0,0 +1,337 @@
import type { Context } from '../../tools/serialisation/context'
import { ConsoleApiName } from '../../tools/display'
import { NO_ERROR_STACK_PRESENT_MESSAGE, isError } from '../error/error'
import { toStackTraceString } from '../../tools/stackTrace/handlingStack'
import { getExperimentalFeatures } from '../../tools/experimentalFeatures'
import type { Configuration } from '../configuration'
import { buildTags } from '../tags'
import { INTAKE_SITE_STAGING, INTAKE_SITE_US1_FED } from '../intakeSites'
import { BufferedObservable, Observable } from '../../tools/observable'
import { clocksNow } from '../../tools/utils/timeUtils'
import { displayIfDebugEnabled, startMonitorErrorCollection } from '../../tools/monitor'
import { sendToExtension } from '../../tools/sendToExtension'
import { performDraw } from '../../tools/utils/numberUtils'
import { jsonStringify } from '../../tools/serialisation/jsonStringify'
import { combine } from '../../tools/mergeInto'
import { NonErrorPrefix } from '../error/error.types'
import type { StackTrace } from '../../tools/stackTrace/computeStackTrace'
import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace'
import { getConnectivity } from '../connectivity'
import {
canUseEventBridge,
createFlushController,
createHttpRequest,
getEventBridge,
createBatch,
} from '../../transport'
import { createIdentityEncoder } from '../../tools/encoder'
import { createPageMayExitObservable } from '../../browser/pageMayExitObservable'
import type { AbstractHooks, RecursivePartial } from '../../tools/abstractHooks'
import { HookNames, DISCARDED } from '../../tools/abstractHooks'
import { globalObject, isWorkerEnvironment } from '../../tools/globalObject'
import { noop } from '../../tools/utils/functionUtils'
import type { TelemetryEvent } from './telemetryEvent.types'
import type {
RawTelemetryConfiguration,
RawTelemetryEvent,
RuntimeEnvInfo,
RawTelemetryUsage,
} from './rawTelemetryEvent.types'
import { StatusType, TelemetryType } from './rawTelemetryEvent.types'
// replaced at build time
declare const __BUILD_ENV__SDK_VERSION__: string
declare const __BUILD_ENV__SDK_SETUP__: string
const ALLOWED_FRAME_URLS = [
'https://www.datadoghq-browser-agent.com',
'https://www.datad0g-browser-agent.com',
'https://d3uc069fcn7uxw.cloudfront.net',
'https://d20xtzwzcl0ceb.cloudfront.net',
'http://localhost',
'<anonymous>',
]
export const enum TelemetryService {
LOGS = 'browser-logs-sdk',
RUM = 'browser-rum-sdk',
}
export interface Telemetry {
stop: () => void
enabled: boolean
metricsEnabled: boolean
}
export const enum TelemetryMetrics {
CUSTOMER_DATA_METRIC_NAME = 'Customer data measures',
REMOTE_CONFIGURATION_METRIC_NAME = 'remote configuration metrics',
RECORDER_INIT_METRICS_TELEMETRY_NAME = 'Recorder init metrics',
SEGMENT_METRICS_TELEMETRY_NAME = 'Segment network request metrics',
INITIAL_VIEW_METRICS_TELEMETRY_NAME = 'Initial view metrics',
}
const METRIC_SAMPLE_RATE = 1
const TELEMETRY_EXCLUDED_SITES: string[] = [INTAKE_SITE_US1_FED]
const MAX_TELEMETRY_EVENTS_PER_PAGE = 15
let telemetryObservable: BufferedObservable<{ rawEvent: RawTelemetryEvent; metricName?: string }> | undefined
export function getTelemetryObservable() {
if (!telemetryObservable) {
telemetryObservable = new BufferedObservable(100)
}
return telemetryObservable
}
export function startTelemetry(
telemetryService: TelemetryService,
configuration: Configuration,
hooks: AbstractHooks
): Telemetry {
const observable = new Observable<TelemetryEvent & Context>()
const { enabled, metricsEnabled } = startTelemetryCollection(telemetryService, configuration, hooks, observable)
const { stop } = startTelemetryTransport(configuration, observable)
return {
stop,
enabled,
metricsEnabled,
}
}
export function startTelemetryCollection(
telemetryService: TelemetryService,
configuration: Configuration,
hooks: AbstractHooks,
observable: Observable<TelemetryEvent & Context>,
metricSampleRate = METRIC_SAMPLE_RATE,
maxTelemetryEventsPerPage = MAX_TELEMETRY_EVENTS_PER_PAGE
) {
const alreadySentEventsByKind: Record<string, Set<string>> = {}
const telemetryEnabled =
!TELEMETRY_EXCLUDED_SITES.includes(configuration.site) && performDraw(configuration.telemetrySampleRate)
const telemetryEnabledPerType = {
[TelemetryType.LOG]: telemetryEnabled,
[TelemetryType.CONFIGURATION]: telemetryEnabled && performDraw(configuration.telemetryConfigurationSampleRate),
[TelemetryType.USAGE]: telemetryEnabled && performDraw(configuration.telemetryUsageSampleRate),
// not an actual "type" but using a single draw for all metrics
metric: telemetryEnabled && performDraw(metricSampleRate),
}
const runtimeEnvInfo = getRuntimeEnvInfo()
const telemetryObservable = getTelemetryObservable()
telemetryObservable.subscribe(({ rawEvent, metricName }) => {
if ((metricName && !telemetryEnabledPerType['metric']) || !telemetryEnabledPerType[rawEvent.type!]) {
return
}
const kind = metricName || (rawEvent.status as string | undefined) || rawEvent.type!
let alreadySentEvents = alreadySentEventsByKind[kind]
if (!alreadySentEvents) {
alreadySentEvents = alreadySentEventsByKind[kind] = new Set()
}
if (alreadySentEvents.size >= maxTelemetryEventsPerPage) {
return
}
const stringifiedEvent = jsonStringify(rawEvent)!
if (alreadySentEvents.has(stringifiedEvent)) {
return
}
const defaultTelemetryEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, {
startTime: clocksNow().relative,
})
if (defaultTelemetryEventAttributes === DISCARDED) {
return
}
const event = toTelemetryEvent(
defaultTelemetryEventAttributes as RecursivePartial<TelemetryEvent>,
telemetryService,
rawEvent,
runtimeEnvInfo
)
observable.notify(event)
sendToExtension('telemetry', event)
alreadySentEvents.add(stringifiedEvent)
})
telemetryObservable.unbuffer()
startMonitorErrorCollection(addTelemetryError)
return {
enabled: telemetryEnabled,
metricsEnabled: telemetryEnabledPerType['metric'],
}
function toTelemetryEvent(
defaultTelemetryEventAttributes: RecursivePartial<TelemetryEvent>,
telemetryService: TelemetryService,
rawEvent: RawTelemetryEvent,
runtimeEnvInfo: RuntimeEnvInfo
): TelemetryEvent & Context {
const clockNow = clocksNow()
const event = {
type: 'telemetry' as const,
date: clockNow.timeStamp,
service: telemetryService,
version: __BUILD_ENV__SDK_VERSION__,
source: 'browser' as const,
_dd: {
format_version: 2 as const,
},
telemetry: combine(rawEvent, {
runtime_env: runtimeEnvInfo,
connectivity: getConnectivity(),
sdk_setup: __BUILD_ENV__SDK_SETUP__,
}) as TelemetryEvent['telemetry'],
ddtags: buildTags(configuration).join(','),
experimental_features: Array.from(getExperimentalFeatures()),
}
return combine(event, defaultTelemetryEventAttributes) as TelemetryEvent & Context
}
}
export function startTelemetryTransport(
configuration: Configuration,
telemetryObservable: Observable<TelemetryEvent & Context>
) {
const cleanupTasks: Array<() => void> = []
if (canUseEventBridge()) {
const bridge = getEventBridge<'internal_telemetry', TelemetryEvent>()!
const telemetrySubscription = telemetryObservable.subscribe((event) => bridge.send('internal_telemetry', event))
cleanupTasks.push(telemetrySubscription.unsubscribe)
} else {
const endpoints = [configuration.rumEndpointBuilder]
if (configuration.replica && isTelemetryReplicationAllowed(configuration)) {
endpoints.push(configuration.replica.rumEndpointBuilder)
}
const telemetryBatch = createBatch({
encoder: createIdentityEncoder(),
request: createHttpRequest(
endpoints,
// Ignore transport errors for telemetry
noop
),
flushController: createFlushController({
pageMayExitObservable: createPageMayExitObservable(configuration),
// We don't use an actual session expire observable here, to make telemetry collection
// independent of the session. This allows to start and send telemetry events earlier.
sessionExpireObservable: new Observable(),
}),
})
cleanupTasks.push(telemetryBatch.stop)
const telemetrySubscription = telemetryObservable.subscribe(telemetryBatch.add)
cleanupTasks.push(telemetrySubscription.unsubscribe)
}
return {
stop: () => cleanupTasks.forEach((task) => task()),
}
}
function getRuntimeEnvInfo(): RuntimeEnvInfo {
return {
is_local_file: globalObject.location?.protocol === 'file:',
is_worker: isWorkerEnvironment,
}
}
export function resetTelemetry() {
telemetryObservable = undefined
}
/**
* Avoid mixing telemetry events from different data centers
* but keep replicating staging events for reliability
*/
function isTelemetryReplicationAllowed(configuration: Configuration) {
return configuration.site === INTAKE_SITE_STAGING
}
export function addTelemetryDebug(message: string, context?: Context) {
displayIfDebugEnabled(ConsoleApiName.debug, message, context)
getTelemetryObservable().notify({
rawEvent: {
type: TelemetryType.LOG,
message,
status: StatusType.debug,
...context,
},
})
}
export function addTelemetryError(e: unknown, context?: Context) {
getTelemetryObservable().notify({
rawEvent: {
type: TelemetryType.LOG,
status: StatusType.error,
...formatError(e),
...context,
},
})
}
export function addTelemetryConfiguration(configuration: RawTelemetryConfiguration) {
getTelemetryObservable().notify({
rawEvent: {
type: TelemetryType.CONFIGURATION,
configuration,
},
})
}
export function addTelemetryMetrics(metricName: TelemetryMetrics, context?: Context) {
getTelemetryObservable().notify({
rawEvent: {
type: TelemetryType.LOG,
message: metricName,
status: StatusType.debug,
...context,
},
metricName,
})
}
export function addTelemetryUsage(usage: RawTelemetryUsage) {
getTelemetryObservable().notify({
rawEvent: {
type: TelemetryType.USAGE,
usage,
},
})
}
export function formatError(e: unknown) {
if (isError(e)) {
const stackTrace = computeStackTrace(e)
return {
error: {
kind: stackTrace.name,
stack: toStackTraceString(scrubCustomerFrames(stackTrace)),
},
message: stackTrace.message!,
}
}
return {
error: {
stack: NO_ERROR_STACK_PRESENT_MESSAGE,
},
message: `${NonErrorPrefix.UNCAUGHT} ${jsonStringify(e)!}`,
}
}
export function scrubCustomerFrames(stackTrace: StackTrace) {
stackTrace.stack = stackTrace.stack.filter(
(frame) => !frame.url || ALLOWED_FRAME_URLS.some((allowedFrameUrl) => frame.url!.startsWith(allowedFrameUrl))
)
return stackTrace
}
@@ -0,0 +1,34 @@
import { Observable } from '../tools/observable'
export const TrackingConsent = {
GRANTED: 'granted',
NOT_GRANTED: 'not-granted',
} as const
export type TrackingConsent = (typeof TrackingConsent)[keyof typeof TrackingConsent]
export interface TrackingConsentState {
tryToInit: (trackingConsent: TrackingConsent) => void
update: (trackingConsent: TrackingConsent) => void
isGranted: () => boolean
observable: Observable<void>
}
export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState {
const observable = new Observable<void>()
return {
tryToInit(trackingConsent: TrackingConsent) {
if (!currentConsent) {
currentConsent = trackingConsent
}
},
update(trackingConsent: TrackingConsent) {
currentConsent = trackingConsent
observable.notify()
},
isGranted() {
return currentConsent === TrackingConsent.GRANTED
},
observable,
}
}
+72
View File
@@ -0,0 +1,72 @@
import { combine } from './mergeInto'
export const enum HookNames {
Assemble,
AssembleTelemetry,
}
// This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript
// project configured with `isolatedModules: true`. Even if the const enum is declared in this
// module, we cannot use it directly to define the EventMap interface keys (TS error: "Cannot access
// ambient const enums when the '--isolatedModules' flag is provided.").
export declare const HookNamesAsConst: {
ASSEMBLE: HookNames.Assemble
ASSEMBLE_TELEMETRY: HookNames.AssembleTelemetry
}
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<RecursivePartial<U>>
: T[P] extends object | undefined
? RecursivePartial<T[P]>
: T[P]
}
// Discards the event from being sent
export const DISCARDED = 'DISCARDED'
// Skips from the assembly of the event
export const SKIPPED = 'SKIPPED'
export type DISCARDED = typeof DISCARDED
export type SKIPPED = typeof SKIPPED
export type AbstractHooks = ReturnType<typeof abstractHooks>
export function abstractHooks<T extends { [K in HookNames]: (...args: any[]) => unknown }>() {
const callbacks: { [K in HookNames]?: Array<T[K]> } = {}
return {
register<K extends HookNames>(hookName: K, callback: T[K]) {
if (!callbacks[hookName]) {
callbacks[hookName] = []
}
callbacks[hookName]!.push(callback)
return {
unregister: () => {
callbacks[hookName] = callbacks[hookName]!.filter((cb) => cb !== callback)
},
}
},
triggerHook<K extends HookNames>(
hookName: K,
param: Parameters<T[K]>[0]
): Exclude<ReturnType<T[K]>, SKIPPED> | DISCARDED | undefined {
const hookCallbacks = callbacks[hookName] || []
const results: any[] = []
for (const callback of hookCallbacks) {
const result = callback(param)
if (result === DISCARDED) {
return DISCARDED
}
if (result === SKIPPED) {
continue
}
results.push(result)
}
return combine(...(results as [any, any])) as Exclude<ReturnType<T[K]>, SKIPPED>
},
}
}
@@ -0,0 +1,46 @@
import type { Subscription } from './observable'
/**
* Type helper to extract event types that have "void" data. This allows to call `notify` without a
* second argument. Ex:
*
* ```
* interface EventMap {
* foo: void
* }
* const LifeCycle = AbstractLifeCycle<EventMap>
* new LifeCycle().notify('foo')
* ```
*/
type EventTypesWithoutData<EventMap> = {
[K in keyof EventMap]: EventMap[K] extends void ? K : never
}[keyof EventMap]
// eslint-disable-next-line no-restricted-syntax
export class AbstractLifeCycle<EventMap> {
private callbacks: { [key in keyof EventMap]?: Array<(data: any) => void> } = {}
notify<EventType extends EventTypesWithoutData<EventMap>>(eventType: EventType): void
notify<EventType extends keyof EventMap>(eventType: EventType, data: EventMap[EventType]): void
notify(eventType: keyof EventMap, data?: unknown) {
const eventCallbacks = this.callbacks[eventType]
if (eventCallbacks) {
eventCallbacks.forEach((callback) => callback(data))
}
}
subscribe<EventType extends keyof EventMap>(
eventType: EventType,
callback: (data: EventMap[EventType]) => void
): Subscription {
if (!this.callbacks[eventType]) {
this.callbacks[eventType] = []
}
this.callbacks[eventType]!.push(callback)
return {
unsubscribe: () => {
this.callbacks[eventType] = this.callbacks[eventType]!.filter((other) => callback !== other)
},
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { removeItem } from './utils/arrayUtils'
const BUFFER_LIMIT = 500
/**
* BoundedBuffer is a deprecated interface.
*
* @deprecated Use `BufferedObservable` instead.
*/
export interface BoundedBuffer<T = void> {
add: (callback: (arg: T) => void) => void
remove: (callback: (arg: T) => void) => void
drain: (arg: T) => void
}
/**
* createBoundedBuffer creates a BoundedBuffer.
*
* @deprecated Use `BufferedObservable` instead.
*/
export function createBoundedBuffer<T = void>(): BoundedBuffer<T> {
const buffer: Array<(arg: T) => void> = []
const add: BoundedBuffer<T>['add'] = (callback: (arg: T) => void) => {
const length = buffer.push(callback)
if (length > BUFFER_LIMIT) {
buffer.splice(0, 1)
}
}
const remove: BoundedBuffer<T>['remove'] = (callback: (arg: T) => void) => {
removeItem(buffer, callback)
}
const drain = (arg: T) => {
buffer.forEach((callback) => callback(arg))
buffer.length = 0
}
return {
add,
remove,
drain,
}
}
@@ -0,0 +1,11 @@
import { display } from './display'
export function catchUserErrors<Args extends any[], R>(fn: (...args: Args) => R, errorMsg: string) {
return (...args: Args) => {
try {
return fn(...args)
} catch (err) {
display.error(errorMsg, err)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
/* eslint-disable local-rules/disallow-side-effects */
/**
* Keep references on console methods to avoid triggering patched behaviors
*
* NB: in some setup, console could already be patched by another SDK.
* In this case, some display messages can be sent by the other SDK
* but we should be safe from infinite loop nonetheless.
*/
export const ConsoleApiName = {
log: 'log',
debug: 'debug',
info: 'info',
warn: 'warn',
error: 'error',
} as const
export type ConsoleApiName = (typeof ConsoleApiName)[keyof typeof ConsoleApiName]
interface Display {
debug: typeof console.debug
log: typeof console.log
info: typeof console.info
warn: typeof console.warn
error: typeof console.error
}
/**
* When building JS bundles, some users might use a plugin[1] or configuration[2] to remove
* "console.*" references. This causes some issue as we expect `console.*` to be defined.
* As a workaround, let's use a variable alias, so those expressions won't be taken into account by
* simple static analysis.
*
* [1]: https://babeljs.io/docs/babel-plugin-transform-remove-console/
* [2]: https://github.com/terser/terser#compress-options (look for drop_console)
*/
export const globalConsole = console
export const originalConsoleMethods = {} as Display
Object.keys(ConsoleApiName).forEach((name) => {
originalConsoleMethods[name as ConsoleApiName] = globalConsole[name as ConsoleApiName]
})
const PREFIX = 'Datadog Browser SDK:'
export const display: Display = {
debug: originalConsoleMethods.debug.bind(globalConsole, PREFIX),
log: originalConsoleMethods.log.bind(globalConsole, PREFIX),
info: originalConsoleMethods.info.bind(globalConsole, PREFIX),
warn: originalConsoleMethods.warn.bind(globalConsole, PREFIX),
error: originalConsoleMethods.error.bind(globalConsole, PREFIX),
}
export const DOCS_ORIGIN = 'https://docs.datadoghq.com'
export const DOCS_TROUBLESHOOTING = `${DOCS_ORIGIN}/real_user_monitoring/browser/troubleshooting`
export const MORE_DETAILS = 'More details:'
+103
View File
@@ -0,0 +1,103 @@
import type { Uint8ArrayBuffer } from './utils/byteUtils'
import { computeBytesCount } from './utils/byteUtils'
export interface Encoder<Output extends string | Uint8ArrayBuffer = string | Uint8ArrayBuffer> {
/**
* Whether this encoder might call the provided callbacks asynchronously
*/
isAsync: boolean
/**
* Whether some data has been written since the last finish() or finishSync() call
*/
isEmpty: boolean
/**
* Write a string to be encoded.
*
* This operation can be synchronous or asynchronous depending on the encoder implementation.
*
* If specified, the callback will be invoked when the operation finishes, unless the operation is
* asynchronous and finish() or finishSync() is called in the meantime.
*/
write(data: string, callback?: (additionalEncodedBytesCount: number) => void): void
/**
* Waits for pending data to be encoded and resets the encoder state.
*
* This operation can be synchronous or asynchronous depending on the encoder implementation.
*
* The callback will be invoked when the operation finishes, unless the operation is asynchronous
* and another call to finish() or finishSync() occurs in the meantime.
*/
finish(callback: (result: EncoderResult<Output>) => void): void
/**
* Resets the encoder state then returns the encoded data and any potential pending data directly,
* discarding all pending write operations and finish() callbacks.
*/
finishSync(): EncoderResult<Output> & { pendingData: string }
/**
* Returns a rough estimation of the bytes count if the data was encoded.
*/
estimateEncodedBytesCount(data: string): number
}
export interface EncoderResult<Output extends string | Uint8ArrayBuffer = string | Uint8ArrayBuffer> {
output: Output
outputBytesCount: number
/**
* An encoding type supported by HTTP Content-Encoding, if applicable.
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding#directives
*/
encoding?: 'deflate'
/**
* Total bytes count of the input strings encoded to UTF-8.
*/
rawBytesCount: number
}
export function createIdentityEncoder(): Encoder<string> {
let output = ''
let outputBytesCount = 0
return {
isAsync: false,
get isEmpty() {
return !output
},
write(data, callback) {
const additionalEncodedBytesCount = computeBytesCount(data)
outputBytesCount += additionalEncodedBytesCount
output += data
if (callback) {
callback(additionalEncodedBytesCount)
}
},
finish(callback) {
callback(this.finishSync())
},
finishSync() {
const result = {
output,
outputBytesCount,
rawBytesCount: outputBytesCount,
pendingData: '',
}
output = ''
outputBytesCount = 0
return result
},
estimateEncodedBytesCount(data) {
return data.length
},
}
}
@@ -0,0 +1,58 @@
/**
* LIMITATION:
* For NPM setup, this feature flag singleton is shared between RUM and Logs product.
* This means that an experimental flag set on the RUM product will be set on the Logs product.
* So keep in mind that in certain configurations, your experimental feature flag may affect other products.
*
* FORMAT:
* All feature flags should be snake_cased
*/
// We want to use a real enum (i.e. not a const enum) here, to be able to check whether an arbitrary
// string is an expected feature flag
import { objectHasValue } from './utils/objectUtils'
// eslint-disable-next-line no-restricted-syntax
export enum ExperimentalFeature {
TRACK_INTAKE_REQUESTS = 'track_intake_requests',
USE_TREE_WALKER_FOR_ACTION_NAME = 'use_tree_walker_for_action_name',
FEATURE_OPERATION_VITAL = 'feature_operation_vital',
SHORT_SESSION_INVESTIGATION = 'short_session_investigation',
START_STOP_ACTION = 'start_stop_action',
START_STOP_RESOURCE = 'start_stop_resource',
USE_CHANGE_RECORDS = 'use_change_records',
USE_INCREMENTAL_CHANGE_RECORDS = 'use_incremental_change_records',
LCP_SUBPARTS = 'lcp_subparts',
INP_SUBPARTS = 'inp_subparts',
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
}
const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
export function initFeatureFlags(enableExperimentalFeatures: string[] | undefined) {
if (Array.isArray(enableExperimentalFeatures)) {
addExperimentalFeatures(
enableExperimentalFeatures.filter((flag): flag is ExperimentalFeature =>
objectHasValue(ExperimentalFeature, flag)
)
)
}
}
export function addExperimentalFeatures(enabledFeatures: ExperimentalFeature[]): void {
enabledFeatures.forEach((flag) => {
enabledExperimentalFeatures.add(flag)
})
}
export function isExperimentalFeatureEnabled(featureName: ExperimentalFeature): boolean {
return enabledExperimentalFeatures.has(featureName)
}
export function resetExperimentalFeatures(): void {
enabledExperimentalFeatures.clear()
}
export function getExperimentalFeatures(): Set<ExperimentalFeature> {
return enabledExperimentalFeatures
}
@@ -0,0 +1,38 @@
import { getGlobalObject } from './globalObject'
export interface BrowserWindowWithZoneJs extends Window {
Zone?: {
// All Zone.js versions expose the __symbol__ method, but we observed that some website have a
// 'Zone' global variable unrelated to Zone.js, so let's consider this method optional
// nonetheless.
__symbol__?: (name: string) => string
}
}
/**
* Gets the original value for a DOM API that was potentially patched by Zone.js.
*
* Zone.js[1] is a library that patches a bunch of JS and DOM APIs. It usually stores the original
* value of the patched functions/constructors/methods in a hidden property prefixed by
* __zone_symbol__.
*
* In multiple occasions, we observed that Zone.js is the culprit of important issues leading to
* browser resource exhaustion (memory leak, high CPU usage). This method is used as a workaround to
* use the original DOM API instead of the one patched by Zone.js.
*
* [1]: https://github.com/angular/angular/tree/main/packages/zone.js
*/
export function getZoneJsOriginalValue<Target, Name extends keyof Target & string>(
target: Target,
name: Name
): Target[Name] {
const browserWindow = getGlobalObject<BrowserWindowWithZoneJs>()
let original: Target[Name] | undefined
if (browserWindow.Zone && typeof browserWindow.Zone.__symbol__ === 'function') {
original = (target as any)[browserWindow.Zone.__symbol__(name)]
}
if (!original) {
original = target[name]
}
return original
}
+51
View File
@@ -0,0 +1,51 @@
/**
* inspired by https://mathiasbynens.be/notes/globalthis
*/
// Extend/Create the WorkerGlobalScope interface to avoid issues when used in a non-browser tsconfig environment
interface WorkerGlobalScope {
empty: never
}
// Utility type to enforce that exactly one of the two types is used
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U
export type GlobalObject = XOR<Window, WorkerGlobalScope>
export function getGlobalObject<T = typeof globalThis>(): T {
if (typeof globalThis === 'object') {
return globalThis as unknown as T
}
Object.defineProperty(Object.prototype, '_dd_temp_', {
get() {
return this as object
},
configurable: true,
})
// @ts-ignore _dd_temp is defined using defineProperty
let globalObject: unknown = _dd_temp_
// @ts-ignore _dd_temp is defined using defineProperty
delete Object.prototype._dd_temp_
if (typeof globalObject !== 'object') {
// on safari _dd_temp_ is available on window but not globally
// fallback on other browser globals check
if (typeof self === 'object') {
globalObject = self
} else if (typeof window === 'object') {
globalObject = window
} else {
globalObject = {}
}
}
return globalObject as T
}
/**
* Cached reference to the global object so it can be imported and re-used without
* re-evaluating the heavyweight fallback logic in `getGlobalObject()`.
*/
// eslint-disable-next-line local-rules/disallow-side-effects
export const globalObject = getGlobalObject<GlobalObject>()
export const isWorkerEnvironment = 'WorkerGlobalScope' in globalObject
@@ -0,0 +1,170 @@
import { setTimeout } from './timer'
import { callMonitored } from './monitor'
import { noop } from './utils/functionUtils'
import { createHandlingStack } from './stackTrace/handlingStack'
/**
* Object passed to the callback of an instrumented method call. See `instrumentMethod` for more
* info.
*/
export interface InstrumentedMethodCall<TARGET extends { [key: string]: any }, METHOD extends keyof TARGET> {
/**
* The target object on which the method was called.
*/
target: TARGET
/**
* The parameters with which the method was called.
*
* Note: if needed, parameters can be mutated by the instrumentation
*/
parameters: Parameters<TARGET[METHOD]>
/**
* Registers a callback that will be called after the original method is called, with the method
* result passed as argument.
*/
onPostCall: (callback: PostCallCallback<TARGET, METHOD>) => void
/**
* The stack trace of the method call.
*/
handlingStack?: string
}
type PostCallCallback<TARGET extends { [key: string]: any }, METHOD extends keyof TARGET> = (
result: ReturnType<TARGET[METHOD]>
) => void
/**
* Instruments a method on a object, calling the given callback before the original method is
* invoked. The callback receives an object with information about the method call.
*
* This function makes sure that we are "good citizens" regarding third party instrumentations: when
* removing the instrumentation, the original method is usually restored, but if a third party
* instrumentation was set after ours, we keep it in place and just replace our instrumentation with
* a noop.
*
* Note: it is generally better to instrument methods that are "owned" by the object instead of ones
* that are inherited from the prototype chain. Example:
* * do: `instrumentMethod(Array.prototype, 'push', ...)`
* * don't: `instrumentMethod([], 'push', ...)`
*
* This method is also used to set event handler properties (ex: window.onerror = ...), as it has
* the same requirements as instrumenting a method:
* * if the event handler is already set by a third party, we need to call it and not just blindly
* override it.
* * if the event handler is set by a third party after us, we need to keep it in place when
* removing ours.
*
* @example
*
* instrumentMethod(window, 'fetch', ({ target, parameters, onPostCall }) => {
* console.log('Before calling fetch on', target, 'with parameters', parameters)
*
* onPostCall((result) => {
* console.log('After fetch calling on', target, 'with parameters', parameters, 'and result', result)
* })
* })
*/
export function instrumentMethod<TARGET extends { [key: string]: any }, METHOD extends keyof TARGET>(
targetPrototype: TARGET,
method: METHOD,
onPreCall: (this: null, callInfos: InstrumentedMethodCall<TARGET, METHOD>) => void,
{ computeHandlingStack }: { computeHandlingStack?: boolean } = {}
) {
let original = targetPrototype[method]
if (typeof original !== 'function') {
if (method in targetPrototype && typeof method === 'string' && method.startsWith('on')) {
original = noop as TARGET[METHOD]
} else {
return { stop: noop }
}
}
let stopped = false
const instrumentation = function (this: TARGET): ReturnType<TARGET[METHOD]> {
if (stopped) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
return original.apply(this, arguments as unknown as Parameters<TARGET[METHOD]>)
}
const parameters = Array.from(arguments) as Parameters<TARGET[METHOD]>
let postCallCallback: PostCallCallback<TARGET, METHOD> | undefined
callMonitored(onPreCall, null, [
{
target: this,
parameters,
onPostCall: (callback) => {
postCallCallback = callback
},
handlingStack: computeHandlingStack ? createHandlingStack('instrumented method') : undefined,
},
])
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = original.apply(this, parameters)
if (postCallCallback) {
callMonitored(postCallCallback, null, [result])
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return result
}
targetPrototype[method] = instrumentation as TARGET[METHOD]
return {
stop: () => {
stopped = true
// If the instrumentation has been removed by a third party, keep the last one
if (targetPrototype[method] === instrumentation) {
targetPrototype[method] = original
}
},
}
}
export function instrumentSetter<TARGET extends { [key: string]: any }, PROPERTY extends keyof TARGET>(
targetPrototype: TARGET,
property: PROPERTY,
after: (target: TARGET, value: TARGET[PROPERTY]) => void
) {
const originalDescriptor = Object.getOwnPropertyDescriptor(targetPrototype, property)
if (!originalDescriptor || !originalDescriptor.set || !originalDescriptor.configurable) {
return { stop: noop }
}
const stoppedInstrumentation = noop
let instrumentation = (target: TARGET, value: TARGET[PROPERTY]) => {
// put hooked setter into event loop to avoid of set latency
setTimeout(() => {
if (instrumentation !== stoppedInstrumentation) {
after(target, value)
}
}, 0)
}
const instrumentationWrapper = function (this: TARGET, value: TARGET[PROPERTY]) {
originalDescriptor.set!.call(this, value)
instrumentation(this, value)
}
Object.defineProperty(targetPrototype, property, {
set: instrumentationWrapper,
})
return {
stop: () => {
if (Object.getOwnPropertyDescriptor(targetPrototype, property)?.set === instrumentationWrapper) {
Object.defineProperty(targetPrototype, property, originalDescriptor)
}
instrumentation = stoppedInstrumentation
},
}
}
+31
View File
@@ -0,0 +1,31 @@
import { display } from './display'
import { getType } from './utils/typeUtils'
export type MatchOption = string | RegExp | ((value: string) => boolean)
export function isMatchOption(item: unknown): item is MatchOption {
const itemType = getType(item)
return itemType === 'string' || itemType === 'function' || item instanceof RegExp
}
/**
* Returns true if value can be matched by at least one of the provided MatchOptions.
* When comparing strings, setting useStartsWith to true will compare the value with the start of
* the option, instead of requiring an exact match.
*/
export function matchList(list: MatchOption[], value: string, useStartsWith = false): boolean {
return list.some((item) => {
try {
if (typeof item === 'function') {
return item(value)
} else if (item instanceof RegExp) {
return item.test(value)
} else if (typeof item === 'string') {
return useStartsWith ? value.startsWith(item) : item === value
}
} catch (e) {
display.error(e)
}
return false
})
}
+177
View File
@@ -0,0 +1,177 @@
import { getType } from './utils/typeUtils'
type Merged<TDestination, TSource> =
// case 1 - source is undefined - return destination
TSource extends undefined
? TDestination
: // case 2 - destination is undefined - return source
TDestination extends undefined
? TSource
: // case 3 - source is an array - see if it merges or overwrites
TSource extends any[]
? TDestination extends any[]
? TDestination & TSource
: TSource
: // case 4 - source is an object - see if it merges or overwrites
TSource extends object
? TDestination extends object
? TDestination extends any[]
? TSource
: TDestination & TSource
: TSource
: // case 5 - cannot merge - return source
TSource
/**
* Iterate over source and affect its sub values into destination, recursively.
* If the source and destination can't be merged, return source.
*/
export function mergeInto<D, S>(
destination: D,
source: S,
circularReferenceChecker = createCircularReferenceChecker()
): Merged<D, S> {
// ignore the source if it is undefined
if (source === undefined) {
return destination as Merged<D, S>
}
if (typeof source !== 'object' || source === null) {
// primitive values - just return source
return source as Merged<D, S>
} else if (source instanceof Date) {
return new Date(source.getTime()) as unknown as Merged<D, S>
} else if (source instanceof RegExp) {
const flags =
source.flags ||
// old browsers compatibility
[
source.global ? 'g' : '',
source.ignoreCase ? 'i' : '',
source.multiline ? 'm' : '',
source.sticky ? 'y' : '',
source.unicode ? 'u' : '',
].join('')
return new RegExp(source.source, flags) as unknown as Merged<D, S>
}
if (circularReferenceChecker.hasAlreadyBeenSeen(source)) {
// remove circular references
return undefined as unknown as Merged<D, S>
} else if (Array.isArray(source)) {
const merged: any[] = Array.isArray(destination) ? destination : []
for (let i = 0; i < source.length; ++i) {
merged[i] = mergeInto(merged[i], source[i], circularReferenceChecker)
}
return merged as unknown as Merged<D, S>
}
const merged = getType(destination) === 'object' ? (destination as Record<any, any>) : {}
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
merged[key] = mergeInto(merged[key], source[key], circularReferenceChecker)
}
}
return merged as unknown as Merged<D, S>
}
/**
* A simplistic implementation of a deep clone algorithm.
* Caveats:
* - It doesn't maintain prototype chains - don't use with instances of custom classes.
* - It doesn't handle Map and Set
*/
export function deepClone<T>(value: T): T {
return mergeInto(undefined, value) as T
}
type Combined<A, B> = A extends null ? B : B extends null ? A : Merged<A, B>
/*
* Performs a deep merge of objects and arrays.
* - Arguments won't be mutated
* - Object and arrays in the output value are de-referenced ("deep cloned")
* - Arrays values are merged index by index
* - Objects are merged by keys
* - Values get replaced, unless undefined
*/
export function combine<A, B>(a: A, b: B): Combined<A, B>
export function combine<A, B, C>(a: A, b: B, c: C): Combined<Combined<A, B>, C>
export function combine<A, B, C, D>(a: A, b: B, c: C, d: D): Combined<Combined<Combined<A, B>, C>, D>
export function combine<A, B, C, D, E>(
a: A,
b: B,
c: C,
d: D,
e: E
): Combined<Combined<Combined<Combined<A, B>, C>, D>, E>
export function combine<A, B, C, D, E, F>(
a: A,
b: B,
c: C,
d: D,
e: E,
f: F
): Combined<Combined<Combined<Combined<Combined<A, B>, C>, D>, E>, F>
export function combine<A, B, C, D, E, F, G>(
a: A,
b: B,
c: C,
d: D,
e: E,
f: F,
g: G
): Combined<Combined<Combined<Combined<Combined<Combined<A, B>, C>, D>, E>, F>, G>
export function combine<A, B, C, D, E, F, G, H>(
a: A,
b: B,
c: C,
d: D,
e: E,
f: F,
g: G,
h: H
): Combined<Combined<Combined<Combined<Combined<Combined<Combined<A, B>, C>, D>, E>, F>, G>, H>
export function combine(...sources: any[]): unknown {
let destination: any
for (const source of sources) {
// Ignore any undefined or null sources.
if (source === undefined || source === null) {
continue
}
destination = mergeInto(destination, source)
}
return destination as unknown
}
interface CircularReferenceChecker {
hasAlreadyBeenSeen(value: any): boolean
}
function createCircularReferenceChecker(): CircularReferenceChecker {
if (typeof WeakSet !== 'undefined') {
const set: WeakSet<any> = new WeakSet()
return {
hasAlreadyBeenSeen(value) {
const has = set.has(value)
if (!has) {
set.add(value)
}
return has
},
}
}
const array: any[] = []
return {
hasAlreadyBeenSeen(value) {
const has = array.indexOf(value) >= 0
if (!has) {
array.push(value)
}
return has
},
}
}
+32
View File
@@ -0,0 +1,32 @@
declare const __BUILD_ENV__SDK_VERSION__: string
export const mockableReplacements = new Map<unknown, unknown>()
/**
* Wraps a value to make it mockable in tests. In production builds, this is a no-op
* that returns the value as-is. In test builds, it checks if a mock replacement has
* been registered and returns that instead.
*
* @example
* // In source file:
* import { mockable } from '../tools/mockable'
* export function formatNavigationEntry(): string {
* const navigationEntry = mockable(getNavigationEntry)()
* ...
* }
*
* // In test file:
* import { replaceMockable } from '@datadog/browser-core/test'
* it('...', () => {
* replaceMockable(getNavigationEntry, () => FAKE_NAVIGATION_ENTRY)
* expect(formatNavigationEntry()).toEqual(...)
* })
*/
export function mockable<T>(value: T): T {
// In test builds, return a wrapper that checks for mocks at call time
if (__BUILD_ENV__SDK_VERSION__ === 'test' && mockableReplacements.has(value)) {
return mockableReplacements.get(value)! as T
}
// In production, return the value as-is
return value
}
+70
View File
@@ -0,0 +1,70 @@
import { display } from './display'
let onMonitorErrorCollected: undefined | ((error: unknown) => void)
let debugMode = false
export function startMonitorErrorCollection(newOnMonitorErrorCollected: (error: unknown) => void) {
onMonitorErrorCollected = newOnMonitorErrorCollected
}
export function setDebugMode(newDebugMode: boolean) {
debugMode = newDebugMode
}
export function resetMonitor() {
onMonitorErrorCollected = undefined
debugMode = false
}
export function monitored<T extends (...params: any[]) => unknown>(
_: any,
__: string,
descriptor: TypedPropertyDescriptor<T>
) {
const originalMethod = descriptor.value!
descriptor.value = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const decorated = onMonitorErrorCollected ? monitor(originalMethod) : originalMethod
return decorated.apply(this, args) as ReturnType<T>
} as T
}
export function monitor<T extends (...args: any[]) => unknown>(fn: T): T {
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
return callMonitored(fn, this, args)
} as unknown as T // consider output type has input type
}
export function callMonitored<T extends (...args: any[]) => unknown>(
fn: T,
context: ThisParameterType<T>,
args: Parameters<T>
): ReturnType<T> | undefined
export function callMonitored<T extends (this: void) => unknown>(fn: T): ReturnType<T> | undefined
export function callMonitored<T extends (...args: any[]) => unknown>(
fn: T,
context?: any,
args?: any
): ReturnType<T> | undefined {
try {
return fn.apply(context, args) as ReturnType<T>
} catch (e) {
monitorError(e)
}
}
export function monitorError(e: unknown) {
displayIfDebugEnabled(e)
if (onMonitorErrorCollected) {
try {
onMonitorErrorCollected(e)
} catch (e) {
displayIfDebugEnabled(e)
}
}
}
export function displayIfDebugEnabled(...args: unknown[]) {
if (debugMode) {
display.error('[MONITOR]', ...args)
}
}
+104
View File
@@ -0,0 +1,104 @@
import { queueMicrotask } from './queueMicrotask'
export interface Subscription {
unsubscribe: () => void
}
type Observer<T> = (data: T) => void
// eslint-disable-next-line no-restricted-syntax
export class Observable<T> {
protected observers: Array<Observer<T>> = []
private onLastUnsubscribe?: () => void
constructor(private onFirstSubscribe?: (observable: Observable<T>) => (() => void) | void) {}
subscribe(observer: Observer<T>): Subscription {
this.addObserver(observer)
return {
unsubscribe: () => this.removeObserver(observer),
}
}
notify(data: T) {
this.observers.forEach((observer) => observer(data))
}
protected addObserver(observer: Observer<T>) {
this.observers.push(observer)
if (this.observers.length === 1 && this.onFirstSubscribe) {
this.onLastUnsubscribe = this.onFirstSubscribe(this) || undefined
}
}
protected removeObserver(observer: Observer<T>) {
this.observers = this.observers.filter((other) => observer !== other)
if (!this.observers.length && this.onLastUnsubscribe) {
this.onLastUnsubscribe()
}
}
}
export function mergeObservables<T>(...observables: Array<Observable<T>>) {
return new Observable<T>((globalObservable) => {
const subscriptions: Subscription[] = observables.map((observable) =>
observable.subscribe((data) => globalObservable.notify(data))
)
return () => subscriptions.forEach((subscription) => subscription.unsubscribe())
})
}
// eslint-disable-next-line no-restricted-syntax
export class BufferedObservable<T> extends Observable<T> {
private buffer: T[] = []
constructor(private maxBufferSize: number) {
super()
}
notify(data: T) {
this.buffer.push(data)
if (this.buffer.length > this.maxBufferSize) {
this.buffer.shift()
}
super.notify(data)
}
subscribe(observer: Observer<T>): Subscription {
let closed = false
const subscription = {
unsubscribe: () => {
closed = true
this.removeObserver(observer)
},
}
queueMicrotask(() => {
for (const data of this.buffer) {
if (closed) {
return
}
observer(data)
}
if (!closed) {
this.addObserver(observer)
}
})
return subscription
}
/**
* Drop buffered data and don't buffer future data. This is to avoid leaking memory when it's not
* needed anymore. This can be seen as a performance optimization, and things will work probably
* even if this method isn't called, but still useful to clarify our intent and lowering our
* memory impact.
*/
unbuffer() {
queueMicrotask(() => {
this.maxBufferSize = this.buffer.length = 0
})
}
}
@@ -0,0 +1,19 @@
import { monitor } from './monitor'
import { globalObject } from './globalObject'
export function queueMicrotask(callback: () => void) {
// Intentionally avoid .bind(globalObject): in some environments (e.g. Selenium GeckoDriver's
// executeScript), globalThis is not a proper global object, so calling the bound function throws
// 'queueMicrotask called on an object that does not implement interface Window'. Calling it as an
// unbound method is fine, as the proper global object will be used implicitly.
// See https://github.com/mozilla/geckodriver/issues/1798
// eslint-disable-next-line @typescript-eslint/unbound-method
const nativeImplementation = globalObject.queueMicrotask
if (typeof nativeImplementation === 'function') {
nativeImplementation(monitor(callback))
} else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- the callback is monitored, so it'll never throw
Promise.resolve().then(monitor(callback))
}
}
@@ -0,0 +1,37 @@
import type { Uint8ArrayBuffer } from './utils/byteUtils'
import { concatBuffers } from './utils/byteUtils'
import { noop } from './utils/functionUtils'
interface Options {
// TODO(next-major): always collect stream body when `trackEarlyRequests` is removed, as we don't
// need to use this function to just wait for the end of the stream without collecting it
collectStreamBody?: boolean
}
/**
* Read bytes from a ReadableStream until the end of the stream.
* Returns the bytes if collectStreamBody is true, otherwise returns undefined.
*/
export async function readBytesFromStream(stream: ReadableStream<Uint8ArrayBuffer>, options: Options) {
const reader = stream.getReader()
const chunks: Uint8ArrayBuffer[] = []
while (true) {
const result = await reader.read()
if (result.done) {
break
}
if (options.collectStreamBody) {
chunks.push(result.value)
}
}
reader.cancel().catch(
// we don't care if cancel fails, but we still need to catch the error to avoid reporting it
// as an unhandled rejection
noop
)
return options.collectStreamBody ? concatBuffers(chunks) : undefined
}
@@ -0,0 +1,40 @@
import { setTimeout, clearTimeout } from './timer'
import { monitor } from './monitor'
import { dateNow } from './utils/timeUtils'
// This type is not yet supported in TS 3.8. Imported from the TS source until we upgrade the
// minimum supported TS version.
// https://github.com/microsoft/TypeScript/blob/13c374a868c926f6a907666a5599992c1351b773/src/lib/dom.generated.d.ts#L9513-L9516
export interface IdleDeadline {
readonly didTimeout: boolean
timeRemaining(): DOMHighResTimeStamp
}
/**
* 'requestIdleCallback' with a shim.
*/
export function requestIdleCallback(callback: (deadline: IdleDeadline) => void, opts?: { timeout?: number }) {
// Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'.
if (window.requestIdleCallback && window.cancelIdleCallback) {
const id = window.requestIdleCallback(monitor(callback), opts)
return () => window.cancelIdleCallback(id)
}
return requestIdleCallbackShim(callback)
}
export const MAX_TASK_TIME = 50
/*
* Shim from https://developer.chrome.com/blog/using-requestidlecallback#checking_for_requestidlecallback
* Note: there is no simple way to support the "timeout" option, so we ignore it.
*/
export function requestIdleCallbackShim(callback: (deadline: IdleDeadline) => void) {
const start = dateNow()
const timeoutId = setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => Math.max(0, MAX_TASK_TIME - (dateNow() - start)),
})
}, 0)
return () => clearTimeout(timeoutId)
}
@@ -0,0 +1,14 @@
import { globalObject } from './globalObject'
interface BrowserWindow {
__ddBrowserSdkExtensionCallback?: (message: unknown) => void
}
type ExtensionMessageType = 'logs' | 'record' | 'rum' | 'telemetry'
export function sendToExtension(type: ExtensionMessageType, payload: unknown) {
const callback = (globalObject as BrowserWindow).__ddBrowserSdkExtensionCallback
if (callback) {
callback({ type, payload })
}
}
@@ -0,0 +1,53 @@
import { noop } from '../utils/functionUtils'
/**
* Custom implementation of JSON.stringify that ignores some toJSON methods. We need to do that
* because some sites badly override toJSON on certain objects. Removing all toJSON methods from
* nested values would be too costly, so we just detach them from the root value, and native classes
* used to build JSON values (Array and Object).
*
* Note: this still assumes that JSON.stringify is correct.
*/
export function jsonStringify(
value: unknown,
replacer?: Array<string | number>,
space?: string | number
): string | undefined {
if (typeof value !== 'object' || value === null) {
return JSON.stringify(value)
}
// Note: The order matter here. We need to detach toJSON methods on parent classes before their
// subclasses.
const restoreObjectPrototypeToJson = detachToJsonMethod(Object.prototype)
const restoreArrayPrototypeToJson = detachToJsonMethod(Array.prototype)
const restoreValuePrototypeToJson = detachToJsonMethod(Object.getPrototypeOf(value))
const restoreValueToJson = detachToJsonMethod(value)
try {
return JSON.stringify(value, replacer, space)
} catch {
return '<error: unable to serialize object>'
} finally {
restoreObjectPrototypeToJson()
restoreArrayPrototypeToJson()
restoreValuePrototypeToJson()
restoreValueToJson()
}
}
export interface ObjectWithToJsonMethod {
toJSON?: () => unknown
}
export function detachToJsonMethod(value: object) {
const object = value as ObjectWithToJsonMethod
const objectToJson = object.toJSON
if (objectToJson) {
delete object.toJSON
return () => {
object.toJSON = objectToJson
}
}
return noop
}
@@ -0,0 +1,273 @@
import { display } from '../display'
import { ONE_KIBI_BYTE } from '../utils/byteUtils'
import type { Context, ContextArray, ContextValue } from './context'
import type { ObjectWithToJsonMethod } from './jsonStringify'
import { detachToJsonMethod } from './jsonStringify'
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
type PrimitivesAndFunctions = string | number | boolean | undefined | null | symbol | bigint | Function
type ExtendedContextValue = PrimitivesAndFunctions | ExtendedContext | ExtendedContextArray
interface ExtendedContext {
[key: string]: ExtendedContextValue
}
type ExtendedContextArray = ExtendedContextValue[]
interface ContainerElementToProcess {
source: ExtendedContextArray | ExtendedContext
target: ContextArray | Context
path: string
}
interface SanitizedEvent extends Context {
type: string
isTrusted: boolean
currentTarget: string | null | undefined
target: string | null | undefined
}
// The maximum size of a single event is 256KiB. By default, we ensure that user-provided data
// going through sanitize fits inside our events, while leaving room for other contexts, metadata, ...
export const SANITIZE_DEFAULT_MAX_CHARACTER_COUNT = 220 * ONE_KIBI_BYTE
// Symbol for the root element of the JSONPath used for visited objects
const JSON_PATH_ROOT_ELEMENT = '$'
// When serializing (using JSON.stringify) a key of an object, { key: 42 } gets wrapped in quotes as "key".
// With the separator (:), we need to add 3 characters to the count.
const KEY_DECORATION_LENGTH = 3
/**
* Ensures user-provided data is 'safe' for the SDK
* - Deep clones data
* - Removes cyclic references
* - Transforms unserializable types to a string representation
*
* LIMITATIONS:
* - Size is in characters, not byte count (may differ according to character encoding)
* - Size does not take into account indentation that can be applied to JSON.stringify
* - Non-numerical properties of Arrays are ignored. Same behavior as JSON.stringify
*
* @param source - User-provided data meant to be serialized using JSON.stringify
* @param maxCharacterCount - Maximum number of characters allowed in serialized form
*/
export function sanitize(source: string, maxCharacterCount?: number): string | undefined
export function sanitize(source: Context, maxCharacterCount?: number): Context
export function sanitize(source: unknown, maxCharacterCount?: number): ContextValue
export function sanitize(source: unknown, maxCharacterCount = SANITIZE_DEFAULT_MAX_CHARACTER_COUNT) {
// Unbind any toJSON function we may have on [] or {} prototypes
const restoreObjectPrototypeToJson = detachToJsonMethod(Object.prototype)
const restoreArrayPrototypeToJson = detachToJsonMethod(Array.prototype)
// Initial call to sanitizeProcessor - will populate containerQueue if source is an Array or a plain Object
const containerQueue: ContainerElementToProcess[] = []
const visitedObjectsWithPath = new WeakMap<object, string>()
const sanitizedData = sanitizeProcessor(
source as ExtendedContextValue,
JSON_PATH_ROOT_ELEMENT,
undefined,
containerQueue,
visitedObjectsWithPath
)
const serializedSanitizedData = JSON.stringify(sanitizedData)
let accumulatedCharacterCount = serializedSanitizedData ? serializedSanitizedData.length : 0
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'discarded', source)
return undefined
}
while (containerQueue.length > 0 && accumulatedCharacterCount < maxCharacterCount) {
const containerToProcess = containerQueue.shift()!
let separatorLength = 0 // 0 for the first element, 1 for subsequent elements
// Arrays and Objects have to be handled distinctly to ensure
// we do not pick up non-numerical properties from Arrays
if (Array.isArray(containerToProcess.source)) {
for (let key = 0; key < containerToProcess.source.length; key++) {
const targetData = sanitizeProcessor(
containerToProcess.source[key],
containerToProcess.path,
key,
containerQueue,
visitedObjectsWithPath
)
if (targetData !== undefined) {
accumulatedCharacterCount += JSON.stringify(targetData).length
} else {
// When an element of an Array (targetData) is undefined, it is serialized as null:
// JSON.stringify([undefined]) => '[null]' - This accounts for 4 characters
accumulatedCharacterCount += 4
}
accumulatedCharacterCount += separatorLength
separatorLength = 1
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'truncated', source)
break
}
;(containerToProcess.target as ContextArray)[key] = targetData
}
} else {
for (const key in containerToProcess.source) {
if (Object.prototype.hasOwnProperty.call(containerToProcess.source, key)) {
const targetData = sanitizeProcessor(
containerToProcess.source[key],
containerToProcess.path,
key,
containerQueue,
visitedObjectsWithPath
)
// When a property of an object has an undefined value, it will be dropped during serialization:
// JSON.stringify({a:undefined}) => '{}'
if (targetData !== undefined) {
accumulatedCharacterCount +=
JSON.stringify(targetData).length + separatorLength + key.length + KEY_DECORATION_LENGTH
separatorLength = 1
}
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'truncated', source)
break
}
;(containerToProcess.target as Context)[key] = targetData
}
}
}
}
// Rebind detached toJSON functions
restoreObjectPrototypeToJson()
restoreArrayPrototypeToJson()
return sanitizedData
}
/**
* Internal function to factorize the process common to the
* initial call to sanitize, and iterations for Arrays and Objects
*
*/
function sanitizeProcessor(
source: ExtendedContextValue,
parentPath: string,
key: string | number | undefined,
queue: ContainerElementToProcess[],
visitedObjectsWithPath: WeakMap<object, string>
) {
// Start by handling toJSON, as we want to sanitize its output
const sourceToSanitize = tryToApplyToJSON(source)
if (!sourceToSanitize || typeof sourceToSanitize !== 'object') {
return sanitizePrimitivesAndFunctions(sourceToSanitize)
}
const sanitizedSource = sanitizeObjects(sourceToSanitize)
if (sanitizedSource !== '[Object]' && sanitizedSource !== '[Array]' && sanitizedSource !== '[Error]') {
return sanitizedSource
}
// Handle potential cyclic references
// We need to use source as sourceToSanitize could be a reference to a new object
// At this stage, we know the source is an object type
const sourceAsObject = source as object
if (visitedObjectsWithPath.has(sourceAsObject)) {
return `[Reference seen at ${visitedObjectsWithPath.get(sourceAsObject)!}]`
}
// Add processed source to queue
const currentPath = key !== undefined ? `${parentPath}.${key}` : parentPath
const target = Array.isArray(sourceToSanitize) ? ([] as ContextArray) : ({} as Context)
visitedObjectsWithPath.set(sourceAsObject, currentPath)
queue.push({ source: sourceToSanitize, target, path: currentPath })
return target
}
/**
* Handles sanitization of simple, non-object types
*
*/
function sanitizePrimitivesAndFunctions(value: PrimitivesAndFunctions) {
// BigInt cannot be serialized by JSON.stringify(), convert it to a string representation
if (typeof value === 'bigint') {
return `[BigInt] ${value.toString()}`
}
// Functions cannot be serialized by JSON.stringify(). Moreover, if a faulty toJSON is present, it needs to be converted
// so it won't prevent stringify from serializing later
if (typeof value === 'function') {
return `[Function] ${value.name || 'unknown'}`
}
// JSON.stringify() does not serialize symbols.
if (typeof value === 'symbol') {
// symbol.description is part of ES2019+
type symbolWithDescription = symbol & { description: string }
return `[Symbol] ${(value as symbolWithDescription).description || value.toString()}`
}
return value
}
/**
* Handles sanitization of object types
*
* LIMITATIONS
* - If a class defines a toStringTag Symbol, it will fall in the catch-all method and prevent enumeration of properties.
* To avoid this, a toJSON method can be defined.
*/
function sanitizeObjects(value: object): string | SanitizedEvent {
try {
if (value instanceof Event) {
return sanitizeEvent(value)
}
if (value instanceof RegExp) {
return `[RegExp] ${value.toString()}`
}
// Handle all remaining object types in a generic way
const result = Object.prototype.toString.call(value)
const match = result.match(/\[object (.*)\]/)
if (match && match[1]) {
return `[${match[1]}]`
}
} catch {
// If the previous serialization attempts failed, and we cannot convert using
// Object.prototype.toString, declare the value unserializable
}
return '[Unserializable]'
}
function sanitizeEvent(event: Event): SanitizedEvent {
return {
type: event.type,
isTrusted: event.isTrusted,
currentTarget: event.currentTarget ? (sanitizeObjects(event.currentTarget) as string) : null,
target: event.target ? (sanitizeObjects(event.target) as string) : null,
}
}
/**
* Checks if a toJSON function exists and tries to execute it
*
*/
function tryToApplyToJSON(value: ExtendedContextValue) {
const object = value as ObjectWithToJsonMethod
if (object && typeof object.toJSON === 'function') {
try {
return object.toJSON() as ExtendedContextValue
} catch {
// If toJSON fails, we continue by trying to serialize the value manually
}
}
return value
}
/**
* Helper function to display the warning when the accumulated character count is over the limit
*/
function warnOverCharacterLimit(maxCharacterCount: number, changeType: 'discarded' | 'truncated', source: unknown) {
display.warn(
`The data provided has been ${changeType} as it is over the limit of ${maxCharacterCount} characters:`,
source
)
}
@@ -0,0 +1,252 @@
/**
* Cross-browser stack trace computation.
*
* Reference implementation: https://github.com/csnover/TraceKit/blob/04530298073c3823de72deb0b97e7b38ca7bcb59/tracekit.js
*/
import { isIndexableObject } from '../utils/typeUtils'
export interface StackFrame {
url?: string
func?: string
/** The arguments passed to the function, if known. */
args?: string[]
line?: number
column?: number
/** An array of source code lines; the middle element corresponds to the correct line. */
context?: string[]
}
export interface StackTrace {
name?: string
message?: string
url?: string
stack: StackFrame[]
incomplete?: boolean
partial?: boolean
}
const UNKNOWN_FUNCTION = '?'
export function computeStackTrace(ex: unknown): StackTrace {
const stack: StackFrame[] = []
let stackProperty = tryToGetString(ex, 'stack')
const exString = String(ex)
if (stackProperty && stackProperty.startsWith(exString)) {
stackProperty = stackProperty.slice(exString.length)
}
if (stackProperty) {
stackProperty.split('\n').forEach((line) => {
const stackFrame =
parseChromeLine(line) || parseChromeAnonymousLine(line) || parseWinLine(line) || parseGeckoLine(line)
if (stackFrame) {
if (!stackFrame.func && stackFrame.line) {
stackFrame.func = UNKNOWN_FUNCTION
}
stack.push(stackFrame)
}
})
}
if (stack.length > 0 && isWronglyReportingCustomErrors() && ex instanceof Error) {
// if we are wrongly reporting custom errors
const constructors: string[] = []
// go through each inherited constructor
let currentPrototype: object | undefined = ex
while (
(currentPrototype = Object.getPrototypeOf(currentPrototype)) &&
isNonNativeClassPrototype(currentPrototype)
) {
const constructorName = currentPrototype.constructor?.name || UNKNOWN_FUNCTION
constructors.push(constructorName)
}
// traverse the stacktrace in reverse order because the stacktrace starts with the last inherited constructor
// we check constructor names to ensure we remove the correct frame (and there isn't a weird unsupported environment behavior)
for (let i = constructors.length - 1; i >= 0 && stack[0]?.func === constructors[i]; i--) {
// if the first stack frame is the custom error constructor
// null stack frames may represent frames that failed to be parsed because the error class did not have a constructor
stack.shift() // remove it
}
}
return {
message: tryToGetString(ex, 'message'),
name: tryToGetString(ex, 'name'),
stack,
}
}
const fileUrl =
'((?:file|https?|blob|chrome-extension|electron|native|eval|webpack|snippet|<anonymous>|\\w+\\.|\\/).*?)'
const filePosition = '(?::(\\d+))'
const CHROME_LINE_RE = new RegExp(`^\\s*at (.*?) ?\\(${fileUrl}${filePosition}?${filePosition}?\\)?\\s*$`, 'i')
const CHROME_EVAL_RE = new RegExp(`\\((\\S*)${filePosition}${filePosition}\\)`)
function parseChromeLine(line: string): StackFrame | undefined {
const parts = CHROME_LINE_RE.exec(line)
if (!parts) {
return
}
const isNative = parts[2] && parts[2].indexOf('native') === 0 // start of line
const isEval = parts[2] && parts[2].indexOf('eval') === 0 // start of line
const submatch = CHROME_EVAL_RE.exec(parts[2])
if (isEval && submatch) {
// throw out eval line/column and use top-most line/column number
parts[2] = submatch[1] // url
parts[3] = submatch[2] // line
parts[4] = submatch[3] // column
}
return {
args: isNative ? [parts[2]] : [],
column: parts[4] ? +parts[4] : undefined,
func: parts[1] || UNKNOWN_FUNCTION,
line: parts[3] ? +parts[3] : undefined,
url: !isNative ? parts[2] : undefined,
}
}
const htmlAnonymousPart = '(?:(.*)?(?: @))'
const CHROME_ANONYMOUS_FUNCTION_RE = new RegExp(
`^\\s*at\\s*${htmlAnonymousPart}?\\s*${fileUrl}${filePosition}?${filePosition}??\\s*$`,
'i'
)
function parseChromeAnonymousLine(line: string): StackFrame | undefined {
const parts = CHROME_ANONYMOUS_FUNCTION_RE.exec(line)
if (!parts) {
return
}
return {
args: [],
column: parts[4] ? +parts[4] : undefined,
func: parts[1] || UNKNOWN_FUNCTION,
line: parts[3] ? +parts[3] : undefined,
url: parts[2],
}
}
const WINJS_LINE_RE =
/^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i
function parseWinLine(line: string): StackFrame | undefined {
const parts = WINJS_LINE_RE.exec(line)
if (!parts) {
return
}
return {
args: [],
column: parts[4] ? +parts[4] : undefined,
func: parts[1] || UNKNOWN_FUNCTION,
line: +parts[3],
url: parts[2],
}
}
const GECKO_LINE_RE =
/^\s*(.*?)(?:\((.*?)\))?(?:(?:(?:^|@)((?:file|https?|blob|chrome|webpack|resource|capacitor|\[native).*?|[^@]*bundle|\[wasm code\])(?::(\d+))?(?::(\d+))?)|@)\s*$/i
const GECKO_EVAL_RE = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i
function parseGeckoLine(line: string): StackFrame | undefined {
const parts = GECKO_LINE_RE.exec(line)
if (!parts) {
return
}
const isEval = parts[3] && parts[3].indexOf(' > eval') > -1
const submatch = GECKO_EVAL_RE.exec(parts[3])
if (isEval && submatch) {
// throw out eval line/column and use top-most line number
parts[3] = submatch[1]
parts[4] = submatch[2]
parts[5] = undefined! // no column when eval
}
return {
args: parts[2] ? parts[2].split(',') : [],
column: parts[5] ? +parts[5] : undefined,
func: parts[1] || UNKNOWN_FUNCTION,
line: parts[4] ? +parts[4] : undefined,
url: parts[3],
}
}
function tryToGetString(candidate: unknown, property: string) {
return isIndexableObject(candidate) && typeof candidate[property] === 'string' ? candidate[property] : undefined
}
export function computeStackTraceFromOnErrorMessage(
messageObj: unknown,
url?: string,
line?: number,
column?: number
): StackTrace | undefined {
if (url === undefined) {
return
}
const { name, message } = tryToParseMessage(messageObj)
return {
name,
message,
stack: [{ url, column, line }],
}
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types
const ERROR_TYPES_RE =
/^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?([\s\S]*)$/
function tryToParseMessage(messageObj: unknown) {
let name
let message
if ({}.toString.call(messageObj) === '[object String]') {
;[, name, message] = ERROR_TYPES_RE.exec(messageObj as string)!
}
return { name, message }
}
// Custom error stacktrace fix
// Some browsers (safari/firefox) add the error constructor as a frame in the stacktrace
// In order to normalize the stacktrace, we need to remove it
function isNonNativeClassPrototype(prototype: object) {
return String(prototype.constructor).startsWith('class ')
}
let isWronglyReportingCustomErrorsCache: boolean | undefined
function isWronglyReportingCustomErrors() {
if (isWronglyReportingCustomErrorsCache !== undefined) {
return isWronglyReportingCustomErrorsCache
}
/* eslint-disable no-restricted-syntax */
class DatadogTestCustomError extends Error {
constructor() {
super()
this.name = 'Error' // set name to Error so that no browser would default to the constructor name
}
}
const [customError, nativeError] = [DatadogTestCustomError, Error].map((errConstructor) => new errConstructor()) // so that both errors should exactly have the same stacktrace
isWronglyReportingCustomErrorsCache =
// If customError is not a class, it means that this was built with ES5 as target, converting the class to a normal object.
// Thus, error constructors will be reported on all browsers, which is the expected behavior.
isNonNativeClassPrototype(Object.getPrototypeOf(customError)) &&
// If the browser is correctly reporting the stacktrace, the normal error stacktrace should be the same as the custom error stacktrace
nativeError.stack !== customError.stack
return isWronglyReportingCustomErrorsCache
}
@@ -0,0 +1,48 @@
import { callMonitored } from '../monitor'
import type { StackTrace } from './computeStackTrace'
import { computeStackTrace } from './computeStackTrace'
/**
* Creates a stacktrace without SDK internal frames.
* Constraints:
* - Has to be called at the utmost position of the call stack.
* - No monitored function should encapsulate it, that is why we need to use callMonitored inside it.
*/
export function createHandlingStack(
type: 'console error' | 'action' | 'error' | 'instrumented method' | 'log' | 'react error' | 'view' | 'vital'
): string {
/**
* Skip the two internal frames:
* - SDK API (console.error, ...)
* - this function
* in order to keep only the user calls
*/
const internalFramesToSkip = 2
const error = new Error(type)
error.name = 'HandlingStack'
let formattedStack: string
callMonitored(() => {
const stackTrace = computeStackTrace(error)
stackTrace.stack = stackTrace.stack.slice(internalFramesToSkip)
formattedStack = toStackTraceString(stackTrace)
})
return formattedStack!
}
export function toStackTraceString(stack: StackTrace) {
let result = formatErrorMessage(stack)
stack.stack.forEach((frame) => {
const func = frame.func === '?' ? '<anonymous>' : frame.func
const args = frame.args && frame.args.length > 0 ? `(${frame.args.join(', ')})` : ''
const line = frame.line ? `:${frame.line}` : ''
const column = frame.line && frame.column ? `:${frame.column}` : ''
result += `\n at ${func!}${args} @ ${frame.url!}${line}${column}`
})
return result
}
export function formatErrorMessage(stack: StackTrace) {
return `${stack.name || 'Error'}: ${stack.message!}`
}
+63
View File
@@ -0,0 +1,63 @@
import { ONE_SECOND } from './utils/timeUtils'
import { requestIdleCallback } from './requestIdleCallback'
/**
* Maximum delay before starting to execute tasks in the queue. We don't want to wait too long
* before running tasks, as it might hurt reliability (ex: if the user navigates away, we might lose
* the opportunity to send some data). We also don't want to run tasks too often, as it might hurt
* performance.
*/
const IDLE_CALLBACK_TIMEOUT = ONE_SECOND
/**
* Maximum amount of time allocated to running tasks when a timeout (`IDLE_CALLBACK_TIMEOUT`) is
* reached. We should not run tasks for too long as it will hurt performance, but we should still
* run some tasks to avoid postponing them forever.
*
* Rational: Running tasks for 30ms every second (IDLE_CALLBACK_TIMEOUT) should be acceptable.
*/
export const MAX_EXECUTION_TIME_ON_TIMEOUT = 30
export interface TaskQueue {
push(task: Task): void
stop(): void
}
type Task = () => void
export function createTaskQueue(): TaskQueue {
const pendingTasks: Task[] = []
function run(deadline: IdleDeadline) {
let executionTimeRemaining: () => number
if (deadline.didTimeout) {
const start = performance.now()
executionTimeRemaining = () => MAX_EXECUTION_TIME_ON_TIMEOUT - (performance.now() - start)
} else {
executionTimeRemaining = deadline.timeRemaining.bind(deadline)
}
while (executionTimeRemaining() > 0 && pendingTasks.length) {
pendingTasks.shift()!()
}
if (pendingTasks.length) {
scheduleNextRun()
}
}
function scheduleNextRun() {
requestIdleCallback(run, { timeout: IDLE_CALLBACK_TIMEOUT })
}
return {
push(task) {
if (pendingTasks.push(task) === 1) {
scheduleNextRun()
}
},
stop() {
pendingTasks.length = 0
},
}
}
+21
View File
@@ -0,0 +1,21 @@
import { getZoneJsOriginalValue } from './getZoneJsOriginalValue'
import { monitor } from './monitor'
import { getGlobalObject } from './globalObject'
export type TimeoutId = ReturnType<typeof globalThis.setTimeout>
export function setTimeout(callback: () => void, delay?: number): TimeoutId {
return getZoneJsOriginalValue(getGlobalObject(), 'setTimeout')(monitor(callback), delay)
}
export function clearTimeout(timeoutId: TimeoutId | undefined) {
getZoneJsOriginalValue(getGlobalObject(), 'clearTimeout')(timeoutId)
}
export function setInterval(callback: () => void, delay?: number): TimeoutId {
return getZoneJsOriginalValue(getGlobalObject(), 'setInterval')(monitor(callback), delay)
}
export function clearInterval(timeoutId: TimeoutId | undefined) {
getZoneJsOriginalValue(getGlobalObject(), 'clearInterval')(timeoutId)
}
@@ -0,0 +1,15 @@
export function removeDuplicates<T>(array: T[]) {
const set = new Set<T>()
array.forEach((item) => set.add(item))
return Array.from(set)
}
export function removeItem<T>(array: T[], item: T) {
const index = array.indexOf(item)
if (index >= 0) {
array.splice(index, 1)
}
}
export function isNonEmptyArray<T>(value: unknown): value is T[] {
return Array.isArray(value) && value.length > 0
}
@@ -0,0 +1,39 @@
// Exported only for tests
export const enum Browser {
CHROMIUM,
SAFARI,
OTHER,
}
export function isChromium() {
return detectBrowserCached() === Browser.CHROMIUM
}
export function isSafari() {
return detectBrowserCached() === Browser.SAFARI
}
let browserCache: Browser | undefined
function detectBrowserCached() {
return browserCache ?? (browserCache = detectBrowser())
}
// Exported only for tests
export function detectBrowser(browserWindow: Window = window) {
const userAgent = browserWindow.navigator.userAgent
if ((browserWindow as any).chrome || /HeadlessChrome/.test(userAgent)) {
return Browser.CHROMIUM
}
if (
// navigator.vendor is deprecated, but it is the most resilient way we found to detect
// "Apple maintained browsers" (AKA Safari). If one day it gets removed, we still have the
// useragent test as a semi-working fallback.
browserWindow.navigator.vendor?.indexOf('Apple') === 0 ||
(/safari/i.test(userAgent) && !/chrome|android/i.test(userAgent))
) {
return Browser.SAFARI
}
return Browser.OTHER
}
@@ -0,0 +1,40 @@
export const ONE_KIBI_BYTE = 1024
export const ONE_MEBI_BYTE = 1024 * ONE_KIBI_BYTE
// eslint-disable-next-line no-control-regex
const HAS_MULTI_BYTES_CHARACTERS = /[^\u0000-\u007F]/
export interface Uint8ArrayBuffer extends Uint8Array {
readonly buffer: ArrayBuffer
subarray(begin?: number, end?: number): Uint8ArrayBuffer
}
export function computeBytesCount(candidate: string): number {
// Accurate bytes count computations can degrade performances when there is a lot of events to process
if (!HAS_MULTI_BYTES_CHARACTERS.test(candidate)) {
return candidate.length
}
if (window.TextEncoder !== undefined) {
return new TextEncoder().encode(candidate).length
}
return new Blob([candidate]).size
}
export function concatBuffers(buffers: Uint8ArrayBuffer[]): Uint8ArrayBuffer {
// Optimization: if there is a single buffer, no need to copy it
if (buffers.length === 1) {
return buffers[0]
}
const length = buffers.reduce((total, buffer) => total + buffer.length, 0)
const result: Uint8ArrayBuffer = new Uint8Array(length)
let offset = 0
for (const buffer of buffers) {
result.set(buffer, offset)
offset += buffer.length
}
return result
}
@@ -0,0 +1,45 @@
import type { TimeoutId } from '../timer'
import { setTimeout, clearTimeout } from '../timer'
// use lodash API
export function throttle<T extends (...args: any[]) => void>(
fn: T,
wait: number,
options?: { leading?: boolean; trailing?: boolean }
) {
const needLeadingExecution = options && options.leading !== undefined ? options.leading : true
const needTrailingExecution = options && options.trailing !== undefined ? options.trailing : true
let inWaitPeriod = false
let pendingExecutionWithParameters: Parameters<T> | undefined
let pendingTimeoutId: TimeoutId
return {
throttled: (...parameters: Parameters<T>) => {
if (inWaitPeriod) {
pendingExecutionWithParameters = parameters
return
}
if (needLeadingExecution) {
fn(...parameters)
} else {
pendingExecutionWithParameters = parameters
}
inWaitPeriod = true
pendingTimeoutId = setTimeout(() => {
if (needTrailingExecution && pendingExecutionWithParameters) {
fn(...pendingExecutionWithParameters)
}
inWaitPeriod = false
pendingExecutionWithParameters = undefined
}, wait)
},
cancel: () => {
clearTimeout(pendingTimeoutId)
inWaitPeriod = false
pendingExecutionWithParameters = undefined
},
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
export function noop() {}
@@ -0,0 +1,20 @@
/**
* Return true if the draw is successful
*
* @param threshold - Threshold between 0 and 100
*/
export function performDraw(threshold: number): boolean {
return threshold !== 0 && Math.random() * 100 <= threshold
}
export function round(num: number, decimals: 0 | 1 | 2 | 3 | 4) {
return +num.toFixed(decimals)
}
export function isPercentage(value: unknown) {
return isNumber(value) && value >= 0 && value <= 100
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number'
}
@@ -0,0 +1,27 @@
export function tryJsonParse<T = unknown>(text: string): T | undefined {
try {
return JSON.parse(text) as T
} catch {
// ignore
}
}
export function shallowClone<T>(object: T): T & Record<string, never> {
return { ...object } as T & Record<string, never>
}
export function objectHasValue<T extends { [key: string]: unknown }>(object: T, value: unknown): value is T[keyof T] {
return Object.keys(object).some((key) => object[key] === value)
}
export function isEmptyObject(object: object) {
return Object.keys(object).length === 0
}
export function mapValues<A, B>(object: { [key: string]: A }, fn: (arg: A) => B) {
const newObject: { [key: string]: B } = {}
for (const key of Object.keys(object)) {
newObject[key] = fn(object[key])
}
return newObject
}
@@ -0,0 +1,23 @@
export function findLast<T, S extends T>(
array: readonly T[],
predicate: (item: T, index: number, array: readonly T[]) => item is S
): S | undefined {
for (let i = array.length - 1; i >= 0; i -= 1) {
const item = array[i]
if (predicate(item, i, array)) {
return item
}
}
return undefined
}
// Keep the following wrapper functions as it can be mangled and will result in smaller bundle size that using
// the native Object.values and Object.entries directly
export function objectValues<T = unknown>(object: { [key: string]: T }) {
return Object.values(object)
}
export function objectEntries<T = unknown>(object: { [key: string]: T }): Array<[string, T]> {
return Object.entries(object)
}
@@ -0,0 +1,12 @@
export function isServerError(status: number) {
return status >= 500
}
export function tryToClone(response: Response): Response | undefined {
try {
return response.clone()
} catch {
// clone can throw if the response has already been used by another instrumentation or is disturbed
return
}
}
@@ -0,0 +1,89 @@
/**
* UUID v4
* from https://gist.github.com/jed/982883
*/
export function generateUUID(placeholder?: string): string {
return placeholder
? // eslint-disable-next-line no-bitwise
(parseInt(placeholder, 10) ^ ((Math.random() * 16) >> (parseInt(placeholder, 10) / 4))).toString(16)
: `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace(/[018]/g, generateUUID)
}
// Assuming input string is following the HTTP Cookie format defined in
// https://www.ietf.org/rfc/rfc2616.txt and https://www.ietf.org/rfc/rfc6265.txt, we don't need to
// be too strict with this regex.
const COMMA_SEPARATED_KEY_VALUE = /(\S+?)\s*=\s*(.+?)(?:;|$)/g
/**
* Returns the value of the key with the given name
* If there are multiple values with the same key, returns the first one
*/
export function findCommaSeparatedValue(rawString: string, name: string): string | undefined {
COMMA_SEPARATED_KEY_VALUE.lastIndex = 0
while (true) {
const match = COMMA_SEPARATED_KEY_VALUE.exec(rawString)
if (match) {
if (match[1] === name) {
return match[2]
}
} else {
break
}
}
}
/**
* Returns a map of all the values with the given key
* If there are multiple values with the same key, returns all the values
*/
export function findAllCommaSeparatedValues(rawString: string): Map<string, string[]> {
const result = new Map<string, string[]>()
COMMA_SEPARATED_KEY_VALUE.lastIndex = 0
while (true) {
const match = COMMA_SEPARATED_KEY_VALUE.exec(rawString)
if (match) {
const key = match[1]
const value = match[2]
if (result.has(key)) {
result.get(key)!.push(value)
} else {
result.set(key, [value])
}
} else {
break
}
}
return result
}
/**
* Returns a map of the values with the given key
* ⚠️ If there are multiple values with the same key, returns the LAST one
*
* @deprecated use `findAllCommaSeparatedValues()` instead
*/
export function findCommaSeparatedValues(rawString: string): Map<string, string> {
const result = new Map<string, string>()
COMMA_SEPARATED_KEY_VALUE.lastIndex = 0
while (true) {
const match = COMMA_SEPARATED_KEY_VALUE.exec(rawString)
if (match) {
result.set(match[1], match[2])
} else {
break
}
}
return result
}
export function safeTruncate(candidate: string, length: number, suffix = '') {
const lastChar = candidate.charCodeAt(length - 1)
const isLastCharSurrogatePair = lastChar >= 0xd800 && lastChar <= 0xdbff
const correctedLength = isLastCharSurrogatePair ? length + 1 : length
if (candidate.length <= correctedLength) {
return candidate
}
return `${candidate.slice(0, correctedLength)}${suffix}`
}
@@ -0,0 +1,117 @@
import { isNumber, round } from './numberUtils'
export const ONE_SECOND = 1000
export const ONE_MINUTE = 60 * ONE_SECOND
export const ONE_HOUR = 60 * ONE_MINUTE
export const ONE_DAY = 24 * ONE_HOUR
export const ONE_YEAR = 365 * ONE_DAY
export type Duration = number & { d: 'Duration in ms' }
export type ServerDuration = number & { s: 'Duration in ns' }
export type TimeStamp = number & { t: 'Epoch time' }
export type RelativeTime = number & { r: 'Time relative to navigation start' } & { d: 'Duration in ms' }
export interface ClocksState {
relative: RelativeTime
timeStamp: TimeStamp
}
export function relativeToClocks(relative: RelativeTime) {
return { relative, timeStamp: getCorrectedTimeStamp(relative) }
}
export function timeStampToClocks(timeStamp: TimeStamp) {
return { relative: getRelativeTime(timeStamp), timeStamp }
}
function getCorrectedTimeStamp(relativeTime: RelativeTime) {
const correctedOrigin = (dateNow() - performance.now()) as TimeStamp
// apply correction only for positive drift
if (correctedOrigin > getNavigationStart()) {
return Math.round(addDuration(correctedOrigin, relativeTime)) as TimeStamp
}
return getTimeStamp(relativeTime)
}
export function currentDrift() {
return Math.round(dateNow() - addDuration(getNavigationStart(), performance.now() as Duration))
}
export function toServerDuration(duration: Duration): ServerDuration
export function toServerDuration(duration: Duration | undefined): ServerDuration | undefined
export function toServerDuration(duration: Duration | undefined) {
if (!isNumber(duration)) {
return duration
}
return round(duration * 1e6, 0) as ServerDuration
}
export function dateNow() {
// Do not use `Date.now` because sometimes websites are wrongly "polyfilling" it. For example, we
// had some users using a very old version of `datejs`, which patched `Date.now` to return a Date
// instance instead of a timestamp[1]. Those users are unlikely to fix this, so let's handle this
// case ourselves.
// [1]: https://github.com/datejs/Datejs/blob/97f5c7c58c5bc5accdab8aa7602b6ac56462d778/src/core-debug.js#L14-L16
return new Date().getTime()
}
export function timeStampNow() {
return dateNow() as TimeStamp
}
export function relativeNow() {
return performance.now() as RelativeTime
}
export function clocksNow() {
return { relative: relativeNow(), timeStamp: timeStampNow() }
}
export function clocksOrigin() {
return { relative: 0 as RelativeTime, timeStamp: getNavigationStart() }
}
export function elapsed(start: TimeStamp, end: TimeStamp): Duration
export function elapsed(start: RelativeTime, end: RelativeTime): Duration
export function elapsed(start: number, end: number) {
return (end - start) as Duration
}
export function addDuration(a: TimeStamp, b: Duration): TimeStamp
export function addDuration(a: RelativeTime, b: Duration): RelativeTime
export function addDuration(a: Duration, b: Duration): Duration
export function addDuration(a: number, b: number) {
return a + b
}
// Get the time since the navigation was started.
export function getRelativeTime(timestamp: TimeStamp) {
return (timestamp - getNavigationStart()) as RelativeTime
}
export function getTimeStamp(relativeTime: RelativeTime) {
return Math.round(addDuration(getNavigationStart(), relativeTime)) as TimeStamp
}
export function looksLikeRelativeTime(time: RelativeTime | TimeStamp): time is RelativeTime {
return time < ONE_YEAR
}
/**
* Navigation start slightly change on some rare cases
*/
let navigationStart: TimeStamp | undefined
/**
* Notes: this does not use `performance.timeOrigin` because:
* - It doesn't seem to reflect the actual time on which the navigation has started: it may be much farther in the past,
* at least in Firefox 71. (see: https://bugzilla.mozilla.org/show_bug.cgi?id=1429926)
* - It is not supported in Safari <15
*/
function getNavigationStart() {
if (navigationStart === undefined) {
// ServiceWorkers do not support navigationStart (it's deprecated), so we fallback to timeOrigin
navigationStart = (performance.timing?.navigationStart ?? performance.timeOrigin) as TimeStamp
}
return navigationStart
}
@@ -0,0 +1,9 @@
export function getTimeZone() {
try {
const intl = new Intl.DateTimeFormat()
return intl.resolvedOptions().timeZone
} catch {
return undefined
}
}
@@ -0,0 +1,36 @@
/**
* Similar to `typeof`, but distinguish plain objects from `null` and arrays
*/
export function getType(value: unknown) {
if (value === null) {
return 'null'
}
if (Array.isArray(value)) {
return 'array'
}
return typeof value
}
/**
* Checks whether a value can have properties. Use this when you have an unknown value and you want
* to access its properties as unknown. This is a friendly solution for dealing with unknown objects
* in TypeScript.
*
* This function is intended to be used on values that will be used as "plain objects", i.e. not
* Array, Date, RegExp or other class instances. But it's safe to use on any value.
*
* @example
* ```
* // Before:
* if (typeof value === 'object' && value !== null && 'property' in value && typeof value.property === 'string') {
* // use value.property
* }
* // After:
* if (isIndexableObject(value) && typeof value.property === 'string') {
* // use value.property
* }
* ```
*/
export function isIndexableObject(value: unknown): value is Record<any, unknown> {
return getType(value) === 'object'
}
@@ -0,0 +1,56 @@
import { globalObject } from '../globalObject'
export function normalizeUrl(url: string) {
return buildUrl(url, location.href).href
}
export function isValidUrl(url: string) {
try {
return !!buildUrl(url)
} catch {
return false
}
}
export function getPathName(url: string) {
const pathname = buildUrl(url).pathname
return pathname[0] === '/' ? pathname : `/${pathname}`
}
export function buildUrl(url: string, base?: string) {
const { URL } = getPristineWindow()
try {
return base !== undefined ? new URL(url, base) : new URL(url)
} catch (error) {
throw new Error(`Failed to construct URL: ${String(error)}`)
}
}
/**
* Get native URL constructor from a clean iframe
* This avoids polyfill issues by getting the native implementation from a fresh iframe context
* Falls back to the original URL constructor if iframe approach fails
*/
let getPristineGlobalObjectCache: Pick<typeof window, 'URL'> | undefined
export function getPristineWindow() {
if (!getPristineGlobalObjectCache) {
let iframe: HTMLIFrameElement | undefined
let pristineWindow: Window & typeof globalThis
try {
iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
pristineWindow = iframe.contentWindow as Window & typeof globalThis
} catch {
pristineWindow = globalObject as unknown as Window & typeof globalThis
}
getPristineGlobalObjectCache = {
URL: pristineWindow.URL,
}
iframe?.remove()
}
return getPristineGlobalObjectCache
}
+170
View File
@@ -0,0 +1,170 @@
import { setInterval, clearInterval } from './timer'
import type { TimeoutId } from './timer'
import { removeItem } from './utils/arrayUtils'
import type { Duration, RelativeTime } from './utils/timeUtils'
import { addDuration, relativeNow, ONE_MINUTE } from './utils/timeUtils'
const END_OF_TIMES = Infinity as RelativeTime
export interface ValueHistoryEntry<T> {
startTime: RelativeTime
endTime: RelativeTime
value: T
remove(): void
close(endTime: RelativeTime): void
}
export const CLEAR_OLD_VALUES_INTERVAL = ONE_MINUTE
/**
* Store and keep track of values spans. This whole cache assumes that values are added in
* chronological order (i.e. all entries have an increasing start time).
*/
export interface ValueHistory<Value> {
add: (value: Value, startTime: RelativeTime) => ValueHistoryEntry<Value>
find: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => Value | undefined
closeActive: (endTime: RelativeTime) => void
findAll: (startTime?: RelativeTime, duration?: Duration) => Value[]
getEntries: (startTime: RelativeTime) => Array<ValueHistoryEntry<Value>>
reset: () => void
stop: () => void
}
let cleanupHistoriesInterval: TimeoutId | undefined
const cleanupTasks: Set<() => void> = new Set()
function cleanupHistories() {
cleanupTasks.forEach((task) => task())
}
export function createValueHistory<Value>({
expireDelay,
maxEntries,
}: {
expireDelay: number
maxEntries?: number
}): ValueHistory<Value> {
let entries: Array<ValueHistoryEntry<Value>> = []
if (!cleanupHistoriesInterval) {
cleanupHistoriesInterval = setInterval(() => cleanupHistories(), CLEAR_OLD_VALUES_INTERVAL)
}
const clearExpiredValues = () => {
const oldTimeThreshold = relativeNow() - expireDelay
while (entries.length > 0 && entries[entries.length - 1].endTime < oldTimeThreshold) {
entries.pop()
}
}
cleanupTasks.add(clearExpiredValues)
/**
* Add a value to the history associated with a start time. Returns a reference to this newly
* added entry that can be removed or closed.
*/
function add(value: Value, startTime: RelativeTime): ValueHistoryEntry<Value> {
const entry: ValueHistoryEntry<Value> = {
value,
startTime,
endTime: END_OF_TIMES,
remove: () => {
removeItem(entries, entry)
},
close: (endTime: RelativeTime) => {
entry.endTime = endTime
},
}
if (maxEntries && entries.length >= maxEntries) {
entries.pop()
}
entries.unshift(entry)
return entry
}
/**
* Return the latest value that was active during `startTime`, or the currently active value
* if no `startTime` is provided. This method assumes that entries are not overlapping.
*
* If `option.returnInactive` is true, returns the value at `startTime` (active or not).
*/
function find(
startTime: RelativeTime = END_OF_TIMES,
options: { returnInactive: boolean } = { returnInactive: false }
): Value | undefined {
for (const entry of entries) {
if (entry.startTime <= startTime) {
if (options.returnInactive || startTime <= entry.endTime) {
return entry.value
}
break
}
}
}
/**
* Helper function to close the currently active value, if any. This method assumes that entries
* are not overlapping.
*/
function closeActive(endTime: RelativeTime) {
const latestEntry = entries[0]
if (latestEntry && latestEntry.endTime === END_OF_TIMES) {
latestEntry.close(endTime)
}
}
/**
* Return all values with an active period overlapping with the duration,
* or all values that were active during `startTime` if no duration is provided,
* or all currently active values if no `startTime` is provided.
*/
function findAll(startTime: RelativeTime = END_OF_TIMES, duration = 0 as Duration): Value[] {
const endTime = addDuration(startTime, duration)
return entries
.filter((entry) => entry.startTime <= endTime && startTime <= entry.endTime)
.map((entry) => entry.value)
}
/**
* Return all the entries whose start time is equal to the given startTime.
*/
function getEntries(startTime: RelativeTime): Array<ValueHistoryEntry<Value>> {
return entries.filter((entry) => entry.startTime === startTime)
}
/**
* Remove all entries from this collection.
*/
function reset() {
entries = []
}
/**
* Stop internal garbage collection of past entries.
*/
function stop() {
cleanupTasks.delete(clearExpiredValues)
if (cleanupTasks.size === 0 && cleanupHistoriesInterval) {
clearInterval(cleanupHistoriesInterval)
cleanupHistoriesInterval = undefined
}
}
return { add, find, closeActive, findAll, getEntries, reset, stop }
}
/**
* Reset all global state. This is useful for testing to ensure clean state between tests.
*
* @internal
*/
export function resetValueHistoryGlobals() {
cleanupTasks.clear()
clearInterval(cleanupHistoriesInterval)
cleanupHistoriesInterval = undefined
}
+144
View File
@@ -0,0 +1,144 @@
import { DOCS_TROUBLESHOOTING, MORE_DETAILS, display } from '../tools/display'
import type { Context } from '../tools/serialisation/context'
import { objectValues } from '../tools/utils/polyfills'
import { isPageExitReason } from '../browser/pageMayExitObservable'
import { jsonStringify } from '../tools/serialisation/jsonStringify'
import type { Encoder, EncoderResult } from '../tools/encoder'
import { computeBytesCount, ONE_KIBI_BYTE } from '../tools/utils/byteUtils'
import type { HttpRequest, Payload } from './httpRequest'
import type { FlushController, FlushEvent } from './flushController'
export const MESSAGE_BYTES_LIMIT = 256 * ONE_KIBI_BYTE
export interface Batch {
flushController: FlushController
add: (message: Context) => void
upsert: (message: Context, key: string) => void
stop: () => void
}
export function createBatch({
encoder,
request,
flushController,
}: {
encoder: Encoder
request: HttpRequest
flushController: FlushController
}): Batch {
let upsertBuffer: { [key: string]: string } = {}
const flushSubscription = flushController.flushObservable.subscribe((event) => flush(event))
function push(serializedMessage: string, estimatedMessageBytesCount: number, key?: string) {
flushController.notifyBeforeAddMessage(estimatedMessageBytesCount)
if (key !== undefined) {
upsertBuffer[key] = serializedMessage
flushController.notifyAfterAddMessage()
} else {
encoder.write(encoder.isEmpty ? serializedMessage : `\n${serializedMessage}`, (realMessageBytesCount) => {
flushController.notifyAfterAddMessage(realMessageBytesCount - estimatedMessageBytesCount)
})
}
}
function hasMessageFor(key?: string): key is string {
return key !== undefined && upsertBuffer[key] !== undefined
}
function remove(key: string) {
const removedMessage = upsertBuffer[key]
delete upsertBuffer[key]
const messageBytesCount = encoder.estimateEncodedBytesCount(removedMessage)
flushController.notifyAfterRemoveMessage(messageBytesCount)
}
function addOrUpdate(message: Context, key?: string) {
const serializedMessage = jsonStringify(message)!
const estimatedMessageBytesCount = encoder.estimateEncodedBytesCount(serializedMessage)
if (estimatedMessageBytesCount >= MESSAGE_BYTES_LIMIT) {
display.warn(
`Discarded a message whose size was bigger than the maximum allowed size ${MESSAGE_BYTES_LIMIT / ONE_KIBI_BYTE}KiB. ${MORE_DETAILS} ${DOCS_TROUBLESHOOTING}/#technical-limitations`
)
return
}
if (hasMessageFor(key)) {
remove(key)
}
push(serializedMessage, estimatedMessageBytesCount, key)
}
function flush(event: FlushEvent) {
const upsertMessages = objectValues(upsertBuffer).join('\n')
upsertBuffer = {}
const pageMightExit = isPageExitReason(event.reason)
const send = pageMightExit ? request.sendOnExit : request.send
if (
pageMightExit &&
// Note: checking that the encoder is async is not strictly needed, but it's an optimization:
// if the encoder is async we need to send two requests in some cases (one for encoded data
// and the other for non-encoded data). But if it's not async, we don't have to worry about
// it and always send a single request.
encoder.isAsync
) {
const encoderResult = encoder.finishSync()
// Send encoded messages
if (encoderResult.outputBytesCount) {
send(formatPayloadFromEncoder(encoderResult))
}
// Send messages that are not yet encoded at this point
const pendingMessages = [encoderResult.pendingData, upsertMessages].filter(Boolean).join('\n')
if (pendingMessages) {
send({
data: pendingMessages,
bytesCount: computeBytesCount(pendingMessages),
})
}
} else {
if (upsertMessages) {
encoder.write(encoder.isEmpty ? upsertMessages : `\n${upsertMessages}`)
}
encoder.finish((encoderResult) => {
send(formatPayloadFromEncoder(encoderResult))
})
}
}
return {
flushController,
add: addOrUpdate,
upsert: addOrUpdate,
stop: flushSubscription.unsubscribe,
}
}
function formatPayloadFromEncoder(encoderResult: EncoderResult): Payload {
let data: string | Blob
if (typeof encoderResult.output === 'string') {
data = encoderResult.output
} else {
data = new Blob([encoderResult.output], {
// This will set the 'Content-Type: text/plain' header. Reasoning:
// * The intake rejects the request if there is no content type.
// * The browser will issue CORS preflight requests if we set it to 'application/json', which
// could induce higher intake load (and maybe has other impacts).
// * Also it's not quite JSON, since we are concatenating multiple JSON objects separated by
// new lines.
type: 'text/plain',
})
}
return {
data,
bytesCount: encoderResult.outputBytesCount,
encoding: encoderResult.encoding,
}
}
@@ -0,0 +1,61 @@
import { getGlobalObject } from '../tools/globalObject'
import type { DefaultPrivacyLevel } from '../domain/configuration'
export interface BrowserWindowWithEventBridge extends Window {
DatadogEventBridge?: DatadogEventBridge
}
export interface DatadogEventBridge {
getCapabilities?(): string
getPrivacyLevel?(): DefaultPrivacyLevel
getAllowedWebViewHosts(): string
send(msg: string): void
}
export const enum BridgeCapability {
RECORDS = 'records',
}
export function getEventBridge<T, E>() {
const eventBridgeGlobal = getEventBridgeGlobal()
if (!eventBridgeGlobal) {
return
}
return {
getCapabilities() {
return JSON.parse(eventBridgeGlobal.getCapabilities?.() || '[]') as BridgeCapability[]
},
getPrivacyLevel() {
return eventBridgeGlobal.getPrivacyLevel?.()
},
getAllowedWebViewHosts() {
return JSON.parse(eventBridgeGlobal.getAllowedWebViewHosts()) as string[]
},
send(eventType: T, event: E, viewId?: string) {
const view = viewId ? { id: viewId } : undefined
eventBridgeGlobal.send(JSON.stringify({ eventType, event, view }))
},
}
}
export function bridgeSupports(capability: BridgeCapability): boolean {
const bridge = getEventBridge()
return !!bridge && bridge.getCapabilities().includes(capability)
}
export function canUseEventBridge(currentHost = getGlobalObject<Window>().location?.hostname): boolean {
const bridge = getEventBridge()
return (
!!bridge &&
bridge
.getAllowedWebViewHosts()
.some((allowedHost) => currentHost === allowedHost || currentHost.endsWith(`.${allowedHost}`))
)
}
function getEventBridgeGlobal() {
return getGlobalObject<BrowserWindowWithEventBridge>().DatadogEventBridge
}
@@ -0,0 +1,150 @@
import type { PageMayExitEvent, PageExitReason } from '../browser/pageMayExitObservable'
import { isWorkerEnvironment } from '../tools/globalObject'
import { Observable } from '../tools/observable'
import type { TimeoutId } from '../tools/timer'
import { clearTimeout, setTimeout } from '../tools/timer'
import { ONE_SECOND } from '../tools/utils/timeUtils'
import type { Duration } from '../tools/utils/timeUtils'
import { RECOMMENDED_REQUEST_BYTES_LIMIT } from './httpRequest'
export type FlushReason = PageExitReason | 'duration_limit' | 'bytes_limit' | 'messages_limit' | 'session_expire'
/**
* flush automatically, aim to be lower than ALB connection timeout
* to maximize connection reuse.
*/
export const FLUSH_DURATION_LIMIT = (30 * ONE_SECOND) as Duration
/**
* When using the SDK in a Worker Environment, we limit the batch size to 1 to ensure it can be sent
* in a single event.
*/
export const MESSAGES_LIMIT = isWorkerEnvironment ? 1 : 50
export type FlushController = ReturnType<typeof createFlushController>
export interface FlushEvent {
reason: FlushReason
bytesCount: number
messagesCount: number
}
interface FlushControllerOptions {
pageMayExitObservable: Observable<PageMayExitEvent>
sessionExpireObservable: Observable<void>
}
/**
* Returns a "flush controller", responsible of notifying when flushing a pool of pending data needs
* to happen. The implementation is designed to support both synchronous and asynchronous usages,
* but relies on invariants described in each method documentation to keep a coherent state.
*/
export function createFlushController({ pageMayExitObservable, sessionExpireObservable }: FlushControllerOptions) {
const pageMayExitSubscription = pageMayExitObservable.subscribe((event) => flush(event.reason))
const sessionExpireSubscription = sessionExpireObservable.subscribe(() => flush('session_expire'))
const flushObservable = new Observable<FlushEvent>(() => () => {
pageMayExitSubscription.unsubscribe()
sessionExpireSubscription.unsubscribe()
})
let currentBytesCount = 0
let currentMessagesCount = 0
function flush(flushReason: FlushReason) {
if (currentMessagesCount === 0) {
return
}
const messagesCount = currentMessagesCount
const bytesCount = currentBytesCount
currentMessagesCount = 0
currentBytesCount = 0
cancelDurationLimitTimeout()
flushObservable.notify({
reason: flushReason,
messagesCount,
bytesCount,
})
}
let durationLimitTimeoutId: TimeoutId | undefined
function scheduleDurationLimitTimeout() {
if (durationLimitTimeoutId === undefined) {
durationLimitTimeoutId = setTimeout(() => {
flush('duration_limit')
}, FLUSH_DURATION_LIMIT)
}
}
function cancelDurationLimitTimeout() {
clearTimeout(durationLimitTimeoutId)
durationLimitTimeoutId = undefined
}
return {
flushObservable,
get messagesCount() {
return currentMessagesCount
},
/**
* Notifies that a message will be added to a pool of pending messages waiting to be flushed.
*
* This function needs to be called synchronously, right before adding the message, so no flush
* event can happen after `notifyBeforeAddMessage` and before adding the message.
*
* @param estimatedMessageBytesCount - an estimation of the message bytes count once it is
* actually added.
*/
notifyBeforeAddMessage(estimatedMessageBytesCount: number) {
if (currentBytesCount + estimatedMessageBytesCount >= RECOMMENDED_REQUEST_BYTES_LIMIT) {
flush('bytes_limit')
}
// Consider the message to be added now rather than in `notifyAfterAddMessage`, because if no
// message was added yet and `notifyAfterAddMessage` is called asynchronously, we still want
// to notify when a flush is needed (for example on page exit).
currentMessagesCount += 1
currentBytesCount += estimatedMessageBytesCount
scheduleDurationLimitTimeout()
},
/**
* Notifies that a message *was* added to a pool of pending messages waiting to be flushed.
*
* This function can be called asynchronously after the message was added, but in this case it
* should not be called if a flush event occurred in between.
*
* @param messageBytesCountDiff - the difference between the estimated message bytes count and
* its actual bytes count once added to the pool.
*/
notifyAfterAddMessage(messageBytesCountDiff = 0) {
currentBytesCount += messageBytesCountDiff
if (currentMessagesCount >= MESSAGES_LIMIT) {
flush('messages_limit')
} else if (currentBytesCount >= RECOMMENDED_REQUEST_BYTES_LIMIT) {
flush('bytes_limit')
}
},
/**
* Notifies that a message was removed from a pool of pending messages waiting to be flushed.
*
* This function needs to be called synchronously, right after removing the message, so no flush
* event can happen after removing the message and before `notifyAfterRemoveMessage`.
*
* @param messageBytesCount - the message bytes count that was added to the pool. Should
* correspond to the sum of bytes counts passed to `notifyBeforeAddMessage` and
* `notifyAfterAddMessage`.
*/
notifyAfterRemoveMessage(messageBytesCount: number) {
currentBytesCount -= messageBytesCount
currentMessagesCount -= 1
if (currentMessagesCount === 0) {
cancelDurationLimitTimeout()
}
},
}
}
@@ -0,0 +1,146 @@
import type { EndpointBuilder } from '../domain/configuration'
import type { Context } from '../tools/serialisation/context'
import { fetch } from '../browser/fetch'
import { monitor, monitorError } from '../tools/monitor'
import type { RawError } from '../domain/error/error.types'
import { Observable } from '../tools/observable'
import { ONE_KIBI_BYTE } from '../tools/utils/byteUtils'
import { newRetryState, sendWithRetryStrategy } from './sendWithRetryStrategy'
/**
* beacon payload max queue size implementation is 64kb
* ensure that we leave room for logs, rum and potential other users
*/
export const RECOMMENDED_REQUEST_BYTES_LIMIT = 16 * ONE_KIBI_BYTE
/**
* Use POST request without content type to:
* - avoid CORS preflight requests
* - allow usage of sendBeacon
*
* multiple elements are sent separated by \n in order
* to be parsed correctly without content type header
*/
export interface HttpRequest<Body extends Payload = Payload> {
observable: Observable<HttpRequestEvent<Body>>
send(this: void, payload: Body): void
sendOnExit(this: void, payload: Body): void
}
export interface HttpResponse extends Context {
status: number
type?: ResponseType
}
export interface BandwidthStats {
ongoingByteCount: number
ongoingRequestCount: number
}
export type HttpRequestEvent<Body extends Payload = Payload> =
| {
// A request to send the given payload failed. (We may retry.)
type: 'failure'
bandwidth: BandwidthStats
payload: Body
}
| {
// The given payload was discarded because the request queue is full.
type: 'queue-full'
bandwidth: BandwidthStats
payload: Body
}
| {
// A request to send the given payload succeeded.
type: 'success'
bandwidth: BandwidthStats
payload: Body
}
export interface Payload {
data: string | FormData | Blob
bytesCount: number
retry?: RetryInfo
encoding?: 'deflate'
}
export interface RetryInfo {
count: number
lastFailureStatus: number
}
export function createHttpRequest<Body extends Payload = Payload>(
endpointBuilders: EndpointBuilder[],
reportError: (error: RawError) => void,
bytesLimit: number = RECOMMENDED_REQUEST_BYTES_LIMIT
): HttpRequest<Body> {
const observable = new Observable<HttpRequestEvent<Body>>()
const retryState = newRetryState<Body>()
return {
observable,
send: (payload: Body) => {
for (const endpointBuilder of endpointBuilders) {
sendWithRetryStrategy(
payload,
retryState,
(payload, onResponse) => {
fetchStrategy(endpointBuilder, payload, onResponse)
},
endpointBuilder.trackType,
reportError,
observable
)
}
},
/**
* Since fetch keepalive behaves like regular fetch on Firefox,
* keep using sendBeaconStrategy on exit
*/
sendOnExit: (payload: Body) => {
for (const endpointBuilder of endpointBuilders) {
sendBeaconStrategy(endpointBuilder, bytesLimit, payload)
}
},
}
}
function sendBeaconStrategy(endpointBuilder: EndpointBuilder, bytesLimit: number, payload: Payload) {
const canUseBeacon = !!navigator.sendBeacon && payload.bytesCount < bytesLimit
if (canUseBeacon) {
try {
const beaconUrl = endpointBuilder.build('beacon', payload)
const isQueued = navigator.sendBeacon(beaconUrl, payload.data)
if (isQueued) {
return
}
} catch (e) {
reportBeaconError(e)
}
}
fetchStrategy(endpointBuilder, payload)
}
let hasReportedBeaconError = false
function reportBeaconError(e: unknown) {
if (!hasReportedBeaconError) {
hasReportedBeaconError = true
monitorError(e)
}
}
export function fetchStrategy(
endpointBuilder: EndpointBuilder,
payload: Payload,
onResponse?: (r: HttpResponse) => void
) {
const fetchUrl = endpointBuilder.build('fetch', payload)
fetch(fetchUrl, { method: 'POST', body: payload.data, mode: 'cors' })
.then(monitor((response: Response) => onResponse?.({ status: response.status, type: response.type })))
.catch(monitor(() => onResponse?.({ status: 0 })))
}
@@ -0,0 +1,222 @@
import type { TrackType } from '../domain/configuration'
import { setTimeout } from '../tools/timer'
import { clocksNow, ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
import { ONE_MEBI_BYTE, ONE_KIBI_BYTE } from '../tools/utils/byteUtils'
import { isServerError } from '../tools/utils/responseUtils'
import type { RawError } from '../domain/error/error.types'
import { ErrorSource } from '../domain/error/error.types'
import type { Observable } from '../tools/observable'
import type { Payload, HttpRequestEvent, HttpResponse, BandwidthStats } from './httpRequest'
export const MAX_ONGOING_BYTES_COUNT = 80 * ONE_KIBI_BYTE
export const MAX_ONGOING_REQUESTS = 32
export const MAX_QUEUE_BYTES_COUNT = 20 * ONE_MEBI_BYTE
export const MAX_BACKOFF_TIME = ONE_MINUTE
export const INITIAL_BACKOFF_TIME = ONE_SECOND
const enum TransportStatus {
UP,
FAILURE_DETECTED,
DOWN,
}
const enum RetryReason {
AFTER_SUCCESS,
AFTER_RESUME,
}
export interface RetryState<Body extends Payload> {
transportStatus: TransportStatus
currentBackoffTime: number
bandwidthMonitor: ReturnType<typeof newBandwidthMonitor>
queuedPayloads: ReturnType<typeof newPayloadQueue<Body>>
queueFullReported: boolean
}
type SendStrategy<Body extends Payload> = (payload: Body, onResponse: (r: HttpResponse) => void) => void
export function sendWithRetryStrategy<Body extends Payload>(
payload: Body,
state: RetryState<Body>,
sendStrategy: SendStrategy<Body>,
trackType: TrackType,
reportError: (error: RawError) => void,
requestObservable: Observable<HttpRequestEvent<Body>>
) {
if (
state.transportStatus === TransportStatus.UP &&
state.queuedPayloads.size() === 0 &&
state.bandwidthMonitor.canHandle(payload)
) {
send(payload, state, sendStrategy, requestObservable, {
onSuccess: () =>
retryQueuedPayloads(RetryReason.AFTER_SUCCESS, state, sendStrategy, trackType, reportError, requestObservable),
onFailure: () => {
if (!state.queuedPayloads.enqueue(payload)) {
requestObservable.notify({ type: 'queue-full', bandwidth: state.bandwidthMonitor.stats(), payload })
}
scheduleRetry(state, sendStrategy, trackType, reportError, requestObservable)
},
})
} else {
if (!state.queuedPayloads.enqueue(payload)) {
requestObservable.notify({ type: 'queue-full', bandwidth: state.bandwidthMonitor.stats(), payload })
}
}
}
function scheduleRetry<Body extends Payload>(
state: RetryState<Body>,
sendStrategy: SendStrategy<Body>,
trackType: TrackType,
reportError: (error: RawError) => void,
requestObservable: Observable<HttpRequestEvent<Body>>
) {
if (state.transportStatus !== TransportStatus.DOWN) {
return
}
setTimeout(() => {
const payload = state.queuedPayloads.first()
send(payload, state, sendStrategy, requestObservable, {
onSuccess: () => {
state.queuedPayloads.dequeue()
state.currentBackoffTime = INITIAL_BACKOFF_TIME
retryQueuedPayloads(RetryReason.AFTER_RESUME, state, sendStrategy, trackType, reportError, requestObservable)
},
onFailure: () => {
state.currentBackoffTime = Math.min(MAX_BACKOFF_TIME, state.currentBackoffTime * 2)
scheduleRetry(state, sendStrategy, trackType, reportError, requestObservable)
},
})
}, state.currentBackoffTime)
}
function send<Body extends Payload>(
payload: Body,
state: RetryState<Body>,
sendStrategy: SendStrategy<Body>,
requestObservable: Observable<HttpRequestEvent<Body>>,
{ onSuccess, onFailure }: { onSuccess: () => void; onFailure: () => void }
) {
state.bandwidthMonitor.add(payload)
sendStrategy(payload, (response) => {
state.bandwidthMonitor.remove(payload)
if (!shouldRetryRequest(response)) {
state.transportStatus = TransportStatus.UP
requestObservable.notify({ type: 'success', bandwidth: state.bandwidthMonitor.stats(), payload })
onSuccess()
} else {
// do not consider transport down if another ongoing request could succeed
state.transportStatus =
state.bandwidthMonitor.ongoingRequestCount > 0 ? TransportStatus.FAILURE_DETECTED : TransportStatus.DOWN
payload.retry = {
count: payload.retry ? payload.retry.count + 1 : 1,
lastFailureStatus: response.status,
}
requestObservable.notify({ type: 'failure', bandwidth: state.bandwidthMonitor.stats(), payload })
onFailure()
}
})
}
function retryQueuedPayloads<Body extends Payload>(
reason: RetryReason,
state: RetryState<Body>,
sendStrategy: SendStrategy<Body>,
trackType: TrackType,
reportError: (error: RawError) => void,
requestObservable: Observable<HttpRequestEvent<Body>>
) {
if (reason === RetryReason.AFTER_SUCCESS && state.queuedPayloads.isFull() && !state.queueFullReported) {
reportError({
message: `Reached max ${trackType} events size queued for upload: ${MAX_QUEUE_BYTES_COUNT / ONE_MEBI_BYTE}MiB`,
source: ErrorSource.AGENT,
startClocks: clocksNow(),
})
state.queueFullReported = true
}
const previousQueue = state.queuedPayloads
state.queuedPayloads = newPayloadQueue()
while (previousQueue.size() > 0) {
sendWithRetryStrategy(previousQueue.dequeue()!, state, sendStrategy, trackType, reportError, requestObservable)
}
}
function shouldRetryRequest(response: HttpResponse) {
return (
response.type !== 'opaque' &&
((response.status === 0 && !navigator.onLine) ||
response.status === 408 ||
response.status === 429 ||
isServerError(response.status))
)
}
export function newRetryState<Body extends Payload>(): RetryState<Body> {
return {
transportStatus: TransportStatus.UP,
currentBackoffTime: INITIAL_BACKOFF_TIME,
bandwidthMonitor: newBandwidthMonitor(),
queuedPayloads: newPayloadQueue(),
queueFullReported: false,
}
}
function newPayloadQueue<Body extends Payload>() {
const queue: Body[] = []
return {
bytesCount: 0,
enqueue(payload: Body) {
if (this.isFull()) {
return false
}
queue.push(payload)
this.bytesCount += payload.bytesCount
return true
},
first() {
return queue[0]
},
dequeue() {
const payload = queue.shift()
if (payload) {
this.bytesCount -= payload.bytesCount
}
return payload
},
size() {
return queue.length
},
isFull() {
return this.bytesCount >= MAX_QUEUE_BYTES_COUNT
},
}
}
function newBandwidthMonitor() {
return {
ongoingRequestCount: 0,
ongoingByteCount: 0,
canHandle(payload: Payload) {
return (
this.ongoingRequestCount === 0 ||
(this.ongoingByteCount + payload.bytesCount <= MAX_ONGOING_BYTES_COUNT &&
this.ongoingRequestCount < MAX_ONGOING_REQUESTS)
)
},
add(payload: Payload) {
this.ongoingRequestCount += 1
this.ongoingByteCount += payload.bytesCount
},
remove(payload: Payload) {
this.ongoingRequestCount -= 1
this.ongoingByteCount -= payload.bytesCount
},
stats(): BandwidthStats {
return {
ongoingByteCount: this.ongoingByteCount,
ongoingRequestCount: this.ongoingRequestCount,
}
},
}
}
+347
View File
@@ -0,0 +1,347 @@
import type {
TrackingConsentState,
DeflateWorker,
Context,
ContextManager,
BoundedBuffer,
Telemetry,
TimeStamp,
} from '@datadog/browser-core'
import {
createBoundedBuffer,
display,
canUseEventBridge,
displayAlreadyInitializedError,
willSyntheticsInjectRum,
noop,
timeStampNow,
clocksNow,
getEventBridge,
initFeatureFlags,
addTelemetryConfiguration,
initFetchObservable,
CustomerContextKey,
buildAccountContextManager,
buildGlobalContextManager,
buildUserContextManager,
monitorError,
sanitize,
startTelemetry,
TelemetryService,
mockable,
} from '@datadog/browser-core'
import type { Hooks } from '../domain/hooks'
import { createHooks } from '../domain/hooks'
import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration'
import {
validateAndBuildRumConfiguration,
fetchAndApplyRemoteConfiguration,
serializeRumConfiguration,
} from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
import type {
DurationVital,
CustomVitalsState,
FeatureOperationOptions,
FailureReason,
} from '../domain/vital/vitalCollection'
import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection'
import { callPluginsMethod } from '../domain/plugins'
import type { StartRumResult } from './startRum'
import type { RumPublicApiOptions, Strategy } from './rumPublicApi'
export type DoStartRum = (
configuration: RumConfiguration,
deflateWorker: DeflateWorker | undefined,
initialViewOptions: ViewOptions | undefined,
telemetry: Telemetry,
hooks: Hooks
) => StartRumResult
export function createPreStartStrategy(
{ ignoreInitIfSyntheticsWillInjectRum = true, startDeflateWorker }: RumPublicApiOptions,
trackingConsentState: TrackingConsentState,
customVitalsState: CustomVitalsState,
doStartRum: DoStartRum
): Strategy {
const bufferApiCalls = createBoundedBuffer<StartRumResult>()
// TODO next major: remove the globalContextManager, userContextManager and accountContextManager from preStartStrategy and use an empty context instead
const globalContext = buildGlobalContextManager()
bufferContextCalls(globalContext, CustomerContextKey.globalContext, bufferApiCalls)
const userContext = buildUserContextManager()
bufferContextCalls(userContext, CustomerContextKey.userContext, bufferApiCalls)
const accountContext = buildAccountContextManager()
bufferContextCalls(accountContext, CustomerContextKey.accountContext, bufferApiCalls)
let firstStartViewCall:
| { options: ViewOptions | undefined; callback: (startRumResult: StartRumResult) => void }
| undefined
let deflateWorker: DeflateWorker | undefined
let cachedInitConfiguration: RumInitConfiguration | undefined
let cachedConfiguration: RumConfiguration | undefined
let telemetry: Telemetry | undefined
const hooks = createHooks()
const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartRum)
const emptyContext: Context = {}
function tryStartRum() {
if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) {
return
}
// Start telemetry only once, when we have consent and configuration
if (!telemetry) {
telemetry = mockable(startTelemetry)(TelemetryService.RUM, cachedConfiguration, hooks)
}
trackingConsentStateSubscription.unsubscribe()
let initialViewOptions: ViewOptions | undefined
if (cachedConfiguration.trackViewsManually) {
if (!firstStartViewCall) {
return
}
// An initial view is always created when starting RUM.
// When tracking views automatically, any startView call before RUM start creates an extra
// view.
// When tracking views manually, we use the ViewOptions from the first startView call as the
// initial view options, and we remove the actual startView call so we don't create an extra
// view.
bufferApiCalls.remove(firstStartViewCall.callback)
initialViewOptions = firstStartViewCall.options
}
const startRumResult = doStartRum(cachedConfiguration, deflateWorker, initialViewOptions, telemetry, hooks)
bufferApiCalls.drain(startRumResult)
}
function doInit(initConfiguration: RumInitConfiguration, errorStack?: string) {
const eventBridgeAvailable = canUseEventBridge()
if (eventBridgeAvailable) {
initConfiguration = overrideInitConfigurationForBridge(initConfiguration)
}
// Update the exposed initConfiguration to reflect the bridge and remote configuration overrides
cachedInitConfiguration = initConfiguration
addTelemetryConfiguration(serializeRumConfiguration(initConfiguration))
if (cachedConfiguration) {
displayAlreadyInitializedError('DD_RUM', initConfiguration)
return
}
const configuration = validateAndBuildRumConfiguration(initConfiguration, errorStack)
if (!configuration) {
return
}
if (!eventBridgeAvailable && !configuration.sessionStoreStrategyType) {
display.warn('No storage available for session. We will not send any data.')
return
}
if (configuration.compressIntakeRequests && !eventBridgeAvailable && startDeflateWorker) {
deflateWorker = startDeflateWorker(
configuration,
'Datadog RUM',
// Worker initialization can fail asynchronously, especially in Firefox where even CSP
// issues are reported asynchronously. For now, the SDK will continue its execution even if
// data won't be sent to Datadog. We could improve this behavior in the future.
noop
)
if (!deflateWorker) {
// `startDeflateWorker` should have logged an error message explaining the issue
return
}
}
cachedConfiguration = configuration
// Instrument fetch to track network requests
// This is needed in case the consent is not granted and some customer
// library (Apollo Client) is storing uninstrumented fetch to be used later
// The subscription is needed so that the instrumentation process is completed
initFetchObservable().subscribe(noop)
trackingConsentState.tryToInit(configuration.trackingConsent)
tryStartRum()
}
const addDurationVital = (vital: DurationVital) => {
bufferApiCalls.add((startRumResult) => startRumResult.addDurationVital(vital))
}
const addOperationStepVital = (
name: string,
stepType: 'start' | 'end',
options?: FeatureOperationOptions,
failureReason?: FailureReason
) => {
bufferApiCalls.add((startRumResult) =>
startRumResult.addOperationStepVital(
sanitize(name)!,
stepType,
sanitize(options) as FeatureOperationOptions,
sanitize(failureReason) as FailureReason | undefined
)
)
}
const strategy: Strategy = {
init(initConfiguration, publicApi, errorStack) {
if (!initConfiguration) {
display.error('Missing configuration')
return
}
// Set the experimental feature flags as early as possible, so we can use them in most places
initFeatureFlags(initConfiguration.enableExperimentalFeatures)
// Expose the initial configuration regardless of initialization success.
cachedInitConfiguration = initConfiguration
// If we are in a Synthetics test configured to automatically inject a RUM instance, we want
// to completely discard the customer application RUM instance by ignoring their init() call.
// But, we should not ignore the init() call from the Synthetics-injected RUM instance, so the
// internal `ignoreInitIfSyntheticsWillInjectRum` option is here to bypass this condition.
if (ignoreInitIfSyntheticsWillInjectRum && willSyntheticsInjectRum()) {
return
}
callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi })
if (initConfiguration.remoteConfigurationId) {
fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext })
.then((initConfiguration) => {
if (initConfiguration) {
doInit(initConfiguration, errorStack)
}
})
.catch(monitorError)
} else {
doInit(initConfiguration, errorStack)
}
},
get initConfiguration() {
return cachedInitConfiguration
},
getInternalContext: noop as () => undefined,
stopSession: noop,
addTiming(name, time = timeStampNow()) {
bufferApiCalls.add((startRumResult) => startRumResult.addTiming(name, time))
},
setLoadingTime: ((callTimestamp: TimeStamp) => {
bufferApiCalls.add((startRumResult) => startRumResult.setLoadingTime(callTimestamp))
}) as Strategy['setLoadingTime'],
startView(options, startClocks = clocksNow()) {
const callback = (startRumResult: StartRumResult) => {
startRumResult.startView(options, startClocks)
}
bufferApiCalls.add(callback)
if (!firstStartViewCall) {
firstStartViewCall = { options, callback }
tryStartRum()
}
},
setViewName(name) {
bufferApiCalls.add((startRumResult) => startRumResult.setViewName(name))
},
// View context APIs
setViewContext(context) {
bufferApiCalls.add((startRumResult) => startRumResult.setViewContext(context))
},
setViewContextProperty(key, value) {
bufferApiCalls.add((startRumResult) => startRumResult.setViewContextProperty(key, value))
},
getViewContext: () => emptyContext,
globalContext,
userContext,
accountContext,
addAction(action) {
bufferApiCalls.add((startRumResult) => startRumResult.addAction(action))
},
startAction(name, options) {
const startClocks = clocksNow()
bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, options, startClocks))
},
stopAction(name, options) {
const stopClocks = clocksNow()
bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, options, stopClocks))
},
startResource(url, options) {
const startClocks = clocksNow()
bufferApiCalls.add((startRumResult) => startRumResult.startResource(url, options, startClocks))
},
stopResource(url, options) {
const stopClocks = clocksNow()
bufferApiCalls.add((startRumResult) => startRumResult.stopResource(url, options, stopClocks))
},
addError(providedError) {
bufferApiCalls.add((startRumResult) => startRumResult.addError(providedError))
},
addFeatureFlagEvaluation(key, value) {
bufferApiCalls.add((startRumResult) => startRumResult.addFeatureFlagEvaluation(key, value))
},
startDurationVital(name, options) {
return startDurationVital(customVitalsState, name, options)
},
stopDurationVital(name, options) {
stopDurationVital(addDurationVital, customVitalsState, name, options)
},
addDurationVital,
addOperationStepVital,
}
return strategy
}
function overrideInitConfigurationForBridge(initConfiguration: RumInitConfiguration): RumInitConfiguration {
return {
...initConfiguration,
applicationId: '00000000-aaaa-0000-aaaa-000000000000',
clientToken: 'empty',
sessionSampleRate: 100,
defaultPrivacyLevel: initConfiguration.defaultPrivacyLevel ?? getEventBridge()?.getPrivacyLevel(),
}
}
function bufferContextCalls(
preStartContextManager: ContextManager,
name: CustomerContextKey,
bufferApiCalls: BoundedBuffer<StartRumResult>
) {
preStartContextManager.changeObservable.subscribe(() => {
const context = preStartContextManager.getContext()
bufferApiCalls.add((startRumResult) => startRumResult[name].setContext(context))
})
}
@@ -0,0 +1,977 @@
import type {
Context,
TimeStamp,
RelativeTime,
DeflateWorker,
DeflateEncoderStreamId,
DeflateEncoder,
PublicApi,
Duration,
ContextManager,
TrackingConsent,
User,
Account,
RumInternalContext,
Telemetry,
Encoder,
ResourceType,
} from '@datadog/browser-core'
import {
ContextManagerMethod,
addTelemetryUsage,
deepClone,
makePublicApi,
monitor,
clocksNow,
callMonitored,
createHandlingStack,
sanitize,
createIdentityEncoder,
displayAlreadyInitializedError,
createTrackingConsentState,
timeStampToClocks,
CustomerContextKey,
defineContextMethod,
startBufferingData,
isExperimentalFeatureEnabled,
ExperimentalFeature,
mockable,
generateUUID,
timeStampNow,
} from '@datadog/browser-core'
import type { LifeCycle } from '../domain/lifeCycle'
import type { ViewHistory } from '../domain/contexts/viewHistory'
import type { RumSessionManager } from '../domain/rumSessionManager'
import type { ReplayStats } from '../rawRumEvent.types'
import { ActionType, VitalType } from '../rawRumEvent.types'
import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
import type {
AddDurationVitalOptions,
DurationVitalReference,
DurationVitalOptions,
FeatureOperationOptions,
FailureReason,
} from '../domain/vital/vitalCollection'
import { createCustomVitalsState } from '../domain/vital/vitalCollection'
import { callPluginsMethod } from '../domain/plugins'
import type { Hooks } from '../domain/hooks'
import type { SdkName } from '../domain/contexts/defaultContext'
import type { ActionOptions } from '../domain/action/trackManualActions'
import type { ResourceOptions, ResourceStopOptions } from '../domain/resource/trackManualResources'
import { createPreStartStrategy } from './preStartRum'
import type { StartRumResult } from './startRum'
import { startRum } from './startRum'
export interface StartRecordingOptions {
force: boolean
}
/**
* Public API for the RUM browser SDK.
*
* See [RUM Browser Monitoring Setup](https://docs.datadoghq.com/real_user_monitoring/browser) for further information.
*
* @category Main
*/
export interface RumPublicApi extends PublicApi {
/**
* Init the RUM browser SDK.
*
* See [RUM Browser Monitoring Setup](https://docs.datadoghq.com/real_user_monitoring/browser) for further information.
*
* @category Init
* @param initConfiguration - Configuration options of the SDK
* @example
* ```ts
* datadogRum.init({
* applicationId: '<DATADOG_APPLICATION_ID>',
* clientToken: '<DATADOG_CLIENT_TOKEN>',
* site: '<DATADOG_SITE>',
* // ...
* })
* ```
*/
init: (initConfiguration: RumInitConfiguration) => void
/**
* Set the tracking consent of the current user.
*
* Data will be sent only if it is set to "granted". This value won't be stored by the library
* across page loads: you will need to call this method or set the appropriate `trackingConsent`
* field in the init() method at each page load.
*
* If this method is called before the init() method, the provided value will take precedence
* over the one provided as initialization parameter.
*
* See [User tracking consent](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-tracking-consent) for further information.
*
* @category Privacy
* @param trackingConsent - The user tracking consent
*/
setTrackingConsent: (trackingConsent: TrackingConsent) => void
/**
* Set View Name.
*
* Enable to manually change the name of the current view.
* See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#override-default-rum-view-names) for further information.
*
* @category Context - View
* @param name - Name of the view
*/
setViewName: (name: string) => void
/**
* Set View Context.
*
* Enable to manually set the context of the current view.
*
* @category Context - View
* @param context - Context of the view
*/
setViewContext: (context: Context) => void
/**
* Set View Context Property.
*
* Enable to manually set a property of the context of the current view.
*
* @category Context - View
* @param key - key of the property
* @param value - value of the property
*/
setViewContextProperty: (key: string, value: any) => void
/**
* Get View Context.
*
* @category Context - View
*/
getViewContext(): Context
/**
* [Internal API] Get the internal SDK context
*
* @internal
*/
getInternalContext: (startTime?: number) => RumInternalContext | undefined
/**
* Get the init configuration
*
* @category Init
* @returns RumInitConfiguration | undefined
*/
getInitConfiguration: () => RumInitConfiguration | undefined
/**
* Add a custom action, stored in `@action`
*
* See [Send RUM Custom Actions](https://docs.datadoghq.com/real_user_monitoring/guide/send-rum-custom-actions) for further information.
*
* @category Data Collection
* @param name - Name of the action
* @param context - Context of the action
*/
addAction: (name: string, context?: object) => void
/**
* [Experimental] Start an action, stored in `@action`
*
* @category Data Collection
* @param name - Name of the action
* @param options - Options of the action (@default type: 'custom')
*/
startAction: (name: string, options?: ActionOptions) => void
/**
* [Experimental] Stop an action, stored in `@action`
*
* @category Data Collection
* @param name - Name of the action
* @param options - Options of the action
*/
stopAction: (name: string, options?: ActionOptions) => void
/**
* [Experimental] Start tracking a resource, stored in `@resource`
*
* @category Data Collection
* @param url - URL of the resource
* @param options - Options of the resource (@default type: 'other')
*/
startResource: (url: string, options?: ResourceOptions) => void
/**
* [Experimental] Stop tracking a resource, stored in `@resource`
*
* @category Data Collection
* @param url - URL of the resource
* @param options - Options of the resource
*/
stopResource: (url: string, options?: ResourceStopOptions) => void
/**
* Add a custom error, stored in `@error`.
*
* See [Send RUM Custom Actions](https://docs.datadoghq.com/real_user_monitoring/guide/send-rum-custom-actions) for further information.
*
* @category Data Collection
* @param error - Error. Favor sending a [Javascript Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) to have a stack trace attached to the error event.
* @param context - Context of the error
*/
addError: (error: unknown, context?: object) => void
/**
* Add a custom timing relative to the start of the current view,
* stored in `@view.custom_timings.<timing_name>`
*
* Note: passing a relative time is discouraged since it is actually used as-is but displayed relative to the view start.
* We currently don't provide a way to retrieve the view start time, so it can be challenging to provide a timing relative to the view start.
* see https://github.com/DataDog/browser-sdk/issues/2552
*
* @category Data Collection
* @param name - Name of the custom timing
* @param [time] - Epoch timestamp of the custom timing (if not set, will use current time)
*/
addTiming: (name: string, time?: number) => void
/**
* [Experimental] Manually set the current view's loading time.
*
* Call this method when the view has finished loading. The loading time is computed as the
* elapsed time since the view started. Each call replaces any previously set value (last-call-wins).
*
* @category Data Collection
*/
setViewLoadingTime: () => void
/**
* Set the global context information to all events, stored in `@context`
* See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information.
*
* @category Context - Global Context
* @param context - Global context
*/
setGlobalContext: (context: Context) => void
/**
* Get the global Context
*
* See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information.
*
* @category Context - Global Context
*/
getGlobalContext: () => Context
/**
* Set or update a global context property, stored in `@context.<key>`
*
* See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information.
*
* @category Context - Global Context
* @param key - Key of the property
* @param value - Value of the property
*/
setGlobalContextProperty: (key: any, value: any) => void
/**
* Remove a global context property
*
* See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information.
*
* @category Context - Global Context
*/
removeGlobalContextProperty: (key: any) => void
/**
* Clear the global context
*
* See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information.
*
* @category Context - Global Context
*/
clearGlobalContext(): void
/**
* Set user information to all events, stored in `@usr`
*
* See [User session](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-session) for further information.
*
* @category Context - User
* @param newUser - User information
*/
setUser(newUser: User & { id: string }): void
/**
* Set user information to all events, stored in `@usr`
*
* @category Context - User
* @deprecated You must specify a user id, favor using {@link setUser} instead
* @param newUser - User information with optional id
*/
setUser(newUser: User): void
/**
* Get user information
*
* See [User session](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-session) for further information.
*
* @category Context - User
* @returns User information
*/
getUser: () => Context
/**
* Set or update the user property, stored in `@usr.<key>`
*
* See [User session](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-session) for further information.
*
* @category Context - User
* @param key - Key of the property
* @param property - Value of the property
*/
setUserProperty: (key: any, property: any) => void
/**
* Remove a user property
*
* @category Context - User
* @param key - Key of the property to remove
* @see [User session](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-session) for further information.
*/
removeUserProperty: (key: any) => void
/**
* Clear all user information
*
* See [User session](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-session) for further information.
*
* @category Context - User
*/
clearUser: () => void
/**
* Set account information to all events, stored in `@account`
*
* @category Context - Account
* @param newAccount - Account information
*/
setAccount: (newAccount: Account) => void
/**
* Get account information
*
* @category Context - Account
* @returns Account information
*/
getAccount: () => Context
/**
* Set or update the account property, stored in `@account.<key>`
*
* @category Context - Account
* @param key - Key of the property
* @param property - Value of the property
*/
setAccountProperty: (key: string, property: any) => void
/**
* Remove an account property
*
* @category Context - Account
* @param key - Key of the property to remove
*/
removeAccountProperty: (key: string) => void
/**
* Clear all account information
*
* @category Context - Account
* @returns Clear all account information
*/
clearAccount: () => void
/**
* Start a view manually.
* Enable to manual start a view, use `trackViewsManually: true` init parameter and call `startView()` to create RUM views and be aligned with how youve defined them in your SPA application routing.
*
* See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#override-default-rum-view-names) for further information.
*
* Context - @category Data Collection
*
* @param nameOrOptions - The view name, or a {@link ViewOptions} object to configure the view
*/
startView(nameOrOptions?: string | ViewOptions): void
/**
* Stop the session. A new session will start at the next user interaction with the page.
*
* @category Session
*/
stopSession(): void
/**
* Add a feature flag evaluation,
* stored in `@feature_flags.<feature_flag_key>`
*
* We recommend enabling the intake request compression when using feature flags `compressIntakeRequests: true`.
*
* See [Feature Flag Tracking](https://docs.datadoghq.com/real_user_monitoring/feature_flag_tracking/) for further information.
*
* @category Data Collection
* @param key - The key of the feature flag.
* @param value - The value of the feature flag.
*/
addFeatureFlagEvaluation: (key: string, value: any) => void
/**
* Get the Session Replay Link.
*
* See [Connect Session Replay To Your Third-Party Tools](https://docs.datadoghq.com/real_user_monitoring/guide/connect-session-replay-to-your-third-party-tools) for further information.
*
* @category Session Replay
*/
getSessionReplayLink: () => string | undefined
/**
* Start Session Replay recording.
* Enable to conditionally start the recording, use the `startSessionReplayRecordingManually:true` init parameter and call `startSessionReplayRecording()`
*
* See [Browser Session Replay](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser) for further information.
*
* @category Session Replay
*/
startSessionReplayRecording: (options?: StartRecordingOptions) => void
/**
* Stop Session Replay recording.
*
* See [Browser Session Replay](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser) for further information.
*
* @category Session Replay
*/
stopSessionReplayRecording: () => void
/**
* Add a custom duration vital
*
* @category Vital - Duration
* @param name - Name of the custom vital
* @param options - Options for the custom vital (startTime, duration, context, description)
*/
addDurationVital: (name: string, options: AddDurationVitalOptions) => void
/**
* Start a custom duration vital.
*
* If you plan to have multiple durations for the same vital, you should use the reference returned by this method.
*
* @category Vital - Duration
* @param name - Name of the custom vital
* @param options - Options for the custom vital (context, description)
* @returns reference to the custom vital
*/
startDurationVital: (name: string, options?: DurationVitalOptions) => DurationVitalReference
/**
* Stop a custom duration vital
*
* @category Vital - Duration
* @param nameOrRef - Name or reference of the custom vital
* @param options - Options for the custom vital (operationKey, context, description)
*/
stopDurationVital: (nameOrRef: string | DurationVitalReference, options?: DurationVitalOptions) => void
/**
* start a feature operation
*
* @category Vital - Feature Operation
* @param name - Name of the operation step
* @param options - Options for the operation step (operationKey, context, description)
* @hidden // TODO: replace by @since when GA
*/
startFeatureOperation: (name: string, options?: FeatureOperationOptions) => void
/**
* succeed a feature operation
*
* @category Vital - Feature Operation
* @param name - Name of the operation step
* @param options - Options for the operation step (operationKey, context, description)
* @hidden // TODO: replace by @since when GA
*/
succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => void
/**
* fail a feature operation
*
* @category Vital - Feature Operation
* @param name - Name of the operation step
* @param failureReason - Reason for the failure
* @param options - Options for the operation step (operationKey, context, description)
* @hidden // TODO: replace by @since when GA
*/
failFeatureOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => void
}
export interface RecorderApi {
start: (options?: StartRecordingOptions) => void
stop: () => void
onRumStart: (
lifeCycle: LifeCycle,
configuration: RumConfiguration,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
deflateWorker: DeflateWorker | undefined,
telemetry: Telemetry
) => void
isRecording: () => boolean
getReplayStats: (viewId: string) => ReplayStats | undefined
getSessionReplayLink: () => string | undefined
}
export interface ProfilerApi {
stop: () => void
onRumStart: (
lifeCycle: LifeCycle,
hooks: Hooks,
configuration: RumConfiguration,
sessionManager: RumSessionManager,
viewHistory: ViewHistory,
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder
) => void
}
export interface RumPublicApiOptions {
ignoreInitIfSyntheticsWillInjectRum?: boolean
startDeflateWorker?: (
configuration: RumConfiguration,
source: string,
onInitializationFailure: () => void
) => DeflateWorker | undefined
createDeflateEncoder?: (
configuration: RumConfiguration,
worker: DeflateWorker,
streamId: DeflateEncoderStreamId
) => DeflateEncoder
sdkName?: SdkName
}
export interface Strategy {
init: (initConfiguration: RumInitConfiguration, publicApi: RumPublicApi, errorStack?: string) => void
initConfiguration: RumInitConfiguration | undefined
getInternalContext: StartRumResult['getInternalContext']
stopSession: StartRumResult['stopSession']
addTiming: StartRumResult['addTiming']
setLoadingTime: StartRumResult['setLoadingTime']
startView: StartRumResult['startView']
setViewName: StartRumResult['setViewName']
setViewContext: StartRumResult['setViewContext']
setViewContextProperty: StartRumResult['setViewContextProperty']
getViewContext: StartRumResult['getViewContext']
globalContext: ContextManager
userContext: ContextManager
accountContext: ContextManager
addAction: StartRumResult['addAction']
startAction: StartRumResult['startAction']
stopAction: StartRumResult['stopAction']
startResource: StartRumResult['startResource']
stopResource: StartRumResult['stopResource']
addError: StartRumResult['addError']
addFeatureFlagEvaluation: StartRumResult['addFeatureFlagEvaluation']
startDurationVital: StartRumResult['startDurationVital']
stopDurationVital: StartRumResult['stopDurationVital']
addDurationVital: StartRumResult['addDurationVital']
addOperationStepVital: StartRumResult['addOperationStepVital']
}
export function makeRumPublicApi(
recorderApi: RecorderApi,
profilerApi: ProfilerApi,
options: RumPublicApiOptions = {}
): RumPublicApi {
const trackingConsentState = createTrackingConsentState()
const customVitalsState = createCustomVitalsState()
const bufferedDataObservable = startBufferingData().observable
let strategy = createPreStartStrategy(
options,
trackingConsentState,
customVitalsState,
(configuration, deflateWorker, initialViewOptions, telemetry, hooks) => {
const createEncoder =
deflateWorker && options.createDeflateEncoder
? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId)
: createIdentityEncoder
const startRumResult = mockable(startRum)(
configuration,
recorderApi,
profilerApi,
initialViewOptions,
createEncoder,
trackingConsentState,
customVitalsState,
bufferedDataObservable,
telemetry,
hooks,
options.sdkName
)
recorderApi.onRumStart(
startRumResult.lifeCycle,
configuration,
startRumResult.session,
startRumResult.viewHistory,
deflateWorker,
startRumResult.telemetry
)
profilerApi.onRumStart(
startRumResult.lifeCycle,
startRumResult.hooks,
configuration,
startRumResult.session,
startRumResult.viewHistory,
createEncoder
)
strategy = createPostStartStrategy(strategy, startRumResult)
callPluginsMethod(configuration.plugins, 'onRumStart', {
strategy, // TODO: remove this in the next major release
addEvent: startRumResult.addEvent,
})
return startRumResult
}
)
const getStrategy = () => strategy
const startView: {
(name?: string): void
(options: ViewOptions): void
} = (options?: string | ViewOptions) => {
const handlingStack = createHandlingStack('view')
callMonitored(() => {
const sanitizedOptions = typeof options === 'object' ? options : { name: options }
strategy.startView({ ...sanitizedOptions, handlingStack })
addTelemetryUsage({ feature: 'start-view' })
})
}
const rumPublicApi: RumPublicApi = makePublicApi<RumPublicApi>({
init: (initConfiguration) => {
const errorStack = new Error().stack
callMonitored(() => strategy.init(initConfiguration, rumPublicApi, errorStack))
},
setTrackingConsent: monitor((trackingConsent) => {
trackingConsentState.update(trackingConsent)
addTelemetryUsage({ feature: 'set-tracking-consent', tracking_consent: trackingConsent })
}),
setViewName: monitor((name: string) => {
strategy.setViewName(name)
addTelemetryUsage({ feature: 'set-view-name' })
}),
setViewContext: monitor((context: Context) => {
strategy.setViewContext(context)
addTelemetryUsage({ feature: 'set-view-context' })
}),
setViewContextProperty: monitor((key: string, value: any) => {
strategy.setViewContextProperty(key, value)
addTelemetryUsage({ feature: 'set-view-context-property' })
}),
getViewContext: monitor(() => {
addTelemetryUsage({ feature: 'set-view-context-property' })
return strategy.getViewContext()
}),
getInternalContext: monitor((startTime) => strategy.getInternalContext(startTime)),
getInitConfiguration: monitor(() => deepClone(strategy.initConfiguration)),
addAction: (name, context) => {
const handlingStack = createHandlingStack('action')
callMonitored(() => {
strategy.addAction({
name: sanitize(name)!,
context: sanitize(context) as Context,
startClocks: clocksNow(),
type: ActionType.CUSTOM,
handlingStack,
})
addTelemetryUsage({ feature: 'add-action' })
})
},
startAction: monitor((name, options) => {
// Check feature flag only after init; pre-init calls should be buffered
if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) {
return
}
// addTelemetryUsage({ feature: 'start-action' })
strategy.startAction(sanitize(name)!, {
type: sanitize(options && options.type) as ActionType | undefined,
context: sanitize(options && options.context) as Context,
actionKey: options && options.actionKey,
})
}),
stopAction: monitor((name, options) => {
if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) {
return
}
// addTelemetryUsage({ feature: 'stop-action' })
strategy.stopAction(sanitize(name)!, {
type: sanitize(options && options.type) as ActionType | undefined,
context: sanitize(options && options.context) as Context,
actionKey: options && options.actionKey,
})
}),
startResource: monitor((url, options) => {
// Check feature flag only after init; pre-init calls should be buffered
if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_RESOURCE)) {
return
}
// addTelemetryUsage({ feature: 'start-resource' })
strategy.startResource(sanitize(url)!, {
type: sanitize(options && options.type) as ResourceType | undefined,
method: sanitize(options && options.method) as string | undefined,
context: sanitize(options && options.context) as Context,
resourceKey: options && options.resourceKey,
})
}),
stopResource: monitor((url, options) => {
if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_RESOURCE)) {
return
}
// addTelemetryUsage({ feature: 'stop-resource' })
strategy.stopResource(sanitize(url)!, {
type: sanitize(options && options.type) as ResourceType | undefined,
statusCode: options && options.statusCode,
size: options && options.size,
context: sanitize(options && options.context) as Context,
resourceKey: options && options.resourceKey,
})
}),
addError: (error, context) => {
const handlingStack = createHandlingStack('error')
callMonitored(() => {
strategy.addError({
error, // Do not sanitize error here, it is needed unserialized by computeRawError()
handlingStack,
context: sanitize(context) as Context,
startClocks: clocksNow(),
})
addTelemetryUsage({ feature: 'add-error' })
})
},
addTiming: monitor((name, time) => {
// TODO: next major decide to drop relative time support or update its behaviour
strategy.addTiming(sanitize(name)!, time as RelativeTime | TimeStamp | undefined)
}),
setViewLoadingTime: monitor(() => {
const callTimestamp = timeStampNow()
strategy.setLoadingTime(callTimestamp)
addTelemetryUsage({
feature: 'addViewLoadingTime',
})
}),
setGlobalContext: defineContextMethod(
getStrategy,
CustomerContextKey.globalContext,
ContextManagerMethod.setContext,
'set-global-context'
),
getGlobalContext: defineContextMethod(
getStrategy,
CustomerContextKey.globalContext,
ContextManagerMethod.getContext,
'get-global-context'
),
setGlobalContextProperty: defineContextMethod(
getStrategy,
CustomerContextKey.globalContext,
ContextManagerMethod.setContextProperty,
'set-global-context-property'
),
removeGlobalContextProperty: defineContextMethod(
getStrategy,
CustomerContextKey.globalContext,
ContextManagerMethod.removeContextProperty,
'remove-global-context-property'
),
clearGlobalContext: defineContextMethod(
getStrategy,
CustomerContextKey.globalContext,
ContextManagerMethod.clearContext,
'clear-global-context'
),
setUser: defineContextMethod(
getStrategy,
CustomerContextKey.userContext,
ContextManagerMethod.setContext,
'set-user'
),
getUser: defineContextMethod(
getStrategy,
CustomerContextKey.userContext,
ContextManagerMethod.getContext,
'get-user'
),
setUserProperty: defineContextMethod(
getStrategy,
CustomerContextKey.userContext,
ContextManagerMethod.setContextProperty,
'set-user-property'
),
removeUserProperty: defineContextMethod(
getStrategy,
CustomerContextKey.userContext,
ContextManagerMethod.removeContextProperty,
'remove-user-property'
),
clearUser: defineContextMethod(
getStrategy,
CustomerContextKey.userContext,
ContextManagerMethod.clearContext,
'clear-user'
),
setAccount: defineContextMethod(
getStrategy,
CustomerContextKey.accountContext,
ContextManagerMethod.setContext,
'set-account'
),
getAccount: defineContextMethod(
getStrategy,
CustomerContextKey.accountContext,
ContextManagerMethod.getContext,
'get-account'
),
setAccountProperty: defineContextMethod(
getStrategy,
CustomerContextKey.accountContext,
ContextManagerMethod.setContextProperty,
'set-account-property'
),
removeAccountProperty: defineContextMethod(
getStrategy,
CustomerContextKey.accountContext,
ContextManagerMethod.removeContextProperty,
'remove-account-property'
),
clearAccount: defineContextMethod(
getStrategy,
CustomerContextKey.accountContext,
ContextManagerMethod.clearContext,
'clear-account'
),
startView,
stopSession: monitor(() => {
strategy.stopSession()
addTelemetryUsage({ feature: 'stop-session' })
}),
addFeatureFlagEvaluation: monitor((key, value) => {
strategy.addFeatureFlagEvaluation(sanitize(key)!, sanitize(value))
addTelemetryUsage({ feature: 'add-feature-flag-evaluation' })
}),
getSessionReplayLink: monitor(() => recorderApi.getSessionReplayLink()),
startSessionReplayRecording: monitor((options?: StartRecordingOptions) => {
recorderApi.start(options)
addTelemetryUsage({ feature: 'start-session-replay-recording', force: options && options.force })
}),
stopSessionReplayRecording: monitor(() => recorderApi.stop()),
addDurationVital: (name, options) => {
const handlingStack = createHandlingStack('vital')
callMonitored(() => {
addTelemetryUsage({ feature: 'add-duration-vital' })
strategy.addDurationVital({
id: generateUUID(),
name: sanitize(name)!,
type: VitalType.DURATION,
startClocks: timeStampToClocks(options.startTime as TimeStamp),
duration: options.duration as Duration,
context: sanitize(options && options.context) as Context,
description: sanitize(options && options.description) as string | undefined,
handlingStack,
})
})
},
startDurationVital: (name, options) => {
const handlingStack = createHandlingStack('vital')
return callMonitored(() => {
addTelemetryUsage({ feature: 'start-duration-vital' })
return strategy.startDurationVital(sanitize(name)!, {
context: sanitize(options && options.context) as Context,
description: sanitize(options && options.description) as string | undefined,
handlingStack,
})
}) as DurationVitalReference
},
stopDurationVital: monitor((nameOrRef, options) => {
addTelemetryUsage({ feature: 'stop-duration-vital' })
strategy.stopDurationVital(typeof nameOrRef === 'string' ? sanitize(nameOrRef)! : nameOrRef, {
context: sanitize(options && options.context) as Context,
description: sanitize(options && options.description) as string | undefined,
})
}),
startFeatureOperation: (name, options) => {
const handlingStack = createHandlingStack('vital')
callMonitored(() => {
addTelemetryUsage({ feature: 'add-operation-step-vital', action_type: 'start' })
strategy.addOperationStepVital(name, 'start', { ...options, handlingStack })
})
},
succeedFeatureOperation: monitor((name, options) => {
addTelemetryUsage({ feature: 'add-operation-step-vital', action_type: 'succeed' })
strategy.addOperationStepVital(name, 'end', options)
}),
failFeatureOperation: monitor((name, failureReason, options) => {
addTelemetryUsage({ feature: 'add-operation-step-vital', action_type: 'fail' })
strategy.addOperationStepVital(name, 'end', options, failureReason)
}),
})
return rumPublicApi
}
function createPostStartStrategy(preStartStrategy: Strategy, startRumResult: StartRumResult): Strategy {
return {
init: (initConfiguration: RumInitConfiguration) => {
displayAlreadyInitializedError('DD_RUM', initConfiguration)
},
initConfiguration: preStartStrategy.initConfiguration,
...startRumResult,
}
}
+275
View File
@@ -0,0 +1,275 @@
import type {
Observable,
RawError,
DeflateEncoderStreamId,
Encoder,
TrackingConsentState,
BufferedData,
BufferedObservable,
Telemetry,
} from '@datadog/browser-core'
import {
sendToExtension,
createPageMayExitObservable,
canUseEventBridge,
addTelemetryDebug,
startAccountContext,
startGlobalContext,
startUserContext,
} 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 { startActionCollection } from '../domain/action/actionCollection'
import { startErrorCollection } from '../domain/error/errorCollection'
import { startResourceCollection } from '../domain/resource/resourceCollection'
import { startViewCollection } from '../domain/view/viewCollection'
import type { RumSessionManager } from '../domain/rumSessionManager'
import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager'
import { startRumBatch } from '../transport/startRumBatch'
import { startRumEventBridge } from '../transport/startRumEventBridge'
import { startUrlContexts } from '../domain/contexts/urlContexts'
import { createLocationChangeObservable } from '../browser/locationChangeObservable'
import type { RumConfiguration } from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext'
import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry'
import { startPageStateHistory } from '../domain/contexts/pageStateHistory'
import { startDisplayContext } from '../domain/contexts/displayContext'
import type { CustomVitalsState } from '../domain/vital/vitalCollection'
import { startVitalCollection } from '../domain/vital/vitalCollection'
import { startCiVisibilityContext } from '../domain/contexts/ciVisibilityContext'
import { startLongTaskCollection } from '../domain/longTask/longTaskCollection'
import { startSyntheticsContext } from '../domain/contexts/syntheticsContext'
import { startRumAssembly } from '../domain/assembly'
import { startSessionContext } from '../domain/contexts/sessionContext'
import { startConnectivityContext } from '../domain/contexts/connectivityContext'
import type { SdkName } from '../domain/contexts/defaultContext'
import { startDefaultContext } from '../domain/contexts/defaultContext'
import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext'
import type { Hooks } from '../domain/hooks'
import { startEventCollection } from '../domain/event/eventCollection'
import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry'
import { startSourceCodeContext } from '../domain/contexts/sourceCodeContext'
import type { RecorderApi, ProfilerApi } from './rumPublicApi'
export type StartRum = typeof startRum
export type StartRumResult = ReturnType<StartRum>
export function startRum(
configuration: RumConfiguration,
recorderApi: RecorderApi,
profilerApi: ProfilerApi,
initialViewOptions: ViewOptions | undefined,
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder,
// `startRum` and its subcomponents assume tracking consent is granted initially and starts
// collecting logs unconditionally. As such, `startRum` should be called with a
// `trackingConsentState` set to "granted".
trackingConsentState: TrackingConsentState,
customVitalsState: CustomVitalsState,
bufferedDataObservable: BufferedObservable<BufferedData>,
telemetry: Telemetry,
hooks: Hooks,
sdkName?: SdkName
) {
const cleanupTasks: Array<() => void> = []
const lifeCycle = new LifeCycle()
lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => sendToExtension('rum', event))
const reportError = (error: RawError) => {
lifeCycle.notify(LifeCycleEventType.RAW_ERROR_COLLECTED, { error })
// monitor-until: forever, to keep an eye on the errors reported to customers
addTelemetryDebug('Error reported to customer', { 'error.message': error.message })
}
const pageMayExitObservable = createPageMayExitObservable(configuration)
const pageMayExitSubscription = pageMayExitObservable.subscribe((event) => {
lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, event)
})
cleanupTasks.push(() => pageMayExitSubscription.unsubscribe())
const session = !canUseEventBridge()
? startRumSessionManager(configuration, lifeCycle, trackingConsentState)
: startRumSessionManagerStub()
if (!canUseEventBridge()) {
const batch = startRumBatch(
configuration,
lifeCycle,
reportError,
pageMayExitObservable,
session.expireObservable,
createEncoder
)
cleanupTasks.push(() => batch.stop())
startCustomerDataTelemetry(telemetry, lifeCycle, batch.flushController.flushObservable)
} else {
startRumEventBridge(lifeCycle)
}
startTrackingConsentContext(hooks, trackingConsentState)
const { stop: stopInitialViewMetricsTelemetry } = startInitialViewMetricsTelemetry(lifeCycle, telemetry)
cleanupTasks.push(stopInitialViewMetricsTelemetry)
const { stop: stopRumEventCollection, ...startRumEventCollectionResult } = startRumEventCollection(
lifeCycle,
hooks,
configuration,
session,
recorderApi,
initialViewOptions,
customVitalsState,
bufferedDataObservable,
sdkName,
reportError
)
cleanupTasks.push(stopRumEventCollection)
bufferedDataObservable.unbuffer()
// Add Clean-up tasks for Profiler API.
cleanupTasks.push(() => profilerApi.stop())
return {
...startRumEventCollectionResult,
lifeCycle,
session,
stopSession: () => session.expire(),
telemetry,
stop: () => {
cleanupTasks.forEach((task) => task())
},
hooks,
}
}
export function startRumEventCollection(
lifeCycle: LifeCycle,
hooks: Hooks,
configuration: RumConfiguration,
session: RumSessionManager,
recorderApi: RecorderApi,
initialViewOptions: ViewOptions | undefined,
customVitalsState: CustomVitalsState,
bufferedDataObservable: Observable<BufferedData>,
sdkName: SdkName | undefined,
reportError: (error: RawError) => void
) {
const cleanupTasks: Array<() => void> = []
const domMutationObservable = createDOMMutationObservable()
const locationChangeObservable = createLocationChangeObservable(configuration)
const { observable: windowOpenObservable, stop: stopWindowOpen } = createWindowOpenObservable()
cleanupTasks.push(stopWindowOpen)
startDefaultContext(hooks, configuration, sdkName)
const pageStateHistory = startPageStateHistory(hooks, configuration)
cleanupTasks.push(() => pageStateHistory.stop())
const viewHistory = startViewHistory(lifeCycle)
cleanupTasks.push(() => viewHistory.stop())
const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable)
cleanupTasks.push(() => urlContexts.stop())
const featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, configuration)
startSessionContext(hooks, session, recorderApi, viewHistory)
startConnectivityContext(hooks)
const globalContext = startGlobalContext(hooks, configuration, 'rum', true)
const userContext = startUserContext(hooks, configuration, session, 'rum')
const accountContext = startAccountContext(hooks, configuration, 'rum')
const actionCollection = startActionCollection(
lifeCycle,
hooks,
domMutationObservable,
windowOpenObservable,
configuration
)
cleanupTasks.push(actionCollection.stop)
const eventCollection = startEventCollection(lifeCycle)
const displayContext = startDisplayContext(hooks, configuration)
cleanupTasks.push(displayContext.stop)
const ciVisibilityContext = startCiVisibilityContext(configuration, hooks)
cleanupTasks.push(ciVisibilityContext.stop)
startSyntheticsContext(hooks)
startRumAssembly(configuration, lifeCycle, hooks, reportError)
const {
addTiming,
setLoadingTime,
startView,
setViewName,
setViewContext,
setViewContextProperty,
getViewContext,
stop: stopViewCollection,
} = startViewCollection(
lifeCycle,
hooks,
configuration,
domMutationObservable,
windowOpenObservable,
locationChangeObservable,
recorderApi,
viewHistory,
initialViewOptions
)
startSourceCodeContext(hooks)
cleanupTasks.push(stopViewCollection)
const resourceCollection = startResourceCollection(lifeCycle, configuration, pageStateHistory)
cleanupTasks.push(resourceCollection.stop)
const { stop: stopLongTaskCollection } = startLongTaskCollection(lifeCycle, configuration)
cleanupTasks.push(stopLongTaskCollection)
const { addError } = startErrorCollection(lifeCycle, configuration, bufferedDataObservable)
startRequestCollection(lifeCycle, configuration, session, userContext, accountContext)
const vitalCollection = startVitalCollection(lifeCycle, pageStateHistory, customVitalsState)
const internalContext = startInternalContext(
configuration.applicationId,
session,
viewHistory,
actionCollection.actionContexts,
urlContexts
)
return {
addAction: actionCollection.addAction,
startAction: actionCollection.startAction,
stopAction: actionCollection.stopAction,
startResource: resourceCollection.startResource,
stopResource: resourceCollection.stopResource,
addEvent: eventCollection.addEvent,
addError,
addTiming,
setLoadingTime,
addFeatureFlagEvaluation: featureFlagContexts.addFeatureFlagEvaluation,
startView,
setViewContext,
setViewContextProperty,
getViewContext,
setViewName,
viewHistory,
getInternalContext: internalContext.get,
startDurationVital: vitalCollection.startDurationVital,
stopDurationVital: vitalCollection.stopDurationVital,
addDurationVital: vitalCollection.addDurationVital,
addOperationStepVital: vitalCollection.addOperationStepVital,
globalContext,
userContext,
accountContext,
stop: () => cleanupTasks.forEach((task) => task()),
}
}
@@ -0,0 +1,64 @@
import type { Configuration, CookieStore } from '@datadog/browser-core'
import {
setInterval,
clearInterval,
Observable,
addEventListener,
ONE_SECOND,
findCommaSeparatedValue,
DOM_EVENT,
} from '@datadog/browser-core'
export interface CookieStoreWindow {
cookieStore?: CookieStore
}
export type CookieObservable = ReturnType<typeof createCookieObservable>
export function createCookieObservable(configuration: Configuration, cookieName: string) {
const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore
? listenToCookieStoreChange(configuration)
: watchCookieFallback
return new Observable<string | undefined>((observable) =>
detectCookieChangeStrategy(cookieName, (event) => observable.notify(event))
)
}
function listenToCookieStoreChange(configuration: Configuration) {
return (cookieName: string, callback: (event: string | undefined) => void) => {
const listener = addEventListener(
configuration,
(window as CookieStoreWindow).cookieStore!,
DOM_EVENT.CHANGE,
(event) => {
// Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays.
// However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226
const changeEvent =
event.changed.find((event) => event.name === cookieName) ||
event.deleted.find((event) => event.name === cookieName)
if (changeEvent) {
callback(changeEvent.value)
}
}
)
return listener.stop
}
}
export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND
function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) {
let previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName)
const watchCookieIntervalId = setInterval(() => {
const cookieValue = findCommaSeparatedValue(document.cookie, cookieName)
if (cookieValue !== previousCookieValue) {
previousCookieValue = cookieValue
callback(cookieValue)
}
}, WATCH_COOKIE_INTERVAL_DELAY)
return () => {
clearInterval(watchCookieIntervalId)
}
}
@@ -0,0 +1,98 @@
import { monitor, noop, Observable, getZoneJsOriginalValue } from '@datadog/browser-core'
// https://dom.spec.whatwg.org/#interface-mutationrecord
export interface RumCharacterDataMutationRecord {
type: 'characterData'
target: Node
oldValue: string | null
}
export interface RumAttributesMutationRecord {
type: 'attributes'
target: Element
oldValue: string | null
attributeName: string | null
}
export interface RumChildListMutationRecord {
type: 'childList'
target: Node
addedNodes: NodeList
removedNodes: NodeList
}
export type RumMutationRecord =
| RumCharacterDataMutationRecord
| RumAttributesMutationRecord
| RumChildListMutationRecord
export function createDOMMutationObservable() {
const MutationObserver = getMutationObserverConstructor()
return new Observable<RumMutationRecord[]>((observable) => {
if (!MutationObserver) {
return
}
const observer = new MutationObserver(monitor((records) => observable.notify(records)))
observer.observe(document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
})
return () => observer.disconnect()
})
}
type MutationObserverConstructor = new (callback: (records: RumMutationRecord[]) => void) => MutationObserver
export interface BrowserWindow extends Window {
MutationObserver?: MutationObserverConstructor
Zone?: unknown
}
export function getMutationObserverConstructor(): MutationObserverConstructor | undefined {
let constructor: MutationObserverConstructor | undefined
const browserWindow = window as BrowserWindow
// Angular uses Zone.js to provide a context persisting across async tasks. Zone.js replaces the
// global MutationObserver constructor with a patched version to support the context propagation.
// There is an ongoing issue[1][2] with this setup when using a MutationObserver within a Angular
// component: on some occasions, the callback is being called in an infinite loop, causing the
// page to freeze (even if the callback is completely empty).
//
// To work around this issue, we try to get the original MutationObserver constructor stored by
// Zone.js.
//
// [1] https://github.com/angular/angular/issues/26948
// [2] https://github.com/angular/angular/issues/31712
if (browserWindow.Zone) {
// Zone.js 0.8.6+ is storing original class constructors into the browser 'window' object[3].
//
// [3] https://github.com/angular/angular/blob/6375fa79875c0fe7b815efc45940a6e6f5c9c9eb/packages/zone.js/lib/common/utils.ts#L288
constructor = getZoneJsOriginalValue(browserWindow, 'MutationObserver')
if (browserWindow.MutationObserver && constructor === browserWindow.MutationObserver) {
// Anterior Zone.js versions (used in Angular 2) does not expose the original MutationObserver
// in the 'window' object. Luckily, the patched MutationObserver class is storing an original
// instance in its properties[4]. Let's get the original MutationObserver constructor from
// there.
//
// [4] https://github.com/angular/zone.js/blob/v0.8.5/lib/common/utils.ts#L412
const patchedInstance = new browserWindow.MutationObserver(noop) as {
originalInstance?: { constructor: MutationObserverConstructor }
}
const originalInstance = getZoneJsOriginalValue(patchedInstance, 'originalInstance')
constructor = originalInstance && originalInstance.constructor
}
}
if (!constructor) {
constructor = browserWindow.MutationObserver
}
return constructor
}
@@ -0,0 +1,87 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import { addEventListeners, dateNow, DOM_EVENT, relativeNow } from '@datadog/browser-core'
import type { RumConfiguration } from '../domain/configuration'
/**
* first-input timing entry polyfill based on
* https://github.com/GoogleChrome/web-vitals/blob/master/src/lib/polyfills/firstInputPolyfill.ts
*/
export function retrieveFirstInputTiming(
configuration: RumConfiguration,
callback: (timing: PerformanceEventTiming) => void
) {
const startTimeStamp = dateNow()
let timingSent = false
const { stop: removeEventListeners } = addEventListeners(
configuration,
window,
[DOM_EVENT.CLICK, DOM_EVENT.MOUSE_DOWN, DOM_EVENT.KEY_DOWN, DOM_EVENT.TOUCH_START, DOM_EVENT.POINTER_DOWN],
(evt) => {
// Only count cancelable events, which should trigger behavior important to the user.
if (!evt.cancelable) {
return
}
// This timing will be used to compute the "first Input delay", which is the delta between
// when the system received the event (e.g. evt.timeStamp) and when it could run the callback
// (e.g. performance.now()).
const timing: PerformanceEventTiming = {
entryType: 'first-input',
processingStart: relativeNow(),
processingEnd: relativeNow(),
startTime: evt.timeStamp as RelativeTime,
duration: 0 as Duration, // arbitrary value to avoid nullable duration and simplify INP logic
name: '',
cancelable: false,
target: null,
toJSON: () => ({}),
}
if (evt.type === DOM_EVENT.POINTER_DOWN) {
sendTimingIfPointerIsNotCancelled(configuration, timing)
} else {
sendTiming(timing)
}
},
{ passive: true, capture: true }
)
return { stop: removeEventListeners }
/**
* Pointer events are a special case, because they can trigger main or compositor thread behavior.
* We differentiate these cases based on whether or not we see a pointercancel event, which are
* fired when we scroll. If we're scrolling we don't need to report input delay since FID excludes
* scrolling and pinch/zooming.
*/
function sendTimingIfPointerIsNotCancelled(configuration: RumConfiguration, timing: PerformanceEventTiming) {
addEventListeners(
configuration,
window,
[DOM_EVENT.POINTER_UP, DOM_EVENT.POINTER_CANCEL],
(event) => {
if (event.type === DOM_EVENT.POINTER_UP) {
sendTiming(timing)
}
},
{ once: true }
)
}
function sendTiming(timing: PerformanceEventTiming) {
if (!timingSent) {
timingSent = true
removeEventListeners()
// In some cases the recorded delay is clearly wrong, e.g. it's negative or it's larger than
// the time between now and when the page was loaded.
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/4
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/6
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/7
const delay = timing.processingStart - timing.startTime
if (delay >= 0 && delay < dateNow() - startTimeStamp) {
callback(timing)
}
}
}
}
@@ -0,0 +1,60 @@
export function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE
}
export function isCommentNode(node: Node): node is Comment {
return node.nodeType === Node.COMMENT_NODE
}
export function isElementNode(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE
}
export function isNodeShadowHost(node: Node): node is Element & { shadowRoot: ShadowRoot } {
return isElementNode(node) && Boolean(node.shadowRoot)
}
export function isNodeShadowRoot(node: Node): node is ShadowRoot {
const shadowRoot = node as ShadowRoot
return !!shadowRoot.host && shadowRoot.nodeType === Node.DOCUMENT_FRAGMENT_NODE && isElementNode(shadowRoot.host)
}
export function hasChildNodes(node: Node) {
return node.childNodes.length > 0 || isNodeShadowHost(node)
}
export function forEachChildNodes(node: Node, callback: (child: Node) => void) {
let child = node.firstChild
while (child) {
callback(child)
child = child.nextSibling
}
if (isNodeShadowHost(node)) {
callback(node.shadowRoot)
}
}
/**
* Return `host` in case if the current node is a shadow root otherwise will return the `parentNode`
*/
export function getParentNode(node: Node): Node | null {
return isNodeShadowRoot(node) ? node.host : node.parentNode
}
/**
* Return the parent element, crossing shadow DOM boundaries.
* If the element is a direct child of a shadow root, returns the shadow host.
*/
export function getParentElement(element: Element): Element | null {
if (element.parentElement) {
return element.parentElement
}
const parentNode = element.parentNode
if (parentNode && isNodeShadowRoot(parentNode)) {
return parentNode.host
}
return null
}
@@ -0,0 +1,69 @@
import { addEventListener, DOM_EVENT, instrumentMethod, Observable, shallowClone } from '@datadog/browser-core'
import type { RumConfiguration } from '../domain/configuration'
export interface LocationChange {
oldLocation: Readonly<Location>
newLocation: Readonly<Location>
}
export function createLocationChangeObservable(configuration: RumConfiguration) {
let currentLocation = shallowClone(location)
return new Observable<LocationChange>((observable) => {
const { stop: stopHistoryTracking } = trackHistory(configuration, onLocationChange)
const { stop: stopHashTracking } = trackHash(configuration, onLocationChange)
function onLocationChange() {
if (currentLocation.href === location.href) {
return
}
const newLocation = shallowClone(location)
observable.notify({
newLocation,
oldLocation: currentLocation,
})
currentLocation = newLocation
}
return () => {
stopHistoryTracking()
stopHashTracking()
}
})
}
function trackHistory(configuration: RumConfiguration, onHistoryChange: () => void) {
const { stop: stopInstrumentingPushState } = instrumentMethod(
getHistoryInstrumentationTarget('pushState'),
'pushState',
({ onPostCall }) => {
onPostCall(onHistoryChange)
}
)
const { stop: stopInstrumentingReplaceState } = instrumentMethod(
getHistoryInstrumentationTarget('replaceState'),
'replaceState',
({ onPostCall }) => {
onPostCall(onHistoryChange)
}
)
const { stop: removeListener } = addEventListener(configuration, window, DOM_EVENT.POP_STATE, onHistoryChange)
return {
stop: () => {
stopInstrumentingPushState()
stopInstrumentingReplaceState()
removeListener()
},
}
}
function trackHash(configuration: RumConfiguration, onHashChange: () => void) {
return addEventListener(configuration, window, DOM_EVENT.HASH_CHANGE, onHashChange)
}
function getHistoryInstrumentationTarget(methodName: 'pushState' | 'replaceState') {
// Ideally we should always instument the method on the prototype, however some frameworks (e.g [Next.js](https://github.com/vercel/next.js/blob/d3f5532065f3e3bb84fb54bd2dfd1a16d0f03a21/packages/next/src/client/components/app-router.tsx#L429))
// are wrapping the instance method. In that case we should also wrap the instance method.
return Object.prototype.hasOwnProperty.call(history, methodName) ? history : History.prototype
}

Some files were not shown because too many files have changed in this diff Show More