Skip to content

fix(performance): Fix INP spans not retrieving correct origin transaction name #12358

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});

Expand All @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
addFidInstrumentationHandler,
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
isPerformanceEventTiming,
} from './metrics/instrument';

export {
Expand Down
24 changes: 14 additions & 10 deletions packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,10 +14,12 @@ import { getBrowserPerformanceAPI, msToSec } from './utils';
/**
* Start tracking INP webvital events.
*/
export function startTrackingINP(): () => void {
export function startTrackingINP(
interactionIdToRouteNameMapping: Record<number, { startTime: number; duration: number; routeName: string }>,
): () => void {
const performance = getBrowserPerformanceAPI();
if (performance && browserPerformanceTimeOrigin) {
const inpCallback = _trackINP();
const inpCallback = _trackINP(interactionIdToRouteNameMapping);

return (): void => {
inpCallback();
Expand Down Expand Up @@ -60,7 +59,9 @@ const INP_ENTRY_MAP: Record<string, 'click' | 'hover' | 'drag' | 'press'> = {
};

/** Starts tracking the Interaction to Next Paint on the current page. */
function _trackINP(): () => void {
function _trackINP(
interactionIdToRouteNameMapping: Record<number, { startTime: number; duration: number; routeName: string }>,
): () => void {
return addInpInstrumentationHandler(({ metric }) => {
const client = getClient();
if (!client || metric.value == undefined) {
Expand All @@ -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;
}

Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions packages/browser-utils/src/metrics/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
90 changes: 88 additions & 2 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import {
addHistoryInstrumentationHandler,
addPerformanceEntries,
addPerformanceInstrumentationHandler,
isPerformanceEventTiming,
startTrackingINP,
startTrackingInteractions,
startTrackingLongTasks,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -192,9 +197,12 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
};

const _collectWebVitals = startTrackingWebVitals();
/** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */
const interactionIdToRouteNameMapping: Record<string, { startTime: number; duration: number; routeName: string }> =
{};

if (enableInp) {
startTrackingINP();
startTrackingINP(interactionIdToRouteNameMapping);
}

if (enableLongTask) {
Expand Down Expand Up @@ -375,6 +383,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
registerInteractionListener(idleTimeout, finalTimeout, childSpanTimeout, latestRoute);
}

if (enableInp) {
registerInpInteractionListener(latestRoute, interactionIdToRouteNameMapping);
}

instrumentOutgoingRequests({
traceFetch,
traceXHR,
Expand Down Expand Up @@ -439,7 +451,10 @@ function registerInteractionListener(
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: { name: string | undefined; source: TransactionSource | undefined },
latestRoute: {
name: string | undefined;
source: TransactionSource | undefined;
},
): void {
let inflightInteractionSpan: Span | undefined;
const registerInteractionTransaction = (): void => {
Expand Down Expand Up @@ -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<string, { startTime: number; duration: number; routeName: string }>,
): 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);
}
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
12 changes: 12 additions & 0 deletions packages/types/src/interactionContext.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading