import { uuid4 } from '../misc.js'; import { fill, addNonEnumerableProperty } from '../object.js'; import { GLOBAL_OBJ } from '../worldwide.js'; import { addHandler, maybeInstrument, triggerHandlers } from './_handlers.js'; const WINDOW = GLOBAL_OBJ ; const DEBOUNCE_DURATION = 1000; let debounceTimerID; let lastCapturedEventType; let lastCapturedEventTargetId; /** * Add an instrumentation handler for when a click or a keypress happens. * * Use at your own risk, this might break without changelog notice, only used internally. * @hidden */ function addClickKeypressInstrumentationHandler(handler) { const type = 'dom'; addHandler(type, handler); maybeInstrument(type, instrumentDOM); } /** Exported for tests only. */ function instrumentDOM() { if (!WINDOW.document) { return; } // Make it so that any click or keypress that is unhandled / bubbled up all the way to the document triggers our dom // handlers. (Normally we have only one, which captures a breadcrumb for each click or keypress.) Do this before // we instrument `addEventListener` so that we don't end up attaching this handler twice. const triggerDOMHandler = triggerHandlers.bind(null, 'dom'); const globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true); WINDOW.document.addEventListener('click', globalDOMEventHandler, false); WINDOW.document.addEventListener('keypress', globalDOMEventHandler, false); // After hooking into click and keypress events bubbled up to `document`, we also hook into user-handled // clicks & keypresses, by adding an event listener of our own to any element to which they add a listener. That // way, whenever one of their handlers is triggered, ours will be, too. (This is needed because their handler // could potentially prevent the event from bubbling up to our global listeners. This way, our handler are still // guaranteed to fire at least once.) ['EventTarget', 'Node'].forEach((target) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const proto = (WINDOW )[target] && (WINDOW )[target].prototype; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-prototype-builtins if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) { return; } fill(proto, 'addEventListener', function (originalAddEventListener) { return function ( type, listener, options, ) { if (type === 'click' || type == 'keypress') { try { const el = this ; const handlers = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {}); const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 }); if (!handlerForType.handler) { const handler = makeDOMEventHandler(triggerDOMHandler); handlerForType.handler = handler; originalAddEventListener.call(this, type, handler, options); } handlerForType.refCount++; } catch (e) { // Accessing dom properties is always fragile. // Also allows us to skip `addEventListenrs` calls with no proper `this` context. } } return originalAddEventListener.call(this, type, listener, options); }; }); fill( proto, 'removeEventListener', function (originalRemoveEventListener) { return function ( type, listener, options, ) { if (type === 'click' || type == 'keypress') { try { const el = this ; const handlers = el.__sentry_instrumentation_handlers__ || {}; const handlerForType = handlers[type]; if (handlerForType) { handlerForType.refCount--; // If there are no longer any custom handlers of the current type on this element, we can remove ours, too. if (handlerForType.refCount <= 0) { originalRemoveEventListener.call(this, type, handlerForType.handler, options); handlerForType.handler = undefined; delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete } // If there are no longer any custom handlers of any type on this element, cleanup everything. if (Object.keys(handlers).length === 0) { delete el.__sentry_instrumentation_handlers__; } } } catch (e) { // Accessing dom properties is always fragile. // Also allows us to skip `addEventListenrs` calls with no proper `this` context. } } return originalRemoveEventListener.call(this, type, listener, options); }; }, ); }); } /** * Check whether the event is similar to the last captured one. For example, two click events on the same button. */ function isSimilarToLastCapturedEvent(event) { // If both events have different type, then user definitely performed two separate actions. e.g. click + keypress. if (event.type !== lastCapturedEventType) { return false; } try { // If both events have the same type, it's still possible that actions were performed on different targets. // e.g. 2 clicks on different buttons. if (!event.target || (event.target )._sentryId !== lastCapturedEventTargetId) { return false; } } catch (e) { // just accessing `target` property can throw an exception in some rare circumstances // see: https://github.com/getsentry/sentry-javascript/issues/838 } // If both events have the same type _and_ same `target` (an element which triggered an event, _not necessarily_ // to which an event listener was attached), we treat them as the same action, as we want to capture // only one breadcrumb. e.g. multiple clicks on the same button, or typing inside a user input box. return true; } /** * Decide whether an event should be captured. * @param event event to be captured */ function shouldSkipDOMEvent(eventType, target) { // We are only interested in filtering `keypress` events for now. if (eventType !== 'keypress') { return false; } if (!target || !target.tagName) { return true; } // Only consider keypress events on actual input elements. This will disregard keypresses targeting body // e.g.tabbing through elements, hotkeys, etc. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return false; } return true; } /** * Wraps addEventListener to capture UI breadcrumbs */ function makeDOMEventHandler( handler, globalListener = false, ) { return (event) => { // It's possible this handler might trigger multiple times for the same // event (e.g. event propagation through node ancestors). // Ignore if we've already captured that event. if (!event || event['_sentryCaptured']) { return; } const target = getEventTarget(event); // We always want to skip _some_ events. if (shouldSkipDOMEvent(event.type, target)) { return; } // Mark event as "seen" addNonEnumerableProperty(event, '_sentryCaptured', true); if (target && !target._sentryId) { // Add UUID to event target so we can identify if addNonEnumerableProperty(target, '_sentryId', uuid4()); } const name = event.type === 'keypress' ? 'input' : event.type; // If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons. // If there is a last captured event, see if the new event is different enough to treat it as a unique one. // If that's the case, emit the previous event and store locally the newly-captured DOM event. if (!isSimilarToLastCapturedEvent(event)) { const handlerData = { event, name, global: globalListener }; handler(handlerData); lastCapturedEventType = event.type; lastCapturedEventTargetId = target ? target._sentryId : undefined; } // Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together. clearTimeout(debounceTimerID); debounceTimerID = WINDOW.setTimeout(() => { lastCapturedEventTargetId = undefined; lastCapturedEventType = undefined; }, DEBOUNCE_DURATION); }; } function getEventTarget(event) { try { return event.target ; } catch (e) { // just accessing `target` property can throw an exception in some rare circumstances // see: https://github.com/getsentry/sentry-javascript/issues/838 return null; } } export { addClickKeypressInstrumentationHandler, instrumentDOM }; //# sourceMappingURL=dom.js.map