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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ module.exports = [
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '225 KB',
limit: '228 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down
127 changes: 99 additions & 28 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ import {
debug,
getActiveSpan,
getComponentName,
getCurrentScope,
parseUrl,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setMeasurement,
spanToJSON,
startInactiveSpan,
} from '@sentry/core';
import { htmlTreeAsString } from '../htmlTreeAsString';
import { WINDOW } from '../types';
Expand Down Expand Up @@ -73,9 +79,59 @@ let _measurements: Measurements = {};
let _lcpEntry: LargestContentfulPaint | undefined;
let _clsEntry: LayoutShift | undefined;

/**
* Routes a web vital measurement to the correct destination.
* For hard navigations, stores in `_measurements` to be flushed onto the pageload span.
* For soft navigations, emits a web vital span. With span streaming (v2) enabled,
* the span is buffered by trace ID and sent alongside the navigation span.
*/
function _emitMeasurement(
metric: { navigationType: string; navigationId: string },
name: string,
value: number,
unit: string,
): void {
if (metric.navigationType !== 'soft-navigation') {
_measurements[name] = { value, unit };
return;
}

_emitSoftNavWebVitalSpan(name, value, unit);
}

/**
* Creates a v2 web vital span for a soft navigation metric.
* This is a regular (non-standalone) span so it flows through the span streaming
* pipeline (afterSpanEnd -> captureSpan -> SpanBuffer) and gets grouped with
* the navigation span by trace ID.
*/
function _emitSoftNavWebVitalSpan(name: string, value: number, unit: string): void {
const startTime = msToSec(browserPerformanceTimeOrigin() || 0);
const routeName = getCurrentScope().getScopeData().transactionName;

const span = startInactiveSpan({
name: routeName || '',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.softnavigation',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.webvital.${name}`,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
},
startTime,
});

if (span) {
span.addEvent(name, {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit,
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value,
});
span.end(startTime);
}
}

interface StartTrackingWebVitalsOptions {
trackCls: boolean;
trackLcp: boolean;
reportSoftNavs?: boolean;
client: Client;
}

Expand All @@ -85,19 +141,27 @@ interface StartTrackingWebVitalsOptions {
*
* @returns A function that forces web vitals collection
*/
export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebVitalsOptions): () => void {
export function startTrackingWebVitals({
trackCls,
trackLcp,
reportSoftNavs,
}: StartTrackingWebVitalsOptions): () => void {
const performance = getBrowserPerformanceAPI();
if (performance && browserPerformanceTimeOrigin()) {
const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined;
const clsCleanupCallback = trackCls ? _trackCLS() : undefined;
const ttfbCleanupCallback = _trackTtfb();
const lcpCleanupCallback = trackLcp ? _trackLCP(reportSoftNavs) : undefined;
const clsCleanupCallback = trackCls ? _trackCLS(reportSoftNavs) : undefined;
const ttfbCleanupCallback = _trackTtfb(reportSoftNavs);
const fpFcpCleanupCallback = _trackFpFcp();

return (): void => {
ttfbCleanupCallback();
fpFcpCleanupCallback();
lcpCleanupCallback?.();
clsCleanupCallback?.();
// When soft navs are enabled, keep the LCP and CLS observers alive across the page
// lifetime so vitals for subsequent soft navigations are still captured.
if (!reportSoftNavs) {
lcpCleanupCallback?.();
clsCleanupCallback?.();
}
};
}

Expand Down Expand Up @@ -238,39 +302,46 @@ export { registerInpInteractionListener } from './inp';
* Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry
* to the `_measurements` object which ultimately is applied to the pageload span's measurements.
*/
function _trackCLS(): () => void {
return addClsInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
if (!entry) {
return;
}
_measurements['cls'] = { value: metric.value, unit: '' };
_clsEntry = entry;
}, true);
function _trackCLS(reportSoftNavs?: boolean): () => void {
return addClsInstrumentationHandler(
({ metric }) => {
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
if (!entry) {
return;
}
_emitMeasurement(metric, 'cls', metric.value, '');
_clsEntry = entry;
},
!reportSoftNavs,
reportSoftNavs,
);
}

/** Starts tracking the Largest Contentful Paint on the current page. */
function _trackLCP(): () => void {
return addLcpInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1];
if (!entry || !isValidLcpMetric(metric.value)) {
return;
}

_measurements['lcp'] = { value: metric.value, unit: 'millisecond' };
_lcpEntry = entry as LargestContentfulPaint;
}, true);
function _trackLCP(reportSoftNavs?: boolean): () => void {
return addLcpInstrumentationHandler(
({ metric }) => {
const entry = metric.entries[metric.entries.length - 1];
if (!entry || !isValidLcpMetric(metric.value)) {
return;
}
_emitMeasurement(metric, 'lcp', metric.value, 'millisecond');
_lcpEntry = entry as LargestContentfulPaint;
},
!reportSoftNavs,
reportSoftNavs,
);
}

function _trackTtfb(): () => void {
function _trackTtfb(reportSoftNavs?: boolean): () => void {
return addTtfbInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1];
if (!entry) {
return;
}

_measurements['ttfb'] = { value: metric.value, unit: 'millisecond' };
});
_emitMeasurement(metric, 'ttfb', metric.value, 'millisecond');
}, reportSoftNavs);
}

