From 9af7fb8675cd9766f1e0f0749c92046824c31f96 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 14:06:14 +0100 Subject: [PATCH 1/6] fix: cache element names as interactions happen --- packages/browser-utils/src/metrics/inp.ts | 83 ++++++++++++++++++++--- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 30a628b5997f..70a39f865310 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -12,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import { WINDOW } from '../types'; import type { InstrumentationHandlerCallback } from './instrument'; import { addInpInstrumentationHandler, @@ -20,8 +21,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 +120,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 +158,63 @@ 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); + 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 +228,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 +237,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, + }); }); }; From a5b9afe899a7d9b721ad846ce51c92cba7999429 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 14:06:47 +0100 Subject: [PATCH 2/6] tests: update the test --- .../suites/tracing/metrics/web-vitals-inp-navigate/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 6d13ff70189b13b4b83f7995f036eb50015491fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 14:34:31 +0100 Subject: [PATCH 3/6] fix: check for browser first --- packages/browser-utils/src/metrics/inp.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 70a39f865310..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, @@ -160,9 +161,11 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { export function registerInpInteractionListener(): void { // Listen for all interaction events that could contribute to INP const interactionEvents = Object.keys(INP_ENTRY_MAP); - interactionEvents.forEach(eventType => { - WINDOW.addEventListener(eventType, captureElementFromEvent, { capture: true, passive: true }); - }); + 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. From 93b88788c7d8e392579a9cddcd31fe0cddbd232b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 14:40:49 +0100 Subject: [PATCH 4/6] test: added unit for unknown case --- .../browser-utils/test/metrics/inpt.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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, + }); + }); }); From 906493f0a28c7f8e694e127a31bbf525b0a85b45 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 16:58:44 +0100 Subject: [PATCH 5/6] chore: bump size limits --- .size-limit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index ed7fbc7ccc80..ef6499558c8e 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) { From c6f3d503b91ae4874ff370727e0bb249f9f6941c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 29 Oct 2025 21:54:18 +0100 Subject: [PATCH 6/6] fix: bump size --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index ef6499558c8e..cada598de81b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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',