Skip to content

Commit f078a3b

Browse files
authored
fix(metrics): use web-vitals ttfb calculation (#11185)
Recommend reading through https://web.dev/articles/ttfb before review. In https://www.notion.so/sentry/TTFB-vital-is-0-for-navigation-events-2337114dd75542569eb70255a467aba6 we identified that ttfb was being incorrectly calculated in certain scenarios. This was because we were calculating `ttfb` relative to transaction start time, **before** it has been adjusted by `browser` related spans about request/response ( remember browser tracing adjusts the start timestamp of a pageload transaction after adding certain request/response related spans). This meant that `Math.max(responseStartTimestamp - transactionStartTime, 0)` would just end up being `0` most of the time because using `transactionStartTime` was not correct. To fix this, we avoid trying to rely on our `transactionStartTime` timestamp at all, but instead using the web vitals version helper for this. When this gets merged in, I'll backport it to v7. I'm doing this in v8 first because I don't want to deal with the merge conflict that comes when we eventually migrate this code from tracing internal into the browser package. As a next step, we should seriously think about getting rid of all of our vendored code and just rely on the web vitals library - it's a huge pain to maintain this, and I'm sure there are some insidious bugs sneaking about.
1 parent e878dce commit f078a3b

File tree

10 files changed

+236
-113
lines changed

10 files changed

+236
-113
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export {
1515
addPerformanceInstrumentationHandler,
1616
addClsInstrumentationHandler,
1717
addFidInstrumentationHandler,
18+
addTtfbInstrumentationHandler,
1819
addLcpInstrumentationHandler,
1920
} from './instrument';

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { onCLS } from './web-vitals/getCLS';
55
import { onFID } from './web-vitals/getFID';
66
import { onLCP } from './web-vitals/getLCP';
77
import { observe } from './web-vitals/lib/observe';
8+
import { onTTFB } from './web-vitals/onTTFB';
89

910
type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource';
1011

11-
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid';
12+
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb';
1213

1314
// We provide this here manually instead of relying on a global, as this is not available in non-browser environements
1415
// And we do not want to expose such types
@@ -86,6 +87,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};
8687
let _previousCls: Metric | undefined;
8788
let _previousFid: Metric | undefined;
8889
let _previousLcp: Metric | undefined;
90+
let _previousTtfb: Metric | undefined;
8991

9092
/**
9193
* Add a callback that will be triggered when a CLS metric is available.
@@ -123,6 +125,13 @@ export function addFidInstrumentationHandler(callback: (data: { metric: Metric }
123125
return addMetricObserver('fid', callback, instrumentFid, _previousFid);
124126
}
125127

128+
/**
129+
* Add a callback that will be triggered when a FID metric is available.
130+
*/
131+
export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
132+
return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb);
133+
}
134+
126135
export function addPerformanceInstrumentationHandler(
127136
type: 'event',
128137
callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void,
@@ -199,6 +208,15 @@ function instrumentLcp(): StopListening {
199208
});
200209
}
201210

211+
function instrumentTtfb(): StopListening {
212+
return onTTFB(metric => {
213+
triggerHandlers('ttfb', {
214+
metric,
215+
});
216+
_previousTtfb = metric;
217+
});
218+
}
219+
202220
function addMetricObserver(
203221
type: InstrumentHandlerTypeMetric,
204222
callback: InstrumentHandlerCallback,

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

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import {
1111
addFidInstrumentationHandler,
1212
addLcpInstrumentationHandler,
1313
addPerformanceInstrumentationHandler,
14+
addTtfbInstrumentationHandler,
1415
} from '../instrument';
1516
import { WINDOW } from '../types';
17+
import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry';
1618
import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher';
1719
import type { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types';
20+
import type { TTFBMetric } from '../web-vitals/types/ttfb';
1821
import { isMeasurementValue, startAndEndSpan } from './utils';
1922

2023
const MAX_INT_AS_BYTES = 2147483647;
@@ -54,11 +57,13 @@ export function startTrackingWebVitals(): () => void {
5457
const fidCallback = _trackFID();
5558
const clsCallback = _trackCLS();
5659
const lcpCallback = _trackLCP();
60+
const ttfbCallback = _trackTtfb();
5761

5862
return (): void => {
5963
fidCallback();
6064
clsCallback();
6165
lcpCallback();
66+
ttfbCallback();
6267
};
6368
}
6469

@@ -173,6 +178,18 @@ function _trackFID(): () => void {
173178
});
174179
}
175180

