From f8bad8d974810492686e180ffe0984ecb9b7b659 Mon Sep 17 00:00:00 2001 From: edwardgou-sentry <83961295+edwardgou-sentry@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:45:01 -0400 Subject: [PATCH] feat(performance): Port INP span instrumentation to old browser tracing (#11085) Adds INP span instrumentation to the old `BrowserTracing` integration. --- .size-limit.js | 2 +- packages/core/src/span.ts | 10 +- .../src/browser/browsertracing.ts | 109 ++++++++++++++++-- .../src/browser/metrics/index.ts | 4 +- .../test/browser/browsertracing.test.ts | 3 + 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index ef140e160170..79313d5aa0ac 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -45,7 +45,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: '{ init, BrowserTracing }', gzip: true, - limit: '35 KB', + limit: '37 KB', }, { name: '@sentry/browser (incl. browserTracingIntegration) - Webpack (gzipped)', diff --git a/packages/core/src/span.ts b/packages/core/src/span.ts index 405ceeddab30..2a2992fad68c 100644 --- a/packages/core/src/span.ts +++ b/packages/core/src/span.ts @@ -1,15 +1,19 @@ -import type { SpanEnvelope, SpanItem } from '@sentry/types'; +import type { DsnComponents, SpanEnvelope, SpanItem } from '@sentry/types'; import type { Span } from '@sentry/types'; -import { createEnvelope } from '@sentry/utils'; +import { createEnvelope, dsnToString } from '@sentry/utils'; /** * Create envelope from Span item. */ -export function createSpanEnvelope(spans: Span[]): SpanEnvelope { +export function createSpanEnvelope(spans: Span[], dsn?: DsnComponents): SpanEnvelope { const headers: SpanEnvelope[0] = { sent_at: new Date().toISOString(), }; + if (dsn) { + headers.dsn = dsnToString(dsn); + } + const items = spans.map(createSpanItem); return createEnvelope(headers, items); } diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts index e2b1c120ccd5..7a32b5768886 100644 --- a/packages/tracing-internal/src/browser/browsertracing.ts +++ b/packages/tracing-internal/src/browser/browsertracing.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import type { Hub, IdleTransaction } from '@sentry/core'; +import { getClient, getCurrentScope } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, TRACING_DEFAULTS, @@ -12,8 +13,10 @@ import { getDomElement, logger, propagationContextFromHeaders } from '@sentry/ut import { DEBUG_BUILD } from '../common/debug-build'; import { registerBackgroundTabDetection } from './backgroundtab'; +import { addPerformanceInstrumentationHandler } from './instrument'; import { addPerformanceEntries, + startTrackingINP, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals, @@ -22,6 +25,7 @@ import type { RequestInstrumentationOptions } from './request'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; import { instrumentRoutingWithDefaults } from './router'; import { WINDOW } from './types'; +import type { InteractionRouteNameMapping } from './web-vitals/types'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -87,6 +91,13 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { */ enableLongTask: boolean; + /** + * If true, Sentry will capture INP web vitals as standalone spans . + * + * Default: false + */ + enableInp: boolean; + /** * _metricOptions allows the user to send options to change how metrics are collected. * @@ -146,10 +157,14 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { startTransactionOnLocationChange: true, startTransactionOnPageLoad: true, enableLongTask: true, + enableInp: false, _experiments: {}, ...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. @@ -175,12 +190,14 @@ export class BrowserTracing implements Integration { private _getCurrentHub?: () => Hub; - private _latestRouteName?: string; - private _latestRouteSource?: TransactionSource; - private _collectWebVitals: () => void; private _hasSetTracePropagationTargets: boolean; + private _interactionIdtoRouteNameMapping: InteractionRouteNameMapping; + private _latestRoute: { + name: string | undefined; + context: TransactionContext | undefined; + }; public constructor(_options?: Partial) { this.name = BROWSER_TRACING_INTEGRATION_ID; @@ -217,12 +234,23 @@ export class BrowserTracing implements Integration { } this._collectWebVitals = startTrackingWebVitals(); + /** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */ + this._interactionIdtoRouteNameMapping = {}; + + if (this.options.enableInp) { + startTrackingINP(this._interactionIdtoRouteNameMapping); + } if (this.options.enableLongTask) { startTrackingLongTasks(); } if (this.options._experiments.enableInteractions) { startTrackingInteractions(); } + + this._latestRoute = { + name: undefined, + context: undefined, + }; } /** @@ -287,6 +315,10 @@ export class BrowserTracing implements Integration { this._registerInteractionListener(); } + if (this.options.enableInp) { + this._registerInpInteractionListener(); + } + instrumentOutgoingRequests({ traceFetch, traceXHR, @@ -349,8 +381,8 @@ export class BrowserTracing implements Integration { : // eslint-disable-next-line deprecation/deprecation finalContext.metadata; - this._latestRouteName = finalContext.name; - this._latestRouteSource = getSource(finalContext); + this._latestRoute.name = finalContext.name; + this._latestRoute.context = finalContext; // eslint-disable-next-line deprecation/deprecation if (finalContext.sampled === false) { @@ -420,7 +452,7 @@ export class BrowserTracing implements Integration { return undefined; } - if (!this._latestRouteName) { + if (!this._latestRoute.name) { DEBUG_BUILD && logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`); return undefined; } @@ -429,11 +461,13 @@ export class BrowserTracing implements Integration { const { location } = WINDOW; const context: TransactionContext = { - name: this._latestRouteName, + name: this._latestRoute.name, op, trimEnd: true, data: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: this._latestRouteSource || 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: this._latestRoute.context + ? getSource(this._latestRoute.context) + : undefined || 'url', }, }; @@ -452,6 +486,61 @@ export class BrowserTracing implements Integration { addEventListener(type, registerInteractionTransaction, { once: false, capture: true }); }); } + + /** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */ + private _registerInpInteractionListener(): void { + addPerformanceInstrumentationHandler('event', ({ entries }) => { + const client = getClient(); + // 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 + const replay = + client !== undefined && client.getIntegrationByName !== undefined + ? (client.getIntegrationByName('Replay') as Integration & { getReplayId: () => string }) + : undefined; + const replayId = replay !== undefined ? replay.getReplayId() : undefined; + // eslint-disable-next-line deprecation/deprecation + const activeTransaction = getActiveTransaction(); + const currentScope = getCurrentScope(); + const user = currentScope !== undefined ? currentScope.getUser() : undefined; + for (const entry of entries) { + if (isPerformanceEventTiming(entry)) { + const duration = entry.duration; + const keys = Object.keys(this._interactionIdtoRouteNameMapping); + const minInteractionId = + keys.length > 0 + ? keys.reduce((a, b) => { + return this._interactionIdtoRouteNameMapping[a].duration < + this._interactionIdtoRouteNameMapping[b].duration + ? a + : b; + }) + : undefined; + if ( + minInteractionId === undefined || + duration > this._interactionIdtoRouteNameMapping[minInteractionId].duration + ) { + const interactionId = entry.interactionId; + const routeName = this._latestRoute.name; + const parentContext = this._latestRoute.context; + if (interactionId && routeName && parentContext) { + if (minInteractionId && Object.keys(this._interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this._interactionIdtoRouteNameMapping[minInteractionId]; + } + this._interactionIdtoRouteNameMapping[interactionId] = { + routeName, + duration, + parentContext, + user, + activeTransaction, + replayId, + }; + } + } + } + } + }); + } } /** Returns the value of a meta tag */ @@ -473,3 +562,7 @@ function getSource(context: TransactionContext): TransactionSource | undefined { return sourceFromAttributes || sourceFromData || sourceFromMetadata; } + +function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming { + return 'duration' in entry; +} diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index b9c08f7dffaf..95fac5a98ba7 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -204,7 +204,7 @@ function _trackFID(): () => void { /** Starts tracking the Interaction to Next Paint on the current page. */ function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping): () => void { return addInpInstrumentationHandler(({ metric }) => { - const entry = metric.entries.find(e => e.name === 'click'); + const entry = metric.entries.find(e => e.name === 'click' || e.name === 'pointerdown'); const client = getClient(); if (!entry || !client) { return; @@ -252,7 +252,7 @@ function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping) } if (Math.random() < (sampleRate as number | boolean)) { - const envelope = span ? createSpanEnvelope([span]) : undefined; + const envelope = span ? createSpanEnvelope([span], client.getDsn()) : undefined; const transport = client && client.getTransport(); if (transport && envelope) { transport.send(envelope).then(null, reason => { diff --git a/packages/tracing-internal/test/browser/browsertracing.test.ts b/packages/tracing-internal/test/browser/browsertracing.test.ts index b9830b8d754c..777704785d55 100644 --- a/packages/tracing-internal/test/browser/browsertracing.test.ts +++ b/packages/tracing-internal/test/browser/browsertracing.test.ts @@ -91,6 +91,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => { const browserTracing = createBrowserTracing(); expect(browserTracing.options).toEqual({ + enableInp: false, enableLongTask: true, _experiments: {}, ...TRACING_DEFAULTS, @@ -110,6 +111,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => { }); expect(browserTracing.options).toEqual({ + enableInp: false, enableLongTask: false, ...TRACING_DEFAULTS, markBackgroundTransactions: true, @@ -129,6 +131,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => { }); expect(browserTracing.options).toEqual({ + enableInp: false, enableLongTask: false, _experiments: {}, ...TRACING_DEFAULTS,