Skip to content

Commit

Permalink
feat(rum): Add measurements support and web vitals (#2909)
Browse files Browse the repository at this point in the history
  • Loading branch information
dashed committed Oct 7, 2020
1 parent dd8e392 commit ce16e33
Show file tree
Hide file tree
Showing 16 changed files with 558 additions and 75 deletions.
124 changes: 52 additions & 72 deletions packages/tracing/src/browser/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
/* eslint-disable max-lines */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SpanContext } from '@sentry/types';
import { Measurements, SpanContext } from '@sentry/types';
import { browserPerformanceTimeOrigin, getGlobalObject, logger } from '@sentry/utils';

import { Span } from '../span';
import { Transaction } from '../transaction';
import { msToSec } from '../utils';
import { getFID } from './web-vitals/getFID';
import { getLCP } from './web-vitals/getLCP';

const global = getGlobalObject<Window>();

/** Class tracking metrics */
export class MetricsInstrumentation {
private _lcp: Record<string, any> = {};
private _measurements: Measurements = {};

private _performanceCursor: number = 0;

Expand All @@ -22,6 +24,7 @@ export class MetricsInstrumentation {
}

this._trackLCP();
this._trackFID();
}
}

Expand All @@ -34,16 +37,6 @@ export class MetricsInstrumentation {

logger.log('[Tracing] Adding & adjusting spans using Performance API');

// TODO(fixme): depending on the 'op' directly is brittle.
if (transaction.op === 'pageload') {
// Force any pending records to be dispatched.
this._forceLCP();
if (this._lcp) {
// Set the last observed LCP score.
transaction.setData('_sentry_web_vitals', { LCP: this._lcp });
}
}

const timeOrigin = msToSec(browserPerformanceTimeOrigin);
let entryScriptSrc: string | undefined;

Expand Down Expand Up @@ -85,6 +78,21 @@ export class MetricsInstrumentation {
if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') {
tracingInitMarkStartTime = startTimestamp;
}

// capture web vitals

if (entry.name === 'first-paint') {
logger.log('[Measurements] Adding FP');
this._measurements['fp'] = { value: entry.startTime };
this._measurements['mark.fp'] = { value: startTimestamp };
}

if (entry.name === 'first-contentful-paint') {
logger.log('[Measurements] Adding FCP');
this._measurements['fcp'] = { value: entry.startTime };
this._measurements['mark.fcp'] = { value: startTimestamp };
}

break;
}
case 'resource': {
Expand All @@ -111,73 +119,45 @@ export class MetricsInstrumentation {
}

this._performanceCursor = Math.max(performance.getEntries().length - 1, 0);
}

private _forceLCP: () => void = () => {
/* No-op, replaced later if LCP API is available. */
return;
};
// Measurements are only available for pageload transactions
if (transaction.op === 'pageload') {
transaction.setMeasurements(this._measurements);
}
}

/** Starts tracking the Largest Contentful Paint on the current page. */
private _trackLCP(): void {
// Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript.
// Use a try/catch instead of feature detecting `largest-contentful-paint`
// support, since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
try {
// Keep track of whether (and when) the page was first hidden, see:
// https://github.com/w3c/page-visibility/issues/29
// NOTE: ideally this check would be performed in the document <head>
// to avoid cases where the visibility state changes before this code runs.
let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
document.addEventListener(
'visibilitychange',
event => {
firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp);
},
{ once: true },
);

const updateLCP = (entry: PerformanceEntry): void => {
// Only include an LCP entry if the page wasn't hidden prior to
// the entry being dispatched. This typically happens when a page is
// loaded in a background tab.
if (entry.startTime < firstHiddenTime) {
// NOTE: the `startTime` value is a getter that returns the entry's
// `renderTime` value, if available, or its `loadTime` value otherwise.
// The `renderTime` value may not be available if the element is an image
// that's loaded cross-origin without the `Timing-Allow-Origin` header.
this._lcp = {
// @ts-ignore can't access id on entry
...(entry.id && { elementId: entry.id }),
// @ts-ignore can't access id on entry
...(entry.size && { elementSize: entry.size }),
value: entry.startTime,
};
}
};
getLCP(metric => {
const entry = metric.entries.pop();

// Create a PerformanceObserver that calls `updateLCP` for each entry.
const po = new PerformanceObserver(entryList => {
entryList.getEntries().forEach(updateLCP);
});
if (!entry) {
return;
}

// Observe entries of type `largest-contentful-paint`, including buffered entries,
// i.e. entries that occurred before calling `observe()` below.
po.observe({
buffered: true,
// @ts-ignore type does not exist on obj
type: 'largest-contentful-paint',
});
const timeOrigin = msToSec(performance.timeOrigin);
const startTime = msToSec(entry.startTime as number);
logger.log('[Measurements] Adding LCP');
this._measurements['lcp'] = { value: metric.value };
this._measurements['mark.lcp'] = { value: timeOrigin + startTime };
});
}

