Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ export {
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
addInpInstrumentationHandler,
enableSoftNavigationReporting,
} from './instrumentation/performanceObserver';

export { startSoftNavigationCorrelation, supportsSoftNavigations } from './web-vitals/softNavs';

export {
addPerformanceEntries,
startTrackingInteractions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type InstrumentHandlerTypePerformanceObserver =
| 'paint'
| 'resource'
| 'element'
| 'soft-navigation'
// fist-input is still needed for INP
| 'first-input';

Expand All @@ -32,6 +33,16 @@ export interface PerformanceEventTiming extends PerformanceEntry {
interactionId?: number;
}

/**
* A `soft-navigation` entry, minted by the browser once a history change is followed by a
* confirming paint. `interactionId` is the id of the `PerformanceEventTiming` entry for the
* interaction that drove the navigation, which is how we join it back to a Sentry navigation span.
*/
export interface PerformanceSoftNavigation extends PerformanceEntry {
readonly interactionId: number;
readonly navigationId: number;
}

interface PerformanceScriptTiming extends PerformanceEntry {
sourceURL: string;
sourceFunctionName: string;
Expand Down Expand Up @@ -103,6 +114,29 @@ interface Metric {
| 'prerender'
| 'restore'
| 'soft-navigation';

/**
* The id of the navigation the metric belongs to. For soft navigations this is the
* `navigationId` of the `soft-navigation` entry, otherwise it's the id of the hard navigation.
*/
navigationId: number;

/**
* For soft navigations, the `interactionId` of the interaction that triggered the navigation.
*/
navigationInteractionId?: number;

/**
* The start time the metric value is relative to. Non-zero for soft navigations, where the
* time origin is the triggering interaction rather than the start of the document.
*/
navigationStartTime?: number;

/**
* The URL the metric was recorded for. Relevant for soft navigations, where a metric can be
* reported long after the URL has moved on.
*/
navigationURL?: string;
}

type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver;
Expand All @@ -122,6 +156,29 @@ let _previousLcp: Metric | undefined;
let _previousTtfb: Metric | undefined;
let _previousInp: Metric | undefined;

let _reportSoftNavs = false;

/**
* Opt the CLS, LCP and INP observers into reporting metrics for soft navigations.
*
* This also turns `reportAllChanges` off for CLS and LCP. web-vitals force-reports a metric when
* the navigation it belongs to is over, so without the intermediate updates every value a handler
* receives is already the final one for its navigation. That only holds because soft navigations
* are limited to span streaming, where CLS and LCP are sent as their own spans - the static
* lifecycle instead writes them onto the pageload span as it ends, which is what `reportAllChanges`
* was originally added for (#11934, #12360).
*
* Each observer is instrumented lazily, on its first handler, and web-vitals takes its options at
* that point only. So this has to be called before any of the `add*InstrumentationHandler`
* functions, otherwise it won't take effect for observers that are already running.
*
* On browsers without the Soft Navigation API this is a no-op: web-vitals feature-detects the API
* and keeps reporting hard-navigation metrics as usual.
*/
export function enableSoftNavigationReporting(): void {
_reportSoftNavs = true;
}

/**
* Add a callback that will be triggered when a CLS metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
Expand Down Expand Up @@ -247,7 +304,7 @@ function instrumentCls(): StopListening {
}),
// We want the callback to be called whenever the CLS value updates.
// By default, the callback is only called when the tab goes to the background.
{ reportAllChanges: true },
{ reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs },
);
}

Expand All @@ -261,7 +318,7 @@ function instrumentLcp(): StopListening {
}),
// We want the callback to be called whenever the LCP value updates.
// By default, the callback is only called when the tab goes to the background.
{ reportAllChanges: true },
{ reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs },
);
}

Expand All @@ -284,6 +341,7 @@ function instrumentInp(): StopListening {
});
_previousInp = metric;
}),
{ reportSoftNavs: _reportSoftNavs },
);
}

Expand Down
150 changes: 150 additions & 0 deletions packages/browser-utils/src/web-vitals/emitSpan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { Integration, Span, SpanAttributes } from '@sentry/core';
import {
getClient,
getCurrentScope,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
spanToJSON,
startInactiveSpan,
} from '@sentry/core';
import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes';
import { WINDOW } from '../types';
import type { WebVitalReportEvent } from './reportEvents';
import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';

// Locally-defined interfaces to avoid leaking bare global type references into the
// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these
// available during this package's compilation but are NOT carried to consumers.
// This mirrors the pattern used for PerformanceEventTiming in instrument.ts.
export interface LayoutShift extends PerformanceEntry {
value: number;
sources: Array<{ node: Node | null }>;
hadRecentInput: boolean;
}

export interface LargestContentfulPaint extends PerformanceEntry {
readonly renderTime: DOMHighResTimeStamp;
readonly loadTime: DOMHighResTimeStamp;
readonly size: number;
readonly id: string;
readonly url: string;
readonly element: Element | null;
}

interface WebVitalSpanOptions {
name: string;
op: string;
origin: string;
metricName: 'lcp' | 'cls' | 'inp';
value: number;
attributes?: SpanAttributes;
parentSpan?: Span;
reportEvent?: WebVitalReportEvent;
startTime: number;
endTime?: number;
/** Set when the vital was reported for a soft navigation rather than the initial page load. */
softNavigationId?: number;
/**
* When `true`, the span is sent on its own as a v2 streamed span instead of being folded into a
* transaction. Used for INP when span streaming is disabled (it reports late, so it can't ride
* the pageload transaction).
*
* TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always streams.
*/
standalone?: boolean;
}

/**
* Emits a web vital span. When `standalone` is set it is sent on its own as a v2 streamed span;
* otherwise it flows through the span streaming pipeline as a child of `parentSpan`.
*/
export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
const {
name,
op,
origin,
metricName,
value,
attributes: passedAttributes,
parentSpan,
reportEvent,
startTime,
endTime,
standalone,
softNavigationId,
} = options;

// A web vital can be reported long after the user left the route it belongs to: a soft
// navigation's CLS and INP only finalize at the next navigation. The scope's transaction name has
// moved on to that next route by then, so prefer the name of the span the vital is attributed to.
const routeName = (parentSpan && spanToJSON(parentSpan).name) || getCurrentScope().getScopeData().transactionName;

const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
[`browser.web_vital.${metricName}.value`]: value,
// oxlint-disable-next-line typescript-eslint/no-deprecated
[SENTRY_TRANSACTION]: routeName,
[SENTRY_SEGMENT_NAME]: routeName,
// Web vital score calculation relies on the user agent
'user_agent.original': WINDOW.navigator?.userAgent,
...passedAttributes,
};

