Skip to content

Commit bf4ea76

Browse files
authored
fix(tracing): [v7] use web-vitals ttfb calculation (#11231)
Backport of #11185
1 parent add5d5f commit bf4ea76

File tree

7 files changed

+240
-69
lines changed

7 files changed

+240
-69
lines changed

packages/tracing-internal/src/browser/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ export {
2020
addClsInstrumentationHandler,
2121
addFidInstrumentationHandler,
2222
addLcpInstrumentationHandler,
23+
addTtfbInstrumentationHandler,
24+
addInpInstrumentationHandler,
2325
} from './instrument';

packages/tracing-internal/src/browser/instrument.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { onFID } from './web-vitals/getFID';
66
import { onINP } from './web-vitals/getINP';
77
import { onLCP } from './web-vitals/getLCP';
88
import { observe } from './web-vitals/lib/observe';
9+
import { onTTFB } from './web-vitals/onTTFB';
910

1011
type InstrumentHandlerTypePerformanceObserver =
1112
| 'longtask'
@@ -15,7 +16,7 @@ type InstrumentHandlerTypePerformanceObserver =
1516
| 'resource'
1617
| 'first-input';
1718

18-
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'inp';
19+
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp';
1920

2021
// We provide this here manually instead of relying on a global, as this is not available in non-browser environements
2122
// And we do not want to expose such types
@@ -101,6 +102,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};
101102
let _previousCls: Metric | undefined;
102103
let _previousFid: Metric | undefined;
103104
let _previousLcp: Metric | undefined;
105+
let _previousTtfb: Metric | undefined;
104106
let _previousInp: Metric | undefined;
105107

