diff --git a/.size-limit.js b/.size-limit.js index ed7fbc7ccc80..cada598de81b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41 KB', + limit: '41.3 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43 KB', + limit: '43.3 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43 KB', + limit: '43.1 KB', }, // Svelte SDK (ESM) { @@ -190,7 +190,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '124 KB', + limit: '124.1 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index ad7862926ebf..cf3cdb552cbf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -159,7 +159,7 @@ sentryTest( value: inpValue, }, }, - description: '', // FIXME: currently unable to get the target name when element is removed from DOM + description: 'body > nav#navigation > NavigationLink', exclusive_time: inpValue, op: 'ui.interaction.click', origin: 'auto.http.browser.inp', diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 30a628b5997f..831565f07408 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -5,6 +5,7 @@ import { getCurrentScope, getRootSpan, htmlTreeAsString, + isBrowser, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, @@ -12,6 +13,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import { WINDOW } from '../types'; import type { InstrumentationHandlerCallback } from './instrument'; import { addInpInstrumentationHandler, @@ -20,8 +22,16 @@ import { } from './instrument'; import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils'; +interface InteractionContext { + span: Span | undefined; + elementName: string; +} + const LAST_INTERACTIONS: number[] = []; -const INTERACTIONS_SPAN_MAP = new Map(); +const INTERACTIONS_SPAN_MAP = new Map(); + +// Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry +const ELEMENT_NAME_TIMESTAMP_MAP = new Map(); /** * 60 seconds is the maximum for a plausible INP value @@ -111,17 +121,17 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - // We first try to lookup the span from our INTERACTIONS_SPAN_MAP, - // where we cache the route per interactionId - const cachedSpan = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; + // We first try to lookup the interaction context from our INTERACTIONS_SPAN_MAP, + // where we cache the route and element name per interactionId + const cachedInteractionContext = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; - const spanToUse = cachedSpan || rootSpan; + const spanToUse = cachedInteractionContext?.span || rootSpan; // Else, we try to use the active span. // Finally, we fall back to look at the transactionName on the scope const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName; - const name = htmlTreeAsString(entry.target); + const name = cachedInteractionContext?.elementName || htmlTreeAsString(entry.target); const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`, @@ -149,12 +159,65 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { * Register a listener to cache route information for INP interactions. */ export function registerInpInteractionListener(): void { + // Listen for all interaction events that could contribute to INP + const interactionEvents = Object.keys(INP_ENTRY_MAP); + if (isBrowser()) { + interactionEvents.forEach(eventType => { + WINDOW.addEventListener(eventType, captureElementFromEvent, { capture: true, passive: true }); + }); + } + + /** + * Captures the element name from a DOM event and stores it in the ELEMENT_NAME_TIMESTAMP_MAP. + */ + function captureElementFromEvent(event: Event): void { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + const elementName = htmlTreeAsString(target); + const timestamp = Math.round(event.timeStamp); + + // Store the element name by timestamp so we can match it with the PerformanceEntry + ELEMENT_NAME_TIMESTAMP_MAP.set(timestamp, elementName); + + // Clean up old + if (ELEMENT_NAME_TIMESTAMP_MAP.size > 50) { + const firstKey = ELEMENT_NAME_TIMESTAMP_MAP.keys().next().value; + if (firstKey !== undefined) { + ELEMENT_NAME_TIMESTAMP_MAP.delete(firstKey); + } + } + } + + /** + * Tries to get the element name from the timestamp map. + */ + function resolveElementNameFromEntry(entry: PerformanceEntry): string { + const timestamp = Math.round(entry.startTime); + let elementName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp); + + // try nearby timestamps (±5ms) + if (!elementName) { + for (let offset = -5; offset <= 5; offset++) { + const nearbyName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp + offset); + if (nearbyName) { + elementName = nearbyName; + break; + } + } + } + + return elementName || ''; + } + const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => { const activeSpan = getActiveSpan(); const activeRootSpan = activeSpan && getRootSpan(activeSpan); entries.forEach(entry => { - if (!isPerformanceEventTiming(entry) || !activeRootSpan) { + if (!isPerformanceEventTiming(entry)) { return; } @@ -168,6 +231,8 @@ export function registerInpInteractionListener(): void { return; } + const elementName = entry.target ? htmlTreeAsString(entry.target) : resolveElementNameFromEntry(entry); + // We keep max. 10 interactions in the list, then remove the oldest one & clean up if (LAST_INTERACTIONS.length > 10) { const last = LAST_INTERACTIONS.shift() as number; @@ -175,9 +240,12 @@ export function registerInpInteractionListener(): void { } // We add the interaction to the list of recorded interactions - // and store the span for this interaction + // and store both the span and element name for this interaction LAST_INTERACTIONS.push(interactionId); - INTERACTIONS_SPAN_MAP.set(interactionId, activeRootSpan); + INTERACTIONS_SPAN_MAP.set(interactionId, { + span: activeRootSpan, + elementName, + }); }); }; diff --git a/packages/browser-utils/test/metrics/inpt.test.ts b/packages/browser-utils/test/metrics/inpt.test.ts index bfa44b17a5b4..f53fa40bf6da 100644 --- a/packages/browser-utils/test/metrics/inpt.test.ts +++ b/packages/browser-utils/test/metrics/inpt.test.ts @@ -113,4 +113,35 @@ describe('_onInp', () => { transaction: undefined, }); }); + + it('uses as element name when entry.target is null and no cached name exists', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: 150, + entries: [ + { + name: 'click', + duration: 150, + interactionId: 999, + target: null, // Element was removed from DOM + startTime: 1234567, + }, + ], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1); + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({ + attributes: { + 'sentry.exclusive_time': 150, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + }, + name: '', // Should fall back to when element cannot be determined + startTime: expect.any(Number), + transaction: undefined, + }); + }); });