diff --git a/eslint.config.mjs b/eslint.config.mjs index a8bdfa4a..73ae2a16 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -82,80 +82,79 @@ export default [ ], '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/interface-name-prefix': 'off', - // '@typescript-eslint/member-ordering': [ - // 'error', - // { - // default: { - // memberTypes: [ - // // Index signature - // 'signature', - // - // // Fields - // 'public-static-field', - // 'protected-static-field', - // 'private-static-field', - // - // 'public-decorated-field', - // 'protected-decorated-field', - // 'private-decorated-field', - // - // 'public-instance-field', - // 'protected-instance-field', - // 'private-instance-field', - // - // 'public-abstract-field', - // 'protected-abstract-field', - // - // 'public-field', - // 'protected-field', - // 'private-field', - // - // 'static-field', - // 'instance-field', - // 'abstract-field', - // - // 'decorated-field', - // - // 'field', - // - // // Constructors - // 'public-constructor', - // 'protected-constructor', - // 'private-constructor', - // - // 'constructor', - // - // // Methods - // 'public-decorated-method', - // 'public-static-method', - // 'public-instance-method', - // 'protected-decorated-method', - // 'protected-static-method', - // 'protected-instance-method', - // 'private-decorated-method', - // 'private-static-method', - // 'private-instance-method', - // - // 'public-abstract-method', - // 'protected-abstract-method', - // - // 'public-method', - // 'protected-method', - // 'private-method', - // - // 'static-method', - // 'instance-method', - // 'abstract-method', - // - // 'decorated-method', - // - // 'method', - // ], - // order: 'alphabetically', - // }, - // }, - // ], - '@typescript-eslint/member-ordering': 'off', // TODO: temporarily disabled + '@typescript-eslint/member-ordering': [ + 'error', + { + default: { + memberTypes: [ + // Index signature + 'signature', + + // Fields + 'public-static-field', + 'protected-static-field', + 'private-static-field', + + 'public-decorated-field', + 'protected-decorated-field', + 'private-decorated-field', + + 'public-instance-field', + 'protected-instance-field', + 'private-instance-field', + + 'public-abstract-field', + 'protected-abstract-field', + + 'public-field', + 'protected-field', + 'private-field', + + 'static-field', + 'instance-field', + 'abstract-field', + + 'decorated-field', + + 'field', + + // Constructors + 'public-constructor', + 'protected-constructor', + 'private-constructor', + + 'constructor', + + // Methods + 'public-decorated-method', + 'public-static-method', + 'public-instance-method', + 'protected-decorated-method', + 'protected-static-method', + 'protected-instance-method', + 'private-decorated-method', + 'private-static-method', + 'private-instance-method', + + 'public-abstract-method', + 'protected-abstract-method', + + 'public-method', + 'protected-method', + 'private-method', + + 'static-method', + 'instance-method', + 'abstract-method', + + 'decorated-method', + + 'method', + ], + order: 'alphabetically', + }, + }, + ], '@typescript-eslint/naming-convention': ['error', { format: ['UPPER_CASE'], selector: ['enumMember'] }], '@typescript-eslint/no-base-to-string': 'off', '@typescript-eslint/no-empty-interface': 'off', diff --git a/packages/session-recorder/src/BatchLogProcessor.ts b/packages/session-recorder/src/BatchLogProcessor.ts index fc46c325..99972d2e 100644 --- a/packages/session-recorder/src/BatchLogProcessor.ts +++ b/packages/session-recorder/src/BatchLogProcessor.ts @@ -26,15 +26,15 @@ export interface BatchLogProcessorConfig { } export class BatchLogProcessor { - private logs: Log[] = [] + exporter: LogExporter + + lastBatchSent: number scheduledDelayMillis: number timeout: NodeJS.Timeout | undefined - exporter: LogExporter - - lastBatchSent: number + private logs: Log[] = [] constructor(exporter: LogExporter, config: BatchLogProcessorConfig) { this.scheduledDelayMillis = config?.scheduledDelayMillis || 5000 @@ -45,6 +45,15 @@ export class BatchLogProcessor { }) } + _flushAll(): void { + this.lastBatchSent = Date.now() + + context.with(suppressTracing(context.active()), () => { + const logsToExport = this.logs.splice(0, this.logs.length) + this.exporter.export(logsToExport) + }) + } + onLog(log: Log): void { this.logs.push(log) @@ -55,15 +64,6 @@ export class BatchLogProcessor { }, this.scheduledDelayMillis) } } - - _flushAll(): void { - this.lastBatchSent = Date.now() - - context.with(suppressTracing(context.active()), () => { - const logsToExport = this.logs.splice(0, this.logs.length) - this.exporter.export(logsToExport) - }) - } } export function convert(body: JsonValue, timeUnixNano: number, attributes?: JsonObject): Log { diff --git a/packages/session-recorder/src/OTLPLogExporter.ts b/packages/session-recorder/src/OTLPLogExporter.ts index 934c2765..29c7e461 100644 --- a/packages/session-recorder/src/OTLPLogExporter.ts +++ b/packages/session-recorder/src/OTLPLogExporter.ts @@ -23,10 +23,10 @@ import { IAnyValue, Log } from './types' import { VERSION } from './version.js' interface OTLPLogExporterConfig { - headers?: Record beaconUrl: string - getResourceAttributes: () => JsonObject debug?: boolean + getResourceAttributes: () => JsonObject + headers?: Record } const defaultHeaders = { diff --git a/packages/session-recorder/src/index.ts b/packages/session-recorder/src/index.ts index c5bbeaff..8e1de022 100644 --- a/packages/session-recorder/src/index.ts +++ b/packages/session-recorder/src/index.ts @@ -33,6 +33,11 @@ interface BasicTracerProvider extends TracerProvider { type RRWebOptions = Parameters[0] export type SplunkRumRecorderConfig = RRWebOptions & { + /** + * @deprecated Use RUM token in rumAccessToken + */ + apiToken?: string + /** Destination for the captured data */ beaconEndpoint?: string @@ -41,6 +46,9 @@ export type SplunkRumRecorderConfig = RRWebOptions & { */ beaconUrl?: string + /** Debug mode */ + debug?: boolean + /** * The name of your organization’s realm. Automatically configures beaconUrl with correct URL */ @@ -58,14 +66,6 @@ export type SplunkRumRecorderConfig = RRWebOptions & { * @deprecated Renamed to `rumAccessToken` **/ rumAuth?: string - - /** - * @deprecated Use RUM token in rumAccessToken - */ - apiToken?: string - - /** Debug mode */ - debug?: boolean } function migrateConfigOption( diff --git a/packages/session-recorder/src/types.ts b/packages/session-recorder/src/types.ts index 8eb53163..375a2ba6 100644 --- a/packages/session-recorder/src/types.ts +++ b/packages/session-recorder/src/types.ts @@ -19,9 +19,9 @@ import type { JsonObject, JsonValue } from 'type-fest' export interface Log { + attributes?: JsonObject body?: JsonValue timeUnixNano: number - attributes?: JsonObject } export interface LogExporter { @@ -36,28 +36,28 @@ export interface LogExporter { // OTLP Logs Interfaces export interface IAnyValue { - /** AnyValue stringValue */ - stringValue?: string | null + /** AnyValue arrayValue */ + + arrayValue?: IArrayValue | null /** AnyValue boolValue */ boolValue?: boolean | null - /** AnyValue intValue */ - intValue?: number | null + /** AnyValue bytesValue */ + bytesValue?: Uint8Array | null /** AnyValue doubleValue */ doubleValue?: number | null - /** AnyValue arrayValue */ - - arrayValue?: IArrayValue | null + /** AnyValue intValue */ + intValue?: number | null /** AnyValue kvlistValue */ kvlistValue?: IKeyValueList | null - /** AnyValue bytesValue */ - bytesValue?: Uint8Array | null + /** AnyValue stringValue */ + stringValue?: string | null } export interface IArrayValue { diff --git a/packages/web/performance-tests/injectingProxy/HtmlInjectorTransform.js b/packages/web/performance-tests/injectingProxy/HtmlInjectorTransform.js index 96fd4b61..729b76d4 100644 --- a/packages/web/performance-tests/injectingProxy/HtmlInjectorTransform.js +++ b/packages/web/performance-tests/injectingProxy/HtmlInjectorTransform.js @@ -18,13 +18,13 @@ const { Transform } = require('stream') exports.HtmlInjectorTransform = class HtmlInjectorTransform extends Transform { - __scriptInjected = false - - __wordIdx = 0 + __content = [] __contentIdx = 0 - __content = [] + __scriptInjected = false + + __wordIdx = 0 constructor({ matchingSuffix, contentToInject }) { super() @@ -32,6 +32,26 @@ exports.HtmlInjectorTransform = class HtmlInjectorTransform extends Transform { this.__injectable = contentToInject } + __runPartialOnlineMatch() { + while (true) { + if (this.__contentIdx === this.__content.length) { + return false + } else if (this.__wordIdx === this.__word.length) { + return true + } else if (this.__contentIdx + this.__wordIdx === this.__content.length) { + return false + } else if (this.__content[this.__contentIdx + this.__wordIdx] === this.__word[this.__wordIdx]) { + this.__wordIdx += 1 + } else if (this.__wordIdx === 0) { + this.__contentIdx += 1 + } else { + // TODO: speed up using KMP failure function + this.__wordIdx = 0 + this.__contentIdx += 1 + } + } + } + _transform(chunk, enc, callback) { if (this.__scriptInjected) { this.push(chunk) @@ -63,24 +83,4 @@ exports.HtmlInjectorTransform = class HtmlInjectorTransform extends Transform { callback() } } - - __runPartialOnlineMatch() { - while (true) { - if (this.__contentIdx === this.__content.length) { - return false - } else if (this.__wordIdx === this.__word.length) { - return true - } else if (this.__contentIdx + this.__wordIdx === this.__content.length) { - return false - } else if (this.__content[this.__contentIdx + this.__wordIdx] === this.__word[this.__wordIdx]) { - this.__wordIdx += 1 - } else if (this.__wordIdx === 0) { - this.__contentIdx += 1 - } else { - // TODO: speed up using KMP failure function - this.__wordIdx = 0 - this.__contentIdx += 1 - } - } - } } diff --git a/packages/web/src/EventTarget.ts b/packages/web/src/EventTarget.ts index 9f718cc1..46cddbca 100644 --- a/packages/web/src/EventTarget.ts +++ b/packages/web/src/EventTarget.ts @@ -19,12 +19,12 @@ import { Attributes } from '@opentelemetry/api' export interface SplunkOtelWebEventTypes { - 'session-changed': { - sessionId: string - } 'global-attributes-changed': { attributes: Attributes } + 'session-changed': { + sessionId: string + } } type SplunkEventListener = (event: { @@ -42,18 +42,6 @@ export class InternalEventTarget { ;(this.events[type] as SplunkEventListener[]).push(listener) } - removeEventListener(type: T, listener: SplunkEventListener): void { - if (!this.events[type]) { - return - } - - const i = (this.events[type] as SplunkEventListener[]).indexOf(listener) - - if (i >= 0) { - this.events[type].splice(i, 1) - } - } - emit(type: T, payload: SplunkOtelWebEventTypes[T]): void { const listeners = this.events[type] if (!listeners) { @@ -65,17 +53,32 @@ export class InternalEventTarget { void Promise.resolve({ payload }).then(listener) }) } + + removeEventListener(type: T, listener: SplunkEventListener): void { + if (!this.events[type]) { + return + } + + const i = (this.events[type] as SplunkEventListener[]).indexOf(listener) + + if (i >= 0) { + this.events[type].splice(i, 1) + } + } } export interface SplunkOtelWebEventTarget { - addEventListener: InternalEventTarget['addEventListener'] /** * @deprecated Use {@link addEventListener} */ _experimental_addEventListener: InternalEventTarget['addEventListener'] - removeEventListener: InternalEventTarget['removeEventListener'] + /** * @deprecated Use {@link removeEventListener} */ _experimental_removeEventListener: InternalEventTarget['removeEventListener'] + + addEventListener: InternalEventTarget['addEventListener'] + + removeEventListener: InternalEventTarget['removeEventListener'] } diff --git a/packages/web/src/SessionBasedSampler.ts b/packages/web/src/SessionBasedSampler.ts index 0a1213e0..0d40be98 100644 --- a/packages/web/src/SessionBasedSampler.ts +++ b/packages/web/src/SessionBasedSampler.ts @@ -21,6 +21,12 @@ import { AlwaysOffSampler, AlwaysOnSampler } from '@opentelemetry/core' import { getRumSessionId } from './session' export interface SessionBasedSamplerConfig { + /** + * Sampler called when session isn't being sampled + * default: AlwaysOffSampler + */ + notSampled?: Sampler + /** * Ratio of sessions that get sampled (0.0 - 1.0, where 1 is all sessions) */ @@ -31,26 +37,20 @@ export interface SessionBasedSamplerConfig { * default: AlwaysOnSampler */ sampled?: Sampler - - /** - * Sampler called when session isn't being sampled - * default: AlwaysOffSampler - */ - notSampled?: Sampler } export class SessionBasedSampler implements Sampler { - protected _ratio: number - - protected _upperBound: number + protected _currentSession: string - protected _sampled: Sampler + protected _currentSessionSampled: boolean protected _notSampled: Sampler - protected _currentSession: string + protected _ratio: number - protected _currentSessionSampled: boolean + protected _sampled: Sampler + + protected _upperBound: number constructor({ ratio = 1, @@ -90,14 +90,6 @@ export class SessionBasedSampler implements Sampler { return `SessionBased{ratio=${this._ratio}, sampled=${this._sampled.toString()}, notSampled=${this._notSampled.toString()}}` } - private _normalize(ratio: number): number { - if (typeof ratio !== 'number' || isNaN(ratio)) { - return 0 - } - - return ratio >= 1 ? 1 : ratio <= 0 ? 0 : ratio - } - private _accumulate(sessionId: string): number { let accumulation = 0 for (let i = 0; i < sessionId.length / 8; i++) { @@ -107,4 +99,12 @@ export class SessionBasedSampler implements Sampler { } return accumulation } + + private _normalize(ratio: number): number { + if (typeof ratio !== 'number' || isNaN(ratio)) { + return 0 + } + + return ratio >= 1 ? 1 : ratio <= 0 ? 0 : ratio + } } diff --git a/packages/web/src/SplunkConnectivityInstrumentation.ts b/packages/web/src/SplunkConnectivityInstrumentation.ts index dab238d7..6357aab3 100644 --- a/packages/web/src/SplunkConnectivityInstrumentation.ts +++ b/packages/web/src/SplunkConnectivityInstrumentation.ts @@ -24,17 +24,20 @@ const MODULE_NAME = 'splunk-connectivity' export class SplunkConnectivityInstrumentation extends InstrumentationBase { offlineListener: any - onlineListener: any - offlineStart: number | null + onlineListener: any + constructor(config: InstrumentationConfig = {}) { super(MODULE_NAME, VERSION, Object.assign({}, config)) // For apps with offline support this.offlineStart = navigator.onLine ? null : Date.now() } - init(): void {} + disable(): void { + window.removeEventListener('offline', this.offlineListener) + window.removeEventListener('online', this.onlineListener) + } enable(): void { this.offlineListener = () => { @@ -53,10 +56,7 @@ export class SplunkConnectivityInstrumentation extends InstrumentationBase { window.addEventListener('online', this.onlineListener) } - disable(): void { - window.removeEventListener('offline', this.offlineListener) - window.removeEventListener('online', this.onlineListener) - } + init(): void {} private _createSpan(online: boolean, startTime: number) { const span = this.tracer.startSpan('connectivity', { startTime }) diff --git a/packages/web/src/SplunkContextManager.ts b/packages/web/src/SplunkContextManager.ts index c5f11f3e..46f18d8d 100644 --- a/packages/web/src/SplunkContextManager.ts +++ b/packages/web/src/SplunkContextManager.ts @@ -23,8 +23,8 @@ import { getOriginalFunction, isFunction, wrapNatively } from './utils' export interface ContextManagerConfig { /** Enable async tracking of span parents */ async?: boolean - onBeforeContextStart?: () => void onBeforeContextEnd?: () => void + onBeforeContextStart?: () => void } type EventListenerWithOrig = EventListener & { _orig?: EventListener } @@ -37,38 +37,36 @@ const ATTACHED_CONTEXT_KEY = '__splunk_context' */ export class SplunkContextManager implements ContextManager { /** - * whether the context manager is enabled or not + * Keeps the reference to current context */ - protected _enabled = false + public _currentContext = ROOT_CONTEXT /** - * Keeps the reference to current context + * Event listeners wrapped to resume context from event registration + * + * _contextResumingListeners.get(Target).get(EventType).get(origListener) */ - public _currentContext = ROOT_CONTEXT + protected _contextResumingListeners = new WeakMap< + EventTarget, + Map< + string, + WeakMap< + EventListener, // User defined + EventListener // Wrapped + > + > + >() + + /** + * whether the context manager is enabled or not + */ + protected _enabled = false protected _hashChangeContext: Context = null - constructor(protected _config: ContextManagerConfig = {}) {} + protected _messagePorts = new WeakMap() - /** - * - * @param target Function to be executed within the context - * @param context - */ - protected _bindFunction unknown>(target: T, context = ROOT_CONTEXT): T { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const manager = this - const contextWrapper = function (this: unknown, ...args: unknown[]) { - return manager.with(context, () => target.apply(this, args)) - } - Object.defineProperty(contextWrapper, 'length', { - enumerable: false, - configurable: true, - writable: false, - value: target.length, - }) - return contextWrapper as unknown as T - } + constructor(protected _config: ContextManagerConfig = {}) {} /** * Returns the active context @@ -135,229 +133,67 @@ export class SplunkContextManager implements ContextManager { } /** - * Bind current zone to function given in arguments - * - * @param args Arguments array - * @param index Argument index to patch + * Calls the callback function [fn] with the provided [context]. If [context] is undefined then it will use the window. + * The context will be set as active + * @param context + * @param fn Callback function + * @param thisArg optional receiver to be used for calling fn + * @param args optional arguments forwarded to fn */ - protected bindActiveToArgument(args: unknown[], index: number): void { - if (isFunction(args[index])) { - // Bind callback to current context - args[index] = this.bind(this.active(), args[index]) + with ReturnType>( + context: Context | null, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + try { + this._config.onBeforeContextStart?.() + } catch { + // ignore any exceptions thrown by context hooks } - } - - protected _patchTimeouts(): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const manager = this + const previousContext = this._currentContext + this._currentContext = context || ROOT_CONTEXT - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore for some reason CI decides error is here while locally happens on next line - wrapNatively( - window, - 'setTimeout', - (original) => - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore expects __promisify__ for some reason - function (...args: Parameters) { - // Don't copy parent context if the timeout is long enough that it isn't really - // expected to happen within interaction (eg polling every second). - // The value for that is a pretty arbitary decision so here's 1 frame at 30fps (1000/30) - if (!args[1] || args[1] <= 34) { - manager.bindActiveToArgument(args, 0) + // Observe for location.hash changes (as it isn't a (re)configurable property)) + const preLocationHash = location.hash + try { + const result = fn.call(thisArg, ...args) + this._config.onBeforeContextEnd?.() + return result + } finally { + this._currentContext = previousContext + if (preLocationHash !== location.hash) { + this._hashChangeContext = context + // Cleanup as macrotask (as hash change can also be done by user) + getOriginalFunction(setTimeout)(() => { + if (this._hashChangeContext === context) { + this._hashChangeContext = null } - - return original.apply(this, args) - }, - ) - - if (window.setImmediate) { - wrapNatively( - window, - 'setImmediate', - (original) => - // @ts-expect-error expects __promisify__ - function (...args: Parameters) { - manager.bindActiveToArgument(args, 0) - - return original.apply(this, args) - }, - ) - } - - if (window.requestAnimationFrame) { - wrapNatively( - window, - 'requestAnimationFrame', - (original) => - function (...args: Parameters) { - manager.bindActiveToArgument(args, 0) - - return original.apply(this, args) - }, - ) - } - } - - protected _unpatchTimeouts(): void { - unwrap(window, 'setTimeout') - if (window.setImmediate) { - unwrap(window, 'setImmediate') + }, 33) + } } } - protected _patchPromise(): void { - if (!window.Promise) { - return - } - + /** + * + * @param target Function to be executed within the context + * @param context + */ + protected _bindFunction unknown>(target: T, context = ROOT_CONTEXT): T { // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this - - // On typings: Don't want to hardcode the amount of parameters for future-safe, - // but using Parameters<...> ignores generics, causing type error, so copy-paste of lib.es5 & lib.es2018 - wrapNatively( - Promise.prototype, - 'then', - (original) => - function ( - this: Promise, - ...args: [ - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | undefined | null, - ] - ) { - manager.bindActiveToArgument(args, 0) - manager.bindActiveToArgument(args, 1) - - return original.apply(this, args) - }, - ) - - wrapNatively( - Promise.prototype, - 'catch', - (original) => - function ( - this: Promise, - ...args: [onrejected?: ((reason: unknown) => TResult | PromiseLike) | undefined | null] - ) { - manager.bindActiveToArgument(args, 0) - - return original.apply(this, args) - }, - ) - - wrapNatively( - Promise.prototype, - 'finally', - (original) => - function (this: Promise, ...args: [onfinally?: (() => void) | undefined | null]) { - manager.bindActiveToArgument(args, 0) - - return original.apply(this, args) - }, - ) - } - - protected _unpatchPromise(): void { - if (!window.Promise) { - return + const contextWrapper = function (this: unknown, ...args: unknown[]) { + return manager.with(context, () => target.apply(this, args)) } - - unwrap(Promise.prototype, 'then') - unwrap(Promise.prototype, 'catch') - unwrap(Promise.prototype, 'finally') - } - - protected _patchMutationObserver(): void { - // 1. Patch mutation observer in general to check if a context is offered to it - // 2. on observe call check for known use cases and patch those to forward the context - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const manager = this - - wrapNatively( - window, - 'MutationObserver', - (original) => - class WrappedMutationObserver extends original { - constructor(...args: [callback: MutationCallback]) { - if (isFunction(args[0])) { - const orig = args[0] - args[0] = function (...innerArgs) { - if (this[ATTACHED_CONTEXT_KEY] && manager._enabled) { - return manager.with(this[ATTACHED_CONTEXT_KEY], orig, this, ...innerArgs) - } else { - return orig.apply(this, innerArgs) - } - } - } - - super(...args) - - Object.defineProperty(this, ATTACHED_CONTEXT_KEY, { - value: null, - writable: true, - enumerable: false, - }) - } - - observe(...args: Parameters) { - // Observing a text node (document.createTextNode) - if ( - args[0] && - args[0] instanceof Text && - !args[0].parentNode && - args[1] && - args[1].characterData - ) { - // Overwrite setting textNode.data to copy active context to mutation observer - // eslint-disable-next-line @typescript-eslint/no-this-alias - const observer = this - const target = args[0] - const descriptor = Object.getOwnPropertyDescriptor(CharacterData.prototype, 'data') - - Object.defineProperty(target, 'data', { - ...descriptor, - enumerable: false, - set: function (...args) { - const context = manager.active() - if (context) { - observer[ATTACHED_CONTEXT_KEY] = context - } - - return descriptor.set.apply(this, args) - }, - }) - } - - return super.observe(...args) - } - }, - ) - } - - protected _unpatchMutationObserver(): void { - unwrap(window, 'MutationObserver') + Object.defineProperty(contextWrapper, 'length', { + enumerable: false, + configurable: true, + writable: false, + value: target.length, + }) + return contextWrapper as unknown as T } - /** - * Event listeners wrapped to resume context from event registration - * - * _contextResumingListeners.get(Target).get(EventType).get(origListener) - */ - protected _contextResumingListeners = new WeakMap< - EventTarget, - Map< - string, - WeakMap< - EventListener, // User defined - EventListener // Wrapped - > - > - >() - protected _getListenersMap(target: EventTarget, type: string): WeakMap { if (!this._contextResumingListeners.has(target)) { this._contextResumingListeners.set(target, new Map()) @@ -371,6 +207,21 @@ export class SplunkContextManager implements ContextManager { return listenersByType.get(type) } + protected _getWrappedEventListener(orig: E, contextGetter: () => Context | void): E { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const manager = this + + return function (...innerArgs) { + const context = contextGetter() + if (context && manager._enabled) { + // @ts-expect-error on orig: Type 'void' is not assignable to type 'ReturnType'. + return manager.with(context, orig, this, ...innerArgs) + } else { + return orig.apply(this, innerArgs) + } + } as E + } + protected _patchEvents(): void { // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this @@ -505,18 +356,11 @@ export class SplunkContextManager implements ContextManager { value = wrapped } - return original.call(this, value) - }, - ) - Object.defineProperty(window, 'onhashchange', desc) - } - - protected _unpatchEvents(): void { - unwrap(XMLHttpRequest.prototype, 'addEventListener') - unwrap(XMLHttpRequest.prototype, 'removeEventListener') - } - - protected _messagePorts = new WeakMap() + return original.call(this, value) + }, + ) + Object.defineProperty(window, 'onhashchange', desc) + } protected _patchMessageChannel(): void { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -650,19 +494,183 @@ export class SplunkContextManager implements ContextManager { Object.defineProperty(MessagePort.prototype, 'onmessage', desc) } - protected _getWrappedEventListener(orig: E, contextGetter: () => Context | void): E { + protected _patchMutationObserver(): void { + // 1. Patch mutation observer in general to check if a context is offered to it + // 2. on observe call check for known use cases and patch those to forward the context + // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this - return function (...innerArgs) { - const context = contextGetter() - if (context && manager._enabled) { - // @ts-expect-error on orig: Type 'void' is not assignable to type 'ReturnType'. - return manager.with(context, orig, this, ...innerArgs) - } else { - return orig.apply(this, innerArgs) - } - } as E + wrapNatively( + window, + 'MutationObserver', + (original) => + class WrappedMutationObserver extends original { + constructor(...args: [callback: MutationCallback]) { + if (isFunction(args[0])) { + const orig = args[0] + args[0] = function (...innerArgs) { + if (this[ATTACHED_CONTEXT_KEY] && manager._enabled) { + return manager.with(this[ATTACHED_CONTEXT_KEY], orig, this, ...innerArgs) + } else { + return orig.apply(this, innerArgs) + } + } + } + + super(...args) + + Object.defineProperty(this, ATTACHED_CONTEXT_KEY, { + value: null, + writable: true, + enumerable: false, + }) + } + + observe(...args: Parameters) { + // Observing a text node (document.createTextNode) + if ( + args[0] && + args[0] instanceof Text && + !args[0].parentNode && + args[1] && + args[1].characterData + ) { + // Overwrite setting textNode.data to copy active context to mutation observer + // eslint-disable-next-line @typescript-eslint/no-this-alias + const observer = this + const target = args[0] + const descriptor = Object.getOwnPropertyDescriptor(CharacterData.prototype, 'data') + + Object.defineProperty(target, 'data', { + ...descriptor, + enumerable: false, + set: function (...args) { + const context = manager.active() + if (context) { + observer[ATTACHED_CONTEXT_KEY] = context + } + + return descriptor.set.apply(this, args) + }, + }) + } + + return super.observe(...args) + } + }, + ) + } + + protected _patchPromise(): void { + if (!window.Promise) { + return + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const manager = this + + // On typings: Don't want to hardcode the amount of parameters for future-safe, + // but using Parameters<...> ignores generics, causing type error, so copy-paste of lib.es5 & lib.es2018 + wrapNatively( + Promise.prototype, + 'then', + (original) => + function ( + this: Promise, + ...args: [ + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | undefined | null, + ] + ) { + manager.bindActiveToArgument(args, 0) + manager.bindActiveToArgument(args, 1) + + return original.apply(this, args) + }, + ) + + wrapNatively( + Promise.prototype, + 'catch', + (original) => + function ( + this: Promise, + ...args: [onrejected?: ((reason: unknown) => TResult | PromiseLike) | undefined | null] + ) { + manager.bindActiveToArgument(args, 0) + + return original.apply(this, args) + }, + ) + + wrapNatively( + Promise.prototype, + 'finally', + (original) => + function (this: Promise, ...args: [onfinally?: (() => void) | undefined | null]) { + manager.bindActiveToArgument(args, 0) + + return original.apply(this, args) + }, + ) + } + + protected _patchTimeouts(): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const manager = this + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore for some reason CI decides error is here while locally happens on next line + wrapNatively( + window, + 'setTimeout', + (original) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expects __promisify__ for some reason + function (...args: Parameters) { + // Don't copy parent context if the timeout is long enough that it isn't really + // expected to happen within interaction (eg polling every second). + // The value for that is a pretty arbitary decision so here's 1 frame at 30fps (1000/30) + if (!args[1] || args[1] <= 34) { + manager.bindActiveToArgument(args, 0) + } + + return original.apply(this, args) + }, + ) + + if (window.setImmediate) { + wrapNatively( + window, + 'setImmediate', + (original) => + // @ts-expect-error expects __promisify__ + function (...args: Parameters) { + manager.bindActiveToArgument(args, 0) + + return original.apply(this, args) + }, + ) + } + + if (window.requestAnimationFrame) { + wrapNatively( + window, + 'requestAnimationFrame', + (original) => + function (...args: Parameters) { + manager.bindActiveToArgument(args, 0) + + return original.apply(this, args) + }, + ) + } + } + + protected _unpatchEvents(): void { + unwrap(XMLHttpRequest.prototype, 'addEventListener') + unwrap(XMLHttpRequest.prototype, 'removeEventListener') } protected _unpatchMessageChannel(): void { @@ -676,45 +684,37 @@ export class SplunkContextManager implements ContextManager { Object.defineProperty(MessagePort.prototype, 'onmessage', desc) } - /** - * Calls the callback function [fn] with the provided [context]. If [context] is undefined then it will use the window. - * The context will be set as active - * @param context - * @param fn Callback function - * @param thisArg optional receiver to be used for calling fn - * @param args optional arguments forwarded to fn - */ - with ReturnType>( - context: Context | null, - fn: F, - thisArg?: ThisParameterType, - ...args: A - ): ReturnType { - try { - this._config.onBeforeContextStart?.() - } catch { - // ignore any exceptions thrown by context hooks + protected _unpatchMutationObserver(): void { + unwrap(window, 'MutationObserver') + } + + protected _unpatchPromise(): void { + if (!window.Promise) { + return } - const previousContext = this._currentContext - this._currentContext = context || ROOT_CONTEXT - // Observe for location.hash changes (as it isn't a (re)configurable property)) - const preLocationHash = location.hash - try { - const result = fn.call(thisArg, ...args) - this._config.onBeforeContextEnd?.() - return result - } finally { - this._currentContext = previousContext - if (preLocationHash !== location.hash) { - this._hashChangeContext = context - // Cleanup as macrotask (as hash change can also be done by user) - getOriginalFunction(setTimeout)(() => { - if (this._hashChangeContext === context) { - this._hashChangeContext = null - } - }, 33) - } + unwrap(Promise.prototype, 'then') + unwrap(Promise.prototype, 'catch') + unwrap(Promise.prototype, 'finally') + } + + protected _unpatchTimeouts(): void { + unwrap(window, 'setTimeout') + if (window.setImmediate) { + unwrap(window, 'setImmediate') + } + } + + /** + * Bind current zone to function given in arguments + * + * @param args Arguments array + * @param index Argument index to patch + */ + protected bindActiveToArgument(args: unknown[], index: number): void { + if (isFunction(args[index])) { + // Bind callback to current context + args[index] = this.bind(this.active(), args[index]) } } } diff --git a/packages/web/src/SplunkDocumentLoadInstrumentation.ts b/packages/web/src/SplunkDocumentLoadInstrumentation.ts index 9beb277f..865c68a1 100644 --- a/packages/web/src/SplunkDocumentLoadInstrumentation.ts +++ b/packages/web/src/SplunkDocumentLoadInstrumentation.ts @@ -42,7 +42,7 @@ function addExtraDocLoadTags(span: api.Span) { } type PerformanceEntriesWithServerTiming = PerformanceEntries & { - serverTiming?: ReadonlyArray<{ name: string; duration: number; description: string }> + serverTiming?: ReadonlyArray<{ description: string; duration: number; name: string }> } type ExposedSuper = { diff --git a/packages/web/src/SplunkErrorInstrumentation.ts b/packages/web/src/SplunkErrorInstrumentation.ts index 63a9f901..4d6755f4 100644 --- a/packages/web/src/SplunkErrorInstrumentation.ts +++ b/packages/web/src/SplunkErrorInstrumentation.ts @@ -73,30 +73,16 @@ export const ERROR_INSTRUMENTATION_NAME = 'errors' export const ERROR_INSTRUMENTATION_VERSION = '1' export class SplunkErrorInstrumentation extends InstrumentationBase { - private readonly _consoleErrorHandler = - (original: Console['error']) => - (...args: any[]) => { - this.report('console.error', args) - return original.apply(this, args) - } - - private readonly _unhandledRejectionListener = (event: PromiseRejectionEvent) => { - this.report('unhandledrejection', event.reason) - } - - private readonly _errorListener = (event: ErrorEvent) => { - this.report('onerror', event) - } - - private readonly _documentErrorListener = (event: ErrorEvent) => { - this.report('eventListener.error', event) - } - constructor(config: InstrumentationConfig) { super(ERROR_INSTRUMENTATION_NAME, ERROR_INSTRUMENTATION_VERSION, config) } - init(): void {} + disable(): void { + shimmer.unwrap(console, 'error') + window.removeEventListener('unhandledrejection', this._unhandledRejectionListener) + window.removeEventListener('error', this._errorListener) + document.documentElement.removeEventListener('error', this._documentErrorListener, { capture: true }) + } enable(): void { shimmer.wrap(console, 'error', this._consoleErrorHandler) @@ -105,11 +91,32 @@ export class SplunkErrorInstrumentation extends InstrumentationBase { document.documentElement.addEventListener('error', this._documentErrorListener, { capture: true }) } - disable(): void { - shimmer.unwrap(console, 'error') - window.removeEventListener('unhandledrejection', this._unhandledRejectionListener) - window.removeEventListener('error', this._errorListener) - document.documentElement.removeEventListener('error', this._documentErrorListener, { capture: true }) + init(): void {} + + public report(source: string, arg: string | Event | ErrorEvent | Array): void { + if (Array.isArray(arg) && arg.length === 0) { + return + } + + if (arg instanceof Array && arg.length === 1) { + arg = arg[0] + } + + if (arg instanceof Error) { + this.reportError(source, arg) + } else if (arg instanceof ErrorEvent) { + this.reportErrorEvent(source, arg) + } else if (arg instanceof Event) { + this.reportEvent(source, arg) + } else if (typeof arg === 'string') { + this.reportString(source, arg) + } else if (arg instanceof Array) { + // if any arguments are Errors then add the stack trace even though the message is handled differently + const firstError = arg.find((x) => x instanceof Error) + this.reportString(source, arg.map((x) => stringifyValue(x)).join(' '), firstError) + } else { + this.reportString(source, stringifyValue(arg)) // FIXME or JSON.stringify? + } } protected reportError(source: string, err: Error): void { @@ -131,24 +138,6 @@ export class SplunkErrorInstrumentation extends InstrumentationBase { span.end(now) } - protected reportString(source: string, message: string, firstError?: Error): void { - if (!useful(message)) { - return - } - - const now = Date.now() - const span = this.tracer.startSpan(source, { startTime: now }) - span.setAttribute('component', 'error') - span.setAttribute('error', true) - span.setAttribute('error.object', 'String') - span.setAttribute('error.message', limitLen(message, MESSAGE_LIMIT)) - if (firstError) { - addStackIfUseful(span, firstError) - } - - span.end(now) - } - protected reportErrorEvent(source: string, ev: ErrorEvent): void { if (ev.error) { this.report(source, ev.error) @@ -177,29 +166,40 @@ export class SplunkErrorInstrumentation extends InstrumentationBase { span.end(now) } - public report(source: string, arg: string | Event | ErrorEvent | Array): void { - if (Array.isArray(arg) && arg.length === 0) { + protected reportString(source: string, message: string, firstError?: Error): void { + if (!useful(message)) { return } - if (arg instanceof Array && arg.length === 1) { - arg = arg[0] + const now = Date.now() + const span = this.tracer.startSpan(source, { startTime: now }) + span.setAttribute('component', 'error') + span.setAttribute('error', true) + span.setAttribute('error.object', 'String') + span.setAttribute('error.message', limitLen(message, MESSAGE_LIMIT)) + if (firstError) { + addStackIfUseful(span, firstError) } - if (arg instanceof Error) { - this.reportError(source, arg) - } else if (arg instanceof ErrorEvent) { - this.reportErrorEvent(source, arg) - } else if (arg instanceof Event) { - this.reportEvent(source, arg) - } else if (typeof arg === 'string') { - this.reportString(source, arg) - } else if (arg instanceof Array) { - // if any arguments are Errors then add the stack trace even though the message is handled differently - const firstError = arg.find((x) => x instanceof Error) - this.reportString(source, arg.map((x) => stringifyValue(x)).join(' '), firstError) - } else { - this.reportString(source, stringifyValue(arg)) // FIXME or JSON.stringify? + span.end(now) + } + + private readonly _consoleErrorHandler = + (original: Console['error']) => + (...args: any[]) => { + this.report('console.error', args) + return original.apply(this, args) } + + private readonly _documentErrorListener = (event: ErrorEvent) => { + this.report('eventListener.error', event) + } + + private readonly _errorListener = (event: ErrorEvent) => { + this.report('onerror', event) + } + + private readonly _unhandledRejectionListener = (event: PromiseRejectionEvent) => { + this.report('unhandledrejection', event.reason) } } diff --git a/packages/web/src/SplunkLongTaskInstrumentation.ts b/packages/web/src/SplunkLongTaskInstrumentation.ts index 8776d0e9..270ef4d1 100644 --- a/packages/web/src/SplunkLongTaskInstrumentation.ts +++ b/packages/web/src/SplunkLongTaskInstrumentation.ts @@ -30,7 +30,13 @@ export class SplunkLongTaskInstrumentation extends InstrumentationBase { super(MODULE_NAME, VERSION, Object.assign({}, config)) } - init(): void {} + disable(): void { + if (!this.isSupported()) { + return + } + + this._longtaskObserver.disconnect() + } enable(): void { if (!this.isSupported()) { @@ -43,13 +49,7 @@ export class SplunkLongTaskInstrumentation extends InstrumentationBase { this._longtaskObserver.observe({ type: LONGTASK_PERFORMANCE_TYPE, buffered: true }) } - disable(): void { - if (!this.isSupported()) { - return - } - - this._longtaskObserver.disconnect() - } + init(): void {} private _createSpanFromEntry(entry: PerformanceEntry) { const span = this.tracer.startSpan(LONGTASK_PERFORMANCE_TYPE, { diff --git a/packages/web/src/SplunkPageVisibilityInstrumentation.ts b/packages/web/src/SplunkPageVisibilityInstrumentation.ts index 6b32dcf0..42a880a2 100644 --- a/packages/web/src/SplunkPageVisibilityInstrumentation.ts +++ b/packages/web/src/SplunkPageVisibilityInstrumentation.ts @@ -22,18 +22,21 @@ import { VERSION } from './version' const MODULE_NAME = 'splunk-visibility' export class SplunkPageVisibilityInstrumentation extends InstrumentationBase { + unloadListener: any + unloading: boolean visibilityListener: any - unloadListener: any - constructor(config: InstrumentationConfig = {}) { super(MODULE_NAME, VERSION, Object.assign({}, config)) this.unloading = false } - init(): void {} + disable(): void { + window.removeEventListener('beforeunload', this.unloadListener) + window.removeEventListener('visibilitychange', this.visibilityListener) + } enable(): void { if (document.hidden) { @@ -55,10 +58,7 @@ export class SplunkPageVisibilityInstrumentation extends InstrumentationBase { window.addEventListener('visibilitychange', this.visibilityListener) } - disable(): void { - window.removeEventListener('beforeunload', this.unloadListener) - window.removeEventListener('visibilitychange', this.visibilityListener) - } + init(): void {} private _createSpan(hidden: boolean) { const now = Date.now() diff --git a/packages/web/src/SplunkPostDocLoadResourceInstrumentation.ts b/packages/web/src/SplunkPostDocLoadResourceInstrumentation.ts index dae6dcd5..920980c0 100644 --- a/packages/web/src/SplunkPostDocLoadResourceInstrumentation.ts +++ b/packages/web/src/SplunkPostDocLoadResourceInstrumentation.ts @@ -36,13 +36,13 @@ const nodeHasSrcAttribute = (node: Node): node is HTMLScriptElement | HTMLImageE node instanceof HTMLScriptElement || node instanceof HTMLImageElement export class SplunkPostDocLoadResourceInstrumentation extends InstrumentationBase { - private performanceObserver: PerformanceObserver | undefined + private config: SplunkPostDocLoadResourceInstrumentationConfig private headMutationObserver: MutationObserver | undefined - private urlToContextMap: Record + private performanceObserver: PerformanceObserver | undefined - private config: SplunkPostDocLoadResourceInstrumentationConfig + private urlToContextMap: Record constructor(config: SplunkPostDocLoadResourceInstrumentationConfig = {}) { const processedConfig: SplunkPostDocLoadResourceInstrumentationConfig = Object.assign( @@ -55,7 +55,15 @@ export class SplunkPostDocLoadResourceInstrumentation extends InstrumentationBas this.urlToContextMap = {} } - init(): void {} + disable(): void { + if (this.performanceObserver) { + this.performanceObserver.disconnect() + } + + if (this.headMutationObserver) { + this.headMutationObserver.disconnect() + } + } enable(): void { if (window.PerformanceObserver) { @@ -69,61 +77,12 @@ export class SplunkPostDocLoadResourceInstrumentation extends InstrumentationBas } } - disable(): void { - if (this.performanceObserver) { - this.performanceObserver.disconnect() - } - - if (this.headMutationObserver) { - this.headMutationObserver.disconnect() - } - } + init(): void {} public onBeforeContextChange(): void { this._processHeadMutationObserverRecords(this.headMutationObserver.takeRecords()) } - private _startPerformanceObserver() { - this.performanceObserver = new PerformanceObserver((list) => { - if (window.document.readyState === 'complete') { - list.getEntries().forEach((entry) => { - // TODO: check how we can amend TS base typing to fix this - if (this.config.allowedInitiatorTypes.includes((entry as any).initiatorType)) { - this._createSpan(entry) - } - }) - } - }) - //apparently safari 13.1 only supports entryTypes - this.performanceObserver.observe({ entryTypes: ['resource'] }) - } - - private _startHeadMutationObserver() { - this.headMutationObserver = new MutationObserver(this._processHeadMutationObserverRecords.bind(this)) - this.headMutationObserver.observe(document.head, { childList: true }) - } - - // for each added node that corresponds to a resource load, create an entry in `this.urlToContextMap` - // that associates its fully-qualified URL to the tracing context at the time that it was added - private _processHeadMutationObserverRecords(mutations: MutationRecord[]) { - if (context.active() === ROOT_CONTEXT) { - return - } - - mutations - .flatMap((mutation) => Array.from(mutation.addedNodes || [])) - .filter(nodeHasSrcAttribute) - .forEach((node) => { - const src = node.getAttribute('src') - if (!src) { - return - } - - const srcUrl = new URL(src, location.origin) - this.urlToContextMap[srcUrl.toString()] = context.active() - }) - } - // TODO: discuss TS built-in types private _createSpan(entry: any) { if (isUrlIgnored(entry.name, this.config.ignoreUrls)) { @@ -153,4 +112,45 @@ export class SplunkPostDocLoadResourceInstrumentation extends InstrumentationBas span.end() } } + + // for each added node that corresponds to a resource load, create an entry in `this.urlToContextMap` + // that associates its fully-qualified URL to the tracing context at the time that it was added + private _processHeadMutationObserverRecords(mutations: MutationRecord[]) { + if (context.active() === ROOT_CONTEXT) { + return + } + + mutations + .flatMap((mutation) => Array.from(mutation.addedNodes || [])) + .filter(nodeHasSrcAttribute) + .forEach((node) => { + const src = node.getAttribute('src') + if (!src) { + return + } + + const srcUrl = new URL(src, location.origin) + this.urlToContextMap[srcUrl.toString()] = context.active() + }) + } + + private _startHeadMutationObserver() { + this.headMutationObserver = new MutationObserver(this._processHeadMutationObserverRecords.bind(this)) + this.headMutationObserver.observe(document.head, { childList: true }) + } + + private _startPerformanceObserver() { + this.performanceObserver = new PerformanceObserver((list) => { + if (window.document.readyState === 'complete') { + list.getEntries().forEach((entry) => { + // TODO: check how we can amend TS base typing to fix this + if (this.config.allowedInitiatorTypes.includes((entry as any).initiatorType)) { + this._createSpan(entry) + } + }) + } + }) + //apparently safari 13.1 only supports entryTypes + this.performanceObserver.observe({ entryTypes: ['resource'] }) + } } diff --git a/packages/web/src/SplunkSocketIoClientInstrumentation.ts b/packages/web/src/SplunkSocketIoClientInstrumentation.ts index d4961441..e022417d 100644 --- a/packages/web/src/SplunkSocketIoClientInstrumentation.ts +++ b/packages/web/src/SplunkSocketIoClientInstrumentation.ts @@ -37,13 +37,13 @@ interface SocketIOSocket { (...args: unknown[]): unknown prototype: { - emit(ev: string, ...args: unknown[]): ThisParameterType - on(ev: string, listener: (...args: unknown[]) => void): ThisParameterType addEventListener(ev: string, listener: (...args: unknown[]) => void): ThisParameterType + emit(ev: string, ...args: unknown[]): ThisParameterType off(ev?: string, listener?: (...args: unknown[]) => void): ThisParameterType - removeListener(ev?: string, listener?: (...args: unknown[]) => void): ThisParameterType + on(ev: string, listener: (...args: unknown[]) => void): ThisParameterType removeAllListeners(ev?: string): ThisParameterType removeEventListener(ev?: string, listener?: (...args: unknown[]) => void): ThisParameterType + removeListener(ev?: string, listener?: (...args: unknown[]) => void): ThisParameterType } } @@ -79,20 +79,41 @@ function seemsLikeSocketIoClient(io: unknown): io is SocketIOClient { const RESERVED_EVENTS = ['connect', 'connect_error', 'disconnect', 'disconnecting', 'newListener', 'removeListener'] export class SplunkSocketIoClientInstrumentation extends InstrumentationBase { + _onDisable?: () => void + protected listeners = new WeakMap<(...args: unknown[]) => void, (...args: unknown[]) => void>() constructor(config: SocketIoClientInstrumentationConfig = {}) { super(MODULE_NAME, VERSION, Object.assign({ target: 'io' }, config)) } - _onDisable?: () => void + disable(): void { + if (this._onDisable) { + this._onDisable() + this._onDisable = undefined + } + } - protected init(): void {} + enable(): void { + const config = this.getConfig() + + if (!config.target) { + return + } + + if (typeof config.target === 'string') { + this._onDisable = waitForGlobal(config.target, (io: SocketIOClient) => this.patchSocketIo(io)) + } else { + this.patchSocketIo(config.target) + } + } override getConfig(): SocketIoClientInstrumentationConfig { return this._config } + protected init(): void {} + protected patchSocketIo(io: SocketIOClient): void { if (!seemsLikeSocketIoClient(io)) { this._diag.debug("Doesn't seem like socket.io-client", io) @@ -217,25 +238,4 @@ export class SplunkSocketIoClientInstrumentation extends InstrumentationBase { io.Socket.prototype.removeAllListeners = io.Socket.prototype.off } } - - enable(): void { - const config = this.getConfig() - - if (!config.target) { - return - } - - if (typeof config.target === 'string') { - this._onDisable = waitForGlobal(config.target, (io: SocketIOClient) => this.patchSocketIo(io)) - } else { - this.patchSocketIo(config.target) - } - } - - disable(): void { - if (this._onDisable) { - this._onDisable() - this._onDisable = undefined - } - } } diff --git a/packages/web/src/SplunkSpanAttributesProcessor.ts b/packages/web/src/SplunkSpanAttributesProcessor.ts index b3660dc0..4909ebdf 100644 --- a/packages/web/src/SplunkSpanAttributesProcessor.ts +++ b/packages/web/src/SplunkSpanAttributesProcessor.ts @@ -27,22 +27,16 @@ export class SplunkSpanAttributesProcessor implements SpanProcessor { this._globalAttributes = globalAttributes ?? {} } - setGlobalAttributes(attributes?: Attributes): void { - if (attributes) { - Object.assign(this._globalAttributes, attributes) - } else { - for (const key of Object.keys(this._globalAttributes)) { - delete this._globalAttributes[key] - } - } + forceFlush(): Promise { + return Promise.resolve() } getGlobalAttributes(): Attributes { return this._globalAttributes } - forceFlush(): Promise { - return Promise.resolve() + onEnd(): void { + // Intentionally empty } onStart(span: Span): void { @@ -52,8 +46,14 @@ export class SplunkSpanAttributesProcessor implements SpanProcessor { span.setAttribute('browser.instance.visibility_state', document.visibilityState) } - onEnd(): void { - // Intentionally empty + setGlobalAttributes(attributes?: Attributes): void { + if (attributes) { + Object.assign(this._globalAttributes, attributes) + } else { + for (const key of Object.keys(this._globalAttributes)) { + delete this._globalAttributes[key] + } + } } shutdown(): Promise { diff --git a/packages/web/src/SplunkUserInteractionInstrumentation.ts b/packages/web/src/SplunkUserInteractionInstrumentation.ts index d8173112..97ce6cb2 100644 --- a/packages/web/src/SplunkUserInteractionInstrumentation.ts +++ b/packages/web/src/SplunkUserInteractionInstrumentation.ts @@ -72,10 +72,10 @@ type ExposedSuper = { } export class SplunkUserInteractionInstrumentation extends UserInteractionInstrumentation { - private _routingTracer: Tracer - private __hashChangeHandler: (ev: Event) => void + private _routingTracer: Tracer + constructor(config: SplunkUserInteractionInstrumentationConfig = {}) { // Prefer otel's eventNames property if (!config.eventNames) { @@ -138,14 +138,26 @@ export class SplunkUserInteractionInstrumentation extends UserInteractionInstrum } } - setTracerProvider(tracerProvider: TracerProvider): void { - super.setTracerProvider(tracerProvider) - this._routingTracer = tracerProvider.getTracer(ROUTING_INSTRUMENTATION_NAME, ROUTING_INSTRUMENTATION_VERSION) + // FIXME find cleaner way to patch + _patchHistoryMethod(): (original: any) => (this: History, ...args: unknown[]) => any { + const that = this + return (original) => + function patchHistoryMethod(...args) { + const oldHref = location.href + const result = original.apply(this, args) + const newHref = location.href + if (oldHref !== newHref) { + that._emitRouteChangeSpan(oldHref) + } + + return result + } } - getZoneWithPrototype(): undefined { - // FIXME work out ngZone issues with Angular PENDING - return undefined + disable(): void { + super.disable() + + window.removeEventListener('hashchange', this.__hashChangeHandler) } enable(): void { @@ -159,26 +171,14 @@ export class SplunkUserInteractionInstrumentation extends UserInteractionInstrum super.enable() } - disable(): void { - super.disable() - - window.removeEventListener('hashchange', this.__hashChangeHandler) + getZoneWithPrototype(): undefined { + // FIXME work out ngZone issues with Angular PENDING + return undefined } - // FIXME find cleaner way to patch - _patchHistoryMethod(): (original: any) => (this: History, ...args: unknown[]) => any { - const that = this - return (original) => - function patchHistoryMethod(...args) { - const oldHref = location.href - const result = original.apply(this, args) - const newHref = location.href - if (oldHref !== newHref) { - that._emitRouteChangeSpan(oldHref) - } - - return result - } + setTracerProvider(tracerProvider: TracerProvider): void { + super.setTracerProvider(tracerProvider) + this._routingTracer = tracerProvider.getTracer(ROUTING_INSTRUMENTATION_NAME, ROUTING_INSTRUMENTATION_VERSION) } private _emitRouteChangeSpan(oldHref) { diff --git a/packages/web/src/SplunkWebSocketInstrumentation.ts b/packages/web/src/SplunkWebSocketInstrumentation.ts index 24b2d1bd..a723fe64 100644 --- a/packages/web/src/SplunkWebSocketInstrumentation.ts +++ b/packages/web/src/SplunkWebSocketInstrumentation.ts @@ -42,7 +42,9 @@ export class SplunkWebSocketInstrumentation extends InstrumentationBase { this._config = config } - init(): void {} + disable(): void { + shimmer.unwrap(window, 'WebSocket') + } enable(): void { const instrumentation = this @@ -103,44 +105,7 @@ export class SplunkWebSocketInstrumentation extends InstrumentationBase { shimmer.wrap(window, 'WebSocket', webSocketWrapper) } - disable(): void { - shimmer.unwrap(window, 'WebSocket') - } - - private startSpan(ws: WebSocket, name: string, spanKind: SpanKind) { - const span = this.tracer.startSpan(name, { kind: spanKind }) - span.setAttribute('component', 'websocket') - span.setAttribute('protocol', ws.protocol) - span.setAttribute('http.url', ws.url) - // FIXME anything else? - return span - } - - private patchSend(ws: WebSocket) { - const instrumentation = this - const origSend = ws.send - ws.send = function instrumentedSend(...args) { - const span = instrumentation.startSpan(ws, 'send', SpanKind.PRODUCER) - const sendSize = args.length > 0 ? size(args[0]) : undefined - span.setAttribute('http.request_content_length', sendSize) - let retVal = undefined - - try { - retVal = origSend.apply(ws, args) - } catch (err) { - instrumentation.endSpanExceptionally(span, err) - throw err - } - - if (retVal === false) { - // Gecko 6.0 - instrumentation.endSpanExceptionally(span, new Error('Failed to send frame.')) - } - - span.end() - return retVal - } - } + init(): void {} // Returns true iff we should use the patched callback; false if it's already been patched private addPatchedListener(ws: WebSocket, origListener, patched) { @@ -158,22 +123,15 @@ export class SplunkWebSocketInstrumentation extends InstrumentationBase { return true } - // Returns patched listener or undefined - private removePatchedListener(ws, origListener) { - const ws2patched = this.listener2ws2patched.get(origListener) - if (!ws2patched) { - return undefined - } - - const patched = ws2patched.get(ws) - if (patched) { - ws2patched.delete(ws) - if (ws2patched.size === 0) { - this.listener2ws2patched.delete(ws) - } - } - - return patched + private endSpanExceptionally(span: Span, err: Error) { + span.setAttribute('error', true) + span.setAttribute('error.message', err.message) + span.setAttribute( + 'error.object', + err.name ? err.name : err.constructor && err.constructor.name ? err.constructor.name : 'Error', + ) + //TODO Should we do span.setStatus( someErroCode ) ? Currently all failed spans are CanonicalCode.OK + span.end() } // FIXME need to share logic better with userinteraction instrumentation @@ -242,14 +200,56 @@ export class SplunkWebSocketInstrumentation extends InstrumentationBase { } } - private endSpanExceptionally(span: Span, err: Error) { - span.setAttribute('error', true) - span.setAttribute('error.message', err.message) - span.setAttribute( - 'error.object', - err.name ? err.name : err.constructor && err.constructor.name ? err.constructor.name : 'Error', - ) - //TODO Should we do span.setStatus( someErroCode ) ? Currently all failed spans are CanonicalCode.OK - span.end() + private patchSend(ws: WebSocket) { + const instrumentation = this + const origSend = ws.send + ws.send = function instrumentedSend(...args) { + const span = instrumentation.startSpan(ws, 'send', SpanKind.PRODUCER) + const sendSize = args.length > 0 ? size(args[0]) : undefined + span.setAttribute('http.request_content_length', sendSize) + let retVal = undefined + + try { + retVal = origSend.apply(ws, args) + } catch (err) { + instrumentation.endSpanExceptionally(span, err) + throw err + } + + if (retVal === false) { + // Gecko 6.0 + instrumentation.endSpanExceptionally(span, new Error('Failed to send frame.')) + } + + span.end() + return retVal + } + } + + // Returns patched listener or undefined + private removePatchedListener(ws, origListener) { + const ws2patched = this.listener2ws2patched.get(origListener) + if (!ws2patched) { + return undefined + } + + const patched = ws2patched.get(ws) + if (patched) { + ws2patched.delete(ws) + if (ws2patched.size === 0) { + this.listener2ws2patched.delete(ws) + } + } + + return patched + } + + private startSpan(ws: WebSocket, name: string, spanKind: SpanKind) { + const span = this.tracer.startSpan(name, { kind: spanKind }) + span.setAttribute('component', 'websocket') + span.setAttribute('protocol', ws.protocol) + span.setAttribute('http.url', ws.url) + // FIXME anything else? + return span } } diff --git a/packages/web/src/exporters/common.ts b/packages/web/src/exporters/common.ts index 7c832804..3c26e174 100644 --- a/packages/web/src/exporters/common.ts +++ b/packages/web/src/exporters/common.ts @@ -20,10 +20,10 @@ import { Attributes } from '@opentelemetry/api' import { ReadableSpan } from '@opentelemetry/sdk-trace-base' export interface SplunkExporterConfig { - url: string + beaconSender?: (url: string, data: string, headers?: Record) => void onAttributesSerializing?: (attributes: Attributes, span: ReadableSpan) => Attributes + url: string xhrSender?: (url: string, data: string, headers?: Record) => void - beaconSender?: (url: string, data: string, headers?: Record) => void } export function NOOP_ATTRIBUTES_TRANSFORMER(attributes: Attributes): Attributes { diff --git a/packages/web/src/exporters/otlp.ts b/packages/web/src/exporters/otlp.ts index ad5c5f78..c7703bdc 100644 --- a/packages/web/src/exporters/otlp.ts +++ b/packages/web/src/exporters/otlp.ts @@ -22,13 +22,13 @@ import { NOOP_ATTRIBUTES_TRANSFORMER, NATIVE_XHR_SENDER, NATIVE_BEACON_SENDER, S import { ReadableSpan } from '@opentelemetry/sdk-trace-base' export class SplunkOTLPTraceExporter extends OTLPTraceExporter { + protected readonly _beaconSender: SplunkExporterConfig['beaconSender'] = + typeof navigator !== 'undefined' && navigator.sendBeacon ? NATIVE_BEACON_SENDER : undefined + protected readonly _onAttributesSerializing: SplunkExporterConfig['onAttributesSerializing'] protected readonly _xhrSender: SplunkExporterConfig['xhrSender'] = NATIVE_XHR_SENDER - protected readonly _beaconSender: SplunkExporterConfig['beaconSender'] = - typeof navigator !== 'undefined' && navigator.sendBeacon ? NATIVE_BEACON_SENDER : undefined - constructor(options: SplunkExporterConfig) { super(options) this._onAttributesSerializing = options.onAttributesSerializing || NOOP_ATTRIBUTES_TRANSFORMER diff --git a/packages/web/src/exporters/rate-limit.ts b/packages/web/src/exporters/rate-limit.ts index 8beaff40..f6aba7b8 100644 --- a/packages/web/src/exporters/rate-limit.ts +++ b/packages/web/src/exporters/rate-limit.ts @@ -24,11 +24,11 @@ const SPAN_RATE_LIMIT_PERIOD = 30000 // millis, sweep to clear out span counts const MAX_SPANS_PER_PERIOD_PER_COMPONENT = 100 export class RateLimitProcessor implements SpanProcessor { - protected readonly _spanCounts = new Map() + protected readonly _limiterHandle: number protected readonly _parents = new Map() - protected readonly _limiterHandle: number + protected readonly _spanCounts = new Map() constructor(protected _processor: SpanProcessor) { this._limiterHandle = window.setInterval(() => { @@ -40,6 +40,21 @@ export class RateLimitProcessor implements SpanProcessor { return this._processor.forceFlush() } + onEnd(span: ReadableSpan): void { + if (this._filter(span)) { + this._processor.onEnd(span) + } + } + + onStart(span: Span, parentContext: Context): void { + return this._processor.onStart(span, parentContext) + } + + shutdown(): Promise { + clearInterval(this._limiterHandle) + return this._processor.shutdown() + } + protected _filter(span: ReadableSpan): boolean { if (span.parentSpanId) { this._parents.set(span.parentSpanId, true) @@ -61,19 +76,4 @@ export class RateLimitProcessor implements SpanProcessor { return counter <= MAX_SPANS_PER_PERIOD_PER_COMPONENT } - - onStart(span: Span, parentContext: Context): void { - return this._processor.onStart(span, parentContext) - } - - onEnd(span: ReadableSpan): void { - if (this._filter(span)) { - this._processor.onEnd(span) - } - } - - shutdown(): Promise { - clearInterval(this._limiterHandle) - return this._processor.shutdown() - } } diff --git a/packages/web/src/exporters/zipkin.ts b/packages/web/src/exporters/zipkin.ts index 366613ab..2f58014d 100644 --- a/packages/web/src/exporters/zipkin.ts +++ b/packages/web/src/exporters/zipkin.ts @@ -37,9 +37,9 @@ export interface ZipkinAnnotation { // TODO: upstream proper exports from ZipkinExporter export interface ZipkinEndpoint { - serviceName?: string ipv4?: string port?: number + serviceName?: string } // TODO: upstream proper exports from ZipkinExporter @@ -49,18 +49,18 @@ export interface ZipkinTags { // TODO: upstream proper exports from ZipkinExporter export interface ZipkinSpan { - traceId: string - name: string - parentId?: string + annotations?: ZipkinAnnotation[] + debug?: boolean + duration: number id: string kind?: 'CLIENT' | 'SERVER' | 'CONSUMER' | 'PRODUCER' - timestamp: number - duration: number - debug?: boolean - shared?: boolean localEndpoint: ZipkinEndpoint - annotations?: ZipkinAnnotation[] + name: string + parentId?: string + shared?: boolean tags: ZipkinTags + timestamp: number + traceId: string } /** @@ -70,12 +70,12 @@ export class SplunkZipkinExporter implements SpanExporter { // TODO: a test which relies on beaconUrl needs to be fixed first public readonly beaconUrl: string + private readonly _beaconSender: SplunkExporterConfig['beaconSender'] + private readonly _onAttributesSerializing: SplunkExporterConfig['onAttributesSerializing'] private readonly _xhrSender: SplunkExporterConfig['xhrSender'] - private readonly _beaconSender: SplunkExporterConfig['beaconSender'] - constructor({ url, onAttributesSerializing = NOOP_ATTRIBUTES_TRANSFORMER, @@ -113,6 +113,31 @@ export class SplunkZipkinExporter implements SpanExporter { return this._postTranslateSpan(zspan) } + private _postTranslateSpan(span: ZipkinSpan) { + delete span.localEndpoint + span.name = limitLen(span.name, MAX_VALUE_LIMIT) + for (const [key, value] of Object.entries(span.tags)) { + span.tags[key] = limitLen(value.toString(), MAX_VALUE_LIMIT) + } + // Remove inaccurate CORS timings + const zero = performance.timeOrigin * 1000 + if ( + span.tags['http.url'] && + !(span.tags['http.url'] as string).startsWith(location.origin) && + span.timestamp > zero && + span.annotations + ) { + span.annotations = span.annotations.filter( + ({ timestamp }) => + // Chrome has increased precision on timeOrigin but otel may round it + // Due to multiple roundings and truncs it can be less than timeOrigin + timestamp > zero, + ) + } + + return span + } + private _preTranslateSpan(span: ReadableSpan): ReadableSpan { return { // todo: once typescript is implemented, conform to ReadableSpan @@ -140,29 +165,4 @@ export class SplunkZipkinExporter implements SpanExporter { droppedLinksCount: span.droppedLinksCount, } } - - private _postTranslateSpan(span: ZipkinSpan) { - delete span.localEndpoint - span.name = limitLen(span.name, MAX_VALUE_LIMIT) - for (const [key, value] of Object.entries(span.tags)) { - span.tags[key] = limitLen(value.toString(), MAX_VALUE_LIMIT) - } - // Remove inaccurate CORS timings - const zero = performance.timeOrigin * 1000 - if ( - span.tags['http.url'] && - !(span.tags['http.url'] as string).startsWith(location.origin) && - span.timestamp > zero && - span.annotations - ) { - span.annotations = span.annotations.filter( - ({ timestamp }) => - // Chrome has increased precision on timeOrigin but otel may round it - // Due to multiple roundings and truncs it can be less than timeOrigin - timestamp > zero, - ) - } - - return span - } } diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 4f9b9fef..da270cbe 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -82,15 +82,15 @@ export * from './SplunkWebTracerProvider' export * from './SessionBasedSampler' interface SplunkOtelWebOptionsInstrumentations { + connectivity?: boolean | InstrumentationConfig document?: boolean | InstrumentationConfig errors?: boolean fetch?: boolean | FetchInstrumentationConfig interactions?: boolean | SplunkUserInteractionInstrumentationConfig longtask?: boolean | InstrumentationConfig - visibility?: boolean | InstrumentationConfig - connectivity?: boolean | InstrumentationConfig postload?: boolean | SplunkPostDocLoadResourceInstrumentationConfig socketio?: boolean | SocketIoClientInstrumentationConfig + visibility?: boolean | InstrumentationConfig websocket?: boolean | InstrumentationConfig webvitals?: boolean | WebVitalsInstrumentationConfig xhr?: boolean | XMLHttpRequestInstrumentationConfig @@ -110,6 +110,11 @@ export interface SplunkOtelWebExporterOptions { } export interface SplunkOtelWebConfig { + /** + * If enabled, all spans are treated as activity and extend the duration of the session. Defaults to false. + */ + _experimental_allSpansExtendSession?: boolean + /** Allows http beacon urls */ allowInsecureBeacon?: boolean @@ -121,15 +126,15 @@ export interface SplunkOtelWebConfig { /** Application name */ applicationName?: string + /** Destination for the captured data */ + beaconEndpoint?: string + /** * Destination for the captured data * @deprecated Renamed to `beaconEndpoint`, or use realm */ beaconUrl?: string - /** Destination for the captured data */ - beaconEndpoint?: string - /** Options for context manager */ context?: ContextManagerConfig @@ -150,11 +155,6 @@ export interface SplunkOtelWebConfig { */ environment?: string - /** - * Sets a value for the 'app.version' attribute - */ - version?: string - /** Allows configuring how telemetry data is sent to the backend */ exporter?: SplunkOtelWebExporterOptions @@ -176,17 +176,17 @@ export interface SplunkOtelWebConfig { realm?: string /** - * Publicly-visible `rumAuth` value. Please do not paste any other access token or auth value into here, as this + * Publicly-visible rum access token value. Please do not paste any other access token or auth value into here, as this * will be visible to every user of your app - * @deprecated Renamed to rumAccessToken */ - rumAuth?: string + rumAccessToken?: string /** - * Publicly-visible rum access token value. Please do not paste any other access token or auth value into here, as this + * Publicly-visible `rumAuth` value. Please do not paste any other access token or auth value into here, as this * will be visible to every user of your app + * @deprecated Renamed to rumAccessToken */ - rumAccessToken?: string + rumAuth?: string /** * Config options passed to web tracer @@ -194,9 +194,9 @@ export interface SplunkOtelWebConfig { tracer?: WebTracerConfig /** - * If enabled, all spans are treated as activity and extend the duration of the session. Defaults to false. + * Sets a value for the 'app.version' attribute */ - _experimental_allSpansExtendSession?: boolean + version?: string } interface SplunkOtelWebConfigInternal extends SplunkOtelWebConfig { @@ -299,52 +299,55 @@ function buildExporter(options: SplunkOtelWebConfigInternal) { } export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { - readonly resource?: Resource + AlwaysOffSampler: typeof AlwaysOffSampler + AlwaysOnSampler: typeof AlwaysOnSampler - deinit: (force?: boolean) => void + DEFAULT_AUTO_INSTRUMENTED_EVENTS: UserInteractionEventsConfig + DEFAULT_AUTO_INSTRUMENTED_EVENT_NAMES: (keyof HTMLElementEventMap)[] - error: (...args: Array) => void + ParentBasedSampler: typeof ParentBasedSampler + SessionBasedSampler: typeof SessionBasedSampler - init: (options: SplunkOtelWebConfig) => void + /** + * @deprecated Use {@link getGlobalAttributes()} + */ + _experimental_getGlobalAttributes: () => Attributes + + /** + * @deprecated Use {@link getSessionId()} + */ + _experimental_getSessionId: () => SessionIdType | undefined /** * Allows experimental options to be passed. No versioning guarantees are given for this method. */ _internalInit: (options: Partial) => void - provider?: SplunkWebTracerProvider - attributesProcessor?: SplunkSpanAttributesProcessor - setGlobalAttributes: (attributes: Attributes) => void + deinit: (force?: boolean) => void + + error: (...args: Array) => void /** * This method provides access to computed, final value of global attributes, which are applied to all created spans. */ getGlobalAttributes: () => Attributes - /** - * @deprecated Use {@link getGlobalAttributes()} - */ - _experimental_getGlobalAttributes: () => Attributes /** * This method returns current session ID */ getSessionId: () => SessionIdType | undefined - /** - * @deprecated Use {@link getSessionId()} - */ - _experimental_getSessionId: () => SessionIdType | undefined - DEFAULT_AUTO_INSTRUMENTED_EVENTS: UserInteractionEventsConfig - DEFAULT_AUTO_INSTRUMENTED_EVENT_NAMES: (keyof HTMLElementEventMap)[] - - AlwaysOnSampler: typeof AlwaysOnSampler - AlwaysOffSampler: typeof AlwaysOffSampler - ParentBasedSampler: typeof ParentBasedSampler - SessionBasedSampler: typeof SessionBasedSampler + init: (options: SplunkOtelWebConfig) => void readonly inited: boolean + + provider?: SplunkWebTracerProvider + + readonly resource?: Resource + + setGlobalAttributes: (attributes: Attributes) => void } let inited = false diff --git a/packages/web/src/session.ts b/packages/web/src/session.ts index 220f7ab1..8944bc5d 100644 --- a/packages/web/src/session.ts +++ b/packages/web/src/session.ts @@ -156,12 +156,12 @@ class ActivitySpanProcessor implements SpanProcessor { return Promise.resolve() } + onEnd(): void {} + onStart(): void { markActivity() } - onEnd(): void {} - shutdown(): Promise { return Promise.resolve() } diff --git a/packages/web/src/upstream/user-interaction/instrumentation.ts b/packages/web/src/upstream/user-interaction/instrumentation.ts index 5c67b083..bc1bb5bc 100644 --- a/packages/web/src/upstream/user-interaction/instrumentation.ts +++ b/packages/web/src/upstream/user-interaction/instrumentation.ts @@ -38,13 +38,18 @@ function defaultShouldPreventSpanCreation() { * addEventListener of Element */ export class UserInteractionInstrumentation extends InstrumentationBase { + readonly moduleName: string = 'user-interaction' + readonly version = VERSION - readonly moduleName: string = 'user-interaction' + private _eventNames: Set - private _spansData = new WeakMap() + // for event bubbling + private _eventsSpanMap: WeakMap = new WeakMap() - private _zonePatched?: boolean + private _shouldPreventSpanCreation: ShouldPreventSpanCreation + + private _spansData = new WeakMap() // for addEventListener/removeEventListener state private _wrappedListeners = new WeakMap< @@ -52,12 +57,7 @@ export class UserInteractionInstrumentation extends InstrumentationBase Map> >() - // for event bubbling - private _eventsSpanMap: WeakMap = new WeakMap() - - private _eventNames: Set - - private _shouldPreventSpanCreation: ShouldPreventSpanCreation + private _zonePatched?: boolean constructor(config?: UserInteractionInstrumentationConfig) { super('@opentelemetry/instrumentation-user-interaction', VERSION, config) @@ -68,6 +68,122 @@ export class UserInteractionInstrumentation extends InstrumentationBase : defaultShouldPreventSpanCreation } + /** + * Patches the history api + */ + _patchHistoryApi() { + this._unpatchHistoryApi() + + this._wrap(history, 'replaceState', this._patchHistoryMethod()) + this._wrap(history, 'pushState', this._patchHistoryMethod()) + this._wrap(history, 'back', this._patchHistoryMethod()) + this._wrap(history, 'forward', this._patchHistoryMethod()) + this._wrap(history, 'go', this._patchHistoryMethod()) + } + + /** + * Patches the certain history api method + */ + _patchHistoryMethod() { + const instrumentation = this + return (original: any) => + function patchHistoryMethod(this: History, ...args: unknown[]) { + const url = `${location.pathname}${location.hash}${location.search}` + const result = original.apply(this, args) + const urlAfter = `${location.pathname}${location.hash}${location.search}` + if (url !== urlAfter) { + instrumentation._updateInteractionName(urlAfter) + } + + return result + } + } + + /** + * unpatch the history api methods + */ + _unpatchHistoryApi() { + if (isWrapped(history.replaceState)) { + this._unwrap(history, 'replaceState') + } + + if (isWrapped(history.pushState)) { + this._unwrap(history, 'pushState') + } + + if (isWrapped(history.back)) { + this._unwrap(history, 'back') + } + + if (isWrapped(history.forward)) { + this._unwrap(history, 'forward') + } + + if (isWrapped(history.go)) { + this._unwrap(history, 'go') + } + } + + /** + * Updates interaction span name + * @param url + */ + _updateInteractionName(url: string) { + const span: api.Span | undefined = api.trace.getSpan(api.context.active()) + if (span && typeof span.updateName === 'function') { + span.updateName(`${EVENT_NAVIGATION_NAME} ${url}`) + } + } + + /** + * implements unpatch function + */ + override disable() { + const targets = this._getPatchableEventTargets() + targets.forEach((target) => { + if (isWrapped(target.addEventListener)) { + this._unwrap(target, 'addEventListener') + } + + if (isWrapped(target.removeEventListener)) { + this._unwrap(target, 'removeEventListener') + } + }) + + this._unpatchHistoryApi() + } + + /** + * implements enable function + */ + override enable() { + this._zonePatched = false + const targets = this._getPatchableEventTargets() + targets.forEach((target) => { + if (isWrapped(target.addEventListener)) { + this._unwrap(target, 'addEventListener') + this._diag.debug('removing previous patch from method addEventListener') + } + + if (isWrapped(target.removeEventListener)) { + this._unwrap(target, 'removeEventListener') + this._diag.debug('removing previous patch from method removeEventListener') + } + + this._wrap(target, 'addEventListener', this._patchAddEventListener()) + this._wrap(target, 'removeEventListener', this._patchRemoveEventListener()) + }) + + this._patchHistoryApi() + } + + /** + * returns Zone + */ + getZoneWithPrototype(): undefined { + return undefined + } + init() {} /** @@ -135,64 +251,19 @@ export class UserInteractionInstrumentation extends InstrumentationBase } /** - * Returns true if we should use the patched callback; false if it's already been patched - */ - private addPatchedListener( - on: Element, - type: string, - listener: EventListenerOrEventListenerObject, - wrappedListener: (...args: unknown[]) => void, - ): boolean { - let listener2Type = this._wrappedListeners.get(listener) - if (!listener2Type) { - listener2Type = new Map() - this._wrappedListeners.set(listener, listener2Type) - } - - let element2patched = listener2Type.get(type) - if (!element2patched) { - element2patched = new Map() - listener2Type.set(type, element2patched) - } - - if (element2patched.has(on)) { - return false - } - - element2patched.set(on, wrappedListener) - return true - } - - /** - * Returns the patched version of the callback (or undefined) + * Most browser provide event listener api via EventTarget in prototype chain. + * Exception to this is IE 11 which has it on the prototypes closest to EventTarget: + * + * * - has addEventListener in IE + * ** - has addEventListener in all other browsers + * ! - missing in IE + * + * Element -> Node * -> EventTarget **! -> Object + * Document -> Node * -> EventTarget **! -> Object + * Window * -> WindowProperties ! -> EventTarget **! -> Object */ - private removePatchedListener( - on: Element, - type: string, - listener: EventListenerOrEventListenerObject, - ): EventListenerOrEventListenerObject | undefined { - const listener2Type = this._wrappedListeners.get(listener) - if (!listener2Type) { - return undefined - } - - const element2patched = listener2Type.get(type) - if (!element2patched) { - return undefined - } - - const patched = element2patched.get(on) - if (patched) { - element2patched.delete(on) - if (element2patched.size === 0) { - listener2Type.delete(type) - if (listener2Type.size === 0) { - this._wrappedListeners.delete(listener) - } - } - } - - return patched + private _getPatchableEventTargets(): EventTarget[] { + return window.EventTarget ? [EventTarget.prototype] : [Node.prototype, Window.prototype] } // utility method to deal with the Function|EventListener nature of addEventListener @@ -278,134 +349,63 @@ export class UserInteractionInstrumentation extends InstrumentationBase } /** - * Most browser provide event listener api via EventTarget in prototype chain. - * Exception to this is IE 11 which has it on the prototypes closest to EventTarget: - * - * * - has addEventListener in IE - * ** - has addEventListener in all other browsers - * ! - missing in IE - * - * Element -> Node * -> EventTarget **! -> Object - * Document -> Node * -> EventTarget **! -> Object - * Window * -> WindowProperties ! -> EventTarget **! -> Object - */ - private _getPatchableEventTargets(): EventTarget[] { - return window.EventTarget ? [EventTarget.prototype] : [Node.prototype, Window.prototype] - } - - /** - * Patches the history api - */ - _patchHistoryApi() { - this._unpatchHistoryApi() - - this._wrap(history, 'replaceState', this._patchHistoryMethod()) - this._wrap(history, 'pushState', this._patchHistoryMethod()) - this._wrap(history, 'back', this._patchHistoryMethod()) - this._wrap(history, 'forward', this._patchHistoryMethod()) - this._wrap(history, 'go', this._patchHistoryMethod()) - } - - /** - * Patches the certain history api method - */ - _patchHistoryMethod() { - const instrumentation = this - return (original: any) => - function patchHistoryMethod(this: History, ...args: unknown[]) { - const url = `${location.pathname}${location.hash}${location.search}` - const result = original.apply(this, args) - const urlAfter = `${location.pathname}${location.hash}${location.search}` - if (url !== urlAfter) { - instrumentation._updateInteractionName(urlAfter) - } - - return result - } - } - - /** - * unpatch the history api methods + * Returns true if we should use the patched callback; false if it's already been patched */ - _unpatchHistoryApi() { - if (isWrapped(history.replaceState)) { - this._unwrap(history, 'replaceState') - } - - if (isWrapped(history.pushState)) { - this._unwrap(history, 'pushState') + private addPatchedListener( + on: Element, + type: string, + listener: EventListenerOrEventListenerObject, + wrappedListener: (...args: unknown[]) => void, + ): boolean { + let listener2Type = this._wrappedListeners.get(listener) + if (!listener2Type) { + listener2Type = new Map() + this._wrappedListeners.set(listener, listener2Type) } - if (isWrapped(history.back)) { - this._unwrap(history, 'back') + let element2patched = listener2Type.get(type) + if (!element2patched) { + element2patched = new Map() + listener2Type.set(type, element2patched) } - if (isWrapped(history.forward)) { - this._unwrap(history, 'forward') + if (element2patched.has(on)) { + return false } - if (isWrapped(history.go)) { - this._unwrap(history, 'go') - } + element2patched.set(on, wrappedListener) + return true } /** - * Updates interaction span name - * @param url + * Returns the patched version of the callback (or undefined) */ - _updateInteractionName(url: string) { - const span: api.Span | undefined = api.trace.getSpan(api.context.active()) - if (span && typeof span.updateName === 'function') { - span.updateName(`${EVENT_NAVIGATION_NAME} ${url}`) + private removePatchedListener( + on: Element, + type: string, + listener: EventListenerOrEventListenerObject, + ): EventListenerOrEventListenerObject | undefined { + const listener2Type = this._wrappedListeners.get(listener) + if (!listener2Type) { + return undefined } - } - - /** - * implements enable function - */ - override enable() { - this._zonePatched = false - const targets = this._getPatchableEventTargets() - targets.forEach((target) => { - if (isWrapped(target.addEventListener)) { - this._unwrap(target, 'addEventListener') - this._diag.debug('removing previous patch from method addEventListener') - } - if (isWrapped(target.removeEventListener)) { - this._unwrap(target, 'removeEventListener') - this._diag.debug('removing previous patch from method removeEventListener') - } - - this._wrap(target, 'addEventListener', this._patchAddEventListener()) - this._wrap(target, 'removeEventListener', this._patchRemoveEventListener()) - }) - - this._patchHistoryApi() - } - - /** - * implements unpatch function - */ - override disable() { - const targets = this._getPatchableEventTargets() - targets.forEach((target) => { - if (isWrapped(target.addEventListener)) { - this._unwrap(target, 'addEventListener') - } + const element2patched = listener2Type.get(type) + if (!element2patched) { + return undefined + } - if (isWrapped(target.removeEventListener)) { - this._unwrap(target, 'removeEventListener') + const patched = element2patched.get(on) + if (patched) { + element2patched.delete(on) + if (element2patched.size === 0) { + listener2Type.delete(type) + if (listener2Type.size === 0) { + this._wrappedListeners.delete(listener) + } } - }) - - this._unpatchHistoryApi() - } + } - /** - * returns Zone - */ - getZoneWithPrototype(): undefined { - return undefined + return patched } } diff --git a/packages/web/test/SplunkExporter.test.ts b/packages/web/test/SplunkExporter.test.ts index 2341ade8..3f735341 100644 --- a/packages/web/test/SplunkExporter.test.ts +++ b/packages/web/test/SplunkExporter.test.ts @@ -39,7 +39,7 @@ function buildDummySpan({ name = '', attributes = {} } = {}) { duration: timeInputToHrTime(1000), status: { code: api.SpanStatusCode.UNSET }, resource: { attributes: {} }, - events: [] as { time: api.HrTime; name: string }[], + events: [] as { name: string; time: api.HrTime }[], } as ReadableSpan } diff --git a/packages/web/test/utils.ts b/packages/web/test/utils.ts index be5583c1..9d9e41fc 100644 --- a/packages/web/test/utils.ts +++ b/packages/web/test/utils.ts @@ -24,13 +24,11 @@ import { assert } from 'chai' export class SpanCapturer implements SpanProcessor { public readonly spans: ReadableSpan[] = [] - forceFlush(): Promise { - return Promise.resolve() + clear(): void { + this.spans.length = 0 } - onStart(): void {} - - shutdown(): Promise { + forceFlush(): Promise { return Promise.resolve() } @@ -38,8 +36,10 @@ export class SpanCapturer implements SpanProcessor { this.spans.push(span) } - clear(): void { - this.spans.length = 0 + onStart(): void {} + + shutdown(): Promise { + return Promise.resolve() } }