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 index 5048dfd754f2..e54da47ff09d 100644 --- a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/template.html +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/template.html @@ -7,5 +7,6 @@ + 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 index 88a1c89fba0d..f965b8c93a83 100644 --- a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/test.ts +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/click/test.ts @@ -55,3 +55,39 @@ sentryTest('captures Breadcrumb for clicks & debounces them for a second', async }, ]); }); + +sentryTest( + 'uses the annotated component name in the breadcrumb messages and adds it to the data object', + 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('#annotated-button'); + await page.evaluate('Sentry.captureException("test exception")'); + + const eventData = await promise; + + expect(eventData.breadcrumbs).toEqual([ + { + timestamp: expect.any(Number), + category: 'ui.click', + message: 'body > AnnotatedButton', + data: { componentName: 'AnnotatedButton' }, + }, + ]); + }, +); 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 index b3d53fbf9a3e..a16ca41e45da 100644 --- a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/template.html +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/template.html @@ -7,5 +7,6 @@ + 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 index b3393561f331..e36e8c17ed2e 100644 --- a/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/test.ts +++ b/packages/browser-integration-tests/suites/integrations/Breadcrumbs/dom/textInput/test.ts @@ -64,3 +64,48 @@ sentryTest('captures Breadcrumb for events on inputs & debounced them', async ({ }, ]); }); + +sentryTest( + 'includes the annotated component name within the breadcrumb message and data', + 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('#annotated-input'); + await page.type('#annotated-input', '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 > AnnotatedInput', + data: { componentName: 'AnnotatedInput' }, + }, + { + timestamp: expect.any(Number), + category: 'ui.input', + message: 'body > AnnotatedInput', + data: { componentName: 'AnnotatedInput' }, + }, + ]); + }, +); diff --git a/packages/browser-integration-tests/suites/replay/captureComponentName/init.js b/packages/browser-integration-tests/suites/replay/captureComponentName/init.js new file mode 100644 index 000000000000..dac512988b9a --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/captureComponentName/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/captureComponentName/template.html b/packages/browser-integration-tests/suites/replay/captureComponentName/template.html new file mode 100644 index 000000000000..1cb45daa349a --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/captureComponentName/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/replay/captureComponentName/test.ts b/packages/browser-integration-tests/suites/replay/captureComponentName/test.ts new file mode 100644 index 000000000000..99b7a71273e3 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/captureComponentName/test.ts @@ -0,0 +1,83 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest('captures component name attribute when available', async ({ forceFlushReplay, getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + await forceFlushReplay(); + + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + return getCustomRecordingEvents(res).breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.input'); + }); + + await page.locator('#button').click(); + + await page.locator('#input').focus(); + await page.keyboard.press('Control+A'); + await page.keyboard.type('Hello', { delay: 10 }); + + await forceFlushReplay(); + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs: breadcrumbs2 } = getCustomRecordingEvents(await reqPromise2); + + // Combine the two together + breadcrumbs2.forEach(breadcrumb => { + if (!breadcrumbs.some(b => b.category === breadcrumb.category && b.timestamp === breadcrumb.timestamp)) { + breadcrumbs.push(breadcrumb); + } + }); + + expect(breadcrumbs).toEqual([ + { + timestamp: expect.any(Number), + type: 'default', + category: 'ui.click', + message: 'body > MyCoolButton', + data: { + nodeId: expect.any(Number), + node: { + attributes: { id: 'button', 'data-sentry-component': 'MyCoolButton' }, + id: expect.any(Number), + tagName: 'button', + textContent: '**', + }, + }, + }, + { + timestamp: expect.any(Number), + type: 'default', + category: 'ui.input', + message: 'body > MyCoolInput', + data: { + nodeId: expect.any(Number), + node: { + attributes: { id: 'input', 'data-sentry-component': 'MyCoolInput' }, + id: expect.any(Number), + tagName: 'input', + textContent: '', + }, + }, + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/assets/script.js b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/assets/script.js index 89d814bd397d..a37a2c70ad27 100644 --- a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/assets/script.js +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/assets/script.js @@ -14,3 +14,4 @@ const delay = e => { }; document.querySelector('[data-test-id=interaction-button]').addEventListener('click', delay); +document.querySelector('[data-test-id=annotated-button]').addEventListener('click', delay); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/template.html b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/template.html index e16deb9ee519..3357fb20a94e 100644 --- a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/template.html +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/template.html @@ -6,6 +6,7 @@
Rendered Before Long Task
+ diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/test.ts b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/test.ts index e79b724ec91a..131403756251 100644 --- a/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/test.ts +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/interactions/test.ts @@ -80,3 +80,35 @@ sentryTest( } }, ); + +sentryTest( + 'should use the component name for a clicked element when it is available', + async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); + + await page.locator('[data-test-id=annotated-button]').click(); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 1); + expect(envelopes).toHaveLength(1); + const eventData = envelopes[0]; + + expect(eventData.spans).toHaveLength(1); + + const interactionSpan = eventData.spans![0]; + expect(interactionSpan.op).toBe('ui.interaction.click'); + expect(interactionSpan.description).toBe('body > AnnotatedButton'); + }, +); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 9c6b4cfb9764..d46846c4d3f2 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -10,6 +10,7 @@ import type { Integration, } from '@sentry/types'; import type { + Breadcrumb, FetchBreadcrumbData, FetchBreadcrumbHint, XhrBreadcrumbData, @@ -22,6 +23,7 @@ import { addFetchInstrumentationHandler, addHistoryInstrumentationHandler, addXhrInstrumentationHandler, + getComponentName, getEventDescription, htmlTreeAsString, logger, @@ -143,6 +145,7 @@ function addSentryBreadcrumb(event: SentryEvent): void { function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDataDom) => void { function _innerDomBreadcrumb(handlerData: HandlerDataDom): void { let target; + let componentName; let keyAttrs = typeof dom === 'object' ? dom.serializeAttribute : undefined; let maxStringLength = @@ -165,6 +168,8 @@ function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDa target = _isEvent(event) ? htmlTreeAsString(event.target, { keyAttrs, maxStringLength }) : htmlTreeAsString(event, { keyAttrs, maxStringLength }); + + componentName = _isEvent(event) ? getComponentName(event.target) : getComponentName(event); } catch (e) { target = ''; } @@ -173,17 +178,20 @@ function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDa return; } - addBreadcrumb( - { - category: `ui.${handlerData.name}`, - message: target, - }, - { - event: handlerData.event, - name: handlerData.name, - global: handlerData.global, - }, - ); + const breadcrumb: Breadcrumb = { + category: `ui.${handlerData.name}`, + message: target, + }; + + if (componentName) { + breadcrumb.data = { componentName }; + } + + addBreadcrumb(breadcrumb, { + event: handlerData.event, + name: handlerData.name, + global: handlerData.global, + }); } return _innerDomBreadcrumb; diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx index 9647d34f0fb4..b8ffe2f1708a 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -63,6 +63,7 @@ class Profiler extends React.Component { description: `<${name}>`, op: REACT_MOUNT_OP, origin: 'auto.ui.react.profiler', + data: { 'ui.component_name': name }, }); } } @@ -87,6 +88,7 @@ class Profiler extends React.Component { this._updateSpan = this._mountSpan.startChild({ data: { changedProps, + 'ui.component_name': this.props.name, }, description: `<${this.props.name}>`, op: REACT_UPDATE_OP, @@ -120,6 +122,7 @@ class Profiler extends React.Component { op: REACT_RENDER_OP, origin: 'auto.ui.react.profiler', startTimestamp: this._mountSpan.endTimestamp, + data: { 'ui.component_name': name }, }); } } @@ -184,6 +187,7 @@ function useProfiler( description: `<${name}>`, op: REACT_MOUNT_OP, origin: 'auto.ui.react.profiler', + data: { 'ui.component_name': name }, }); } @@ -203,6 +207,7 @@ function useProfiler( op: REACT_RENDER_OP, origin: 'auto.ui.react.profiler', startTimestamp: mountSpan.endTimestamp, + data: { 'ui.component_name': name }, }); } }; diff --git a/packages/react/test/profiler.test.tsx b/packages/react/test/profiler.test.tsx index 70eaff2d2c8b..fa629434cdf3 100644 --- a/packages/react/test/profiler.test.tsx +++ b/packages/react/test/profiler.test.tsx @@ -80,6 +80,7 @@ describe('withProfiler', () => { description: `<${UNKNOWN_COMPONENT}>`, op: REACT_MOUNT_OP, origin: 'auto.ui.react.profiler', + data: { 'ui.component_name': 'unknown' }, }); }); }); @@ -99,6 +100,7 @@ describe('withProfiler', () => { op: REACT_RENDER_OP, origin: 'auto.ui.react.profiler', startTimestamp: undefined, + data: { 'ui.component_name': 'unknown' }, }); }); @@ -114,7 +116,6 @@ describe('withProfiler', () => { expect(mockStartChild).toHaveBeenCalledTimes(1); }); }); - describe('update span', () => { it('is created when component is updated', () => { const ProfiledComponent = withProfiler((props: { num: number }) =>
{props.num}
); @@ -126,7 +127,7 @@ describe('withProfiler', () => { rerender(); expect(mockStartChild).toHaveBeenCalledTimes(2); expect(mockStartChild).toHaveBeenLastCalledWith({ - data: { changedProps: ['num'] }, + data: { changedProps: ['num'], 'ui.component_name': 'unknown' }, description: `<${UNKNOWN_COMPONENT}>`, op: REACT_UPDATE_OP, origin: 'auto.ui.react.profiler', @@ -137,7 +138,7 @@ describe('withProfiler', () => { rerender(); expect(mockStartChild).toHaveBeenCalledTimes(3); expect(mockStartChild).toHaveBeenLastCalledWith({ - data: { changedProps: ['num'] }, + data: { changedProps: ['num'], 'ui.component_name': 'unknown' }, description: `<${UNKNOWN_COMPONENT}>`, op: REACT_UPDATE_OP, origin: 'auto.ui.react.profiler', @@ -180,6 +181,7 @@ describe('useProfiler()', () => { description: '', op: REACT_MOUNT_OP, origin: 'auto.ui.react.profiler', + data: { 'ui.component_name': 'Example' }, }); }); }); @@ -203,6 +205,7 @@ describe('useProfiler()', () => { description: '', op: REACT_RENDER_OP, origin: 'auto.ui.react.profiler', + data: { 'ui.component_name': 'Example' }, }), ); }); diff --git a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts index 13c756901028..f50c2b9f9088 100644 --- a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts +++ b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts @@ -12,6 +12,7 @@ const ATTRIBUTES_TO_RECORD = new Set([ 'data-testid', 'disabled', 'aria-disabled', + 'data-sentry-component', ]); /** diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index 5182897cb0b9..651246dfb688 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -1,8 +1,8 @@ /* eslint-disable max-lines */ import type { IdleTransaction, Transaction } from '@sentry/core'; import { getActiveTransaction } from '@sentry/core'; -import type { Measurements } from '@sentry/types'; -import { browserPerformanceTimeOrigin, htmlTreeAsString, logger } from '@sentry/utils'; +import type { Measurements, SpanContext } from '@sentry/types'; +import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../../common/debug-build'; import { @@ -102,13 +102,20 @@ export function startTrackingInteractions(): void { const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime); const duration = msToSec(entry.duration); - transaction.startChild({ + const span: SpanContext = { description: htmlTreeAsString(entry.target), op: `ui.interaction.${entry.name}`, origin: 'auto.ui.browser.metrics', startTimestamp: startTime, endTimestamp: startTime + duration, - }); + }; + + const componentName = getComponentName(entry.target); + if (componentName) { + span.data = { 'ui.component_name': componentName }; + } + + transaction.startChild(span); } } }); diff --git a/packages/utils/src/browser.ts b/packages/utils/src/browser.ts index d2d8f7af9a72..0300133c8c84 100644 --- a/packages/utils/src/browser.ts +++ b/packages/utils/src/browser.ts @@ -6,6 +6,10 @@ const WINDOW = getGlobalObject(); const DEFAULT_MAX_STRING_LENGTH = 80; +type SimpleNode = { + parentNode: SimpleNode; +} | null; + /** * Given a child DOM element, returns a query-selector statement describing that * and its ancestors @@ -16,10 +20,6 @@ export function htmlTreeAsString( elem: unknown, options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, ): string { - type SimpleNode = { - parentNode: SimpleNode; - } | null; - if (!elem) { return ''; } @@ -86,6 +86,11 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { return ''; } + // If using the component name annotation plugin, this value may be available on the DOM node + if (elem instanceof HTMLElement && elem.dataset && elem.dataset['sentryComponent']) { + return elem.dataset['sentryComponent']; + } + out.push(elem.tagName.toLowerCase()); // Pairs of attribute keys defined in `serializeAttribute` and their values on element. @@ -157,3 +162,29 @@ export function getDomElement(selector: string): E | null { } return null; } + +/** + * Given a DOM element, traverses up the tree until it finds the first ancestor node + * that has the `data-sentry-component` attribute. This attribute is added at build-time + * by projects that have the component name annotation plugin installed. + * + * @returns a string representation of the component for the provided DOM element, or `null` if not found + */ +export function getComponentName(elem: unknown): string | null { + let currentElem = elem as SimpleNode; + const MAX_TRAVERSE_HEIGHT = 5; + + for (let i = 0; i < MAX_TRAVERSE_HEIGHT; i++) { + if (!currentElem) { + return null; + } + + if (currentElem instanceof HTMLElement && currentElem.dataset['sentryComponent']) { + return currentElem.dataset['sentryComponent']; + } + + currentElem = currentElem.parentNode; + } + + return null; +} diff --git a/packages/utils/test/browser.test.ts b/packages/utils/test/browser.test.ts index 040789fe8426..5c7df188664e 100644 --- a/packages/utils/test/browser.test.ts +++ b/packages/utils/test/browser.test.ts @@ -6,6 +6,8 @@ beforeAll(() => { const dom = new JSDOM(); // @ts-expect-error need to override global document global.document = dom.window.document; + // @ts-expect-error need to add HTMLElement type or it will not be found + global.HTMLElement = new JSDOM().window.HTMLElement; }); describe('htmlTreeAsString', () => {