Skip to content

feat(performance): create Interaction standalone spans on inp events #10709

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

Merged
merged 37 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2d83f03
Merge branch 'egou/v7/feat/add-span-envelope-and-datacategory' of git…
edwardgou-sentry Feb 18, 2024
abe1bc2
Merge branch 'egou/v7/feat/add-exclusive-time-and-measurements-to-spa…
edwardgou-sentry Feb 18, 2024
9ae7f95
Creates interaction spans with inp when inp is detected
edwardgou-sentry Feb 18, 2024
84a6021
Adds sampling rate to inp spans
edwardgou-sentry Feb 19, 2024
6bd6ff8
export isValidSampleRate
edwardgou-sentry Feb 19, 2024
97ece4e
Merge branch 'v7' of github.com:getsentry/sentry-javascript into egou…
edwardgou-sentry Feb 21, 2024
ccd8ec1
Merge branch 'egou/v7/fix/browser-tracing-latest-route' of github.com…
edwardgou-sentry Feb 21, 2024
b2e4656
Merge branch 'egou/v7/feat/add-exclusive-time-and-measurements-to-spa…
edwardgou-sentry Feb 21, 2024
5d53fa4
Merge branch 'egou/v7/feat/add-span-envelope-and-datacategory' of git…
edwardgou-sentry Feb 21, 2024
c2aa318
Merge branch 'egou/v7/feat/create-interaction-spans-on-inp' of github…
edwardgou-sentry Feb 22, 2024
5ee3045
fix
edwardgou-sentry Feb 22, 2024
9e24b2b
Merge branch 'egou/v7/feat/add-span-envelope-and-datacategory' of git…
edwardgou-sentry Feb 26, 2024
690ecc1
snake case
edwardgou-sentry Feb 27, 2024
a937852
Merge branch 'egou/v7/feat/create-interaction-spans-on-inp' of github…
edwardgou-sentry Feb 27, 2024
0559eab
Adds profile id, replay id, and user to standalone INP spans if they …
edwardgou-sentry Feb 28, 2024
9b6d5a7
htmlTreeAsString span name
edwardgou-sentry Feb 28, 2024
6fddd84
Merge branch 'v7' of github.com:getsentry/sentry-javascript into egou…
edwardgou-sentry Feb 28, 2024
19cf74a
Merge branch 'egou/v7/feat/add-exclusive-time-and-measurements-to-spa…
edwardgou-sentry Feb 28, 2024
bbdc8f0
pull profile id from attributes into top level because relay expects …
edwardgou-sentry Feb 28, 2024
e5f21ab
refactor out some optional chaining
edwardgou-sentry Feb 29, 2024
9a1b12e
Merge branch 'egou/v7/feat/add-exclusive-time-and-measurements-to-spa…
edwardgou-sentry Feb 29, 2024
77f610b
update span creation
edwardgou-sentry Feb 29, 2024
05f55f1
Merge branch 'egou/v7/feat/create-interaction-spans-on-inp' of github…
edwardgou-sentry Feb 29, 2024
d943c8d
refactor optional check
edwardgou-sentry Feb 29, 2024
9e04846
Merge branch 'v7' of github.com:getsentry/sentry-javascript into egou…
edwardgou-sentry Feb 29, 2024
8b929bc
Merge branch 'egou/v7/feat/add-sampling-rate-to-inp-spans' of github.…
edwardgou-sentry Feb 29, 2024
059508c
increase size limit by 1 kb
edwardgou-sentry Feb 29, 2024
65bb1ac
todo comment and update interactionIdtoRouteNameMapping replay to rep…
edwardgou-sentry Feb 29, 2024
9782eaf
comment
edwardgou-sentry Feb 29, 2024
ac749fb
fix import
edwardgou-sentry Feb 29, 2024
3336ff3
feat(webvitals): Add profile id, replay id, and user to standalone IN…
edwardgou-sentry Feb 29, 2024
b515f8e
feat(performance): Add sampling rate to INP spans. Also add replay id…
edwardgou-sentry Feb 29, 2024
93f5c19
Merge branch 'v7' of github.com:getsentry/sentry-javascript into egou…
edwardgou-sentry Feb 29, 2024
a3f235f
move enableInp off experiment
edwardgou-sentry Feb 29, 2024
5807e2d
performanceeventtiming interface
edwardgou-sentry Feb 29, 2024
226c543
interactionId
edwardgou-sentry Feb 29, 2024
fc676fc
use more primitive types
edwardgou-sentry Feb 29, 2024
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
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
Loading