New source found from dndbeyond.com
This commit is contained in:
Generated
Vendored
+8
@@ -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
@@ -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:')())
|
||||
}
|
||||
}
|
||||
+145
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+170
@@ -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)
|
||||
}
|
||||
+61
@@ -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)
|
||||
}
|
||||
+25
@@ -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)
|
||||
})
|
||||
}
|
||||
+143
@@ -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
|
||||
}
|
||||
+30
@@ -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
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+456
@@ -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 application’s 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 application’s 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
|
||||
}
|
||||
Generated
Vendored
+118
@@ -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('&')
|
||||
}
|
||||
Generated
Vendored
+80
@@ -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))
|
||||
}
|
||||
Generated
Vendored
+31
@@ -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,
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+118
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+27
@@ -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]
|
||||
+85
@@ -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
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
Generated
Vendored
+31
@@ -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))
|
||||
})
|
||||
}
|
||||
Generated
Vendored
+55
@@ -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())
|
||||
}
|
||||
Generated
Vendored
+47
@@ -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' },
|
||||
},
|
||||
})
|
||||
}
|
||||
+30
@@ -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')
|
||||
}
|
||||
+64
@@ -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
@@ -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
|
||||
}
|
||||
+64
@@ -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]
|
||||
Generated
Vendored
+50
@@ -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')
|
||||
})
|
||||
}
|
||||
Generated
Vendored
+49
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+28
@@ -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
@@ -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']
|
||||
Generated
Vendored
+133
@@ -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
|
||||
}
|
||||
+21
@@ -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]
|
||||
Generated
Vendored
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+20
@@ -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]
|
||||
+262
@@ -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,
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
}
|
||||
Generated
Vendored
+9
@@ -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))
|
||||
)
|
||||
}
|
||||
+287
@@ -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,
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+131
@@ -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
|
||||
}
|
||||
Generated
Vendored
+146
@@ -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 ?? {}
|
||||
}
|
||||
Generated
Vendored
+44
@@ -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))
|
||||
}
|
||||
Generated
Vendored
+47
@@ -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))
|
||||
}
|
||||
Generated
Vendored
+17
@@ -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
|
||||
}
|
||||
Generated
Vendored
+69
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+22
@@ -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']
|
||||
+337
@@ -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
|
||||
}
|
||||
+34
@@ -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
@@ -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>
|
||||
},
|
||||
}
|
||||
}
|
||||
+46
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
+11
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
+38
@@ -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
@@ -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
|
||||
+170
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
@@ -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))
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
+14
@@ -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 })
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+53
@@ -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
|
||||
}
|
||||
+273
@@ -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
|
||||
)
|
||||
}
|
||||
Generated
Vendored
+252
@@ -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
|
||||
}
|
||||
Generated
Vendored
+48
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+45
@@ -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() {}
|
||||
+20
@@ -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'
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
+89
@@ -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}`
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export function getTimeZone() {
|
||||
try {
|
||||
const intl = new Intl.DateTimeFormat()
|
||||
|
||||
return intl.resolvedOptions().timeZone
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
+36
@@ -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'
|
||||
}
|
||||
+56
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
+150
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+146
@@ -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 })))
|
||||
}
|
||||
Generated
Vendored
+222
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
+977
@@ -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 you’ve 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
@@ -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()),
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+98
@@ -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
|
||||
}
|
||||
Generated
Vendored
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
Generated
Vendored
+69
@@ -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
Reference in New Issue
Block a user