From e591726dba2bdc2f867a63c964c963ee6f214836 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 12:11:55 -0400 Subject: [PATCH 1/5] feat(browser): Report LCP and INP subparts on web vital spans --- .../metrics/web-vitals-inp-late/test.ts | 15 ++ .../metrics/web-vitals-inp-navigate/test.ts | 27 +++ .../web-vitals-inp-parametrized-late/test.ts | 15 ++ .../web-vitals-inp-parametrized/test.ts | 15 ++ .../tracing/metrics/web-vitals-inp/test.ts | 8 + .../tracing/metrics/web-vitals-lcp/test.ts | 14 ++ packages/browser-utils/src/web-vitals/inp.ts | 38 +++- packages/browser-utils/src/web-vitals/lcp.ts | 52 +++++ .../browser-utils/src/web-vitals/spans.ts | 16 +- .../test/web-vitals/spans.test.ts | 189 ++++++++++++++++++ 10 files changed, 386 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 3847ee0df51a..b979e03c01ab 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -8,6 +8,9 @@ import { BROWSER_WEB_VITAL_INP_TARGET, SENTRY_EXCLUSIVE_TIME, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, BROWSER_NAVIGATION_TYPE, SENTRY_TRANSACTION, SENTRY_SEGMENT_NAME, @@ -108,6 +111,18 @@ sentryTest( [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' }, + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, [BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' }, [SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' }, [SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' }, 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 edacd3444d55..e9109ffccdd9 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 @@ -8,6 +8,9 @@ import { BROWSER_WEB_VITAL_INP_TARGET, SENTRY_EXCLUSIVE_TIME, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, BROWSER_NAVIGATION_TYPE, SENTRY_TRANSACTION, SENTRY_SEGMENT_NAME, @@ -108,6 +111,18 @@ sentryTest( [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' }, + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, [BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' }, [SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' }, [SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' }, @@ -198,6 +213,18 @@ sentryTest( [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' }, + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, [BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' }, [SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' }, [SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index d06da7a58912..c3dcd7bc007a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -8,6 +8,9 @@ import { BROWSER_WEB_VITAL_INP_TARGET, SENTRY_EXCLUSIVE_TIME, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, BROWSER_NAVIGATION_TYPE, SENTRY_TRANSACTION, SENTRY_SEGMENT_NAME, @@ -109,6 +112,18 @@ sentryTest( [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' }, + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, [BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span [SENTRY_TRANSACTION]: { value: 'test-route', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index 269c543c4dec..be3cec3bb1a5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -8,6 +8,9 @@ import { BROWSER_WEB_VITAL_INP_TARGET, SENTRY_EXCLUSIVE_TIME, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, BROWSER_NAVIGATION_TYPE, SENTRY_TRANSACTION, SENTRY_SEGMENT_NAME, @@ -109,6 +112,18 @@ sentryTest( [SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, [BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' }, + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: { + value: expect.any(Number), + type: expect.stringMatching(/^(integer)|(double)$/), + }, [BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span [SENTRY_TRANSACTION]: { value: 'test-route', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index ac0f4ea88aa4..091db2e515e0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -9,6 +9,9 @@ import { SENTRY_EXCLUSIVE_TIME, UI_COMPONENT_NAME, BROWSER_WEB_VITAL_INP_TARGET, + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; @@ -97,4 +100,9 @@ sentryTest('captures the slowest interaction as streamed INP span', async ({ get const inpValue = inpSpan.attributes[BROWSER_WEB_VITAL_INP_VALUE]?.value as number; expect(inpValue).toBeGreaterThan(400); + + // The slow button blocks inside its click handler, so the time lands in processing. + expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]?.value).toBeGreaterThan(400); + expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_INPUT_DELAY]?.value).toBeGreaterThanOrEqual(0); + expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]?.value).toBeGreaterThanOrEqual(0); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts index 5753e13389c0..73ed1d4c0b2b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts @@ -11,6 +11,9 @@ import { BROWSER_WEB_VITAL_LCP_RENDER_TIME, BROWSER_WEB_VITAL_LCP_LOAD_TIME, SENTRY_PAGELOAD_SPAN_ID, + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY, + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION, + BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY, } from '@sentry/conventions/attributes'; import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; @@ -73,6 +76,17 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g expect(lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_LOAD_TIME]?.value).toBeGreaterThan(0); expect(lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_VALUE]?.value).toBeCloseTo(renderTime); + // Together with TTFB, the subparts add up to the LCP value. + const subparts = [ + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY, + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION, + BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY, + ].map(key => lcpSpan.attributes[key]?.value as number); + subparts.forEach(subpart => expect(subpart).toBeGreaterThanOrEqual(0)); + expect(subparts.reduce((sum, subpart) => sum + subpart, 0)).toBeLessThanOrEqual( + lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_VALUE]?.value as number, + ); + // Check pageload span id is present expect(lcpSpan.attributes[SENTRY_PAGELOAD_SPAN_ID]?.value).toBe(pageloadSpan.span_id); diff --git a/packages/browser-utils/src/web-vitals/inp.ts b/packages/browser-utils/src/web-vitals/inp.ts index af03ceafcd94..721e7212ae56 100644 --- a/packages/browser-utils/src/web-vitals/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -1,7 +1,13 @@ -import type { Span } from '@sentry/core'; +import type { Span, SpanAttributes } from '@sentry/core'; import { getActiveSpan, getRootSpan, isBrowser } from '@sentry/core'; +import { + BROWSER_WEB_VITAL_INP_INPUT_DELAY, + BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY, + BROWSER_WEB_VITAL_INP_PROCESSING_DURATION, +} from '@sentry/conventions/attributes'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; +import type { PerformanceEventTiming } from '../instrumentation/performanceObserver'; import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from '../instrumentation/performanceObserver'; interface InteractionContext { @@ -174,3 +180,33 @@ export function registerInpInteractionListener(): void { addPerformanceInstrumentationHandler('event', handleEntries); addPerformanceInstrumentationHandler('first-input', handleEntries); } + +/** + * The INP subparts of one interaction, which add up to the time from the input to the next paint. + * + * An interaction is several events (e.g. `pointerdown`, `pointerup`, `click`) that share one + * next paint, and the slow handler can be on any of them. Measuring a single entry would count + * the other events' handlers as presentation delay, so the processing window spans all of them. + */ +export function getInpSubpartAttributes( + entry: PerformanceEventTiming | undefined, + metricEntries: PerformanceEventTiming[] = [], +): SpanAttributes | undefined { + if (!entry) { + return undefined; + } + + const interactionEntries = metricEntries.filter(e => e.interactionId === entry.interactionId); + const entries = interactionEntries.length ? interactionEntries : [entry]; + const startTime = Math.min(...entries.map(entry => entry.startTime)); + const processingStart = Math.min(...entries.map(entry => entry.processingStart)); + const processingEnd = Math.max(...entries.map(entry => entry.processingEnd)); + const nextPaintTime = Math.max(...entries.map(entry => entry.startTime + entry.duration)); + + // `duration` is rounded to 8ms, so the presentation delay it implies can dip below zero. + return { + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: Math.max(processingStart - startTime, 0), + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: Math.max(processingEnd - processingStart, 0), + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: Math.max(nextPaintTime - processingEnd, 0), + }; +} diff --git a/packages/browser-utils/src/web-vitals/lcp.ts b/packages/browser-utils/src/web-vitals/lcp.ts index 756a53670c29..d664bce6d430 100644 --- a/packages/browser-utils/src/web-vitals/lcp.ts +++ b/packages/browser-utils/src/web-vitals/lcp.ts @@ -1,3 +1,13 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY, + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY, + BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION, +} from '@sentry/conventions/attributes'; +import { WINDOW } from '../types'; +import type { LargestContentfulPaint } from './emitSpan'; +import { getNavigationEntry } from './utils'; + /** * 60 seconds is the maximum for a plausible LCP value. */ @@ -6,3 +16,45 @@ export const MAX_PLAUSIBLE_LCP_DURATION = 60_000; export function isValidLcpMetric(lcpValue: number | undefined): lcpValue is number { return lcpValue != null && lcpValue > 0 && lcpValue <= MAX_PLAUSIBLE_LCP_DURATION; } + +/** + * The LCP subparts that follow TTFB, which together with TTFB add up to the LCP value. + * + * Only meaningful for the navigation that loaded the document, since soft navigations and bfcache + * restores have no TTFB to split from. Returns nothing when the LCP image has no resource timing + * entry (e.g. the buffer was full), because guessing would report a load delay that never happened. + */ +export function getLcpSubpartAttributes(lcpValue: number, entry: LargestContentfulPaint): SpanAttributes | undefined { + const navigationEntry = getNavigationEntry(); + if (!navigationEntry) { + return undefined; + } + + const activationStart = navigationEntry.activationStart || 0; + const ttfb = Math.max(navigationEntry.responseStart - activationStart, 0); + if (ttfb > lcpValue) { + return undefined; + } + + let requestStart = ttfb; + let responseEnd = ttfb; + + // Text and inline (data:, blob:) images aren't fetched, so all time after TTFB is render delay. + if (/^https?:/.test(entry.url)) { + const resourceEntry = WINDOW.performance.getEntriesByType('resource').find(resource => resource.name === entry.url); + if (!resourceEntry) { + return undefined; + } + + // `requestStart` is 0 for cross-origin resources without `Timing-Allow-Origin`. + const resourceRequestStart = (resourceEntry.requestStart || resourceEntry.startTime) - activationStart; + requestStart = Math.min(Math.max(resourceRequestStart, ttfb), lcpValue); + responseEnd = Math.min(Math.max(resourceEntry.responseEnd - activationStart, requestStart), lcpValue); + } + + return { + [BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY]: requestStart - ttfb, + [BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION]: responseEnd - requestStart, + [BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY]: lcpValue - responseEnd, + }; +} diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index ba08eceafcb0..64d211643c99 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -22,7 +22,13 @@ import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { getComponentName } from '../component-name'; import type { InteractionType } from './inp'; -import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION, UNKNOWN_ELEMENT_NAME } from './inp'; +import { + getCachedInteractionContext, + getInpSubpartAttributes, + INP_ENTRY_MAP, + MAX_PLAUSIBLE_INP_DURATION, + UNKNOWN_ELEMENT_NAME, +} from './inp'; import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver'; import { addClsInstrumentationHandler, @@ -45,7 +51,7 @@ import { BROWSER_WEB_VITAL_LCP_URL, } from '@sentry/conventions/attributes'; import { _emitWebVitalSpan } from './emitSpan'; -import { isValidLcpMetric } from './lcp'; +import { getLcpSubpartAttributes, isValidLcpMetric } from './lcp'; import { listenForWebVitalReportEvents } from './reportEvents'; import { getNavigationSpanForMetric } from './softNavs'; import { getBrowserPerformanceAPI, msToSec, supportsWebVital } from '../performance/utils'; @@ -231,6 +237,10 @@ export function _sendLcpSpan( entry?.renderTime != null && (attributes[BROWSER_WEB_VITAL_LCP_RENDER_TIME] = entry.renderTime); entry?.size != null && (attributes[BROWSER_WEB_VITAL_LCP_SIZE] = entry.size); + if (entry && navigationType !== 'soft-navigation' && navigationType !== 'back-forward-cache') { + Object.assign(attributes, getLcpSubpartAttributes(lcpValue, entry)); + } + _emitWebVitalSpan({ name, op: UI_WEBVITAL_LCP, @@ -456,6 +466,8 @@ export function _sendInpSpan( selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes[BROWSER_WEB_VITAL_INP_TARGET] = selector); entryInteractionType && (attributes[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE] = entryInteractionType); + Object.assign(attributes, getInpSubpartAttributes(entry, metric?.entries)); + if (componentName) { attributes[UI_COMPONENT_NAME] = componentName; } diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index ebbb00ab9331..e05dcf8f7ad9 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -2,6 +2,7 @@ import * as SentryCore from '@sentry/core'; import * as SentryCoreBrowser from '@sentry/core/browser'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { htmlTreeAsString } from '../../src/htmlTreeAsString'; +import { WINDOW } from '../../src/types'; import * as inpModule from '../../src/web-vitals/inp'; import * as instrument from '../../src/instrumentation/performanceObserver'; import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; @@ -496,6 +497,125 @@ describe('_sendLcpSpan', () => { expect(mockSpan.end).toHaveBeenCalledWith((1000 + sleepDurationMs + 2250) / 1000); }); + describe('subparts', () => { + const url = 'https://example.com/hero.jpg'; + const imageEntry = { element: { tagName: 'img' } as Element, url, startTime: 400 } as LargestContentfulPaint; + const subpartKeys = [ + 'browser.web_vital.lcp.resource_load_delay', + 'browser.web_vital.lcp.resource_load_duration', + 'browser.web_vital.lcp.element_render_delay', + ]; + + function mockPerformanceEntries(navigation: object, resources: object[]): void { + vi.mocked(WINDOW.performance.getEntriesByType).mockImplementation( + type => (type === 'navigation' ? [navigation] : resources) as any, + ); + } + + function expectNoSubparts(): void { + subpartKeys.forEach(key => + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ attributes: expect.not.objectContaining({ [key]: expect.anything() }) }), + ), + ); + } + + beforeEach(() => { + vi.spyOn(performance, 'now').mockReturnValue(10_000); + }); + + afterEach(() => { + vi.mocked(WINDOW.performance.getEntriesByType).mockReturnValue([]); + vi.mocked(performance.now).mockRestore(); + }); + + it('splits an image LCP into load delay, load duration and render delay', () => { + mockPerformanceEntries({ responseStart: 50 }, [ + { name: url, startTime: 100, requestStart: 120, responseEnd: 300 }, + ]); + + _sendLcpSpan(400, imageEntry); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.lcp.resource_load_delay': 70, + 'browser.web_vital.lcp.resource_load_duration': 180, + 'browser.web_vital.lcp.element_render_delay': 100, + }), + }), + ); + }); + + it('falls back to startTime when a cross-origin resource hides requestStart', () => { + mockPerformanceEntries({ responseStart: 50 }, [{ name: url, startTime: 100, requestStart: 0, responseEnd: 300 }]); + + _sendLcpSpan(400, imageEntry); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.lcp.resource_load_delay': 50, + 'browser.web_vital.lcp.resource_load_duration': 200, + 'browser.web_vital.lcp.element_render_delay': 100, + }), + }), + ); + }); + + it('puts all time after TTFB into render delay for a text LCP', () => { + mockPerformanceEntries({ responseStart: 50 }, []); + + _sendLcpSpan(400, { element: { tagName: 'p' } as Element, url: '', startTime: 400 } as LargestContentfulPaint); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.lcp.resource_load_delay': 0, + 'browser.web_vital.lcp.resource_load_duration': 0, + 'browser.web_vital.lcp.element_render_delay': 350, + }), + }), + ); + }); + + it('measures from activationStart on a prerendered page', () => { + mockPerformanceEntries({ responseStart: 50, activationStart: 1000 }, [ + { name: url, startTime: 1100, requestStart: 1100, responseEnd: 1200 }, + ]); + + _sendLcpSpan(300, imageEntry, undefined, undefined, 'prerender'); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.lcp.resource_load_delay': 100, + 'browser.web_vital.lcp.resource_load_duration': 100, + 'browser.web_vital.lcp.element_render_delay': 100, + }), + }), + ); + }); + + it('omits the subparts when the image has no resource timing entry', () => { + mockPerformanceEntries({ responseStart: 50 }, []); + + _sendLcpSpan(400, imageEntry); + + expectNoSubparts(); + }); + + it.each(['soft-navigation', 'back-forward-cache'] as const)('omits the subparts for a %s', navigationType => { + mockPerformanceEntries({ responseStart: 50 }, [ + { name: url, startTime: 100, requestStart: 120, responseEnd: 300 }, + ]); + + _sendLcpSpan(400, imageEntry, undefined, undefined, navigationType); + + expectNoSubparts(); + }); + }); + it('drops implausible LCP values', () => { _sendLcpSpan(0, undefined); _sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined); @@ -691,6 +811,75 @@ describe('_sendInpSpan', () => { expect(mockSpan.end).toHaveBeenCalledWith(1.62); }); + it('reports the INP subparts from the entry', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + + _sendInpSpan(120, { + name: 'pointerdown', + startTime: 500, + processingStart: 540, + processingEnd: 600, + duration: 120, + interactionId: 1, + target: { tagName: 'button' }, + } as any); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.inp.input_delay': 40, + 'browser.web_vital.inp.processing_duration': 60, + 'browser.web_vital.inp.presentation_delay': 20, + }), + }), + ); + }); + + it('measures processing across every event of the interaction', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + // The slow handler is on `click`, but the INP entry is the interaction's first event. + const pointerdown = { + name: 'pointerdown', + startTime: 500, + processingStart: 505, + processingEnd: 506, + duration: 520, + interactionId: 1, + }; + const click = { + name: 'click', + startTime: 510, + processingStart: 515, + processingEnd: 965, + duration: 504, + interactionId: 1, + }; + + _sendInpSpan(520, pointerdown as any, false, undefined, undefined, { entries: [pointerdown, click] } as any); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.inp.input_delay': 5, + 'browser.web_vital.inp.processing_duration': 460, + 'browser.web_vital.inp.presentation_delay': 55, + }), + }), + ); + }); + + it('omits the INP subparts when there is no interaction to attribute', () => { + _sendInpSpan(120, undefined); + + ['input_delay', 'processing_duration', 'presentation_delay'].forEach(subpart => + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ [`browser.web_vital.inp.${subpart}`]: expect.anything() }), + }), + ), + ); + }); + it('uses the time origin from when the interaction happened, not from when it was reported', () => { vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); From 8475d3f4c38414e795ca60ab9ce0adc15cf6116c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 14:40:19 -0400 Subject: [PATCH 2/5] fix(browser): Merge INP subparts across every event of the interaction --- packages/browser-utils/src/web-vitals/inp.ts | 90 +++++++++++++++---- .../browser-utils/src/web-vitals/spans.ts | 2 +- .../browser-utils/test/web-vitals/inp.test.ts | 47 ++++++++++ .../test/web-vitals/spans.test.ts | 59 ++++++------ 4 files changed, 148 insertions(+), 50 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/inp.ts b/packages/browser-utils/src/web-vitals/inp.ts index 721e7212ae56..410c79a7b131 100644 --- a/packages/browser-utils/src/web-vitals/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -18,6 +18,20 @@ interface InteractionContext { const LAST_INTERACTIONS: number[] = []; const INTERACTIONS_SPAN_MAP = new Map(); +interface InteractionTiming { + startTime: number; + processingStart: number; + processingEnd: number; + nextPaintTime: number; +} + +/** + * Timings merged across every event of an interaction, kept for the longest interactions only, the + * way web-vitals keeps its INP candidates. + */ +const INTERACTION_TIMINGS = new Map(); +const MAX_INTERACTION_TIMINGS = 10; + // Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry /** What `htmlTreeAsString` returns when it cannot describe the target. */ export const UNKNOWN_ELEMENT_NAME = ''; @@ -154,6 +168,8 @@ export function registerInpInteractionListener(): void { return; } + recordInteractionTiming(entry); + // If the interaction was already recorded before, nothing more to do if (INTERACTIONS_SPAN_MAP.has(interactionId)) { return; @@ -182,31 +198,67 @@ export function registerInpInteractionListener(): void { } /** - * The INP subparts of one interaction, which add up to the time from the input to the next paint. + * Merges an event entry into the timings of the interaction it belongs to. * - * An interaction is several events (e.g. `pointerdown`, `pointerup`, `click`) that share one - * next paint, and the slow handler can be on any of them. Measuring a single entry would count - * the other events' handlers as presentation delay, so the processing window spans all of them. + * web-vitals only keeps the entries that share the longest entry's start time, which drops e.g. a + * slow `click` that starts after its `pointerdown`. Every entry is seen here instead. */ -export function getInpSubpartAttributes( - entry: PerformanceEventTiming | undefined, - metricEntries: PerformanceEventTiming[] = [], -): SpanAttributes | undefined { - if (!entry) { - return undefined; +export function recordInteractionTiming(entry: PerformanceEventTiming): void { + const interactionId = entry.interactionId; + if (!interactionId) { + return; } - const interactionEntries = metricEntries.filter(e => e.interactionId === entry.interactionId); - const entries = interactionEntries.length ? interactionEntries : [entry]; - const startTime = Math.min(...entries.map(entry => entry.startTime)); - const processingStart = Math.min(...entries.map(entry => entry.processingStart)); - const processingEnd = Math.max(...entries.map(entry => entry.processingEnd)); - const nextPaintTime = Math.max(...entries.map(entry => entry.startTime + entry.duration)); + const nextPaintTime = entry.startTime + entry.duration; + const timing = INTERACTION_TIMINGS.get(interactionId); + if (timing) { + timing.startTime = Math.min(timing.startTime, entry.startTime); + timing.processingStart = Math.min(timing.processingStart, entry.processingStart); + timing.processingEnd = Math.max(timing.processingEnd, entry.processingEnd); + timing.nextPaintTime = Math.max(timing.nextPaintTime, nextPaintTime); + + return; + } + + INTERACTION_TIMINGS.set(interactionId, { + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + nextPaintTime, + }); + + if (INTERACTION_TIMINGS.size > MAX_INTERACTION_TIMINGS) { + let shortestId: number | undefined; + let shortestLatency = Infinity; + INTERACTION_TIMINGS.forEach((t, id) => { + const latency = t.nextPaintTime - t.startTime; + if (latency < shortestLatency) { + shortestLatency = latency; + shortestId = id; + } + }); + INTERACTION_TIMINGS.delete(shortestId as number); + } +} + +/** + * The INP subparts of the interaction an entry belongs to, which add up to the time from the input + * to the next paint. + * + * The slow handler can be on any event of the interaction, so the subparts come from the timings + * merged across all of them. Without those they are left out, since a single entry would count + * the other events' handlers as presentation delay. + */ +export function getInpSubpartAttributes(entry: PerformanceEventTiming | undefined): SpanAttributes | undefined { + const timing = entry?.interactionId ? INTERACTION_TIMINGS.get(entry.interactionId) : undefined; + if (!timing) { + return undefined; + } // `duration` is rounded to 8ms, so the presentation delay it implies can dip below zero. return { - [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: Math.max(processingStart - startTime, 0), - [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: Math.max(processingEnd - processingStart, 0), - [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: Math.max(nextPaintTime - processingEnd, 0), + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: Math.max(timing.processingStart - timing.startTime, 0), + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: Math.max(timing.processingEnd - timing.processingStart, 0), + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: Math.max(timing.nextPaintTime - timing.processingEnd, 0), }; } diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 64d211643c99..36238dcfc2a2 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -466,7 +466,7 @@ export function _sendInpSpan( selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes[BROWSER_WEB_VITAL_INP_TARGET] = selector); entryInteractionType && (attributes[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE] = entryInteractionType); - Object.assign(attributes, getInpSubpartAttributes(entry, metric?.entries)); + Object.assign(attributes, getInpSubpartAttributes(entry)); if (componentName) { attributes[UI_COMPONENT_NAME] = componentName; diff --git a/packages/browser-utils/test/web-vitals/inp.test.ts b/packages/browser-utils/test/web-vitals/inp.test.ts index a7aa2db57f54..e0114f29e644 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -100,3 +100,50 @@ describe('INP element name cache', () => { expect(getCachedInteractionContext(44)?.elementName).toBe(''); }); }); + +describe('INP subpart timings', () => { + beforeEach(() => { + windowListeners.clear(); + performanceHandlers.clear(); + }); + + function eventEntry(interactionId: number, startTime: number, duration: number, processing: [number, number]) { + return { + entryType: 'event', + name: 'click', + interactionId, + startTime, + duration, + processingStart: processing[0], + processingEnd: processing[1], + target: null, + }; + } + + it('merges every event entry of an interaction seen by the observer', async () => { + const { getInpSubpartAttributes, registerInpInteractionListener } = await loadInp(); + registerInpInteractionListener(); + + const pointerdown = eventEntry(7, 500, 520, [505, 506]); + performanceHandlers.get('event')?.({ entries: [pointerdown, eventEntry(7, 510, 504, [515, 965])] }); + + expect(getInpSubpartAttributes(pointerdown as any)).toEqual({ + 'browser.web_vital.inp.input_delay': 5, + 'browser.web_vital.inp.processing_duration': 460, + 'browser.web_vital.inp.presentation_delay': 55, + }); + }); + + it('keeps the longest interactions when it has to evict one', async () => { + const { getInpSubpartAttributes, recordInteractionTiming } = await loadInp(); + + const slow = eventEntry(1, 0, 500, [10, 400]); + recordInteractionTiming(slow as any); + for (let id = 2; id <= 11; id++) { + recordInteractionTiming(eventEntry(id, id * 1000, 50, [id * 1000 + 5, id * 1000 + 10]) as any); + } + + expect(getInpSubpartAttributes(slow as any)).toBeDefined(); + expect(getInpSubpartAttributes(eventEntry(2, 2000, 50, [0, 0]) as any)).toBeUndefined(); + }); +}); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index e05dcf8f7ad9..79c7af835d9d 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -811,31 +811,7 @@ describe('_sendInpSpan', () => { expect(mockSpan.end).toHaveBeenCalledWith(1.62); }); - it('reports the INP subparts from the entry', () => { - vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); - - _sendInpSpan(120, { - name: 'pointerdown', - startTime: 500, - processingStart: 540, - processingEnd: 600, - duration: 120, - interactionId: 1, - target: { tagName: 'button' }, - } as any); - - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'browser.web_vital.inp.input_delay': 40, - 'browser.web_vital.inp.processing_duration': 60, - 'browser.web_vital.inp.presentation_delay': 20, - }), - }), - ); - }); - - it('measures processing across every event of the interaction', () => { + it('reports the INP subparts merged across every event of the interaction', () => { vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); // The slow handler is on `click`, but the INP entry is the interaction's first event. const pointerdown = { @@ -844,18 +820,20 @@ describe('_sendInpSpan', () => { processingStart: 505, processingEnd: 506, duration: 520, - interactionId: 1, - }; + interactionId: 101, + } as any; const click = { name: 'click', startTime: 510, processingStart: 515, processingEnd: 965, duration: 504, - interactionId: 1, - }; + interactionId: 101, + } as any; + inpModule.recordInteractionTiming(pointerdown); + inpModule.recordInteractionTiming(click); - _sendInpSpan(520, pointerdown as any, false, undefined, undefined, { entries: [pointerdown, click] } as any); + _sendInpSpan(520, pointerdown); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -868,6 +846,27 @@ describe('_sendInpSpan', () => { ); }); + it('omits the INP subparts when the interaction was not observed', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + + _sendInpSpan(120, { + name: 'pointerdown', + startTime: 500, + processingStart: 540, + processingEnd: 600, + duration: 120, + interactionId: 102, + } as any); + + ['input_delay', 'processing_duration', 'presentation_delay'].forEach(subpart => + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ [`browser.web_vital.inp.${subpart}`]: expect.anything() }), + }), + ), + ); + }); + it('omits the INP subparts when there is no interaction to attribute', () => { _sendInpSpan(120, undefined); From 558207cee14d83bb73f9e03accd46bd326e21ee7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 14:42:48 -0400 Subject: [PATCH 3/5] fix(browser): Cap INP processing at the next paint --- packages/browser-utils/src/web-vitals/inp.ts | 13 +++++++++---- packages/browser-utils/test/web-vitals/inp.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/inp.ts b/packages/browser-utils/src/web-vitals/inp.ts index 410c79a7b131..63eec417f282 100644 --- a/packages/browser-utils/src/web-vitals/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -255,10 +255,15 @@ export function getInpSubpartAttributes(entry: PerformanceEventTiming | undefine return undefined; } - // `duration` is rounded to 8ms, so the presentation delay it implies can dip below zero. + const processingStart = Math.max(timing.processingStart, timing.startTime); + // `duration` is rounded to 8ms, so the next paint can appear to come before processing starts. + const nextPaintTime = Math.max(timing.nextPaintTime, processingStart); + // A sync modal like `alert()` keeps processing past the next paint (GoogleChrome/web-vitals#492). + const processingEnd = Math.min(Math.max(timing.processingEnd, processingStart), nextPaintTime); + return { - [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: Math.max(timing.processingStart - timing.startTime, 0), - [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: Math.max(timing.processingEnd - timing.processingStart, 0), - [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: Math.max(timing.nextPaintTime - timing.processingEnd, 0), + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: processingStart - timing.startTime, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: processingEnd - processingStart, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: nextPaintTime - processingEnd, }; } diff --git a/packages/browser-utils/test/web-vitals/inp.test.ts b/packages/browser-utils/test/web-vitals/inp.test.ts index e0114f29e644..81a6707f0223 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -134,6 +134,20 @@ describe('INP subpart timings', () => { }); }); + it('caps processing at the next paint so the subparts add up to the interaction', async () => { + const { getInpSubpartAttributes, recordInteractionTiming } = await loadInp(); + + // A sync `alert()` in the handler keeps processing running long after the next paint. + const entry = eventEntry(8, 100, 200, [120, 900]); + recordInteractionTiming(entry as any); + + expect(getInpSubpartAttributes(entry as any)).toEqual({ + 'browser.web_vital.inp.input_delay': 20, + 'browser.web_vital.inp.processing_duration': 180, + 'browser.web_vital.inp.presentation_delay': 0, + }); + }); + it('keeps the longest interactions when it has to evict one', async () => { const { getInpSubpartAttributes, recordInteractionTiming } = await loadInp(); From b6e12131fbd917116ef57de66fdb5fc4d4b69730 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 14:58:05 -0400 Subject: [PATCH 4/5] ref(browser): Keep INP timings on the cached interaction context --- packages/browser-utils/src/web-vitals/inp.ts | 91 ++++--------------- .../browser-utils/src/web-vitals/spans.ts | 2 +- .../browser-utils/test/web-vitals/inp.test.ts | 37 ++++---- .../test/web-vitals/spans.test.ts | 33 ++++--- 4 files changed, 52 insertions(+), 111 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/inp.ts b/packages/browser-utils/src/web-vitals/inp.ts index 63eec417f282..9d3c4b6a1180 100644 --- a/packages/browser-utils/src/web-vitals/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -7,30 +7,21 @@ import { } from '@sentry/conventions/attributes'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; -import type { PerformanceEventTiming } from '../instrumentation/performanceObserver'; import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from '../instrumentation/performanceObserver'; interface InteractionContext { span: Span | undefined; elementName: string; -} - -const LAST_INTERACTIONS: number[] = []; -const INTERACTIONS_SPAN_MAP = new Map(); - -interface InteractionTiming { + // Merged across every event of the interaction. web-vitals only keeps the entries that share the + // longest entry's start time, which drops e.g. a slow `click` that starts after its `pointerdown`. startTime: number; processingStart: number; processingEnd: number; nextPaintTime: number; } -/** - * Timings merged across every event of an interaction, kept for the longest interactions only, the - * way web-vitals keeps its INP candidates. - */ -const INTERACTION_TIMINGS = new Map(); -const MAX_INTERACTION_TIMINGS = 10; +const LAST_INTERACTIONS: number[] = []; +const INTERACTIONS_SPAN_MAP = new Map(); // Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry /** What `htmlTreeAsString` returns when it cannot describe the target. */ @@ -168,10 +159,16 @@ export function registerInpInteractionListener(): void { return; } - recordInteractionTiming(entry); + const nextPaintTime = entry.startTime + entry.duration; + const context = INTERACTIONS_SPAN_MAP.get(interactionId); + + // If the interaction was already recorded before, only its timings can change + if (context) { + context.startTime = Math.min(context.startTime, entry.startTime); + context.processingStart = Math.min(context.processingStart, entry.processingStart); + context.processingEnd = Math.max(context.processingEnd, entry.processingEnd); + context.nextPaintTime = Math.max(context.nextPaintTime, nextPaintTime); - // If the interaction was already recorded before, nothing more to do - if (INTERACTIONS_SPAN_MAP.has(interactionId)) { return; } @@ -189,6 +186,10 @@ export function registerInpInteractionListener(): void { INTERACTIONS_SPAN_MAP.set(interactionId, { span: activeRootSpan, elementName, + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + nextPaintTime, }); }); }; @@ -198,63 +199,9 @@ export function registerInpInteractionListener(): void { } /** - * Merges an event entry into the timings of the interaction it belongs to. - * - * web-vitals only keeps the entries that share the longest entry's start time, which drops e.g. a - * slow `click` that starts after its `pointerdown`. Every entry is seen here instead. + * The INP subparts of an interaction, which add up to the time from the input to the next paint. */ -export function recordInteractionTiming(entry: PerformanceEventTiming): void { - const interactionId = entry.interactionId; - if (!interactionId) { - return; - } - - const nextPaintTime = entry.startTime + entry.duration; - const timing = INTERACTION_TIMINGS.get(interactionId); - if (timing) { - timing.startTime = Math.min(timing.startTime, entry.startTime); - timing.processingStart = Math.min(timing.processingStart, entry.processingStart); - timing.processingEnd = Math.max(timing.processingEnd, entry.processingEnd); - timing.nextPaintTime = Math.max(timing.nextPaintTime, nextPaintTime); - - return; - } - - INTERACTION_TIMINGS.set(interactionId, { - startTime: entry.startTime, - processingStart: entry.processingStart, - processingEnd: entry.processingEnd, - nextPaintTime, - }); - - if (INTERACTION_TIMINGS.size > MAX_INTERACTION_TIMINGS) { - let shortestId: number | undefined; - let shortestLatency = Infinity; - INTERACTION_TIMINGS.forEach((t, id) => { - const latency = t.nextPaintTime - t.startTime; - if (latency < shortestLatency) { - shortestLatency = latency; - shortestId = id; - } - }); - INTERACTION_TIMINGS.delete(shortestId as number); - } -} - -/** - * The INP subparts of the interaction an entry belongs to, which add up to the time from the input - * to the next paint. - * - * The slow handler can be on any event of the interaction, so the subparts come from the timings - * merged across all of them. Without those they are left out, since a single entry would count - * the other events' handlers as presentation delay. - */ -export function getInpSubpartAttributes(entry: PerformanceEventTiming | undefined): SpanAttributes | undefined { - const timing = entry?.interactionId ? INTERACTION_TIMINGS.get(entry.interactionId) : undefined; - if (!timing) { - return undefined; - } - +export function getInpSubpartAttributes(timing: InteractionContext): SpanAttributes { const processingStart = Math.max(timing.processingStart, timing.startTime); // `duration` is rounded to 8ms, so the next paint can appear to come before processing starts. const nextPaintTime = Math.max(timing.nextPaintTime, processingStart); diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 36238dcfc2a2..e5d4590264fe 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -466,7 +466,7 @@ export function _sendInpSpan( selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes[BROWSER_WEB_VITAL_INP_TARGET] = selector); entryInteractionType && (attributes[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE] = entryInteractionType); - Object.assign(attributes, getInpSubpartAttributes(entry)); + cachedContext && Object.assign(attributes, getInpSubpartAttributes(cachedContext)); if (componentName) { attributes[UI_COMPONENT_NAME] = componentName; diff --git a/packages/browser-utils/test/web-vitals/inp.test.ts b/packages/browser-utils/test/web-vitals/inp.test.ts index 81a6707f0223..ee77c860b1d8 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -121,13 +121,15 @@ describe('INP subpart timings', () => { } it('merges every event entry of an interaction seen by the observer', async () => { - const { getInpSubpartAttributes, registerInpInteractionListener } = await loadInp(); + const { getCachedInteractionContext, getInpSubpartAttributes, registerInpInteractionListener } = await loadInp(); registerInpInteractionListener(); - const pointerdown = eventEntry(7, 500, 520, [505, 506]); - performanceHandlers.get('event')?.({ entries: [pointerdown, eventEntry(7, 510, 504, [515, 965])] }); + // The slow handler is on `click`, which starts after the `pointerdown` web-vitals keeps. + performanceHandlers.get('event')?.({ + entries: [eventEntry(7, 500, 520, [505, 506]), eventEntry(7, 510, 504, [515, 965])], + }); - expect(getInpSubpartAttributes(pointerdown as any)).toEqual({ + expect(getInpSubpartAttributes(getCachedInteractionContext(7)!)).toEqual({ 'browser.web_vital.inp.input_delay': 5, 'browser.web_vital.inp.processing_duration': 460, 'browser.web_vital.inp.presentation_delay': 55, @@ -135,29 +137,22 @@ describe('INP subpart timings', () => { }); it('caps processing at the next paint so the subparts add up to the interaction', async () => { - const { getInpSubpartAttributes, recordInteractionTiming } = await loadInp(); + const { getInpSubpartAttributes } = await loadInp(); // A sync `alert()` in the handler keeps processing running long after the next paint. - const entry = eventEntry(8, 100, 200, [120, 900]); - recordInteractionTiming(entry as any); + const timing = { + span: undefined, + elementName: '', + startTime: 100, + processingStart: 120, + processingEnd: 900, + nextPaintTime: 300, + }; - expect(getInpSubpartAttributes(entry as any)).toEqual({ + expect(getInpSubpartAttributes(timing)).toEqual({ 'browser.web_vital.inp.input_delay': 20, 'browser.web_vital.inp.processing_duration': 180, 'browser.web_vital.inp.presentation_delay': 0, }); }); - - it('keeps the longest interactions when it has to evict one', async () => { - const { getInpSubpartAttributes, recordInteractionTiming } = await loadInp(); - - const slow = eventEntry(1, 0, 500, [10, 400]); - recordInteractionTiming(slow as any); - for (let id = 2; id <= 11; id++) { - recordInteractionTiming(eventEntry(id, id * 1000, 50, [id * 1000 + 5, id * 1000 + 10]) as any); - } - - expect(getInpSubpartAttributes(slow as any)).toBeDefined(); - expect(getInpSubpartAttributes(eventEntry(2, 2000, 50, [0, 0]) as any)).toBeUndefined(); - }); }); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 79c7af835d9d..3f4f343545cb 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -811,29 +811,24 @@ describe('_sendInpSpan', () => { expect(mockSpan.end).toHaveBeenCalledWith(1.62); }); - it('reports the INP subparts merged across every event of the interaction', () => { - vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); - // The slow handler is on `click`, but the INP entry is the interaction's first event. - const pointerdown = { + it('reports the INP subparts from the timings merged for the interaction', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue({ + span: undefined, + elementName: '