dom.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { uuid4 } from '../misc.js';
  2. import { fill, addNonEnumerableProperty } from '../object.js';
  3. import { GLOBAL_OBJ } from '../worldwide.js';
  4. import { addHandler, maybeInstrument, triggerHandlers } from './_handlers.js';
  5. const WINDOW = GLOBAL_OBJ ;
  6. const DEBOUNCE_DURATION = 1000;
  7. let debounceTimerID;
  8. let lastCapturedEventType;
  9. let lastCapturedEventTargetId;
  10. /**
  11. * Add an instrumentation handler for when a click or a keypress happens.
  12. *
  13. * Use at your own risk, this might break without changelog notice, only used internally.
  14. * @hidden
  15. */
  16. function addClickKeypressInstrumentationHandler(handler) {
  17. const type = 'dom';
  18. addHandler(type, handler);
  19. maybeInstrument(type, instrumentDOM);
  20. }
  21. /** Exported for tests only. */
  22. function instrumentDOM() {
  23. if (!WINDOW.document) {
  24. return;
  25. }
  26. // Make it so that any click or keypress that is unhandled / bubbled up all the way to the document triggers our dom
  27. // handlers. (Normally we have only one, which captures a breadcrumb for each click or keypress.) Do this before
  28. // we instrument `addEventListener` so that we don't end up attaching this handler twice.
  29. const triggerDOMHandler = triggerHandlers.bind(null, 'dom');
  30. const globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true);
  31. WINDOW.document.addEventListener('click', globalDOMEventHandler, false);
  32. WINDOW.document.addEventListener('keypress', globalDOMEventHandler, false);
  33. // After hooking into click and keypress events bubbled up to `document`, we also hook into user-handled
  34. // clicks & keypresses, by adding an event listener of our own to any element to which they add a listener. That
  35. // way, whenever one of their handlers is triggered, ours will be, too. (This is needed because their handler
  36. // could potentially prevent the event from bubbling up to our global listeners. This way, our handler are still
  37. // guaranteed to fire at least once.)
  38. ['EventTarget', 'Node'].forEach((target) => {
  39. // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  40. const proto = (WINDOW )[target] && (WINDOW )[target].prototype;
  41. // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-prototype-builtins
  42. if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) {
  43. return;
  44. }
  45. fill(proto, 'addEventListener', function (originalAddEventListener) {
  46. return function (
  47. type,
  48. listener,
  49. options,
  50. ) {
  51. if (type === 'click' || type == 'keypress') {
  52. try {
  53. const el = this ;
  54. const handlers = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {});
  55. const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 });
  56. if (!handlerForType.handler) {
  57. const handler = makeDOMEventHandler(triggerDOMHandler);
  58. handlerForType.handler = handler;
  59. originalAddEventListener.call(this, type, handler, options);
  60. }
  61. handlerForType.refCount++;
  62. } catch (e) {
  63. // Accessing dom properties is always fragile.
  64. // Also allows us to skip `addEventListenrs` calls with no proper `this` context.
  65. }
  66. }
  67. return originalAddEventListener.call(this, type, listener, options);
  68. };
  69. });
  70. fill(
  71. proto,
  72. 'removeEventListener',
  73. function (originalRemoveEventListener) {
  74. return function (
  75. type,
  76. listener,
  77. options,
  78. ) {
  79. if (type === 'click' || type == 'keypress') {
  80. try {
  81. const el = this ;
  82. const handlers = el.__sentry_instrumentation_handlers__ || {};
  83. const handlerForType = handlers[type];
  84. if (handlerForType) {
  85. handlerForType.refCount--;
  86. // If there are no longer any custom handlers of the current type on this element, we can remove ours, too.
  87. if (handlerForType.refCount <= 0) {
  88. originalRemoveEventListener.call(this, type, handlerForType.handler, options);
  89. handlerForType.handler = undefined;
  90. delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
  91. }
  92. // If there are no longer any custom handlers of any type on this element, cleanup everything.
  93. if (Object.keys(handlers).length === 0) {
  94. delete el.__sentry_instrumentation_handlers__;
  95. }
  96. }
  97. } catch (e) {
  98. // Accessing dom properties is always fragile.
  99. // Also allows us to skip `addEventListenrs` calls with no proper `this` context.
  100. }
  101. }
  102. return originalRemoveEventListener.call(this, type, listener, options);
  103. };
  104. },
  105. );
  106. });
  107. }
  108. /**
  109. * Check whether the event is similar to the last captured one. For example, two click events on the same button.
  110. */
  111. function isSimilarToLastCapturedEvent(event) {
  112. // If both events have different type, then user definitely performed two separate actions. e.g. click + keypress.
  113. if (event.type !== lastCapturedEventType) {
  114. return false;
  115. }
  116. try {
  117. // If both events have the same type, it's still possible that actions were performed on different targets.
  118. // e.g. 2 clicks on different buttons.
  119. if (!event.target || (event.target )._sentryId !== lastCapturedEventTargetId) {
  120. return false;
  121. }
  122. } catch (e) {
  123. // just accessing `target` property can throw an exception in some rare circumstances
  124. // see: https://github.com/getsentry/sentry-javascript/issues/838
  125. }
  126. // If both events have the same type _and_ same `target` (an element which triggered an event, _not necessarily_
  127. // to which an event listener was attached), we treat them as the same action, as we want to capture
  128. // only one breadcrumb. e.g. multiple clicks on the same button, or typing inside a user input box.
  129. return true;
  130. }
  131. /**
  132. * Decide whether an event should be captured.
  133. * @param event event to be captured
  134. */
  135. function shouldSkipDOMEvent(eventType, target) {
  136. // We are only interested in filtering `keypress` events for now.
  137. if (eventType !== 'keypress') {
  138. return false;
  139. }
  140. if (!target || !target.tagName) {
  141. return true;
  142. }
  143. // Only consider keypress events on actual input elements. This will disregard keypresses targeting body
  144. // e.g.tabbing through elements, hotkeys, etc.
  145. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  146. return false;
  147. }
  148. return true;
  149. }
  150. /**
  151. * Wraps addEventListener to capture UI breadcrumbs
  152. */
  153. function makeDOMEventHandler(
  154. handler,
  155. globalListener = false,
  156. ) {
  157. return (event) => {
  158. // It's possible this handler might trigger multiple times for the same
  159. // event (e.g. event propagation through node ancestors).
  160. // Ignore if we've already captured that event.
  161. if (!event || event['_sentryCaptured']) {
  162. return;
  163. }
  164. const target = getEventTarget(event);
  165. // We always want to skip _some_ events.
  166. if (shouldSkipDOMEvent(event.type, target)) {
  167. return;
  168. }
  169. // Mark event as "seen"
  170. addNonEnumerableProperty(event, '_sentryCaptured', true);
  171. if (target && !target._sentryId) {
  172. // Add UUID to event target so we can identify if
  173. addNonEnumerableProperty(target, '_sentryId', uuid4());
  174. }
  175. const name = event.type === 'keypress' ? 'input' : event.type;
  176. // If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons.
  177. // If there is a last captured event, see if the new event is different enough to treat it as a unique one.
  178. // If that's the case, emit the previous event and store locally the newly-captured DOM event.
  179. if (!isSimilarToLastCapturedEvent(event)) {
  180. const handlerData = { event, name, global: globalListener };
  181. handler(handlerData);
  182. lastCapturedEventType = event.type;
  183. lastCapturedEventTargetId = target ? target._sentryId : undefined;
  184. }
  185. // Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together.
  186. clearTimeout(debounceTimerID);
  187. debounceTimerID = WINDOW.setTimeout(() => {
  188. lastCapturedEventTargetId = undefined;
  189. lastCapturedEventType = undefined;
  190. }, DEBOUNCE_DURATION);
  191. };
  192. }
  193. function getEventTarget(event) {
  194. try {
  195. return event.target ;
  196. } catch (e) {
  197. // just accessing `target` property can throw an exception in some rare circumstances
  198. // see: https://github.com/getsentry/sentry-javascript/issues/838
  199. return null;
  200. }
  201. }
  202. export { addClickKeypressInstrumentationHandler, instrumentDOM };
  203. //# sourceMappingURL=dom.js.map