/** Starts tracking First Paint and First Contentful Paint on the current page. */
Expand Down
77 changes: 52 additions & 25 deletions packages/browser-utils/src/metrics/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,20 @@ interface Metric {
* support that API). For pages that are restored from the bfcache, this
* value will be 'back-forward-cache'.
*/
navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore';
navigationType:
| 'navigate'
| 'reload'
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore'
| 'soft-navigation';

/**
* The navigationId the metric belongs to. Relevant for soft navigations
* where multiple navigations can occur within a single page lifecycle.
*/
navigationId: string;
}

type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver;
Expand Down Expand Up @@ -125,8 +138,9 @@ let _previousInp: Metric | undefined;
export function addClsInstrumentationHandler(
callback: (data: { metric: Metric }) => void,
stopOnCallback = false,
reportSoftNavs?: boolean,
): CleanupHandlerCallback {
return addMetricObserver('cls', callback, instrumentCls, _previousCls, stopOnCallback);
return addMetricObserver('cls', callback, () => instrumentCls(reportSoftNavs), _previousCls, stopOnCallback);
}

/**
Expand All @@ -139,15 +153,19 @@ export function addClsInstrumentationHandler(
export function addLcpInstrumentationHandler(
callback: (data: { metric: Metric }) => void,
stopOnCallback = false,
reportSoftNavs?: boolean,
): CleanupHandlerCallback {
return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp, stopOnCallback);
return addMetricObserver('lcp', callback, () => instrumentLcp(reportSoftNavs), _previousLcp, stopOnCallback);
}

/**
* Add a callback that will be triggered when a TTFD metric is available.
*/
export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb);
export function addTtfbInstrumentationHandler(
callback: (data: { metric: Metric }) => void,
reportSoftNavs?: boolean,
): CleanupHandlerCallback {
return addMetricObserver('ttfb', callback, () => instrumentTtfb(reportSoftNavs), _previousTtfb);
}

export type InstrumentationHandlerCallback = (data: {
Expand All @@ -160,8 +178,11 @@ export type InstrumentationHandlerCallback = (data: {
* 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: InstrumentationHandlerCallback): CleanupHandlerCallback {
return addMetricObserver('inp', callback, instrumentInp, _previousInp);
export function addInpInstrumentationHandler(
callback: InstrumentationHandlerCallback,
reportSoftNavs?: boolean,
): CleanupHandlerCallback {
return addMetricObserver('inp', callback, () => instrumentInp(reportSoftNavs), _previousInp);
}

export function addPerformanceInstrumentationHandler(
Expand Down Expand Up @@ -213,7 +234,7 @@ function triggerHandlers(type: InstrumentHandlerType, data: unknown): void {
}
}

function instrumentCls(): StopListening {
function instrumentCls(reportSoftNavs?: boolean): StopListening {
return onCLS(
metric => {
triggerHandlers('cls', {
Expand All @@ -223,11 +244,11 @@ 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: true, reportSoftNavs },
);
}

function instrumentLcp(): StopListening {
function instrumentLcp(reportSoftNavs?: boolean): StopListening {
return onLCP(
metric => {
triggerHandlers('lcp', {
Expand All @@ -237,26 +258,32 @@ 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: true, reportSoftNavs },
);
}

function instrumentTtfb(): StopListening {
return onTTFB(metric => {
triggerHandlers('ttfb', {
metric,
});
_previousTtfb = metric;
});
function instrumentTtfb(reportSoftNavs?: boolean): StopListening {
return onTTFB(
metric => {
triggerHandlers('ttfb', {
metric,
});
_previousTtfb = metric;
},
{ reportSoftNavs },
);
}

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

function addMetricObserver(
Expand Down
11 changes: 11 additions & 0 deletions packages/browser-utils/src/metrics/web-vitals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,23 @@ This vendored web-vitals library is meant to be used in conjunction with the `@s
`browserTracingIntegration`. As such, logic around `BFCache` and multiple reports were removed from the library as our
web-vitals only report once per pageload.

Experimental soft navigation support (from upstream [#308](https://github.com/GoogleChrome/web-vitals/pull/308)) was
ported on top of our BFCache-free v5.1.0 base. It is gated behind the `reportSoftNavs` report option and, when enabled,
re-initializes each metric on a new `navigationId` so vitals are also reported for Chrome soft navigations. It requires
the Soft Navigation API (origin trial or the `#soft-navigation-heuristics` flag).

## License

[Apache 2.0](https://github.com/GoogleChrome/web-vitals/blob/master/LICENSE)

## CHANGELOG

- Ported experimental soft navigation support from upstream [#308](https://github.com/GoogleChrome/web-vitals/pull/308)
- Upstream shipped it in v6.0.0, entangled with BFCache handling we intentionally removed, so it was adapted onto our
BFCache-free v5.1.0 base rather than taken verbatim
- Gated behind the `reportSoftNavs` report option; adds `navigationId` tracking, `soft-navigation` /
`interaction-contentful-paint` observers, and per-navigation metric re-initialization

- Bumped from Web Vitals 5.0.2 to 5.1.0
- Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627)
- Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637)
Expand Down
Loading
Loading