106108
/**
@@ -131,6 +133,13 @@ export function addLcpInstrumentationHandler(
131133
return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp, stopOnCallback);
132134
}
133135

136+
/**
137+
* Add a callback that will be triggered when a FID metric is available.
138+
*/
139+
export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
140+
return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb);
141+
}
142+
134143
/**
135144
* Add a callback that will be triggered when a FID metric is available.
136145
* Returns a cleanup callback which can be called to remove the instrumentation handler.
@@ -225,6 +234,15 @@ function instrumentLcp(): StopListening {
225234
});
226235
}
227236

237+
function instrumentTtfb(): StopListening {
238+
return onTTFB(metric => {
239+
triggerHandlers('ttfb', {
240+
metric,
241+
});
242+
_previousTtfb = metric;
243+
});
244+
}
245+
228246
function instrumentInp(): void {
229247
return onINP(metric => {
230248
triggerHandlers('inp', {

packages/tracing-internal/src/browser/metrics/index.ts

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
addInpInstrumentationHandler,
2020
addLcpInstrumentationHandler,
2121
addPerformanceInstrumentationHandler,
22+
addTtfbInstrumentationHandler,
2223
} from '../instrument';
2324
import { WINDOW } from '../types';
2425
import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher';
@@ -30,6 +31,8 @@ import type {
3031
import { _startChild, isMeasurementValue } from './utils';
3132

3233
import { createSpanEnvelope } from '@sentry/core';
34+
import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry';
35+
import type { TTFBMetric } from '../web-vitals/types/ttfb';
3336

3437
const MAX_INT_AS_BYTES = 2147483647;
3538

@@ -68,11 +71,13 @@ export function startTrackingWebVitals(): () => void {
6871
const fidCallback = _trackFID();
6972
const clsCallback = _trackCLS();
7073
const lcpCallback = _trackLCP();
74+
const ttfbCallback = _trackTtfb();
7175

7276
return (): void => {
7377
fidCallback();
7478
clsCallback();
7579
lcpCallback();
80+
ttfbCallback();
7681
};
7782
}
7883

@@ -201,6 +206,18 @@ function _trackFID(): () => void {
201206
});
202207
}
203208

209+
function _trackTtfb(): () => void {
210+
return addTtfbInstrumentationHandler(({ metric }) => {
211+
const entry = metric.entries[metric.entries.length - 1];
212+
if (!entry) {
213+
return;
214+
}
215+
216+
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB');
217+
_measurements['ttfb'] = { value: metric.value, unit: 'millisecond' };
218+
});
219+
}
220+
204221
const INP_ENTRY_MAP: Record<string, 'click' | 'hover' | 'drag' | 'press'> = {
205222
click: 'click',
206223
pointerdown: 'click',
@@ -308,9 +325,6 @@ export function addPerformanceEntries(transaction: Transaction): void {
308325

309326
const performanceEntries = performance.getEntries();
310327

311-
let responseStartTimestamp: number | undefined;
312-
let requestStartTimestamp: number | undefined;
313-
314328
const { op, start_timestamp: transactionStartTime } = spanToJSON(transaction);
315329

316330
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -326,8 +340,6 @@ export function addPerformanceEntries(transaction: Transaction): void {
326340
switch (entry.entryType) {
327341
case 'navigation': {
328342
_addNavigationSpans(transaction, entry, timeOrigin);
329-
responseStartTimestamp = timeOrigin + msToSec(entry.responseStart);
330-
requestStartTimestamp = timeOrigin + msToSec(entry.requestStart);
331343
break;
332344
}
333345
case 'mark':
@@ -365,7 +377,7 @@ export function addPerformanceEntries(transaction: Transaction): void {
365377

366378
// Measurements are only available for pageload transactions
367379
if (op === 'pageload') {
368-
_addTtfbToMeasurements(_measurements, responseStartTimestamp, requestStartTimestamp, transactionStartTime);
380+
_addTtfbRequestTimeToMeasurements(_measurements);
369381

370382
['fcp', 'fp', 'lcp'].forEach(name => {
371383
if (!_measurements[name] || !transactionStartTime || timeOrigin >= transactionStartTime) {
@@ -657,40 +669,20 @@ function setResourceEntrySizeData(
657669
}
658670

659671
/**
660-
* Add ttfb information to measurements
672+
* Add ttfb request time information to measurements.
661673
*
662-
* Exported for tests
674+
* ttfb information is added via vendored web vitals library.
663675
*/
664-
export function _addTtfbToMeasurements(
665-
_measurements: Measurements,
666-
responseStartTimestamp: number | undefined,
667-
requestStartTimestamp: number | undefined,
668-
transactionStartTime: number | undefined,
669-
): void {
670-
// Generate TTFB (Time to First Byte), which measured as the time between the beginning of the transaction and the
671-
// start of the response in milliseconds
672-
if (typeof responseStartTimestamp === 'number' && transactionStartTime) {
673-
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB');
674-
_measurements['ttfb'] = {
675-
// As per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart,
676-
// responseStart can be 0 if the request is coming straight from the cache.
677-
// This might lead us to calculate a negative ttfb if we don't use Math.max here.
678-
//
679-
// This logic is the same as what is in the web-vitals library to calculate ttfb
680-
// https://github.com/GoogleChrome/web-vitals/blob/2301de5015e82b09925238a228a0893635854587/src/onTTFB.ts#L92
681-
// TODO(abhi): We should use the web-vitals library instead of this custom calculation.
682-
value: Math.max(responseStartTimestamp - transactionStartTime, 0) * 1000,
676+
function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void {
677+
const navEntry = getNavigationEntry() as TTFBMetric['entries'][number];
678+
const { responseStart, requestStart } = navEntry;
679+
680+
if (requestStart <= responseStart) {
681+
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB Request Time');
682+
_measurements['ttfb.requestTime'] = {
683+
value: responseStart - requestStart,
683684
unit: 'millisecond',
684685
};
685-
686-
if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) {
687-
// Capture the time spent making the request and receiving the first byte of the response.
688-
// This is the time between the start of the request and the start of the response in milliseconds.
689-
_measurements['ttfb.requestTime'] = {
690-
value: (responseStartTimestamp - requestStartTimestamp) * 1000,
691-
unit: 'millisecond',
692-
};
693-
}
694686
}
695687
}
696688

packages/tracing-internal/src/browser/web-vitals/README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,22 @@
44
55
This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.0.4
66

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

910
Current vendored web vitals are:
1011

1112
- LCP (Largest Contentful Paint)
1213
- FID (First Input Delay)
1314
- CLS (Cumulative Layout Shift)
15+
- INP (Interaction to Next Paint)
16+
- TTFB (Time to First Byte)
1417

1518
## Notable Changes from web-vitals library
1619

17-
This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing` integration.
18-
As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only report once per pageload.
20+
This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing`
21+
integration. As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only
22+
report once per pageload.
1923

2024
## License
2125

@@ -24,16 +28,29 @@ As such, logic around `BFCache` and multiple reports were removed from the libra
2428
## CHANGELOG
2529

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

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

3238
https://github.com/getsentry/sentry-javascript/pull/3515
39+
3340
- Remove support for Time to First Byte (TTFB)
3441

3542
https://github.com/getsentry/sentry-javascript/pull/2964
43+
3644
- Added support for Cumulative Layout Shift (CLS) and Time to First Byte (TTFB)
3745

3846
https://github.com/getsentry/sentry-javascript/pull/2909
47+
3948
- Added support for FID (First Input Delay) and LCP (Largest Contentful Paint)
49+
50+
https://github.com/getsentry/sentry-javascript/pull/9690
51+
52+
- Added support for INP (Interaction to Next Paint)
53+
54+
https://github.com/getsentry/sentry-javascript/pull/11231
55+
56+
- Add support for TTFB (Time to First Byte)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 { WINDOW } from '../types';
18+
import { bindReporter } from './lib/bindReporter';
19+
import { getActivationStart } from './lib/getActivationStart';
20+
import { getNavigationEntry } from './lib/getNavigationEntry';
21+
import { initMetric } from './lib/initMetric';
22+
import type { ReportCallback, ReportOpts } from './types';
23+
import type { TTFBMetric } from './types/ttfb';
24+
25+
/**
26+
* Runs in the next task after the page is done loading and/or prerendering.
27+
* @param callback
28+
*/
29+
const whenReady = (callback: () => void): void => {
30+
if (!WINDOW.document) {
31+
return;
32+
}
33+
34+
if (WINDOW.document.prerendering) {
35+
addEventListener('prerenderingchange', () => whenReady(callback), true);
36+
} else if (WINDOW.document.readyState !== 'complete') {
37+
addEventListener('load', () => whenReady(callback), true);
38+
} else {
39+
// Queue a task so the callback runs after `loadEventEnd`.
40+
setTimeout(callback, 0);
41+
}
42+
};
43+
44+
/**
45+
* Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the
46+
* current page and calls the `callback` function once the page has loaded,
47+
* along with the relevant `navigation` performance entry used to determine the
48+
* value. The reported value is a `DOMHighResTimeStamp`.
49+
*
50+
* Note, this function waits until after the page is loaded to call `callback`
51+
* in order to ensure all properties of the `navigation` entry are populated.
52+
* This is useful if you want to report on other metrics exposed by the
53+
* [Navigation Timing API](https://w3c.github.io/navigation-timing/). For
54+
* example, the TTFB metric starts from the page's [time
55+
* origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it
56+
* includes time spent on DNS lookup, connection negotiation, network latency,
57+
* and server processing time.
58+
*/
59+
export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts): void => {
60+
// Set defaults
61+
// eslint-disable-next-line no-param-reassign
62+
opts = opts || {};
63+
64+
// https://web.dev/ttfb/#what-is-a-good-ttfb-score
65+
// const thresholds = [800, 1800];
66+
67+
const metric = initMetric('TTFB');
68+
const report = bindReporter(onReport, metric, opts.reportAllChanges);
69+
70+
whenReady(() => {
71+
const navEntry = getNavigationEntry() as TTFBMetric['entries'][number];
72+
73+
if (navEntry) {
74+
// The activationStart reference is used because TTFB should be
75+
// relative to page activation rather than navigation start if the
76+
// page was prerendered. But in cases where `activationStart` occurs
77+
// after the first byte is received, this time should be clamped at 0.
78+
metric.value = Math.max(navEntry.responseStart - getActivationStart(), 0);
79+
80+
// In some cases the value reported is negative or is larger
81+
// than the current page time. Ignore these cases:
82+
// https://github.com/GoogleChrome/web-vitals/issues/137
83+
// https://github.com/GoogleChrome/web-vitals/issues/162
84+
if (metric.value < 0 || metric.value > performance.now()) return;
85+
86+
metric.entries = [navEntry];
87+
88+
report(true);
89+
}
90+
});
91+
};

0 commit comments

Comments
 (0)