Skip to content

Commit f8bad8d

Browse files
feat(performance): Port INP span instrumentation to old browser tracing (#11085)
Adds INP span instrumentation to the old `BrowserTracing` integration.
1 parent a443a55 commit f8bad8d

File tree

5 files changed

+114
-14
lines changed

5 files changed

+114
-14
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ module.exports = [
4545
path: 'packages/browser/build/npm/esm/index.js',
4646
import: '{ init, BrowserTracing }',
4747
gzip: true,
48-
limit: '35 KB',
48+
limit: '37 KB',
4949
},
5050
{
5151
name: '@sentry/browser (incl. browserTracingIntegration) - Webpack (gzipped)',

packages/core/src/span.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import type { SpanEnvelope, SpanItem } from '@sentry/types';
1+
import type { DsnComponents, SpanEnvelope, SpanItem } from '@sentry/types';
22
import type { Span } from '@sentry/types';
3-
import { createEnvelope } from '@sentry/utils';
3+
import { createEnvelope, dsnToString } from '@sentry/utils';
44

55
/**
66
* Create envelope from Span item.
77
*/
8-
export function createSpanEnvelope(spans: Span[]): SpanEnvelope {
8+
export function createSpanEnvelope(spans: Span[], dsn?: DsnComponents): SpanEnvelope {
99
const headers: SpanEnvelope[0] = {
1010
sent_at: new Date().toISOString(),
1111
};
1212

13+
if (dsn) {
14+
headers.dsn = dsnToString(dsn);
15+
}
16+
1317
const items = spans.map(createSpanItem);
1418
return createEnvelope<SpanEnvelope>(headers, items);
1519
}

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

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable max-lines */
22
import type { Hub, IdleTransaction } from '@sentry/core';
3+
import { getClient, getCurrentScope } from '@sentry/core';
34
import {
45
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
56
TRACING_DEFAULTS,
@@ -12,8 +13,10 @@ import { getDomElement, logger, propagationContextFromHeaders } from '@sentry/ut
1213

1314
import { DEBUG_BUILD } from '../common/debug-build';
1415
import { registerBackgroundTabDetection } from './backgroundtab';
16+
import { addPerformanceInstrumentationHandler } from './instrument';
1517
import {
1618
addPerformanceEntries,
19+
startTrackingINP,
1720
startTrackingInteractions,
1821
startTrackingLongTasks,
1922
startTrackingWebVitals,
@@ -22,6 +25,7 @@ import type { RequestInstrumentationOptions } from './request';
2225
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
2326
import { instrumentRoutingWithDefaults } from './router';
2427
import { WINDOW } from './types';
28+
import type { InteractionRouteNameMapping } from './web-vitals/types';
2529

2630
export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';
2731

@@ -87,6 +91,13 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
8791
*/
8892
enableLongTask: boolean;
8993

94+
/**
95+
* If true, Sentry will capture INP web vitals as standalone spans .
96+
*
97+
* Default: false
98+
*/
99+
enableInp: boolean;
100+
90101
/**
91102
* _metricOptions allows the user to send options to change how metrics are collected.
92103
*
@@ -146,10 +157,14 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
146157
startTransactionOnLocationChange: true,
147158
startTransactionOnPageLoad: true,
148159
enableLongTask: true,
160+
enableInp: false,
149161
_experiments: {},
150162
...defaultRequestInstrumentationOptions,
151163
};
152164

165+
/** We store up to 10 interaction candidates max to cap memory usage. This is the same cap as getINP from web-vitals */
166+
const MAX_INTERACTIONS = 10;
167+
153168
/**
154169
* The Browser Tracing integration automatically instruments browser pageload/navigation
155170
* actions as transactions, and captures requests, metrics and errors as spans.
@@ -175,12 +190,14 @@ export class BrowserTracing implements Integration {
175190

176191
private _getCurrentHub?: () => Hub;
177192

178-
private _latestRouteName?: string;
179-
private _latestRouteSource?: TransactionSource;
180-
181193
private _collectWebVitals: () => void;
182194

183195
private _hasSetTracePropagationTargets: boolean;
196+
private _interactionIdtoRouteNameMapping: InteractionRouteNameMapping;
197+
private _latestRoute: {
198+
name: string | undefined;
199+
context: TransactionContext | undefined;
200+
};
184201

185202
public constructor(_options?: Partial<BrowserTracingOptions>) {
186203
this.name = BROWSER_TRACING_INTEGRATION_ID;
@@ -217,12 +234,23 @@ export class BrowserTracing implements Integration {
217234
}
218235

219236
this._collectWebVitals = startTrackingWebVitals();
237+
/** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */
238+
this._interactionIdtoRouteNameMapping = {};
239+
240+
if (this.options.enableInp) {
241+
startTrackingINP(this._interactionIdtoRouteNameMapping);
242+
}
220243
if (this.options.enableLongTask) {
221244
startTrackingLongTasks();
222245
}
223246
if (this.options._experiments.enableInteractions) {
224247
startTrackingInteractions();
225248
}
249+
250+
this._latestRoute = {
251+
name: undefined,
252+
context: undefined,
253+
};
226254
}
227255

228256
/**
@@ -287,6 +315,10 @@ export class BrowserTracing implements Integration {
287315
this._registerInteractionListener();
288316
}
289317

318+
if (this.options.enableInp) {
319+
this._registerInpInteractionListener();
320+
}
321+
290322
instrumentOutgoingRequests({
291323
traceFetch,
292324
traceXHR,
@@ -349,8 +381,8 @@ export class BrowserTracing implements Integration {
349381
: // eslint-disable-next-line deprecation/deprecation
350382
finalContext.metadata;
351383

352-
this._latestRouteName = finalContext.name;
353-
this._latestRouteSource = getSource(finalContext);
384+
this._latestRoute.name = finalContext.name;
385+
this._latestRoute.context = finalContext;
354386

355387
// eslint-disable-next-line deprecation/deprecation
356388
if (finalContext.sampled === false) {
@@ -420,7 +452,7 @@ export class BrowserTracing implements Integration {
420452
return undefined;
421453
}
422454

423-
if (!this._latestRouteName) {
455+
if (!this._latestRoute.name) {
424456
DEBUG_BUILD && logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
425457
return undefined;
426458
}
@@ -429,11 +461,13 @@ export class BrowserTracing implements Integration {
429461
const { location } = WINDOW;
430462

431463
const context: TransactionContext = {
432-
name: this._latestRouteName,
464+
name: this._latestRoute.name,
433465
op,
434466
trimEnd: true,
435467
data: {
436-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: this._latestRouteSource || 'url',
468+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: this._latestRoute.context
469+
? getSource(this._latestRoute.context)
470+
: undefined || 'url',
437471
},
438472
};
439473

@@ -452,6 +486,61 @@ export class BrowserTracing implements Integration {
452486
addEventListener(type, registerInteractionTransaction, { once: false, capture: true });
453487
});
454488
}
489+
490+
/** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */
491+
private _registerInpInteractionListener(): void {
492+
addPerformanceInstrumentationHandler('event', ({ entries }) => {
493+
const client = getClient();
494+
// We need to get the replay, user, and activeTransaction from the current scope
495+
// so that we can associate replay id, profile id, and a user display to the span
496+
const replay =
497+
client !== undefined && client.getIntegrationByName !== undefined
498+
? (client.getIntegrationByName('Replay') as Integration & { getReplayId: () => string })
499+
: undefined;
500+
const replayId = replay !== undefined ? replay.getReplayId() : undefined;
501+
// eslint-disable-next-line deprecation/deprecation
502+
const activeTransaction = getActiveTransaction();
503+
const currentScope = getCurrentScope();
504+
const user = currentScope !== undefined ? currentScope.getUser() : undefined;
505+
for (const entry of entries) {
506+
if (isPerformanceEventTiming(entry)) {
507+
const duration = entry.duration;
508+
const keys = Object.keys(this._interactionIdtoRouteNameMapping);
509+
const minInteractionId =
510+
keys.length > 0
511+
? keys.reduce((a, b) => {
512+
return this._interactionIdtoRouteNameMapping[a].duration <
513+
this._interactionIdtoRouteNameMapping[b].duration
514+
? a
515+
: b;
516+
})
517+
: undefined;
518+
if (
519+
minInteractionId === undefined ||
520+
duration > this._interactionIdtoRouteNameMapping[minInteractionId].duration
521+
) {
522+
const interactionId = entry.interactionId;
523+
const routeName = this._latestRoute.name;
524+
const parentContext = this._latestRoute.context;
525+
if (interactionId && routeName && parentContext) {
526+
if (minInteractionId && Object.keys(this._interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) {
527+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
528+
delete this._interactionIdtoRouteNameMapping[minInteractionId];
529+
}
530+
this._interactionIdtoRouteNameMapping[interactionId] = {
531+
routeName,
532+
duration,
533+
parentContext,
534+
user,
535+
activeTransaction,
536+
replayId,
537+
};
538+
}
539+
}
540+
}
541+
}
542+
});
543+
}
455544
}
456545

457546
/** Returns the value of a meta tag */
@@ -473,3 +562,7 @@ function getSource(context: TransactionContext): TransactionSource | undefined {
473562

474563
return sourceFromAttributes || sourceFromData || sourceFromMetadata;
475564
}
565+
566+
function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming {
567+
return 'duration' in entry;
568+
}

packages/tracing-internal/src/browser/metrics/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ function _trackFID(): () => void {
204204
/** Starts tracking the Interaction to Next Paint on the current page. */
205205
function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping): () => void {
206206
return addInpInstrumentationHandler(({ metric }) => {
207-
const entry = metric.entries.find(e => e.name === 'click');
207+
const entry = metric.entries.find(e => e.name === 'click' || e.name === 'pointerdown');
208208
const client = getClient();
209209
if (!entry || !client) {
210210
return;
@@ -252,7 +252,7 @@ function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping)
252252
}
253253

254254
if (Math.random() < (sampleRate as number | boolean)) {
255-
const envelope = span ? createSpanEnvelope([span]) : undefined;
255+
const envelope = span ? createSpanEnvelope([span], client.getDsn()) : undefined;
256256
const transport = client && client.getTransport();
257257
if (transport && envelope) {
258258
transport.send(envelope).then(null, reason => {

packages/tracing-internal/test/browser/browsertracing.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
9191
const browserTracing = createBrowserTracing();
9292

9393
expect(browserTracing.options).toEqual({
94+
enableInp: false,
9495
enableLongTask: true,
9596
_experiments: {},
9697
...TRACING_DEFAULTS,
@@ -110,6 +111,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
110111
});
111112

112113
expect(browserTracing.options).toEqual({
114+
enableInp: false,
113115
enableLongTask: false,
114116
...TRACING_DEFAULTS,
115117
markBackgroundTransactions: true,
@@ -129,6 +131,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
129131
});
130132

131133
expect(browserTracing.options).toEqual({
134+
enableInp: false,
132135
enableLongTask: false,
133136
_experiments: {},
134137
...TRACING_DEFAULTS,

0 commit comments

Comments
 (0)