Skip to content

Commit

Permalink
feat(performance): create Interaction standalone spans on inp events (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
edwardgou-sentry committed Feb 29, 2024
2 parents beed7f6 + fc676fc commit 82eaed0
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: '{ init, browserTracingIntegration }',
gzip: true,
limit: '35 KB',
limit: '36 KB',
},
{
name: '@sentry/browser (incl. Feedback) - Webpack (gzipped)',
Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/profiling/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,9 @@ export function createProfilingEvent(
return createProfilePayload(profile_id, start_timestamp, profile, event);
}

// TODO (v8): We need to obtain profile ids in @sentry-internal/tracing,
// but we don't have access to this map because importing this map would
// cause a circular dependancy. We need to resolve this in v8.
const PROFILE_MAP: Map<string, JSSelfProfile> = new Map();
/**
*
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/semanticAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_OP = 'sentry.op';
* Use this attribute to represent the origin of a span.
*/
export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin';

/**
* The id of the profile that this span occured in.
*/
export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'profile_id';
1 change: 1 addition & 0 deletions packages/core/src/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export {
} from './trace';
export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
export { setMeasurement } from './measurement';
export { isValidSampleRate } from './sampling';
2 changes: 1 addition & 1 deletion packages/core/src/tracing/sampling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function sampleTransaction<T extends Transaction>(
/**
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
*/
function isValidSampleRate(rate: unknown): boolean {
export function isValidSampleRate(rate: unknown): boolean {
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/tracing/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/ut

import { DEBUG_BUILD } from '../debug-build';
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
import {
SEMANTIC_ATTRIBUTE_PROFILE_ID,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
} from '../semanticAttributes';
import { getRootSpan } from '../utils/getRootSpan';
import {
TRACE_FLAG_NONE,
Expand Down Expand Up @@ -634,6 +638,7 @@ export class Span implements SpanInterface {
trace_id: this._traceId,
origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
_metrics_summary: getMetricSummaryJsonForSpan(this),
profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined,
exclusive_time: this._exclusiveTime,
measurements: Object.keys(this._measurements).length > 0 ? this._measurements : undefined,
});
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ export class Transaction extends SpanClass implements TransactionInterface {
this._hub = hub;
}

/**
* Get the profile id of the transaction.
*/
public getProfileId(): string | undefined {
if (this._contexts !== undefined && this._contexts['profile'] !== undefined) {
return this._contexts['profile'].profile_id as string;
}
return undefined;
}

/**
* Finish the transaction & prepare the event to send to Sentry.
*/
Expand Down
104 changes: 98 additions & 6 deletions packages/tracing-internal/src/browser/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import type { IdleTransaction } from '@sentry/core';
import { getActiveSpan } from '@sentry/core';
import { getActiveSpan, getClient, getCurrentScope } from '@sentry/core';
import { getCurrentHub } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
Expand All @@ -12,6 +12,7 @@ import {
} from '@sentry/core';
import type {
Client,
Integration,
IntegrationFn,
StartSpanOptions,
Transaction,
Expand All @@ -29,15 +30,18 @@ import {

import { DEBUG_BUILD } from '../common/debug-build';
import { registerBackgroundTabDetection } from './backgroundtab';
import { addPerformanceInstrumentationHandler } from './instrument';
import {
addPerformanceEntries,
startTrackingINP,
startTrackingInteractions,
startTrackingLongTasks,
startTrackingWebVitals,
} from './metrics';
import type { RequestInstrumentationOptions } from './request';
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
import { WINDOW } from './types';
import type { InteractionRouteNameMapping } from './web-vitals/types';

export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';

Expand Down Expand Up @@ -103,6 +107,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.
*
Expand Down Expand Up @@ -142,6 +153,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
instrumentPageLoad: true,
markBackgroundSpan: true,
enableLongTask: true,
enableInp: false,
_experiments: {},
...defaultRequestInstrumentationOptions,
};
Expand Down Expand Up @@ -181,16 +193,25 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio

const _collectWebVitals = startTrackingWebVitals();

/** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */
const interactionIdtoRouteNameMapping: InteractionRouteNameMapping = {};
if (options.enableInp) {
startTrackingINP(interactionIdtoRouteNameMapping);
}

if (options.enableLongTask) {
startTrackingLongTasks();
}
if (options._experiments.enableInteractions) {
startTrackingInteractions();
}

const latestRoute: { name: string | undefined; source: TransactionSource | undefined } = {
const latestRoute: {
name: string | undefined;
context: TransactionContext | undefined;
} = {
name: undefined,
source: undefined,
context: undefined,
};

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

latestRoute.name = finalContext.name;
latestRoute.source = getSource(finalContext);
latestRoute.context = finalContext;

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

if (options.enableInp) {
registerInpInteractionListener(interactionIdtoRouteNameMapping, latestRoute);
}

instrumentOutgoingRequests({
traceFetch,
traceXHR,
Expand Down Expand Up @@ -448,7 +473,10 @@ export function getMetaContent(metaName: string): string | undefined {
/** Start listener for interaction transactions */
function registerInteractionListener(
options: BrowserTracingOptions,
latestRoute: { name: string | undefined; source: TransactionSource | undefined },
latestRoute: {
name: string | undefined;
context: TransactionContext | undefined;
},
): void {
let inflightInteractionTransaction: IdleTransaction | undefined;
const registerInteractionTransaction = (): void => {
Expand Down Expand Up @@ -483,7 +511,7 @@ function registerInteractionListener(
op,
trimEnd: true,
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.context ? getSource(latestRoute.context) : undefined || 'url',
},
};

Expand All @@ -504,6 +532,70 @@ function registerInteractionListener(
});
}

function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming {
return 'duration' in entry;
}

/** 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;

/** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */
function registerInpInteractionListener(
interactionIdtoRouteNameMapping: InteractionRouteNameMapping,
latestRoute: {
name: string | undefined;
context: TransactionContext | undefined;
},
): 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(interactionIdtoRouteNameMapping);
const minInteractionId =
keys.length > 0
? keys.reduce((a, b) => {
return interactionIdtoRouteNameMapping[a].duration < interactionIdtoRouteNameMapping[b].duration
? a
: b;
})
: undefined;
if (minInteractionId === undefined || duration > interactionIdtoRouteNameMapping[minInteractionId].duration) {
const interactionId = entry.interactionId;
const routeName = latestRoute.name;
const parentContext = latestRoute.context;
if (interactionId && routeName && parentContext) {
if (minInteractionId && Object.keys(interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete interactionIdtoRouteNameMapping[minInteractionId];
}
interactionIdtoRouteNameMapping[interactionId] = {
routeName,
duration,
parentContext,
user,
activeTransaction,
replayId,
};
}
}
}
}
});
}

function getSource(context: TransactionContext): TransactionSource | undefined {
const sourceFromAttributes = context.attributes && context.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
// eslint-disable-next-line deprecation/deprecation
Expand Down
33 changes: 31 additions & 2 deletions packages/tracing-internal/src/browser/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { getFunctionName, logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';
import { onCLS } from './web-vitals/getCLS';
import { onFID } from './web-vitals/getFID';
import { onINP } from './web-vitals/getINP';
import { onLCP } from './web-vitals/getLCP';
import { observe } from './web-vitals/lib/observe';

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

type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid';
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'inp';

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

interface Metric {
/**
Expand Down Expand Up @@ -86,6 +95,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};
let _previousCls: Metric | undefined;
let _previousFid: Metric | undefined;
let _previousLcp: Metric | undefined;
let _previousInp: Metric | undefined;

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

/**
* Add a callback that will be triggered when a INP metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
*/
export function addInpInstrumentationHandler(
callback: (data: { metric: Omit<Metric, 'entries'> & { entries: PerformanceEventTiming[] } }) => void,
): CleanupHandlerCallback {
return addMetricObserver('inp', callback, instrumentInp, _previousInp);
}

export function addPerformanceInstrumentationHandler(
type: 'event',
callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void,
callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void,
): CleanupHandlerCallback;
export function addPerformanceInstrumentationHandler(
type: InstrumentHandlerTypePerformanceObserver,
Expand Down Expand Up @@ -199,6 +219,15 @@ function instrumentLcp(): StopListening {
});
}

function instrumentInp(): void {
return onINP(metric => {
triggerHandlers('inp', {
metric,
});
_previousInp = metric;
});
}

function addMetricObserver(
type: InstrumentHandlerTypeMetric,
callback: InstrumentHandlerCallback,
Expand Down

0 comments on commit 82eaed0

Please sign in to comment.