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(rum): Add measurements support and web vitals #2909

Merged
merged 41 commits into from
Oct 7, 2020
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
49db0be
feat(rum): Add measurements support and web vitals
dashed Aug 21, 2020
f26f152
Update packages/tracing/src/browser/metrics.ts
dashed Sep 16, 2020
242a71d
remove some @ts-ignore
dashed Sep 17, 2020
0f06193
Measurements are only available for pageload transactions
dashed Sep 17, 2020
892e0ec
add enableMeasurements option to browser tracing options
dashed Sep 17, 2020
951c8b9
lint fix
dashed Sep 17, 2020
2b8f766
fix test
dashed Sep 17, 2020
d696e62
rename mark.fid_start to mark.fid
dashed Sep 17, 2020
29c4b52
set enableMeasurements to be true by default
dashed Sep 17, 2020
01f1117
remove enableMeasurements option
dashed Sep 17, 2020
4f194a7
Merge branch 'master' into add-web-vitals
dashed Sep 24, 2020
cad03a0
Update packages/tracing/src/browser/metrics.ts
dashed Sep 24, 2020
646ebea
Update packages/tracing/src/browser/metrics.ts
dashed Sep 24, 2020
375e7cc
Update packages/tracing/src/browser/metrics.ts
dashed Sep 24, 2020
bc7e0cc
Update packages/tracing/src/browser/metrics.ts
dashed Sep 24, 2020
2f1821b
save some bytes
dashed Sep 24, 2020
688c985
delete old lcp implementation
dashed Sep 24, 2020
c1b72ae
share firstHiddenTime code
dashed Sep 24, 2020
8c81de5
feature-detect FID and LCP
dashed Sep 24, 2020
8cc49f8
construction transaction event beforehand and add measurements condit…
dashed Sep 24, 2020
9eca498
yarn fix
dashed Sep 24, 2020
7f88469
ref: Restore blank line
rhcarvalho Sep 24, 2020
2318bfb
capture final lcp on hidden
dashed Sep 24, 2020
1dd9e83
Merge branch 'add-web-vitals' of github.com:getsentry/sentry-javascri…
dashed Sep 24, 2020
d5f0a5e
always disconnect the performance observer for FID
dashed Sep 24, 2020
b2671c3
vendor FID and LCP implementations from https://github.com/GoogleChro…
dashed Sep 24, 2020
6564a13
update ts on web-vitals impl
dashed Sep 24, 2020
4928ed3
use getLCP function
dashed Sep 24, 2020
d803955
remove _forceLCP
dashed Sep 24, 2020
18e4060
use getFID function
dashed Sep 24, 2020
7a40c5a
fix import
dashed Sep 24, 2020
6665ab6
rm unused interface
dashed Sep 24, 2020
f13c9ae
add missing file
dashed Sep 24, 2020
0700577
more fixes
dashed Sep 24, 2020
4ef5c48
fix log
dashed Sep 24, 2020
7b71679
add web-vitals README
dashed Sep 24, 2020
b0522af
Merge branch 'master' into add-web-vitals
dashed Sep 28, 2020
d8c89d3
Merge branch 'master' into add-web-vitals
dashed Oct 5, 2020
7006bc8
Merge branch 'master' into add-web-vitals
dashed Oct 6, 2020
3cb996b
note the SHA commit used to vendor web-vitals
dashed Oct 6, 2020
7801463
Merge branch 'master' into add-web-vitals
dashed Oct 7, 2020
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
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 { 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(performance.timeOrigin);
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 };
}
rhcarvalho marked this conversation as resolved.
Show resolved Hide resolved

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
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