UNPKG

@opentelemetry/instrumentation-user-interaction

Version:
551 lines 20.9 kB
/* * Copyright The OpenTelemetry Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /// <reference types="zone.js" /> import { isWrapped, InstrumentationBase } from '@opentelemetry/instrumentation'; import * as api from '@opentelemetry/api'; import { hrTime } from '@opentelemetry/core'; import { getElementXPath } from '@opentelemetry/sdk-trace-web'; import { AttributeNames } from './enums/AttributeNames'; /** @knipignore */ import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; const ZONE_CONTEXT_KEY = 'OT_ZONE_CONTEXT'; const EVENT_NAVIGATION_NAME = 'Navigation:'; const DEFAULT_EVENT_NAMES = ['click']; function defaultShouldPreventSpanCreation() { return false; } /** * This class represents a UserInteraction plugin for auto instrumentation. * If zone.js is available then it patches the zone otherwise it patches * addEventListener of HTMLElement */ export class UserInteractionInstrumentation extends InstrumentationBase { version = PACKAGE_VERSION; moduleName = 'user-interaction'; _spansData = new WeakMap(); // for addEventListener/removeEventListener state _wrappedListeners = new WeakMap(); // for event bubbling _eventsSpanMap = new WeakMap(); _eventNames; _shouldPreventSpanCreation; constructor(config = {}) { super(PACKAGE_NAME, PACKAGE_VERSION, config); this._eventNames = new Set(config?.eventNames ?? DEFAULT_EVENT_NAMES); this._shouldPreventSpanCreation = typeof config?.shouldPreventSpanCreation === 'function' ? config.shouldPreventSpanCreation : defaultShouldPreventSpanCreation; } init() { } /** * This will check if last task was timeout and will save the time to * fix the user interaction when nothing happens * This timeout comes from xhr plugin which is needed to collect information * about last xhr main request from observer * @param task * @param span */ _checkForTimeout(task, span) { const spanData = this._spansData.get(span); if (spanData) { if (task.source === 'setTimeout') { spanData.hrTimeLastTimeout = hrTime(); } else if (task.source !== 'Promise.then' && task.source !== 'setTimeout') { spanData.hrTimeLastTimeout = undefined; } } } /** * Controls whether or not to create a span, based on the event type. */ _allowEventName(eventName) { return this._eventNames.has(eventName); } /** * Creates a new span * @param element * @param eventName * @param parentSpan */ _createSpan(element, eventName, parentSpan) { if (!(element instanceof HTMLElement)) { return undefined; } if (!element.getAttribute) { return undefined; } if (element.hasAttribute('disabled')) { return undefined; } if (!this._allowEventName(eventName)) { return undefined; } const xpath = getElementXPath(element, true); try { const span = this.tracer.startSpan(eventName, { attributes: { [AttributeNames.EVENT_TYPE]: eventName, [AttributeNames.TARGET_ELEMENT]: element.tagName, [AttributeNames.TARGET_XPATH]: xpath, [AttributeNames.HTTP_URL]: window.location.href, }, }, parentSpan ? api.trace.setSpan(api.context.active(), parentSpan) : undefined); if (this._shouldPreventSpanCreation(eventName, element, span) === true) { return undefined; } this._spansData.set(span, { taskCount: 0, }); return span; } catch (e) { this._diag.error('failed to start create new user interaction span', e); } return undefined; } /** * Decrement number of tasks that left in zone, * This is needed to be able to end span when no more tasks left * @param span */ _decrementTask(span) { const spanData = this._spansData.get(span); if (spanData) { spanData.taskCount--; if (spanData.taskCount === 0) { this._tryToEndSpan(span, spanData.hrTimeLastTimeout); } } } /** * Return the current span * @param zone * @private */ _getCurrentSpan(zone) { const context = zone.get(ZONE_CONTEXT_KEY); if (context) { return api.trace.getSpan(context); } return context; } /** * Increment number of tasks that are run within the same zone. * This is needed to be able to end span when no more tasks left * @param span */ _incrementTask(span) { const spanData = this._spansData.get(span); if (spanData) { spanData.taskCount++; } } /** * Returns true iff we should use the patched callback; false if it's already been patched */ addPatchedListener(on, type, listener, wrappedListener) { 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) */ removePatchedListener(on, type, listener) { 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; } // utility method to deal with the Function|EventListener nature of addEventListener _invokeListener(listener, target, args) { if (typeof listener === 'function') { return listener.apply(target, args); } else { return listener.handleEvent(args[0]); } } /** * This patches the addEventListener of HTMLElement to be able to * auto instrument the click events * This is done when zone is not available */ _patchAddEventListener() { const plugin = this; return (original) => { return function addEventListenerPatched(type, listener, useCapture) { // Forward calls with listener = null if (!listener) { return original.call(this, type, listener, useCapture); } // filter out null (typeof null === 'object') const once = useCapture && typeof useCapture === 'object' && useCapture.once; const patchedListener = function (...args) { let parentSpan; const event = args[0]; const target = event?.target; if (event) { parentSpan = plugin._eventsSpanMap.get(event); } if (once) { plugin.removePatchedListener(this, type, listener); } const span = plugin._createSpan(target, type, parentSpan); if (span) { if (event) { plugin._eventsSpanMap.set(event, span); } return api.context.with(api.trace.setSpan(api.context.active(), span), () => { const result = plugin._invokeListener(listener, this, args); // no zone so end span immediately span.end(); return result; }); } else { return plugin._invokeListener(listener, this, args); } }; if (plugin.addPatchedListener(this, type, listener, patchedListener)) { return original.call(this, type, patchedListener, useCapture); } }; }; } /** * This patches the removeEventListener of HTMLElement to handle the fact that * we patched the original callbacks * This is done when zone is not available */ _patchRemoveEventListener() { const plugin = this; return (original) => { return function removeEventListenerPatched(type, listener, useCapture) { const wrappedListener = plugin.removePatchedListener(this, type, listener); if (wrappedListener) { return original.call(this, type, wrappedListener, useCapture); } else { return original.call(this, type, listener, useCapture); } }; }; } /** * 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 * * HTMLElement -> Element -> Node * -> EventTarget **! -> Object * Document -> Node * -> EventTarget **! -> Object * Window * -> WindowProperties ! -> EventTarget **! -> Object */ _getPatchableEventTargets() { 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 plugin = this; return (original) => { return function patchHistoryMethod(...args) { 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) { plugin._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) { const span = api.trace.getSpan(api.context.active()); if (span && typeof span.updateName === 'function') { span.updateName(`${EVENT_NAVIGATION_NAME} ${url}`); } } /** * Patches zone cancel task - this is done to be able to correctly * decrement the number of remaining tasks */ _patchZoneCancelTask() { const plugin = this; return (original) => { return function patchCancelTask(task) { const currentZone = Zone.current; const currentSpan = plugin._getCurrentSpan(currentZone); if (currentSpan && plugin._shouldCountTask(task, currentZone)) { plugin._decrementTask(currentSpan); } return original.call(this, task); }; }; } /** * Patches zone schedule task - this is done to be able to correctly * increment the number of tasks running within current zone but also to * save time in case of timeout running from xhr plugin when waiting for * main request from PerformanceResourceTiming */ _patchZoneScheduleTask() { const plugin = this; return (original) => { return function patchScheduleTask(task) { const currentZone = Zone.current; const currentSpan = plugin._getCurrentSpan(currentZone); if (currentSpan && plugin._shouldCountTask(task, currentZone)) { plugin._incrementTask(currentSpan); plugin._checkForTimeout(task, currentSpan); } return original.call(this, task); }; }; } /** * Patches zone run task - this is done to be able to create a span when * user interaction starts * @private */ _patchZoneRunTask() { const plugin = this; return (original) => { return function patchRunTask(task, applyThis, applyArgs) { const event = Array.isArray(applyArgs) && applyArgs[0] instanceof Event ? applyArgs[0] : undefined; const target = event?.target; let span; const activeZone = this; if (target) { span = plugin._createSpan(target, task.eventName); if (span) { plugin._incrementTask(span); return activeZone.run(() => { try { return api.context.with(api.trace.setSpan(api.context.active(), span), () => { const currentZone = Zone.current; task._zone = currentZone; return original.call(currentZone, task, applyThis, applyArgs); }); } finally { plugin._decrementTask(span); } }); } } else { span = plugin._getCurrentSpan(activeZone); } try { return original.call(activeZone, task, applyThis, applyArgs); } finally { if (span && plugin._shouldCountTask(task, activeZone)) { plugin._decrementTask(span); } } }; }; } /** * Decides if task should be counted. * @param task * @param currentZone * @private */ _shouldCountTask(task, currentZone) { if (task._zone) { currentZone = task._zone; } if (!currentZone || !task.data || task.data.isPeriodic) { return false; } const currentSpan = this._getCurrentSpan(currentZone); if (!currentSpan) { return false; } if (!this._spansData.get(currentSpan)) { return false; } return task.type === 'macroTask' || task.type === 'microTask'; } /** * Will try to end span when such span still exists. * @param span * @param endTime * @private */ _tryToEndSpan(span, endTime) { if (span) { const spanData = this._spansData.get(span); if (spanData) { span.end(endTime); this._spansData.delete(span); } } } /** * implements enable function */ enable() { const ZoneWithPrototype = this._getZoneWithPrototype(); this._diag.debug('applying patch to', this.moduleName, this.version, 'zone:', !!ZoneWithPrototype); if (ZoneWithPrototype) { if (isWrapped(ZoneWithPrototype.prototype.runTask)) { this._unwrap(ZoneWithPrototype.prototype, 'runTask'); this._diag.debug('removing previous patch from method runTask'); } if (isWrapped(ZoneWithPrototype.prototype.scheduleTask)) { this._unwrap(ZoneWithPrototype.prototype, 'scheduleTask'); this._diag.debug('removing previous patch from method scheduleTask'); } if (isWrapped(ZoneWithPrototype.prototype.cancelTask)) { this._unwrap(ZoneWithPrototype.prototype, 'cancelTask'); this._diag.debug('removing previous patch from method cancelTask'); } this._zonePatched = true; this._wrap(ZoneWithPrototype.prototype, 'runTask', this._patchZoneRunTask()); this._wrap(ZoneWithPrototype.prototype, 'scheduleTask', this._patchZoneScheduleTask()); this._wrap(ZoneWithPrototype.prototype, 'cancelTask', this._patchZoneCancelTask()); } else { 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 */ disable() { const ZoneWithPrototype = this._getZoneWithPrototype(); this._diag.debug('removing patch from', this.moduleName, this.version, 'zone:', !!ZoneWithPrototype); if (ZoneWithPrototype && this._zonePatched) { if (isWrapped(ZoneWithPrototype.prototype.runTask)) { this._unwrap(ZoneWithPrototype.prototype, 'runTask'); } if (isWrapped(ZoneWithPrototype.prototype.scheduleTask)) { this._unwrap(ZoneWithPrototype.prototype, 'scheduleTask'); } if (isWrapped(ZoneWithPrototype.prototype.cancelTask)) { this._unwrap(ZoneWithPrototype.prototype, 'cancelTask'); } } else { 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(); } /** * returns Zone */ _getZoneWithPrototype() { const _window = window; return _window.Zone; } } //# sourceMappingURL=instrumentation.js.map