diff --git a/packages/tracing-internal/src/browser/index.ts b/packages/tracing-internal/src/browser/index.ts index d1bbbffe02b3..6d4168329dc4 100644 --- a/packages/tracing-internal/src/browser/index.ts +++ b/packages/tracing-internal/src/browser/index.ts @@ -15,5 +15,6 @@ export { addPerformanceInstrumentationHandler, addClsInstrumentationHandler, addFidInstrumentationHandler, + addTtfbInstrumentationHandler, addLcpInstrumentationHandler, } from './instrument'; diff --git a/packages/tracing-internal/src/browser/instrument.ts b/packages/tracing-internal/src/browser/instrument.ts index 2a4e7acaf3b1..9a7328369e0a 100644 --- a/packages/tracing-internal/src/browser/instrument.ts +++ b/packages/tracing-internal/src/browser/instrument.ts @@ -5,10 +5,11 @@ import { onCLS } from './web-vitals/getCLS'; import { onFID } from './web-vitals/getFID'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; +import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb'; // 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 @@ -86,6 +87,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; let _previousCls: Metric | undefined; let _previousFid: Metric | undefined; let _previousLcp: Metric | undefined; +let _previousTtfb: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -123,6 +125,13 @@ export function addFidInstrumentationHandler(callback: (data: { metric: Metric } return addMetricObserver('fid', callback, instrumentFid, _previousFid); } +/** + * 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); +} + export function addPerformanceInstrumentationHandler( type: 'event', callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void, @@ -199,6 +208,15 @@ function instrumentLcp(): StopListening { }); } +function instrumentTtfb(): StopListening { + return onTTFB(metric => { + triggerHandlers('ttfb', { + metric, + }); + _previousTtfb = metric; + }) +} + function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index 4eabd49f218c..cfcbb3f6c463 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -11,10 +11,13 @@ import { addFidInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, } from '../instrument'; import { WINDOW } from '../types'; +import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; import type { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types'; +import type { TTFBMetric } from '../web-vitals/types/ttfb'; import { isMeasurementValue, startAndEndSpan } from './utils'; const MAX_INT_AS_BYTES = 2147483647; @@ -54,11 +57,13 @@ export function startTrackingWebVitals(): () => void { const fidCallback = _trackFID(); const clsCallback = _trackCLS(); const lcpCallback = _trackLCP(); + const ttfbCallback = _trackTtfb(); return (): void => { fidCallback(); clsCallback(); lcpCallback(); + ttfbCallback(); }; } @@ -173,6 +178,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' }; + }); +} + /** Add performance related spans to a span */ export function addPerformanceEntries(span: Span): void { const performance = getBrowserPerformanceAPI(); @@ -186,9 +203,6 @@ export function addPerformanceEntries(span: Span): void { const performanceEntries = performance.getEntries(); - let responseStartTimestamp: number | undefined; - let requestStartTimestamp: number | undefined; - const { op, start_timestamp: transactionStartTime } = spanToJSON(span); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -203,8 +217,6 @@ export function addPerformanceEntries(span: Span): void { switch (entry.entryType) { case 'navigation': { _addNavigationSpans(span, entry, timeOrigin); - responseStartTimestamp = timeOrigin + msToSec(entry.responseStart); - requestStartTimestamp = timeOrigin + msToSec(entry.requestStart); break; } case 'mark': @@ -242,7 +254,7 @@ export function addPerformanceEntries(span: Span): 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) { @@ -528,35 +540,17 @@ function setResourceEntrySizeData( * * Exported for tests */ -export function _addTtfbToMeasurements( +export function _addTtfbRequestTimeToMeasurements( _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 span and the - // start of the response in milliseconds - if (typeof responseStartTimestamp === 'number' && transactionStartTime) { + const navEntry = getNavigationEntry() as TTFBMetric['entries'][number]; + const { responseStart, requestStart } = navEntry; + + if (requestStart <= responseStart) { 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, + _measurements['ttfb.requestTime'] = { + value: (responseStart - requestStart) * 1000, 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 73a0a72c2e4b..2350f171ce74 100644 --- a/packages/tracing-internal/src/browser/web-vitals/README.md +++ b/packages/tracing-internal/src/browser/web-vitals/README.md @@ -12,6 +12,8 @@ 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 @@ -44,3 +46,12 @@ https://github.com/getsentry/sentry-javascript/pull/2964 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) + +TODO + +- 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/base.ts b/packages/tracing-internal/src/browser/web-vitals/types/base.ts index ea2764c8ea64..5dc45f00558d 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/base.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/base.ts @@ -104,5 +104,3 @@ export interface ReportOpts { * loading. This is equivalent to the corresponding `readyState` value. */ export type LoadState = 'loading' | 'dom-interactive' | 'dom-content-loaded' | 'complete'; - -export type StopListening = () => void; 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/src/index.ts b/packages/tracing-internal/src/index.ts index 98efc8dc1032..2dd4cf2f1768 100644 --- a/packages/tracing-internal/src/index.ts +++ b/packages/tracing-internal/src/index.ts @@ -22,6 +22,7 @@ export { addPerformanceInstrumentationHandler, addClsInstrumentationHandler, addFidInstrumentationHandler, + addTtfbInstrumentationHandler, addLcpInstrumentationHandler, } from './browser';