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(tracing): [v7] use web-vitals ttfb calculation #11231

Merged
merged 2 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/tracing-internal/src/browser/index.ts
Expand Up @@ -20,4 +20,6 @@ export {
addClsInstrumentationHandler,
addFidInstrumentationHandler,
addLcpInstrumentationHandler,
addTtfbInstrumentationHandler,
addInpInstrumentationHandler,
} from './instrument';
20 changes: 19 additions & 1 deletion packages/tracing-internal/src/browser/instrument.ts
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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', {
Expand Down
64 changes: 28 additions & 36 deletions packages/tracing-internal/src/browser/metrics/index.ts
Expand Up @@ -19,6 +19,7 @@ import {
addInpInstrumentationHandler,
addLcpInstrumentationHandler,
addPerformanceInstrumentationHandler,
addTtfbInstrumentationHandler,
} from '../instrument';
import { WINDOW } from '../types';
import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher';
Expand All @@ -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;

Expand Down Expand Up @@ -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();
};
}

Expand Down Expand Up @@ -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<string, 'click' | 'hover' | 'drag' | 'press'> = {
click: 'click',
pointerdown: 'click',
Expand Down Expand Up @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
};
}
}
}

Expand Down
23 changes: 20 additions & 3 deletions packages/tracing-internal/src/browser/web-vitals/README.md
Expand Up @@ -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

Expand All @@ -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/11231

- Add support for TTFB (Time to First Byte)
91 changes: 91 additions & 0 deletions 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);
}
});
};