Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(metrics): use web-vitals ttfb calculation #11185

Merged
merged 9 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/tracing-internal/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export {
addPerformanceInstrumentationHandler,
addClsInstrumentationHandler,
addFidInstrumentationHandler,
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
} from './instrument';
20 changes: 19 additions & 1 deletion packages/tracing-internal/src/browser/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -199,6 +208,15 @@ function instrumentLcp(): StopListening {
});
}

function instrumentTtfb(): StopListening {
return onTTFB(metric => {
triggerHandlers('ttfb', {
metric,
});
_previousTtfb = metric;
});
}

function addMetricObserver(
type: InstrumentHandlerTypeMetric,
callback: InstrumentHandlerCallback,
Expand Down
64 changes: 28 additions & 36 deletions packages/tracing-internal/src/browser/metrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
};
}

Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -524,39 +536,19 @@ 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 span 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',
};
}
}
}
10 changes: 10 additions & 0 deletions packages/tracing-internal/src/browser/web-vitals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -44,3 +46,11 @@ 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)
91 changes: 91 additions & 0 deletions packages/tracing-internal/src/browser/web-vitals/onTTFB.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,4 @@ export interface ReportOpts {
*/
export type LoadState = 'loading' | 'dom-interactive' | 'dom-content-loaded' | 'complete';

export type StopListening = () => void;
export type StopListening = undefined | void | (() => void);
80 changes: 80 additions & 0 deletions packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/tracing-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
addPerformanceInstrumentationHandler,
addClsInstrumentationHandler,
addFidInstrumentationHandler,
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
} from './browser';

Expand Down
Loading
Loading