From 2a4f251af7a25d97f9db8d8fd0bd440b824500d8 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 21 Mar 2024 15:45:31 -0400 Subject: [PATCH 1/2] fix(tracing): [v7] use web-vitals ttfb calculation --- .../tracing-internal/src/browser/index.ts | 2 + .../src/browser/instrument.ts | 20 +++- .../src/browser/metrics/index.ts | 64 ++++++------- .../src/browser/web-vitals/README.md | 23 ++++- .../src/browser/web-vitals/onTTFB.ts | 91 +++++++++++++++++++ .../src/browser/web-vitals/types/ttfb.ts | 80 ++++++++++++++++ .../test/browser/metrics/index.test.ts | 29 ------ 7 files changed, 240 insertions(+), 69 deletions(-) create mode 100644 packages/tracing-internal/src/browser/web-vitals/onTTFB.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts diff --git a/packages/tracing-internal/src/browser/index.ts b/packages/tracing-internal/src/browser/index.ts index a1619d4ea8ae..158878fcd042 100644 --- a/packages/tracing-internal/src/browser/index.ts +++ b/packages/tracing-internal/src/browser/index.ts @@ -20,4 +20,6 @@ export { addClsInstrumentationHandler, addFidInstrumentationHandler, addLcpInstrumentationHandler, + addTtfbInstrumentationHandler, + addInpInstrumentationHandler, } from './instrument'; diff --git a/packages/tracing-internal/src/browser/instrument.ts b/packages/tracing-internal/src/browser/instrument.ts index 3441d730f0b2..c78f50a257e4 100644 --- a/packages/tracing-internal/src/browser/instrument.ts +++ b/packages/tracing-internal/src/browser/instrument.ts @@ -6,6 +6,7 @@ import { onFID } from './web-vitals/getFID'; import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; +import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = | 'longtask' @@ -15,7 +16,7 @@ type InstrumentHandlerTypePerformanceObserver = | 'resource' | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'inp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -101,6 +102,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; let _previousCls: Metric | undefined; let _previousFid: Metric | undefined; let _previousLcp: Metric | undefined; +let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; /** @@ -131,6 +133,13 @@ export function addLcpInstrumentationHandler( return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp, stopOnCallback); } +/** + * Add a callback that will be triggered when a FID metric is available. + */ +export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { + return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); +} + /** * Add a callback that will be triggered when a FID metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. @@ -225,6 +234,15 @@ function instrumentLcp(): StopListening { }); } +function instrumentTtfb(): StopListening { + return onTTFB(metric => { + triggerHandlers('ttfb', { + metric, + }); + _previousTtfb = metric; + }); +} + function instrumentInp(): void { return onINP(metric => { triggerHandlers('inp', { diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index d7d52dc4bf90..778d0a3b28cc 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -19,6 +19,7 @@ import { addInpInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, } from '../instrument'; import { WINDOW } from '../types'; import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; @@ -30,6 +31,8 @@ import type { import { _startChild, isMeasurementValue } from './utils'; import { createSpanEnvelope } from '@sentry/core'; +import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry'; +import type { TTFBMetric } from '../web-vitals/types/ttfb'; const MAX_INT_AS_BYTES = 2147483647; @@ -68,11 +71,13 @@ export function startTrackingWebVitals(): () => void { const fidCallback = _trackFID(); const clsCallback = _trackCLS(); const lcpCallback = _trackLCP(); + const ttfbCallback = _trackTtfb(); return (): void => { fidCallback(); clsCallback(); lcpCallback(); + ttfbCallback(); }; } @@ -201,6 +206,18 @@ function _trackFID(): () => void { }); } +function _trackTtfb(): () => void { + return addTtfbInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1]; + if (!entry) { + return; + } + + DEBUG_BUILD && logger.log('[Measurements] Adding TTFB'); + _measurements['ttfb'] = { value: metric.value, unit: 'millisecond' }; + }); +} + const INP_ENTRY_MAP: Record = { click: 'click', pointerdown: 'click', @@ -308,9 +325,6 @@ export function addPerformanceEntries(transaction: Transaction): void { const performanceEntries = performance.getEntries(); - let responseStartTimestamp: number | undefined; - let requestStartTimestamp: number | undefined; - const { op, start_timestamp: transactionStartTime } = spanToJSON(transaction); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -326,8 +340,6 @@ export function addPerformanceEntries(transaction: Transaction): void { switch (entry.entryType) { case 'navigation': { _addNavigationSpans(transaction, entry, timeOrigin); - responseStartTimestamp = timeOrigin + msToSec(entry.responseStart); - requestStartTimestamp = timeOrigin + msToSec(entry.requestStart); break; } case 'mark': @@ -365,7 +377,7 @@ export function addPerformanceEntries(transaction: Transaction): void { // Measurements are only available for pageload transactions if (op === 'pageload') { - _addTtfbToMeasurements(_measurements, responseStartTimestamp, requestStartTimestamp, transactionStartTime); + _addTtfbRequestTimeToMeasurements(_measurements); ['fcp', 'fp', 'lcp'].forEach(name => { if (!_measurements[name] || !transactionStartTime || timeOrigin >= transactionStartTime) { @@ -657,40 +669,20 @@ function setResourceEntrySizeData( } /** - * Add ttfb information to measurements + * Add ttfb request time information to measurements. * - * Exported for tests + * ttfb information is added via vendored web vitals library. */ -export function _addTtfbToMeasurements( - _measurements: Measurements, - responseStartTimestamp: number | undefined, - requestStartTimestamp: number | undefined, - transactionStartTime: number | undefined, -): void { - // Generate TTFB (Time to First Byte), which measured as the time between the beginning of the transaction and the - // start of the response in milliseconds - if (typeof responseStartTimestamp === 'number' && transactionStartTime) { - DEBUG_BUILD && logger.log('[Measurements] Adding TTFB'); - _measurements['ttfb'] = { - // As per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart, - // responseStart can be 0 if the request is coming straight from the cache. - // This might lead us to calculate a negative ttfb if we don't use Math.max here. - // - // This logic is the same as what is in the web-vitals library to calculate ttfb - // https://github.com/GoogleChrome/web-vitals/blob/2301de5015e82b09925238a228a0893635854587/src/onTTFB.ts#L92 - // TODO(abhi): We should use the web-vitals library instead of this custom calculation. - value: Math.max(responseStartTimestamp - transactionStartTime, 0) * 1000, +function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void { + const navEntry = getNavigationEntry() as TTFBMetric['entries'][number]; + const { responseStart, requestStart } = navEntry; + + if (requestStart <= responseStart) { + DEBUG_BUILD && logger.log('[Measurements] Adding TTFB Request Time'); + _measurements['ttfb.requestTime'] = { + value: responseStart - requestStart, unit: 'millisecond', }; - - if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) { - // Capture the time spent making the request and receiving the first byte of the response. - // This is the time between the start of the request and the start of the response in milliseconds. - _measurements['ttfb.requestTime'] = { - value: (responseStartTimestamp - requestStartTimestamp) * 1000, - unit: 'millisecond', - }; - } } } diff --git a/packages/tracing-internal/src/browser/web-vitals/README.md b/packages/tracing-internal/src/browser/web-vitals/README.md index 09add37239aa..4386e7ef96fe 100644 --- a/packages/tracing-internal/src/browser/web-vitals/README.md +++ b/packages/tracing-internal/src/browser/web-vitals/README.md @@ -4,18 +4,22 @@ This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.0.4 -The commit SHA used is: [7f0ed0bfb03c356e348a558a3eda111b498a2a11](https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11) +The commit SHA used is: +[7f0ed0bfb03c356e348a558a3eda111b498a2a11](https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11) Current vendored web vitals are: - LCP (Largest Contentful Paint) - FID (First Input Delay) - CLS (Cumulative Layout Shift) +- INP (Interaction to Next Paint) +- TTFB (Time to First Byte) ## Notable Changes from web-vitals library -This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing` integration. -As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only report once per pageload. +This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing` +integration. As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only +report once per pageload. ## License @@ -24,16 +28,29 @@ As such, logic around `BFCache` and multiple reports were removed from the libra ## CHANGELOG https://github.com/getsentry/sentry-javascript/pull/5987 + - Bumped from Web Vitals v2.1.0 to v3.0.4 https://github.com/getsentry/sentry-javascript/pull/3781 + - Bumped from Web Vitals v0.2.4 to v2.1.0 https://github.com/getsentry/sentry-javascript/pull/3515 + - Remove support for Time to First Byte (TTFB) https://github.com/getsentry/sentry-javascript/pull/2964 + - Added support for Cumulative Layout Shift (CLS) and Time to First Byte (TTFB) https://github.com/getsentry/sentry-javascript/pull/2909 + - Added support for FID (First Input Delay) and LCP (Largest Contentful Paint) + +https://github.com/getsentry/sentry-javascript/pull/9690 + +- Added support for INP (Interaction to Next Paint) + +https://github.com/getsentry/sentry-javascript/pull/11185 + +- Add support for TTFB (Time to First Byte) diff --git a/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts b/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts new file mode 100644 index 000000000000..946141107fa8 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts @@ -0,0 +1,91 @@ +/* + * 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 { WINDOW } from '../types'; +import { bindReporter } from './lib/bindReporter'; +import { getActivationStart } from './lib/getActivationStart'; +import { getNavigationEntry } from './lib/getNavigationEntry'; +import { initMetric } from './lib/initMetric'; +import type { ReportCallback, ReportOpts } from './types'; +import type { TTFBMetric } from './types/ttfb'; + +/** + * Runs in the next task after the page is done loading and/or prerendering. + * @param callback + */ +const whenReady = (callback: () => void): void => { + if (!WINDOW.document) { + return; + } + + if (WINDOW.document.prerendering) { + addEventListener('prerenderingchange', () => whenReady(callback), true); + } else if (WINDOW.document.readyState !== 'complete') { + addEventListener('load', () => whenReady(callback), true); + } else { + // Queue a task so the callback runs after `loadEventEnd`. + setTimeout(callback, 0); + } +}; + +/** + * Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the + * current page and calls the `callback` function once the page has loaded, + * along with the relevant `navigation` performance entry used to determine the + * value. The reported value is a `DOMHighResTimeStamp`. + * + * Note, this function waits until after the page is loaded to call `callback` + * in order to ensure all properties of the `navigation` entry are populated. + * This is useful if you want to report on other metrics exposed by the + * [Navigation Timing API](https://w3c.github.io/navigation-timing/). For + * example, the TTFB metric starts from the page's [time + * origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it + * includes time spent on DNS lookup, connection negotiation, network latency, + * and server processing time. + */ +export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts): void => { + // Set defaults + // eslint-disable-next-line no-param-reassign + opts = opts || {}; + + // https://web.dev/ttfb/#what-is-a-good-ttfb-score + // const thresholds = [800, 1800]; + + const metric = initMetric('TTFB'); + const report = bindReporter(onReport, metric, opts.reportAllChanges); + + whenReady(() => { + const navEntry = getNavigationEntry() as TTFBMetric['entries'][number]; + + if (navEntry) { + // The activationStart reference is used because TTFB should be + // relative to page activation rather than navigation start if the + // page was prerendered. But in cases where `activationStart` occurs + // after the first byte is received, this time should be clamped at 0. + metric.value = Math.max(navEntry.responseStart - getActivationStart(), 0); + + // In some cases the value reported is negative or is larger + // than the current page time. Ignore these cases: + // https://github.com/GoogleChrome/web-vitals/issues/137 + // https://github.com/GoogleChrome/web-vitals/issues/162 + if (metric.value < 0 || metric.value > performance.now()) return; + + metric.entries = [navEntry]; + + report(true); + } + }); +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts b/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts new file mode 100644 index 000000000000..86f1329ebee8 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2022 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 type { Metric, ReportCallback } from './base'; +import type { NavigationTimingPolyfillEntry } from './polyfills'; + +/** + * A TTFB-specific version of the Metric object. + */ +export interface TTFBMetric extends Metric { + name: 'TTFB'; + entries: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the TTFB value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface TTFBAttribution { + /** + * The total time from when the user initiates loading the page to when the + * DNS lookup begins. This includes redirects, service worker startup, and + * HTTP cache lookup times. + */ + waitingTime: number; + /** + * The total time to resolve the DNS for the current request. + */ + dnsTime: number; + /** + * The total time to create the connection to the requested domain. + */ + connectionTime: number; + /** + * The time time from when the request was sent until the first byte of the + * response was received. This includes network time as well as server + * processing time. + */ + requestTime: number; + /** + * The `PerformanceNavigationTiming` entry used to determine TTFB (or the + * polyfill entry in browsers that don't support Navigation Timing). + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} + +/** + * A TTFB-specific version of the Metric object with attribution. + */ +export interface TTFBMetricWithAttribution extends TTFBMetric { + attribution: TTFBAttribution; +} + +/** + * A TTFB-specific version of the ReportCallback function. + */ +export interface TTFBReportCallback extends ReportCallback { + (metric: TTFBMetric): void; +} + +/** + * A TTFB-specific version of the ReportCallback function with attribution. + */ +export interface TTFBReportCallbackWithAttribution extends TTFBReportCallback { + (metric: TTFBMetricWithAttribution): void; +} diff --git a/packages/tracing-internal/test/browser/metrics/index.test.ts b/packages/tracing-internal/test/browser/metrics/index.test.ts index e405760fbcc7..ce6060497ef7 100644 --- a/packages/tracing-internal/test/browser/metrics/index.test.ts +++ b/packages/tracing-internal/test/browser/metrics/index.test.ts @@ -1,6 +1,5 @@ import { Transaction } from '../../../src'; import type { ResourceEntry } from '../../../src/browser/metrics'; -import { _addTtfbToMeasurements } from '../../../src/browser/metrics'; import { _addMeasureSpans, _addResourceSpans } from '../../../src/browser/metrics'; import { WINDOW } from '../../../src/browser/types'; @@ -262,34 +261,6 @@ describe('_addResourceSpans', () => { }); }); -describe('_addTtfbToMeasurements', () => { - it('adds ttfb to measurements', () => { - const measurements = {}; - _addTtfbToMeasurements(measurements, 300, 200, 100); - expect(measurements).toEqual({ - ttfb: { - unit: 'millisecond', - value: 200000, - }, - 'ttfb.requestTime': { - unit: 'millisecond', - value: 100000, - }, - }); - }); - - it('does not add negative ttfb', () => { - const measurements = {}; - _addTtfbToMeasurements(measurements, 100, 200, 300); - expect(measurements).toEqual({ - ttfb: { - unit: 'millisecond', - value: 0, - }, - }); - }); -}); - const setGlobalLocation = (location: Location) => { // @ts-expect-error need to delete this in order to set to new value delete WINDOW.location; From 95d99c83fc213a9bd3ac425d9d571f7ca9d8ce81 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 21 Mar 2024 15:47:12 -0400 Subject: [PATCH 2/2] correct PR --- packages/tracing-internal/src/browser/web-vitals/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tracing-internal/src/browser/web-vitals/README.md b/packages/tracing-internal/src/browser/web-vitals/README.md index 4386e7ef96fe..f2bfb1b8ac33 100644 --- a/packages/tracing-internal/src/browser/web-vitals/README.md +++ b/packages/tracing-internal/src/browser/web-vitals/README.md @@ -51,6 +51,6 @@ https://github.com/getsentry/sentry-javascript/pull/9690 - Added support for INP (Interaction to Next Paint) -https://github.com/getsentry/sentry-javascript/pull/11185 +https://github.com/getsentry/sentry-javascript/pull/11231 - Add support for TTFB (Time to First Byte)