Skip to content

Commit 82eaed0

Browse files
feat(performance): create Interaction standalone spans on inp events (#10709)
Creates standalone span when onINP is triggered, and sends the span to sentry. An InteractionRouteNameMapping is maintained to get the origin route name of the candidate INP span. The mapping is capped at 10 entries to minimize memory. Tags INP spans with profile id, replay id, user and uses sampling rates.
2 parents beed7f6 + fc676fc commit 82eaed0

File tree

12 files changed

+302
-14
lines changed

12 files changed

+302
-14
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module.exports = [
5252
path: 'packages/browser/build/npm/esm/index.js',
5353
import: '{ init, browserTracingIntegration }',
5454
gzip: true,
55-
limit: '35 KB',
55+
limit: '36 KB',
5656
},
5757
{
5858
name: '@sentry/browser (incl. Feedback) - Webpack (gzipped)',

packages/browser/src/profiling/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,9 @@ export function createProfilingEvent(
583583
return createProfilePayload(profile_id, start_timestamp, profile, event);
584584
}
585585

586+
// TODO (v8): We need to obtain profile ids in @sentry-internal/tracing,
587+
// but we don't have access to this map because importing this map would
588+
// cause a circular dependancy. We need to resolve this in v8.
586589
const PROFILE_MAP: Map<string, JSSelfProfile> = new Map();
587590
/**
588591
*

packages/core/src/semanticAttributes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_OP = 'sentry.op';
1919
* Use this attribute to represent the origin of a span.
2020
*/
2121
export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin';
22+
23+
/**
24+
* The id of the profile that this span occured in.
25+
*/
26+
export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'profile_id';

packages/core/src/tracing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export {
2727
} from './trace';
2828
export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
2929
export { setMeasurement } from './measurement';
30+
export { isValidSampleRate } from './sampling';

packages/core/src/tracing/sampling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function sampleTransaction<T extends Transaction>(
103103
/**
104104
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
105105
*/
106-
function isValidSampleRate(rate: unknown): boolean {
106+
export function isValidSampleRate(rate: unknown): boolean {
107107
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
108108
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109109
if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) {

packages/core/src/tracing/span.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/ut
1818

1919
import { DEBUG_BUILD } from '../debug-build';
2020
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
21-
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
21+
import {
22+
SEMANTIC_ATTRIBUTE_PROFILE_ID,
23+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
24+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
25+
} from '../semanticAttributes';
2226
import { getRootSpan } from '../utils/getRootSpan';
2327
import {
2428
TRACE_FLAG_NONE,
@@ -634,6 +638,7 @@ export class Span implements SpanInterface {
634638
trace_id: this._traceId,
635639
origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
636640
_metrics_summary: getMetricSummaryJsonForSpan(this),
641+
profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined,
637642
exclusive_time: this._exclusiveTime,
638643
measurements: Object.keys(this._measurements).length > 0 ? this._measurements : undefined,
639644
});

packages/core/src/tracing/transaction.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,16 @@ export class Transaction extends SpanClass implements TransactionInterface {
254254
this._hub = hub;
255255
}
256256

257+
/**
258+
* Get the profile id of the transaction.
259+
*/
260+
public getProfileId(): string | undefined {
261+
if (this._contexts !== undefined && this._contexts['profile'] !== undefined) {
262+
return this._contexts['profile'].profile_id as string;
263+
}
264+
return undefined;
265+
}
266+
257267
/**
258268
* Finish the transaction & prepare the event to send to Sentry.
259269
*/

packages/tracing-internal/src/browser/browserTracingIntegration.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable max-lines */
22
import type { IdleTransaction } from '@sentry/core';
3-
import { getActiveSpan } from '@sentry/core';
3+
import { getActiveSpan, getClient, getCurrentScope } from '@sentry/core';
44
import { getCurrentHub } from '@sentry/core';
55
import {
66
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
@@ -12,6 +12,7 @@ import {
1212
} from '@sentry/core';
1313
import type {
1414
Client,
15+
Integration,
1516
IntegrationFn,
1617
StartSpanOptions,
1718
Transaction,
@@ -29,15 +30,18 @@ import {
2930

3031
import { DEBUG_BUILD } from '../common/debug-build';
3132
import { registerBackgroundTabDetection } from './backgroundtab';
33+
import { addPerformanceInstrumentationHandler } from './instrument';
3234
import {
3335
addPerformanceEntries,
36+
startTrackingINP,
3437
startTrackingInteractions,
3538
startTrackingLongTasks,
3639
startTrackingWebVitals,
3740
} from './metrics';
3841
import type { RequestInstrumentationOptions } from './request';
3942
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
4043
import { WINDOW } from './types';
44+
import type { InteractionRouteNameMapping } from './web-vitals/types';
4145

4246
export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';
4347

@@ -103,6 +107,13 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
103107
*/
104108
enableLongTask: boolean;
105109

110+
/**
111+
* If true, Sentry will capture INP web vitals as standalone spans .
112+
*
113+
* Default: false
114+
*/
115+
enableInp: boolean;
116+
106117
/**
107118
* _metricOptions allows the user to send options to change how metrics are collected.
108119
*
@@ -142,6 +153,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
142153
instrumentPageLoad: true,
143154
markBackgroundSpan: true,
144155
enableLongTask: true,
156+
enableInp: false,
145157
_experiments: {},
146158
...defaultRequestInstrumentationOptions,
147159
};
@@ -181,16 +193,25 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
181193

182194
const _collectWebVitals = startTrackingWebVitals();
183195

196+
/** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */
197+
const interactionIdtoRouteNameMapping: InteractionRouteNameMapping = {};
198+
if (options.enableInp) {
199+
startTrackingINP(interactionIdtoRouteNameMapping);
200+
}
201+
184202
if (options.enableLongTask) {
185203
startTrackingLongTasks();
186204
}
187205
if (options._experiments.enableInteractions) {
188206
startTrackingInteractions();
189207
}
190208

191-
const latestRoute: { name: string | undefined; source: TransactionSource | undefined } = {
209+
const latestRoute: {
210+
name: string | undefined;
211+
context: TransactionContext | undefined;
212+
} = {
192213
name: undefined,
193-
source: undefined,
214+
context: undefined,
194215
};
195216

196217
/** Create routing idle transaction. */
@@ -238,7 +259,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
238259
finalContext.metadata;
239260

240261
latestRoute.name = finalContext.name;
241-
latestRoute.source = getSource(finalContext);
262+
latestRoute.context = finalContext;
242263

243264
if (finalContext.sampled === false) {
244265
DEBUG_BUILD && logger.log(`[Tracing] Will not send ${finalContext.op} transaction because of beforeNavigate.`);
@@ -389,6 +410,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
389410
registerInteractionListener(options, latestRoute);
390411
}
391412

413+
if (options.enableInp) {
414+
registerInpInteractionListener(interactionIdtoRouteNameMapping, latestRoute);
415+
}
416+
392417
instrumentOutgoingRequests({
393418
traceFetch,
394419
traceXHR,
@@ -448,7 +473,10 @@ export function getMetaContent(metaName: string): string | undefined {
448473
/** Start listener for interaction transactions */
449474
function registerInteractionListener(
450475
options: BrowserTracingOptions,
451-
latestRoute: { name: string | undefined; source: TransactionSource | undefined },
476+
latestRoute: {
477+
name: string | undefined;
478+
context: TransactionContext | undefined;
479+
},
452480
): void {
453481
let inflightInteractionTransaction: IdleTransaction | undefined;
454482
const registerInteractionTransaction = (): void => {
@@ -483,7 +511,7 @@ function registerInteractionListener(
483511
op,
484512
trimEnd: true,
485513
data: {
486-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url',
514+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.context ? getSource(latestRoute.context) : undefined || 'url',
487515
},
488516
};
489517

@@ -504,6 +532,70 @@ function registerInteractionListener(
504532
});
505533
}
506534

535+
function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming {
536+
return 'duration' in entry;
537+
}
538+
539+
/** We store up to 10 interaction candidates max to cap memory usage. This is the same cap as getINP from web-vitals */
540+
const MAX_INTERACTIONS = 10;
541+
542+
/** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */
543+
function registerInpInteractionListener(
544+
interactionIdtoRouteNameMapping: InteractionRouteNameMapping,
545+
latestRoute: {
546+
name: string | undefined;
547+
context: TransactionContext | undefined;
548+
},
549+
): void {
550+
addPerformanceInstrumentationHandler('event', ({ entries }) => {
551+
const client = getClient();
552+
// We need to get the replay, user, and activeTransaction from the current scope
553+
// so that we can associate replay id, profile id, and a user display to the span
554+
const replay =
555+
client !== undefined && client.getIntegrationByName !== undefined
556+
? (client.getIntegrationByName('Replay') as Integration & { getReplayId: () => string })
557+
: undefined;
558+
const replayId = replay !== undefined ? replay.getReplayId() : undefined;
559+
// eslint-disable-next-line deprecation/deprecation
560+
const activeTransaction = getActiveTransaction();
561+
const currentScope = getCurrentScope();
562+
const user = currentScope !== undefined ? currentScope.getUser() : undefined;
563+
for (const entry of entries) {
564+
if (isPerformanceEventTiming(entry)) {
565+
const duration = entry.duration;
566+
const keys = Object.keys(interactionIdtoRouteNameMapping);
567+
const minInteractionId =
568+
keys.length > 0
569+
? keys.reduce((a, b) => {
570+
return interactionIdtoRouteNameMapping[a].duration < interactionIdtoRouteNameMapping[b].duration
571+
? a
572+
: b;
573+
})
574+
: undefined;
575+
if (minInteractionId === undefined || duration > interactionIdtoRouteNameMapping[minInteractionId].duration) {
576+
const interactionId = entry.interactionId;
577+
const routeName = latestRoute.name;
578+
const parentContext = latestRoute.context;
579+
if (interactionId && routeName && parentContext) {
580+
if (minInteractionId && Object.keys(interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) {
581+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
582+
delete interactionIdtoRouteNameMapping[minInteractionId];
583+
}
584+
interactionIdtoRouteNameMapping[interactionId] = {
585+
routeName,
586+
duration,
587+
parentContext,
588+
user,
589+
activeTransaction,
590+
replayId,
591+
};
592+
}
593+
}
594+
}
595+
}
596+
});
597+
}
598+
507599
function getSource(context: TransactionContext): TransactionSource | undefined {
508600
const sourceFromAttributes = context.attributes && context.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
509601
// eslint-disable-next-line deprecation/deprecation

packages/tracing-internal/src/browser/instrument.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { getFunctionName, logger } from '@sentry/utils';
33
import { DEBUG_BUILD } from '../common/debug-build';
44
import { onCLS } from './web-vitals/getCLS';
55
import { onFID } from './web-vitals/getFID';
6+
import { onINP } from './web-vitals/getINP';
67
import { onLCP } from './web-vitals/getLCP';
78
import { observe } from './web-vitals/lib/observe';
89

910
type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource';
1011

11-
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid';
12+
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'inp';
1213

1314
// We provide this here manually instead of relying on a global, as this is not available in non-browser environements
1415
// And we do not want to expose such types
@@ -19,6 +20,14 @@ interface PerformanceEntry {
1920
readonly startTime: number;
2021
toJSON(): Record<string, unknown>;
2122
}
23+
interface PerformanceEventTiming extends PerformanceEntry {
24+
processingStart: number;
25+
processingEnd: number;
26+
duration: number;
27+
cancelable?: boolean;
28+
target?: unknown | null;
29+
interactionId?: number;
30+
}
2231

2332
interface Metric {
2433
/**
@@ -86,6 +95,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};
8695
let _previousCls: Metric | undefined;
8796
let _previousFid: Metric | undefined;
8897
let _previousLcp: Metric | undefined;
98+
let _previousInp: Metric | undefined;
8999

90100
/**
91101
* Add a callback that will be triggered when a CLS metric is available.
@@ -123,9 +133,19 @@ export function addFidInstrumentationHandler(callback: (data: { metric: Metric }
123133
return addMetricObserver('fid', callback, instrumentFid, _previousFid);
124134
}
125135

136+
/**
137+
* Add a callback that will be triggered when a INP metric is available.
138+
* Returns a cleanup callback which can be called to remove the instrumentation handler.
139+
*/
140+
export function addInpInstrumentationHandler(
141+
callback: (data: { metric: Omit<Metric, 'entries'> & { entries: PerformanceEventTiming[] } }) => void,
142+
): CleanupHandlerCallback {
143+
return addMetricObserver('inp', callback, instrumentInp, _previousInp);
144+
}
145+
126146
export function addPerformanceInstrumentationHandler(
127147
type: 'event',
128-
callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void,
148+
callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void,
129149
): CleanupHandlerCallback;
130150
export function addPerformanceInstrumentationHandler(
131151
type: InstrumentHandlerTypePerformanceObserver,
@@ -199,6 +219,15 @@ function instrumentLcp(): StopListening {
199219
});
200220
}
201221

222+
function instrumentInp(): void {
223+
return onINP(metric => {
224+
triggerHandlers('inp', {
225+
metric,
226+
});
227+
_previousInp = metric;
228+
});
229+
}
230+
202231
function addMetricObserver(
203232
type: InstrumentHandlerTypeMetric,
204233
callback: InstrumentHandlerCallback,

0 commit comments

Comments
 (0)