Skip to content

Commit ce16e33

Browse files
authored
feat(rum): Add measurements support and web vitals (#2909)
1 parent dd8e392 commit ce16e33

File tree

16 files changed

+558
-75
lines changed

16 files changed

+558
-75
lines changed

packages/tracing/src/browser/metrics.ts

Lines changed: 52 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
/* eslint-disable max-lines */
22
/* eslint-disable @typescript-eslint/no-explicit-any */
3-
import { SpanContext } from '@sentry/types';
3+
import { Measurements, SpanContext } from '@sentry/types';
44
import { browserPerformanceTimeOrigin, getGlobalObject, logger } from '@sentry/utils';
55

66
import { Span } from '../span';
77
import { Transaction } from '../transaction';
88
import { msToSec } from '../utils';
9+
import { getFID } from './web-vitals/getFID';
10+
import { getLCP } from './web-vitals/getLCP';
911

1012
const global = getGlobalObject<Window>();
1113

1214
/** Class tracking metrics */
1315
export class MetricsInstrumentation {
14-
private _lcp: Record<string, any> = {};
16+
private _measurements: Measurements = {};
1517

1618
private _performanceCursor: number = 0;
1719

@@ -22,6 +24,7 @@ export class MetricsInstrumentation {
2224
}
2325

2426
this._trackLCP();
27+
this._trackFID();
2528
}
2629
}
2730

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

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

37-
// TODO(fixme): depending on the 'op' directly is brittle.
38-
if (transaction.op === 'pageload') {
39-
// Force any pending records to be dispatched.
40-
this._forceLCP();
41-
if (this._lcp) {
42-
// Set the last observed LCP score.
43-
transaction.setData('_sentry_web_vitals', { LCP: this._lcp });
44-
}
45-
}
46-
4740
const timeOrigin = msToSec(browserPerformanceTimeOrigin);
4841
let entryScriptSrc: string | undefined;
4942

@@ -85,6 +78,21 @@ export class MetricsInstrumentation {
8578
if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') {
8679
tracingInitMarkStartTime = startTimestamp;
8780
}
81+
82+
// capture web vitals
83+
84+
if (entry.name === 'first-paint') {
85+
logger.log('[Measurements] Adding FP');
86+
this._measurements['fp'] = { value: entry.startTime };
87+
this._measurements['mark.fp'] = { value: startTimestamp };
88+
}
89+
90+
if (entry.name === 'first-contentful-paint') {
91+
logger.log('[Measurements] Adding FCP');
92+
this._measurements['fcp'] = { value: entry.startTime };
93+
this._measurements['mark.fcp'] = { value: startTimestamp };
94+
}
95+
8896
break;
8997
}
9098
case 'resource': {
@@ -111,73 +119,45 @@ export class MetricsInstrumentation {
111119
}
112120

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

116-
private _forceLCP: () => void = () => {
117-
/* No-op, replaced later if LCP API is available. */
118-
return;
119-
};
123+
// Measurements are only available for pageload transactions
124+
if (transaction.op === 'pageload') {
125+
transaction.setMeasurements(this._measurements);
126+
}
127+
}
120128

