Skip to content
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

feat(tracing): Update to Web Vitals v3 #5987

Merged
merged 10 commits into from Oct 24, 2022
60 changes: 33 additions & 27 deletions packages/tracing/src/browser/metrics/index.ts
Expand Up @@ -5,11 +5,11 @@ import { browserPerformanceTimeOrigin, htmlTreeAsString, logger, WINDOW } from '
import { IdleTransaction } from '../../idletransaction';
import { Transaction } from '../../transaction';
import { getActiveTransaction, msToSec } from '../../utils';
import { getCLS, LayoutShift } from '../web-vitals/getCLS';
import { getFID } from '../web-vitals/getFID';
import { getLCP, LargestContentfulPaint } from '../web-vitals/getLCP';
import { onCLS } from '../web-vitals/getCLS';
import { onFID } from '../web-vitals/getFID';
import { onLCP } from '../web-vitals/getLCP';
import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher';
import { observe, PerformanceEntryHandler } from '../web-vitals/lib/observe';
import { observe } from '../web-vitals/lib/observe';
import { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types';
import { _startChild, isMeasurementValue } from './utils';

Expand Down Expand Up @@ -42,19 +42,22 @@ export function startTrackingWebVitals(reportAllChanges: boolean = false): void
* Start tracking long tasks.
*/
export function startTrackingLongTasks(): void {
const entryHandler: PerformanceEntryHandler = (entry: PerformanceEntry): void => {
const transaction = getActiveTransaction() as IdleTransaction | undefined;
if (!transaction) {
return;
const entryHandler = (entries: PerformanceEntry[]): void => {
for (const entry of entries) {
const transaction = getActiveTransaction() as IdleTransaction | undefined;
if (!transaction) {
return;
}
const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime);
const duration = msToSec(entry.duration);

transaction.startChild({
description: 'Main UI thread blocked',
op: 'ui.long-task',
startTimestamp: startTime,
endTimestamp: startTime + duration,
});
}
const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime);
const duration = msToSec(entry.duration);
transaction.startChild({
description: 'Main UI thread blocked',
op: 'ui.long-task',
startTimestamp: startTime,
endTimestamp: startTime + duration,
});
};

observe('longtask', entryHandler);
Expand All @@ -65,7 +68,7 @@ function _trackCLS(): void {
// See:
// https://web.dev/evolving-cls/
// https://web.dev/cls-web-tooling/
getCLS(metric => {
onCLS(metric => {
const entry = metric.entries.pop();
if (!entry) {
return;
Expand All @@ -79,21 +82,24 @@ function _trackCLS(): void {

/** Starts tracking the Largest Contentful Paint on the current page. */
function _trackLCP(reportAllChanges: boolean): void {
getLCP(metric => {
const entry = metric.entries.pop();
if (!entry) {
return;
}
onLCP(
metric => {
const entry = metric.entries.pop();
if (!entry) {
return;
}

__DEBUG_BUILD__ && logger.log('[Measurements] Adding LCP');
_measurements['lcp'] = { value: metric.value, unit: 'millisecond' };
_lcpEntry = entry as LargestContentfulPaint;
}, reportAllChanges);
__DEBUG_BUILD__ && logger.log('[Measurements] Adding LCP');
_measurements['lcp'] = { value: metric.value, unit: 'millisecond' };
_lcpEntry = entry as LargestContentfulPaint;
},
{ reportAllChanges },
);
}

/** Starts tracking the First Input Delay on the current page. */
function _trackFID(): void {
getFID(metric => {
onFID(metric => {
const entry = metric.entries.pop();
if (!entry) {
return;
Expand Down
7 changes: 5 additions & 2 deletions packages/tracing/src/browser/web-vitals/README.md
Expand Up @@ -2,9 +2,9 @@

> A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users.
This was vendored from: https://github.com/GoogleChrome/web-vitals: v2.1.0
This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.0.4

The commit SHA used is: [3f3338d994f182172d5b97b22a0fcce0c2846908](https://github.com/GoogleChrome/web-vitals/tree/3f3338d994f182172d5b97b22a0fcce0c2846908)
The commit SHA used is: [7f0ed0bfb03c356e348a558a3eda111b498a2a11](https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11)

Current vendored web vitals are:

Expand All @@ -23,6 +23,9 @@ As such, logic around `BFCache` and multiple reports were removed from the libra

## CHANGELOG

https://github.com/getsentry/sentry-javascript/pull/5987
- Bumped from Web Vitals v2.1.0 to v3.0.4

https://github.com/getsentry/sentry-javascript/pull/3781
- Bumped from Web Vitals v0.2.4 to v2.1.0

Expand Down
106 changes: 57 additions & 49 deletions packages/tracing/src/browser/web-vitals/getCLS.ts
Expand Up @@ -16,72 +16,80 @@

import { bindReporter } from './lib/bindReporter';
import { initMetric } from './lib/initMetric';
import { observe, PerformanceEntryHandler } from './lib/observe';
import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { ReportHandler } from './types';
import { CLSMetric, ReportCallback, ReportOpts } from './types';

// https://wicg.github.io/layout-instability/#sec-layout-shift
export interface LayoutShift extends PerformanceEntry {
value: number;
hadRecentInput: boolean;
sources: Array<LayoutShiftAttribution>;
toJSON(): Record<string, unknown>;
}

export interface LayoutShiftAttribution {
node?: Node;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}

export const getCLS = (onReport: ReportHandler, reportAllChanges?: boolean): void => {
/**
* Calculates the [CLS](https://web.dev/cls/) value for the current page and
* calls the `callback` function once the value is ready to be reported, along
* with all `layout-shift` performance entries that were used in the metric
* value calculation. The reported value is a `double` (corresponding to a
* [layout shift score](https://web.dev/cls/#layout-shift-score)).
*
* If the `reportAllChanges` configuration option is set to `true`, the
* `callback` function will be called as soon as the value is initially
* determined as well as any time the value changes throughout the page
* lifespan.
*
* _**Important:** CLS should be continually monitored for changes throughout
* the entire lifespan of a page—including if the user returns to the page after
* it's been hidden/backgrounded. However, since browsers often [will not fire
* additional callbacks once the user has backgrounded a
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
* `callback` is always called when the page's visibility state changes to
* hidden. As a result, the `callback` function might be called multiple times
* during the same page load._
*/
export const onCLS = (onReport: ReportCallback, opts: ReportOpts = {}): void => {
const metric = initMetric('CLS', 0);
let report: ReturnType<typeof bindReporter>;

let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];

const entryHandler = (entry: LayoutShift): void => {
// Only count layout shifts without recent user input.
// TODO: Figure out why entry can be undefined
if (entry && !entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// const handleEntries = (entries: Metric['entries']) => {
const handleEntries = (entries: LayoutShift[]): void => {
entries.forEach(entry => {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (
sessionValue &&
sessionEntries.length !== 0 &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (
sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}

// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;
if (report) {
report();
// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;
if (report) {
report();
}
}
}
}
});
};

const po = observe('layout-shift', entryHandler as PerformanceEntryHandler);
const po = observe('layout-shift', handleEntries);
if (po) {
report = bindReporter(onReport, metric, reportAllChanges);
report = bindReporter(onReport, metric, opts.reportAllChanges);

onHidden(() => {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
handleEntries(po.takeRecords() as CLSMetric['entries']);
report(true);
});
}
Expand Down
31 changes: 23 additions & 8 deletions packages/tracing/src/browser/web-vitals/getFID.ts
Expand Up @@ -17,29 +17,44 @@
import { bindReporter } from './lib/bindReporter';
import { getVisibilityWatcher } from './lib/getVisibilityWatcher';
import { initMetric } from './lib/initMetric';
import { observe, PerformanceEntryHandler } from './lib/observe';
import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { PerformanceEventTiming, ReportHandler } from './types';
import { FIDMetric, PerformanceEventTiming, ReportCallback, ReportOpts } from './types';

export const getFID = (onReport: ReportHandler, reportAllChanges?: boolean): void => {
/**
* Calculates the [FID](https://web.dev/fid/) value for the current page and
* calls the `callback` function once the value is ready, along with the
* relevant `first-input` performance entry used to determine the value. The
* reported value is a `DOMHighResTimeStamp`.
*
* _**Important:** since FID is only reported after the user interacts with the
* page, it's possible that it will not be reported for some page loads._
*/
export const onFID = (onReport: ReportCallback, opts: ReportOpts = {}): void => {
const visibilityWatcher = getVisibilityWatcher();
const metric = initMetric('FID');
// eslint-disable-next-line prefer-const
let report: ReturnType<typeof bindReporter>;

const entryHandler = (entry: PerformanceEventTiming): void => {
const handleEntry = (entry: PerformanceEventTiming): void => {
// Only report if the page wasn't hidden prior to the first input.
if (report && entry.startTime < visibilityWatcher.firstHiddenTime) {
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
metric.value = entry.processingStart - entry.startTime;
metric.entries.push(entry);
report(true);
}
};

const po = observe('first-input', entryHandler as PerformanceEntryHandler);
const handleEntries = (entries: FIDMetric['entries']): void => {
(entries as PerformanceEventTiming[]).forEach(handleEntry);
};

const po = observe('first-input', handleEntries);
report = bindReporter(onReport, metric, opts.reportAllChanges);

if (po) {
report = bindReporter(onReport, metric, reportAllChanges);
onHidden(() => {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
handleEntries(po.takeRecords() as FIDMetric['entries']);
po.disconnect();
}, true);
}
Expand Down
62 changes: 32 additions & 30 deletions packages/tracing/src/browser/web-vitals/getLCP.ts
Expand Up @@ -15,55 +15,57 @@
*/

import { bindReporter } from './lib/bindReporter';
import { getActivationStart } from './lib/getActivationStart';
import { getVisibilityWatcher } from './lib/getVisibilityWatcher';
import { initMetric } from './lib/initMetric';
import { observe, PerformanceEntryHandler } from './lib/observe';
import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { ReportHandler } from './types';

// https://wicg.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface
export interface LargestContentfulPaint extends PerformanceEntry {
renderTime: DOMHighResTimeStamp;
loadTime: DOMHighResTimeStamp;
size: number;
id: string;
url: string;
element?: Element;
toJSON(): Record<string, string>;
}
import { LCPMetric, ReportCallback, ReportOpts } from './types';

const reportedMetricIDs: Record<string, boolean> = {};

export const getLCP = (onReport: ReportHandler, reportAllChanges?: boolean): void => {
/**
* Calculates the [LCP](https://web.dev/lcp/) value for the current page and
* calls the `callback` function once the value is ready (along with the
* relevant `largest-contentful-paint` performance entry used to determine the
* value). The reported value is a `DOMHighResTimeStamp`.
*
* If the `reportAllChanges` configuration option is set to `true`, the
* `callback` function will be called any time a new `largest-contentful-paint`
* performance entry is dispatched, or once the final value of the metric has
* been determined.
*/
export const onLCP = (onReport: ReportCallback, opts: ReportOpts = {}): void => {
const visibilityWatcher = getVisibilityWatcher();
const metric = initMetric('LCP');
let report: ReturnType<typeof bindReporter>;

const entryHandler = (entry: PerformanceEntry): void => {
// The startTime attribute returns the value of the renderTime if it is not 0,
// and the value of the loadTime otherwise.
const value = entry.startTime;
const handleEntries = (entries: LCPMetric['entries']): void => {
const lastEntry = entries[entries.length - 1] as LargestContentfulPaint;
if (lastEntry) {
// The startTime attribute returns the value of the renderTime if it is
// not 0, and the value of the loadTime otherwise. The activationStart
// reference is used because LCP should be relative to page activation
// rather than navigation start if the page was prerendered.
const value = Math.max(lastEntry.startTime - getActivationStart(), 0);

// If the page was hidden prior to paint time of the entry,
// ignore it and mark the metric as final, otherwise add the entry.
if (value < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries.push(entry);
}

if (report) {
report();
// Only report if the page wasn't hidden prior to LCP.
if (value < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries = [lastEntry];
report();
}
}
};

const po = observe('largest-contentful-paint', entryHandler);
const po = observe('largest-contentful-paint', handleEntries);

if (po) {
report = bindReporter(onReport, metric, reportAllChanges);
report = bindReporter(onReport, metric, opts.reportAllChanges);

const stopListening = (): void => {
if (!reportedMetricIDs[metric.id]) {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
handleEntries(po.takeRecords() as LCPMetric['entries']);
po.disconnect();
reportedMetricIDs[metric.id] = true;
report(true);
Expand Down