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..fea54e7b36de 100644 --- a/packages/browser-utils/src/web-vitals/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -1,16 +1,56 @@ -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'; +import { createInteractionCache } from './interactionCache'; interface InteractionContext { span: Span | undefined; elementName: string; + interactionId: number; + latency: number; } -const LAST_INTERACTIONS: number[] = []; -const INTERACTIONS_SPAN_MAP = new Map(); +/** + * The event timing entries presented in one frame, merged the way web-vitals' attribution build + * merges them to split INP into its subparts. + */ +interface FrameTiming { + startTime: number; + processingStart: number; + processingEnd: number; + renderTime: number; +} + +const INTERACTIONS = createInteractionCache({ + match: (context, entry) => context.interactionId === entry.interactionId, + merge: (context, entry) => { + context.latency = Math.max(context.latency, entry.duration); + }, + latency: context => context.latency, +}); + +const FRAMES = createInteractionCache({ + match: (frame, entry) => Math.abs(entry.startTime + entry.duration - frame.renderTime) <= 8, + merge: (frame, entry) => { + frame.startTime = Math.min(frame.startTime, entry.startTime); + frame.processingStart = Math.min(frame.processingStart, entry.processingStart); + frame.processingEnd = Math.max(frame.processingEnd, entry.processingEnd); + }, + latency: frame => frame.renderTime - frame.startTime, +}); + +/** + * web-vitals observes INP with this `durationThreshold`, so its attribution only ever sees these. + */ +const INP_DURATION_THRESHOLD = 40; // 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. */ @@ -56,11 +96,11 @@ export const INP_ENTRY_MAP: Record = { }; /** - * Look up a cached interaction context (element name + root span) by interactionId. - * Returns undefined if no context was cached for this interaction. + * Look up the cached interaction context (element name + root span) of the interaction an entry + * belongs to. Returns undefined if no context was cached for this interaction. */ -export function getCachedInteractionContext(interactionId: number | undefined): InteractionContext | undefined { - return interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; +export function getCachedInteractionContext(entry: PerformanceEventTiming | undefined): InteractionContext | undefined { + return entry?.interactionId ? INTERACTIONS.get(entry) : undefined; } /** @@ -143,34 +183,53 @@ export function registerInpInteractionListener(): void { return; } - const interactionId = entry.interactionId; - if (interactionId == null) { - return; + if (entry.duration >= INP_DURATION_THRESHOLD) { + FRAMES.add(entry, () => ({ + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + renderTime: entry.startTime + entry.duration, + })); } - // If the interaction was already recorded before, nothing more to do - if (INTERACTIONS_SPAN_MAP.has(interactionId)) { + const interactionId = entry.interactionId; + if (!interactionId) { 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; - INTERACTIONS_SPAN_MAP.delete(last); - } - - // We add the interaction to the list of recorded interactions - // and store both the span and element name for this interaction - LAST_INTERACTIONS.push(interactionId); - INTERACTIONS_SPAN_MAP.set(interactionId, { + // The element and span are taken from the first entry of the interaction + INTERACTIONS.add(entry, () => ({ span: activeRootSpan, - elementName, - }); + elementName: entry.target ? htmlTreeAsString(entry.target) : resolveElementNameFromEntry(entry), + interactionId, + latency: entry.duration, + })); }); }; addPerformanceInstrumentationHandler('event', handleEntries); addPerformanceInstrumentationHandler('first-input', handleEntries); } + +/** + * The INP subparts of the interaction an INP entry belongs to, computed like web-vitals' attribution + * build does: from all events presented in the same frame, so a slow handler on any of them counts. + */ +export function getInpSubpartAttributes(entry: PerformanceEventTiming | undefined): SpanAttributes | undefined { + const frame = entry && FRAMES.get(entry); + if (!frame) { + return undefined; + } + + const processingStart = Math.max(frame.processingStart, entry.startTime); + // `duration` is rounded to 8ms, so the next paint can appear to come before processing starts. + const nextPaintTime = Math.max(entry.startTime + entry.duration, processingStart); + // A sync modal like `alert()` keeps processing past the next paint (GoogleChrome/web-vitals#492). + const processingEnd = Math.min(Math.max(frame.processingEnd, processingStart), nextPaintTime); + + return { + [BROWSER_WEB_VITAL_INP_INPUT_DELAY]: processingStart - entry.startTime, + [BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: processingEnd - processingStart, + [BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: nextPaintTime - processingEnd, + }; +} diff --git a/packages/browser-utils/src/web-vitals/interactionCache.ts b/packages/browser-utils/src/web-vitals/interactionCache.ts new file mode 100644 index 000000000000..5a007f699c0a --- /dev/null +++ b/packages/browser-utils/src/web-vitals/interactionCache.ts @@ -0,0 +1,69 @@ +const MAX_ITEMS_TO_KEEP = 10; + +interface InteractionCacheOptions { + /** + * Whether an entry belongs to an item that is already cached. + */ + match: (item: T, entry: E) => boolean; + /** + * Folds another entry into the item it belongs to. + */ + merge: (item: T, entry: E) => void; + /** + * How long the item took, which decides what is kept once the cache is full. + */ + latency: (item: T) => number; +} + +export interface InteractionCache { + /** + * Merges the entry into the item it belongs to, or caches the item `create` returns. + */ + add(entry: E, create: () => T): void; + get(entry: E): T | undefined; +} + +/** + * Caches what event timing entries are grouped into, and keeps what an INP report can still need + * the way web-vitals' attribution build does: the 10 most recent items, plus the 10 longest. + */ +export function createInteractionCache({ + match, + merge, + latency, +}: InteractionCacheOptions): InteractionCache { + let items: T[] = []; + + const get = (entry: E): T | undefined => { + // The most likely match is the latest item. + for (let i = items.length - 1; i >= 0; i--) { + if (match(items[i]!, entry)) { + return items[i]; + } + } + + return undefined; + }; + + return { + get, + add(entry, create) { + const item = get(entry); + if (item) { + merge(item, entry); + + return; + } + + items.push(create()); + + if (items.length > MAX_ITEMS_TO_KEEP * 2) { + const longest = items + .slice() + .sort((a, b) => latency(b) - latency(a)) + .slice(0, MAX_ITEMS_TO_KEEP); + items = items.filter((cached, i) => i >= items.length - MAX_ITEMS_TO_KEEP || longest.includes(cached)); + } + }, + }; +} 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..e5b9272c3783 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, @@ -431,7 +441,7 @@ export function _sendInpSpan( const entryInteractionType = entry && INP_ENTRY_MAP[entry.name]; const interactionType = entryInteractionType || 'click'; - const cachedContext = entry && getCachedInteractionContext(entry.interactionId); + const cachedContext = getCachedInteractionContext(entry); const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; @@ -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)); + 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..ceaf0b282f79 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -73,7 +73,7 @@ describe('INP element name cache', () => { nameFor(42, 1000); - expect(getCachedInteractionContext(42)?.elementName).toBe('div#clicked'); + expect(getCachedInteractionContext({ interactionId: 42 } as any)?.elementName).toBe('div#clicked'); }); it('replaces an unresolvable name with a real one from the same interaction', async () => { @@ -86,7 +86,7 @@ describe('INP element name cache', () => { nameFor(43, 2000); - expect(getCachedInteractionContext(43)?.elementName).toBe('div#clicked'); + expect(getCachedInteractionContext({ interactionId: 43 } as any)?.elementName).toBe('div#clicked'); }); it('reports an unresolvable name when nothing in the interaction resolves', async () => { @@ -97,6 +97,77 @@ describe('INP element name cache', () => { nameFor(44, 3000); - expect(getCachedInteractionContext(44)?.elementName).toBe(''); + expect(getCachedInteractionContext({ interactionId: 44 } as any)?.elementName).toBe(''); + }); +}); + +describe('INP subparts', () => { + 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, + }; + } + + async function observe(...entries: unknown[]) { + const inp = await loadInp(); + inp.registerInpInteractionListener(); + performanceHandlers.get('event')?.({ entries }); + + return inp; + } + + it('merges every event presented in the same frame', async () => { + // The slow handler is on `click`, which starts after the `pointerdown` web-vitals reports. + const pointerdown = eventEntry(7, 500, 520, [505, 506]); + const { getInpSubpartAttributes } = await observe(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('counts a handler of another event in the same frame as processing', async () => { + // A slow `pointerover` handler (not an interaction) was still running when the click came in. + const click = eventEntry(9, 500, 520, [905, 910]); + const { getInpSubpartAttributes } = await observe(eventEntry(0, 495, 525, [496, 900]), click); + + expect(getInpSubpartAttributes(click as any)).toEqual({ + 'browser.web_vital.inp.input_delay': 0, + 'browser.web_vital.inp.processing_duration': 410, + 'browser.web_vital.inp.presentation_delay': 110, + }); + }); + + it('caps processing at the next paint so the subparts add up to the interaction', async () => { + // A sync `alert()` in the handler keeps processing running long after the next paint. + const entry = eventEntry(8, 100, 200, [120, 900]); + const { getInpSubpartAttributes } = await observe(entry); + + 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('ignores entries below the duration web-vitals observes INP with', async () => { + const entry = eventEntry(10, 100, 32, [105, 120]); + const { getInpSubpartAttributes } = await observe(entry); + + expect(getInpSubpartAttributes(entry as any)).toBeUndefined(); }); }); diff --git a/packages/browser-utils/test/web-vitals/interactionCache.test.ts b/packages/browser-utils/test/web-vitals/interactionCache.test.ts new file mode 100644 index 000000000000..3d9c93ce87f2 --- /dev/null +++ b/packages/browser-utils/test/web-vitals/interactionCache.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { createInteractionCache } from '../../src/web-vitals/interactionCache'; + +interface Item { + id: number; + latency: number; +} + +function createCache() { + return createInteractionCache({ + match: (item, entry) => item.id === entry.id, + merge: (item, entry) => { + item.latency = Math.max(item.latency, entry.latency); + }, + latency: item => item.latency, + }); +} + +function add(cache: ReturnType, id: number, latency: number): void { + cache.add({ id, latency }, () => ({ id, latency })); +} + +describe('createInteractionCache', () => { + it('merges an entry into the item it matches', () => { + const cache = createCache(); + add(cache, 1, 50); + add(cache, 1, 200); + + expect(cache.get({ id: 1, latency: 0 })).toEqual({ id: 1, latency: 200 }); + }); + + it('keeps the 10 longest and the 10 most recent items once it is full', () => { + const cache = createCache(); + const latencies = [ + 900, 50, 800, 60, 700, 70, 600, 80, 500, 90, 400, 100, 300, 110, 200, 120, 150, 130, 140, 145, 10, + ]; + latencies.forEach((latency, id) => add(cache, id, latency)); + + const kept = latencies.map((_, id) => id).filter(id => cache.get({ id, latency: 0 })); + + // The 10 longest are ids 0-16 (even) and 19, the 10 most recent are ids 11-20. + expect(kept).toEqual([0, 2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + }); +}); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index ebbb00ab9331..b24e0cffb50b 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,62 @@ describe('_sendInpSpan', () => { expect(mockSpan.end).toHaveBeenCalledWith(1.62); }); + it('adds the INP subparts of the interaction the entry belongs to', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + vi.spyOn(inpModule, 'getInpSubpartAttributes').mockReturnValueOnce({ + 'browser.web_vital.inp.input_delay': 5, + 'browser.web_vital.inp.processing_duration': 460, + 'browser.web_vital.inp.presentation_delay': 55, + }); + const entry = { name: 'pointerdown', startTime: 500, duration: 520, interactionId: 101 } as any; + + _sendInpSpan(520, entry); + + expect(inpModule.getInpSubpartAttributes).toHaveBeenCalledWith(entry); + 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 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); + + ['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); @@ -769,6 +945,8 @@ describe('_sendInpSpan', () => { vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue({ elementName: 'body > CachedButton', span: mockRootSpan as any, + interactionId: 42, + latency: 100, }); const mockEntry = { @@ -781,7 +959,7 @@ describe('_sendInpSpan', () => { _sendInpSpan(100, mockEntry); - expect(inpModule.getCachedInteractionContext).toHaveBeenCalledWith(42); + expect(inpModule.getCachedInteractionContext).toHaveBeenCalledWith(mockEntry); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({