121129
/** Starts tracking the Largest Contentful Paint on the current page. */
122130
private _trackLCP(): void {
123-
// Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript.
124-
// Use a try/catch instead of feature detecting `largest-contentful-paint`
125-
// support, since some browsers throw when using the new `type` option.
126-
// https://bugs.webkit.org/show_bug.cgi?id=209216
127-
try {
128-
// Keep track of whether (and when) the page was first hidden, see:
129-
// https://github.com/w3c/page-visibility/issues/29
130-
// NOTE: ideally this check would be performed in the document <head>
131-
// to avoid cases where the visibility state changes before this code runs.
132-
let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
133-
document.addEventListener(
134-
'visibilitychange',
135-
event => {
136-
firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp);
137-
},
138-
{ once: true },
139-
);
140-
141-
const updateLCP = (entry: PerformanceEntry): void => {
142-
// Only include an LCP entry if the page wasn't hidden prior to
143-
// the entry being dispatched. This typically happens when a page is
144-
// loaded in a background tab.
145-
if (entry.startTime < firstHiddenTime) {
146-
// NOTE: the `startTime` value is a getter that returns the entry's
147-
// `renderTime` value, if available, or its `loadTime` value otherwise.
148-
// The `renderTime` value may not be available if the element is an image
149-
// that's loaded cross-origin without the `Timing-Allow-Origin` header.
150-
this._lcp = {
151-
// @ts-ignore can't access id on entry
152-
...(entry.id && { elementId: entry.id }),
153-
// @ts-ignore can't access id on entry
154-
...(entry.size && { elementSize: entry.size }),
155-
value: entry.startTime,
156-
};
157-
}
158-
};
131+
getLCP(metric => {
132+
const entry = metric.entries.pop();
159133

160-
// Create a PerformanceObserver that calls `updateLCP` for each entry.
161-
const po = new PerformanceObserver(entryList => {
162-
entryList.getEntries().forEach(updateLCP);
163-
});
134+
if (!entry) {
135+
return;
136+
}
164137

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

173-
this._forceLCP = () => {
174-
if (po.takeRecords) {
175-
po.takeRecords().forEach(updateLCP);
176-
}
177-
};
178-
} catch (e) {
179-
// Do nothing if the browser doesn't support this API.
180-
}
146+
/** Starts tracking the First Input Delay on the current page. */
147+
private _trackFID(): void {
148+
getFID(metric => {
149+
const entry = metric.entries.pop();
150+
151+
if (!entry) {
152+
return;
153+
}
154+
155+
const timeOrigin = msToSec(performance.timeOrigin);
156+
const startTime = msToSec(entry.startTime as number);
157+
logger.log('[Measurements] Adding FID');
158+
this._measurements['fid'] = { value: metric.value };
159+
this._measurements['mark.fid'] = { value: timeOrigin + startTime };
160+
});
181161
}
182162
}
183163

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# web-vitals
2+
3+
> A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users.
4+
5+
This was vendored from: https://github.com/GoogleChrome/web-vitals
6+
7+
The commit SHA used is: [56c736b7c4e80f295bc8a98017671c95231fa225](https://github.com/GoogleChrome/web-vitals/tree/56c736b7c4e80f295bc8a98017671c95231fa225)
8+
9+
Current vendored web vitals are:
10+
11+
- LCP (Largest Contentful Paint)
12+
- FID (First Input Delay)
13+
14+
# License
15+
16+
[Apache 2.0](https://github.com/GoogleChrome/web-vitals/blob/master/LICENSE)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { bindReporter } from './lib/bindReporter';
18+
import { getFirstHidden } from './lib/getFirstHidden';
19+
import { initMetric } from './lib/initMetric';
20+
import { observe, PerformanceEntryHandler } from './lib/observe';
21+
import { onHidden } from './lib/onHidden';
22+
import { ReportHandler } from './types';
23+
24+
interface FIDPolyfillCallback {
25+
(value: number, event: Event): void;
26+
}
27+
28+
interface FIDPolyfill {
29+
onFirstInputDelay: (onReport: FIDPolyfillCallback) => void;
30+
}
31+
32+
declare global {
33+
interface Window {
34+
perfMetrics: FIDPolyfill;
35+
}
36+
}
37+
38+
// https://wicg.github.io/event-timing/#sec-performance-event-timing
39+
interface PerformanceEventTiming extends PerformanceEntry {
40+
processingStart: DOMHighResTimeStamp;
41+
cancelable?: boolean;
42+
target?: Element;
43+
}
44+
45+
export const getFID = (onReport: ReportHandler): void => {
46+
const metric = initMetric('FID');
47+
const firstHidden = getFirstHidden();
48+
49+
const entryHandler = (entry: PerformanceEventTiming): void => {
50+
// Only report if the page wasn't hidden prior to the first input.
51+
if (entry.startTime < firstHidden.timeStamp) {
52+
metric.value = entry.processingStart - entry.startTime;
53+
metric.entries.push(entry);
54+
metric.isFinal = true;
55+
report();
56+
}
57+
};
58+
59+
const po = observe('first-input', entryHandler as PerformanceEntryHandler);
60+
const report = bindReporter(onReport, metric, po);
61+
62+
if (po) {
63+
onHidden(() => {
64+
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
65+
po.disconnect();
66+
}, true);
67+
} else {
68+
if (window.perfMetrics && window.perfMetrics.onFirstInputDelay) {
69+
window.perfMetrics.onFirstInputDelay((value: number, event: Event) => {
70+
// Only report if the page wasn't hidden prior to the first input.
71+
if (event.timeStamp < firstHidden.timeStamp) {
72+
metric.value = value;
73+
metric.isFinal = true;
74+
metric.entries = [
75+
{
76+
entryType: 'first-input',
77+
name: event.type,
78+
target: event.target,
79+
cancelable: event.cancelable,
80+
startTime: event.timeStamp,
81+
processingStart: event.timeStamp + value,
82+
} as PerformanceEventTiming,
83+
];
84+
report();
85+
}
86+
});
87+
}
88+
}
89+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { bindReporter } from './lib/bindReporter';
18+
import { getFirstHidden } from './lib/getFirstHidden';
19+
import { initMetric } from './lib/initMetric';
20+
import { observe, PerformanceEntryHandler } from './lib/observe';
21+
import { onHidden } from './lib/onHidden';
22+
import { whenInput } from './lib/whenInput';
23+
import { ReportHandler } from './types';
24+
25+
export const getLCP = (onReport: ReportHandler, reportAllChanges = false): void => {
26+
const metric = initMetric('LCP');
27+
const firstHidden = getFirstHidden();
28+
29+
let report: ReturnType<typeof bindReporter>;
30+
31+
const entryHandler = (entry: PerformanceEntry): void => {
32+
// The startTime attribute returns the value of the renderTime if it is not 0,
33+
// and the value of the loadTime otherwise.
34+
const value = entry.startTime;
35+
36+
// If the page was hidden prior to paint time of the entry,
37+
// ignore it and mark the metric as final, otherwise add the entry.
38+
if (value < firstHidden.timeStamp) {
39+
metric.value = value;
40+
metric.entries.push(entry);
41+
} else {
42+
metric.isFinal = true;
43+
}
44+
45+
report();
46+
};
47+
48+
const po = observe('largest-contentful-paint', entryHandler);
49+
50+
if (po) {
51+
report = bindReporter(onReport, metric, po, reportAllChanges);
52+
53+
const onFinal = (): void => {
54+
if (!metric.isFinal) {
55+
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
56+
metric.isFinal = true;
57+
report();
58+
}
59+
};
60+
61+
void whenInput().then(onFinal);
62+
onHidden(onFinal, true);
63+
}
64+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Metric, ReportHandler } from '../types';
18+
19+
export const bindReporter = (
20+
callback: ReportHandler,
21+
metric: Metric,
22+
po: PerformanceObserver | undefined,
23+
observeAllUpdates?: boolean,
24+
): (() => void) => {
25+
let prevValue: number;
26+
return () => {
27+
if (po && metric.isFinal) {
28+
po.disconnect();
29+
}
30+
if (metric.value >= 0) {
31+
if (observeAllUpdates || metric.isFinal || document.visibilityState === 'hidden') {
32+
metric.delta = metric.value - (prevValue || 0);
33+
34+
// Report the metric if there's a non-zero delta, if the metric is
35+
// final, or if no previous value exists (which can happen in the case
36+
// of the document becoming hidden when the metric value is 0).
37+
// See: https://github.com/GoogleChrome/web-vitals/issues/14
38+
if (metric.delta || metric.isFinal || prevValue === undefined) {
39+
callback(metric);
40+
prevValue = metric.value;
41+
}
42+
}
43+
}
44+
};
45+
};

0 commit comments

Comments
 (0)