diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js index b558562e4cd4..6a1356f550a3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js @@ -13,3 +13,12 @@ Sentry.init({ ], tracesSampleRate: 1, }); + +const client = Sentry.getClient(); + +if (client) { + // Force page load transaction name to a testable value + Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-route', + }); +} 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 582508f7a584..0afc03fe5f33 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 @@ -63,6 +63,7 @@ sentryTest('should capture an INP click event span.', async ({ browserName, getL sample_rate: '1', sampled: 'true', trace_id: traceId, + transaction: 'test-route', }, }); @@ -76,6 +77,7 @@ sentryTest('should capture an INP click event span.', async ({ browserName, getL 'sentry.origin': 'manual', 'sentry.sample_rate': 1, 'sentry.source': 'custom', + transaction: 'test-route', }, measurements: { inp: { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7e7c4d0a387a..4a018455161e 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addFidInstrumentationHandler, addTtfbInstrumentationHandler, addLcpInstrumentationHandler, + isPerformanceEventTiming, } from './metrics/instrument'; export { diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index c6c0113d6be3..c06d5679f50d 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -2,11 +2,8 @@ import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, - getActiveSpan, getClient, getCurrentScope, - getRootSpan, - spanToJSON, startInactiveSpan, } from '@sentry/core'; import type { Integration, SpanAttributes } from '@sentry/types'; @@ -17,10 +14,12 @@ import { getBrowserPerformanceAPI, msToSec } from './utils'; /** * Start tracking INP webvital events. */ -export function startTrackingINP(): () => void { +export function startTrackingINP( + interactionIdToRouteNameMapping: Record, +): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin) { - const inpCallback = _trackINP(); + const inpCallback = _trackINP(interactionIdToRouteNameMapping); return (): void => { inpCallback(); @@ -60,7 +59,9 @@ const INP_ENTRY_MAP: Record = { }; /** Starts tracking the Interaction to Next Paint on the current page. */ -function _trackINP(): () => void { +function _trackINP( + interactionIdToRouteNameMapping: Record, +): () => void { return addInpInstrumentationHandler(({ metric }) => { const client = getClient(); if (!client || metric.value == undefined) { @@ -69,7 +70,7 @@ function _trackINP(): () => void { const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]); - if (!entry) { + if (!entry || entry.interactionId === undefined) { return; } @@ -80,10 +81,13 @@ function _trackINP(): () => void { const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime); const duration = msToSec(metric.value); const scope = getCurrentScope(); - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const routeName = rootSpan ? spanToJSON(rootSpan).description : undefined; + const routeName = interactionIdToRouteNameMapping[entry.interactionId].routeName; + + if (!routeName) { + return; + } + const user = scope.getUser(); // We need to get the replay, user, and activeTransaction from the current scope diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index acd3717e8bd4..741cdf2b5ab7 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -8,7 +8,13 @@ import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; import { onTTFB } from './web-vitals/onTTFB'; -type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource'; +type InstrumentHandlerTypePerformanceObserver = + | 'longtask' + | 'event' + | 'navigation' + | 'paint' + | 'resource' + | 'first-input'; type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; @@ -153,7 +159,7 @@ export function addInpInstrumentationHandler( } export function addPerformanceInstrumentationHandler( - type: 'event', + type: 'event' | 'first-input', callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, ): CleanupHandlerCallback; export function addPerformanceInstrumentationHandler( @@ -319,3 +325,10 @@ function getCleanupCallback( } }; } + +/** + * Check if a PerformanceEntry is a PerformanceEventTiming by checking for the `duration` property. + */ +export function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming { + return 'duration' in entry; +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index f6528e4d155d..c3779f90203e 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -2,6 +2,8 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, + addPerformanceInstrumentationHandler, + isPerformanceEventTiming, startTrackingINP, startTrackingInteractions, startTrackingLongTasks, @@ -159,6 +161,9 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { ...defaultRequestInstrumentationOptions, }; +/** We store up to 10 interaction candidates max to cap memory usage. This is the same cap as getINP from web-vitals */ +const MAX_INTERACTIONS = 10; + /** * The Browser Tracing integration automatically instruments browser pageload/navigation * actions as transactions, and captures requests, metrics and errors as spans. @@ -192,9 +197,12 @@ export const browserTracingIntegration = ((_options: Partial = + {}; if (enableInp) { - startTrackingINP(); + startTrackingINP(interactionIdToRouteNameMapping); } if (enableLongTask) { @@ -375,6 +383,10 @@ export const browserTracingIntegration = ((_options: Partial { @@ -487,3 +502,74 @@ function registerInteractionListener( addEventListener('click', registerInteractionTransaction, { once: false, capture: true }); } } + +/** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */ +function registerInpInteractionListener( + latestRoute: { name: string | undefined; source: TransactionSource | undefined }, + interactionIdToRouteNameMapping: Record, +): void { + const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => { + // We need to get the replay, user, and activeTransaction from the current scope + // so that we can associate replay id, profile id, and a user display to the span + entries.forEach(entry => { + if (isPerformanceEventTiming(entry)) { + const interactionId = entry.interactionId; + if (interactionId === undefined) { + return; + } + const existingInteraction = interactionIdToRouteNameMapping[String(interactionId)]; + const duration = entry.duration; + const startTime = entry.startTime; + const interactionIds = Object.keys(interactionIdToRouteNameMapping); + // For a first input event to be considered, we must check that an interaction event does not already exist with the same duration and start time. + // This is also checked in the web-vitals library. + if (entry.entryType === 'first-input') { + const matchingEntry = interactionIds + .map(key => interactionIdToRouteNameMapping[key]) + .some(interaction => { + return interaction.duration === duration && interaction.startTime === startTime; + }); + if (matchingEntry) { + return; + } + } + // Interactions with an id of 0 and are not first-input are not valid. + if (!interactionId) { + return; + } + const fastestInteractionId = + interactionIds.length > 0 + ? interactionIds.reduce((a, b) => { + return interactionIdToRouteNameMapping[a].duration < interactionIdToRouteNameMapping[b].duration + ? a + : b; + }) + : undefined; + // If the interaction already exists, we want to use the duration of the longest entry, since that is what the INP metric uses. + if (existingInteraction) { + existingInteraction.duration = Math.max(existingInteraction.duration, duration); + } else if ( + interactionIds.length < MAX_INTERACTIONS || + fastestInteractionId === undefined || + duration > interactionIdToRouteNameMapping[fastestInteractionId].duration + ) { + // If the interaction does not exist, we want to add it to the mapping if there is space, or if the duration is longer than the shortest entry. + const routeName = latestRoute.name; + if (routeName) { + if (fastestInteractionId && Object.keys(interactionIdToRouteNameMapping).length >= MAX_INTERACTIONS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete interactionIdToRouteNameMapping[fastestInteractionId]; + } + interactionIdToRouteNameMapping[String(interactionId)] = { + routeName, + duration, + startTime, + }; + } + } + } + }); + }; + addPerformanceInstrumentationHandler('event', handleEntries); + addPerformanceInstrumentationHandler('first-input', handleEntries); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c90b7841f9ff..693b8128db19 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -169,3 +169,4 @@ export type { } from './metrics'; export type { ParameterizedString } from './parameterize'; export type { ViewHierarchyData, ViewHierarchyWindow } from './view-hierarchy'; +export type { InteractionContext } from './interactionContext'; diff --git a/packages/types/src/interactionContext.ts b/packages/types/src/interactionContext.ts new file mode 100644 index 000000000000..fea3b9048a0d --- /dev/null +++ b/packages/types/src/interactionContext.ts @@ -0,0 +1,12 @@ +import type { Span } from './span'; +import type { User } from './user'; + +export type InteractionContext = { + routeName: string; + duration: number; + parentContext: any; + user?: User; + activeSpan?: Span; + replayId?: string; + startTime: number; +};