this._forceLCP = () => {
if (po.takeRecords) {
po.takeRecords().forEach(updateLCP);
}
};
} catch (e) {
// Do nothing if the browser doesn't support this API.
}
/** Starts tracking the First Input Delay on the current page. */
private _trackFID(): void {
getFID(metric => {
const entry = metric.entries.pop();

if (!entry) {
return;
}

const timeOrigin = msToSec(performance.timeOrigin);
const startTime = msToSec(entry.startTime as number);
logger.log('[Measurements] Adding FID');
this._measurements['fid'] = { value: metric.value };
this._measurements['mark.fid'] = { value: timeOrigin + startTime };
});
}
}

Expand Down
16 changes: 16 additions & 0 deletions packages/tracing/src/browser/web-vitals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# web-vitals

> 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

The commit SHA used is: [56c736b7c4e80f295bc8a98017671c95231fa225](https://github.com/GoogleChrome/web-vitals/tree/56c736b7c4e80f295bc8a98017671c95231fa225)

Current vendored web vitals are:

- LCP (Largest Contentful Paint)
- FID (First Input Delay)

# License

[Apache 2.0](https://github.com/GoogleChrome/web-vitals/blob/master/LICENSE)
89 changes: 89 additions & 0 deletions packages/tracing/src/browser/web-vitals/getFID.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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

interface FIDPolyfillCallback {
(value: number, event: Event): void;
}

interface FIDPolyfill {
onFirstInputDelay: (onReport: FIDPolyfillCallback) => void;
}

declare global {
interface Window {
perfMetrics: FIDPolyfill;
}
}

// https://wicg.github.io/event-timing/#sec-performance-event-timing
interface PerformanceEventTiming extends PerformanceEntry {
processingStart: DOMHighResTimeStamp;
cancelable?: boolean;
target?: Element;
}

export const getFID = (onReport: ReportHandler): void => {
const metric = initMetric('FID');
const firstHidden = getFirstHidden();

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

const po = observe('first-input', entryHandler as PerformanceEntryHandler);
const report = bindReporter(onReport, metric, po);

if (po) {
onHidden(() => {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
po.disconnect();
}, true);
} else {
if (window.perfMetrics && window.perfMetrics.onFirstInputDelay) {
window.perfMetrics.onFirstInputDelay((value: number, event: Event) => {
// Only report if the page wasn't hidden prior to the first input.
if (event.timeStamp < firstHidden.timeStamp) {
metric.value = value;
metric.isFinal = true;
metric.entries = [
{
entryType: 'first-input',
name: event.type,
target: event.target,
cancelable: event.cancelable,
startTime: event.timeStamp,
processingStart: event.timeStamp + value,
} as PerformanceEventTiming,
];
report();
}
});
}
}
};
64 changes: 64 additions & 0 deletions packages/tracing/src/browser/web-vitals/getLCP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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

export const getLCP = (onReport: ReportHandler, reportAllChanges = false): void => {
const metric = initMetric('LCP');
const firstHidden = getFirstHidden();

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;

// 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 < firstHidden.timeStamp) {
metric.value = value;
metric.entries.push(entry);
} else {
metric.isFinal = true;
}

report();
};

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

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

const onFinal = (): void => {
if (!metric.isFinal) {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
metric.isFinal = true;
report();
}
};

void whenInput().then(onFinal);
onHidden(onFinal, true);
}
};
45 changes: 45 additions & 0 deletions packages/tracing/src/browser/web-vitals/lib/bindReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Metric, ReportHandler } from '../types';

export const bindReporter = (
callback: ReportHandler,
metric: Metric,
po: PerformanceObserver | undefined,
observeAllUpdates?: boolean,
): (() => void) => {
let prevValue: number;
return () => {
if (po && metric.isFinal) {
po.disconnect();
}
if (metric.value >= 0) {
if (observeAllUpdates || metric.isFinal || document.visibilityState === 'hidden') {
metric.delta = metric.value - (prevValue || 0);

// Report the metric if there's a non-zero delta, if the metric is
// final, or if no previous value exists (which can happen in the case
// of the document becoming hidden when the metric value is 0).
// See: https://github.com/GoogleChrome/web-vitals/issues/14
if (metric.delta || metric.isFinal || prevValue === undefined) {
callback(metric);
prevValue = metric.value;
}
}
}
};
};
Loading

0 comments on commit ce16e33

Please sign in to comment.