diff --git a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/template.html b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/template.html new file mode 100644 index 000000000000..5048dfd754f2 --- /dev/null +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/test.ts b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/test.ts new file mode 100644 index 000000000000..bd8f0e9270c6 --- /dev/null +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/test.ts @@ -0,0 +1,59 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../../utils/helpers'; + +sentryTest('captures Breadcrumb for clicks & debounces them for a second', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + userNames: ['John', 'Jane'], + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const promise = getFirstSentryEnvelopeRequest(page); + + await page.goto(url); + + await page.click('#button1'); + // not debounced because other target + await page.click('#button2'); + // This should be debounced + await page.click('#button2'); + + // Wait a second for the debounce to finish + await page.waitForTimeout(1000); + await page.click('#button2'); + + await page.evaluate('Sentry.captureException("test exception")'); + + const eventData = await promise; + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData.breadcrumbs).toEqual([ + { + timestamp: expect.any(Number), + category: 'ui.click', + message: 'body > button#button1[type="button"]', + }, + { + timestamp: expect.any(Number), + category: 'ui.click', + message: 'body > button#button2[type="button"]', + }, + { + timestamp: expect.any(Number), + category: 'ui.click', + message: 'body > button#button2[type="button"]', + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/init.js b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/init.js new file mode 100644 index 000000000000..9bd2d9649ed8 --- /dev/null +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, + integrations: [new Sentry.Integrations.Breadcrumbs()], + sampleRate: 1, +}); diff --git a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/template.html b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/template.html new file mode 100644 index 000000000000..b3d53fbf9a3e --- /dev/null +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/test.ts b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/test.ts new file mode 100644 index 000000000000..b3393561f331 --- /dev/null +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/test.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../../utils/helpers'; + +sentryTest('captures Breadcrumb for events on inputs & debounced them', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + userNames: ['John', 'Jane'], + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const promise = getFirstSentryEnvelopeRequest(page); + + await page.goto(url); + + await page.click('#input1'); + // Not debounced because other event type + await page.type('#input1', 'John', { delay: 1 }); + // This should be debounced + await page.type('#input1', 'Abby', { delay: 1 }); + // not debounced because other target + await page.type('#input2', 'Anne', { delay: 1 }); + + // Wait a second for the debounce to finish + await page.waitForTimeout(1000); + await page.type('#input2', 'John', { delay: 1 }); + + await page.evaluate('Sentry.captureException("test exception")'); + + const eventData = await promise; + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData.breadcrumbs).toEqual([ + { + timestamp: expect.any(Number), + category: 'ui.click', + message: 'body > input#input1[type="text"]', + }, + { + timestamp: expect.any(Number), + category: 'ui.input', + message: 'body > input#input1[type="text"]', + }, + { + timestamp: expect.any(Number), + category: 'ui.input', + message: 'body > input#input2[type="text"]', + }, + { + timestamp: expect.any(Number), + category: 'ui.input', + message: 'body > input#input2[type="text"]', + }, + ]); +}); diff --git a/packages/utils/src/instrument.ts b/packages/utils/src/instrument.ts index 6d85ec2036fc..5d5ce0f7c616 100644 --- a/packages/utils/src/instrument.ts +++ b/packages/utils/src/instrument.ts @@ -12,6 +12,7 @@ import type { import { isString } from './is'; import type { ConsoleLevel } from './logger'; import { CONSOLE_LEVELS, logger, originalConsoleMethods } from './logger'; +import { uuid4 } from './misc'; import { addNonEnumerableProperty, fill } from './object'; import { getFunctionName } from './stacktrace'; import { supportsHistory, supportsNativeFetch } from './supports'; @@ -404,21 +405,24 @@ function instrumentHistory(): void { const DEBOUNCE_DURATION = 1000; let debounceTimerID: number | undefined; -let lastCapturedEvent: Event | undefined; +let lastCapturedEventType: string | undefined; +let lastCapturedEventTargetId: string | undefined; + +type SentryWrappedTarget = HTMLElement & { _sentryId?: string }; /** - * Check whether two DOM events are similar to eachother. For example, two click events on the same button. + * Check whether the event is similar to the last captured one. For example, two click events on the same button. */ -function areSimilarDomEvents(a: Event, b: Event): boolean { +function isSimilarToLastCapturedEvent(event: Event): boolean { // If both events have different type, then user definitely performed two separate actions. e.g. click + keypress. - if (a.type !== b.type) { + 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 (a.target !== b.target) { + if (!event.target || (event.target as SentryWrappedTarget)._sentryId !== lastCapturedEventTargetId) { return false; } } catch (e) { @@ -436,30 +440,33 @@ function areSimilarDomEvents(a: Event, b: Event): boolean { * Decide whether an event should be captured. * @param event event to be captured */ -function shouldSkipDOMEvent(event: Event): boolean { +function shouldSkipDOMEvent(eventType: string, target: SentryWrappedTarget | null): boolean { // We are only interested in filtering `keypress` events for now. - if (event.type !== 'keypress') { + if (eventType !== 'keypress') { return false; } - try { - const target = event.target as HTMLElement; + if (!target || !target.tagName) { + return true; + } - 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; + } - // 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; +} + +function getEventTarget(event: Event): SentryWrappedTarget | null { + try { + return event.target as SentryWrappedTarget | null; } 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; } - - return true; } /** @@ -478,32 +485,41 @@ function makeDOMEventHandler(handler: Function, globalListener: boolean = false) return; } + const target = getEventTarget(event); + // We always want to skip _some_ events. - if (shouldSkipDOMEvent(event)) { + 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 (lastCapturedEvent === undefined || !areSimilarDomEvents(lastCapturedEvent, event)) { + if (!isSimilarToLastCapturedEvent(event)) { handler({ event: event, name, global: globalListener, }); - lastCapturedEvent = event; + 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(() => { - lastCapturedEvent = undefined; + lastCapturedEventTargetId = undefined; + lastCapturedEventType = undefined; }, DEBOUNCE_DURATION); }; } diff --git a/rollup/plugins/bundlePlugins.js b/rollup/plugins/bundlePlugins.js index ece32a26f4b3..d012b32d1f0e 100644 --- a/rollup/plugins/bundlePlugins.js +++ b/rollup/plugins/bundlePlugins.js @@ -131,6 +131,9 @@ export function makeTerserPlugin() { '_meta', // Object we inject debug IDs into with bundler plugins '_sentryDebugIds', + // These are used by instrument.ts in utils for identifying HTML elements & events + '_sentryCaptured', + '_sentryId', ], }, },