if (parentSpan && spanToJSON(parentSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') {
// for LCP and CLS, we collect the pageload span id as an attribute
attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId;
}

if (reportEvent) {
attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent;
}

if (softNavigationId != null) {
attributes[SOFT_NAVIGATION_ID_ATTRIBUTE] = softNavigationId;
}

// A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see
// `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it
// here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1.
// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always
// streams, at which point Replay's `processSpan` runs and attaches the replay id.
if (standalone) {
Object.assign(attributes, getReplayAttributes());
}

const span = startInactiveSpan({
name,
attributes,
startTime,
parentSpan,
// oxlint-disable-next-line typescript/no-deprecated -- intentional during the v1/v2 transition; see the TODO(standalone) above
experimental: standalone ? { standalone: true } : undefined,
});

if (span) {
span.end(endTime ?? startTime);
}
}

interface ReplayIntegration extends Integration {
getReplayId: (onlyIfSampled?: boolean) => string | undefined;
getRecordingMode: () => 'session' | 'buffer' | undefined;
}

// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped; Replay's
// `processSpan` then attaches the replay id to the streamed INP span instead.
function getReplayAttributes(): SpanAttributes {
const replay = getClient()?.getIntegrationByName<ReplayIntegration>('Replay');
const replayId = replay?.getReplayId(true);
if (!replayId) {
return {};
}

return {
'sentry.replay_id': replayId,
'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined,
};
}
Loading
Loading