Skip to content

Commit

Permalink
feat(tracing): Update to Web Vitals v3 (#5987)
Browse files Browse the repository at this point in the history
This merges in changes from Web Vitals v3.0.4 while maintaining the original changes and simplifications to the vendored code.
  • Loading branch information
timfish committed Oct 24, 2022
1 parent ef59700 commit db15649
Show file tree
Hide file tree
Showing 18 changed files with 771 additions and 173 deletions.
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

0 comments on commit db15649

Please sign in to comment.