Skip to content

Commit

Permalink
fix(metrics): use web-vitals ttfb calculation
Browse files Browse the repository at this point in the history
  • Loading branch information
AbhiPrasad committed Mar 18, 2024
1 parent a99f260 commit 2a8b25c
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 34 deletions.
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
56 changes: 25 additions & 31 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 @@ -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',
};
}
}
}
11 changes: 11 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,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)

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 @@ -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;
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

0 comments on commit 2a8b25c

Please sign in to comment.