181+
function _trackTtfb(): () => void {
182+
return addTtfbInstrumentationHandler(({ metric }) => {
183+
const entry = metric.entries[metric.entries.length - 1];
184+
if (!entry) {
185+
return;
186+
}
187+
188+
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB');
189+
_measurements['ttfb'] = { value: metric.value, unit: 'millisecond' };
190+
});
191+
}
192+
176193
/** Add performance related spans to a span */
177194
export function addPerformanceEntries(span: Span): void {
178195
const performance = getBrowserPerformanceAPI();
@@ -186,9 +203,6 @@ export function addPerformanceEntries(span: Span): void {
186203

187204
const performanceEntries = performance.getEntries();
188205

189-
let responseStartTimestamp: number | undefined;
190-
let requestStartTimestamp: number | undefined;
191-
192206
const { op, start_timestamp: transactionStartTime } = spanToJSON(span);
193207

194208
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -203,8 +217,6 @@ export function addPerformanceEntries(span: Span): void {
203217
switch (entry.entryType) {
204218
case 'navigation': {
205219
_addNavigationSpans(span, entry, timeOrigin);
206-
responseStartTimestamp = timeOrigin + msToSec(entry.responseStart);
207-
requestStartTimestamp = timeOrigin + msToSec(entry.requestStart);
208220
break;
209221
}
210222
case 'mark':
@@ -242,7 +254,7 @@ export function addPerformanceEntries(span: Span): void {
242254

243255
// Measurements are only available for pageload transactions
244256
if (op === 'pageload') {
245-
_addTtfbToMeasurements(_measurements, responseStartTimestamp, requestStartTimestamp, transactionStartTime);
257+
_addTtfbRequestTimeToMeasurements(_measurements);
246258

247259
['fcp', 'fp', 'lcp'].forEach(name => {
248260
if (!_measurements[name] || !transactionStartTime || timeOrigin >= transactionStartTime) {
@@ -524,39 +536,19 @@ function setResourceEntrySizeData(
524536
}
525537

526538
/**
527-
* Add ttfb information to measurements
539+
* Add ttfb request time information to measurements.
528540
*
529-
* Exported for tests
541+
* ttfb information is added via vendored web vitals library.
530542
*/
531-
export function _addTtfbToMeasurements(
532-
_measurements: Measurements,
533-
responseStartTimestamp: number | undefined,
534-
requestStartTimestamp: number | undefined,
535-
transactionStartTime: number | undefined,
536-
): void {
537-
// Generate TTFB (Time to First Byte), which measured as the time between the beginning of the span and the
538-
// start of the response in milliseconds
539-
if (typeof responseStartTimestamp === 'number' && transactionStartTime) {
540-
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB');
541-
_measurements['ttfb'] = {
542-
// As per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart,
543-
// responseStart can be 0 if the request is coming straight from the cache.
544-
// This might lead us to calculate a negative ttfb if we don't use Math.max here.
545-
//
546-
// This logic is the same as what is in the web-vitals library to calculate ttfb
547-
// https://github.com/GoogleChrome/web-vitals/blob/2301de5015e82b09925238a228a0893635854587/src/onTTFB.ts#L92
548-
// TODO(abhi): We should use the web-vitals library instead of this custom calculation.
549-
value: Math.max(responseStartTimestamp - transactionStartTime, 0) * 1000,
543+
function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void {
544+
const navEntry = getNavigationEntry() as TTFBMetric['entries'][number];
545+
const { responseStart, requestStart } = navEntry;
546+
547+
if (requestStart <= responseStart) {
548+
DEBUG_BUILD && logger.log('[Measurements] Adding TTFB Request Time');
549+
_measurements['ttfb.requestTime'] = {
550+
value: responseStart - requestStart,
550551
unit: 'millisecond',
551552
};
552-
553-
if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) {
554-
// Capture the time spent making the request and receiving the first byte of the response.
555-
// This is the time between the start of the request and the start of the response in milliseconds.
556-
_measurements['ttfb.requestTime'] = {
557-
value: (responseStartTimestamp - requestStartTimestamp) * 1000,
558-
unit: 'millisecond',
559-
};
560-
}
561553
}
562554
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Current vendored web vitals are:
1212
- LCP (Largest Contentful Paint)
1313
- FID (First Input Delay)
1414
- CLS (Cumulative Layout Shift)
15+
- INP (Interaction to Next Paint)
16+
- TTFB (Time to First Byte)
1517

1618
## Notable Changes from web-vitals library
1719

@@ -44,3 +46,11 @@ https://github.com/getsentry/sentry-javascript/pull/2964
4446
https://github.com/getsentry/sentry-javascript/pull/2909
4547

4648
- 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+
TODO
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+
};

packages/tracing-internal/src/browser/web-vitals/types/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,4 @@ export interface ReportOpts {
105105
*/
106106
export type LoadState = 'loading' | 'dom-interactive' | 'dom-content-loaded' | 'complete';
107107

108-
export type StopListening = () => void;
108+
export type StopListening = undefined | void | (() => void);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2022 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 type { Metric, ReportCallback } from './base';
18+
import type { NavigationTimingPolyfillEntry } from './polyfills';
19+
20+
/**
21+
* A TTFB-specific version of the Metric object.
22+
*/
23+
export interface TTFBMetric extends Metric {
24+
name: 'TTFB';
25+
entries: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[];
26+
}
27+
28+
/**
29+
* An object containing potentially-helpful debugging information that
30+
* can be sent along with the TTFB value for the current page visit in order
31+
* to help identify issues happening to real-users in the field.
32+
*/
33+
export interface TTFBAttribution {
34+
/**
35+
* The total time from when the user initiates loading the page to when the
36+
* DNS lookup begins. This includes redirects, service worker startup, and
37+
* HTTP cache lookup times.
38+
*/
39+
waitingTime: number;
40+
/**
41+
* The total time to resolve the DNS for the current request.
42+
*/
43+
dnsTime: number;
44+
/**
45+
* The total time to create the connection to the requested domain.
46+
*/
47+
connectionTime: number;
48+
/**
49+
* The time time from when the request was sent until the first byte of the
50+
* response was received. This includes network time as well as server
51+
* processing time.
52+
*/
53+
requestTime: number;
54+
/**
55+
* The `PerformanceNavigationTiming` entry used to determine TTFB (or the
56+
* polyfill entry in browsers that don't support Navigation Timing).
57+
*/
58+
navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry;
59+
}
60+
61+
/**
62+
* A TTFB-specific version of the Metric object with attribution.
63+
*/
64+
export interface TTFBMetricWithAttribution extends TTFBMetric {
65+
attribution: TTFBAttribution;
66+
}
67+
68+
/**
69+
* A TTFB-specific version of the ReportCallback function.
70+
*/
71+
export interface TTFBReportCallback extends ReportCallback {
72+
(metric: TTFBMetric): void;
73+
}
74+
75+
/**
76+
* A TTFB-specific version of the ReportCallback function with attribution.
77+
*/
78+
export interface TTFBReportCallbackWithAttribution extends TTFBReportCallback {
79+
(metric: TTFBMetricWithAttribution): void;
80+
}

packages/tracing-internal/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
addPerformanceInstrumentationHandler,
2323
addClsInstrumentationHandler,
2424
addFidInstrumentationHandler,
25+
addTtfbInstrumentationHandler,
2526
addLcpInstrumentationHandler,
2627
} from './browser';
2728

0 commit comments

Comments
 (0)