dom.js 8.8 KB

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