Skip to content
Merged
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '42 KB',
limit: '43 KB',
},
// Svelte SDK (ESM)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Event } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest('should create fetch spans with http timing @firefox', async ({ browserName, getLocalTestUrl, page }) => {
sentryTest('creates fetch spans with http timing', async ({ browserName, getLocalTestUrl, page }) => {
const supportedBrowsers = ['chromium', 'firefox'];

if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
Expand Down Expand Up @@ -40,6 +40,8 @@ sentryTest('should create fetch spans with http timing @firefox', async ({ brows
trace_id: tracingEvent.contexts?.trace?.trace_id,
data: expect.objectContaining({
'http.request.redirect_start': expect.any(Number),
'http.request.redirect_end': expect.any(Number),
'http.request.worker_start': expect.any(Number),
'http.request.fetch_start': expect.any(Number),
'http.request.domain_lookup_start': expect.any(Number),
'http.request.domain_lookup_end': expect.any(Number),
Expand All @@ -49,6 +51,7 @@ sentryTest('should create fetch spans with http timing @firefox', async ({ brows
'http.request.request_start': expect.any(Number),
'http.request.response_start': expect.any(Number),
'http.request.response_end': expect.any(Number),
'http.request.time_to_first_byte': expect.any(Number),
'network.protocol.version': expect.any(String),
}),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type Event, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORI
import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest('should add resource spans to pageload transaction', async ({ getLocalTestUrl, page, browserName }) => {
sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}
Expand Down Expand Up @@ -74,6 +74,19 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
'http.decoded_response_content_length': expect.any(Number),
'http.response_content_length': expect.any(Number),
'http.response_transfer_size': expect.any(Number),
'http.request.connect_start': expect.any(Number),
'http.request.connection_end': expect.any(Number),
'http.request.domain_lookup_end': expect.any(Number),
'http.request.domain_lookup_start': expect.any(Number),
'http.request.fetch_start': expect.any(Number),
'http.request.redirect_end': expect.any(Number),
'http.request.redirect_start': expect.any(Number),
'http.request.request_start': expect.any(Number),
'http.request.secure_connection_start': expect.any(Number),
'http.request.worker_start': expect.any(Number),
'http.request.response_end': expect.any(Number),
'http.request.response_start': expect.any(Number),
'http.request.time_to_first_byte': expect.any(Number),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda related to the other comment: Maybe we can check here that it's a decimal number.

Copy link
Member Author

@Lms24 Lms24 Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm so theoretically, this could also be an integer, if TTFB happens to be exactly 1000ms for example. We could test against something like expect(Number.isIntegrer(attributes['http.request.time_to_first_byte'])).toBe(false) but this could introduce flakiness (albeit unlikely). WDYT about rather doing a range check instead? 0 < ttfb < 10?

'network.protocol.name': '',
'network.protocol.version': 'unknown',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img',
Expand All @@ -82,6 +95,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
'url.same_origin': false,
'url.scheme': 'https',
...(!isWebkitRun && {
'http.response.status_code': expect.any(Number),
'resource.render_blocking_status': 'non-blocking',
'http.response_delivery_type': '',
}),
Expand All @@ -96,11 +110,30 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
trace_id: traceId,
});

// range check: TTFB must be >0 (at least in this case) and it's reasonable to
// assume <10 seconds. This also tests that we're reporting TTFB in seconds.
const imgSpanTtfb = imgSpan?.data['http.request.time_to_first_byte'];
expect(imgSpanTtfb).toBeGreaterThan(0);
expect(imgSpanTtfb).toBeLessThan(10);

expect(linkSpan).toEqual({
data: {
'http.decoded_response_content_length': expect.any(Number),
'http.response_content_length': expect.any(Number),
'http.response_transfer_size': expect.any(Number),
'http.request.connect_start': expect.any(Number),
'http.request.connection_end': expect.any(Number),
'http.request.domain_lookup_end': expect.any(Number),
'http.request.domain_lookup_start': expect.any(Number),
'http.request.fetch_start': expect.any(Number),
'http.request.redirect_end': expect.any(Number),
'http.request.redirect_start': expect.any(Number),
'http.request.request_start': expect.any(Number),
'http.request.secure_connection_start': expect.any(Number),
'http.request.worker_start': expect.any(Number),
'http.request.response_end': expect.any(Number),
'http.request.response_start': expect.any(Number),
'http.request.time_to_first_byte': expect.any(Number),
'network.protocol.name': '',
'network.protocol.version': 'unknown',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link',
Expand All @@ -109,6 +142,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
'url.same_origin': false,
'url.scheme': 'https',
...(!isWebkitRun && {
'http.response.status_code': expect.any(Number),
'resource.render_blocking_status': 'non-blocking',
'http.response_delivery_type': '',
}),
Expand All @@ -128,6 +162,19 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
'http.decoded_response_content_length': expect.any(Number),
'http.response_content_length': expect.any(Number),
'http.response_transfer_size': expect.any(Number),
'http.request.connection_end': expect.any(Number),
'http.request.connect_start': expect.any(Number),
'http.request.domain_lookup_end': expect.any(Number),
'http.request.domain_lookup_start': expect.any(Number),
'http.request.fetch_start': expect.any(Number),
'http.request.redirect_end': expect.any(Number),
'http.request.redirect_start': expect.any(Number),
'http.request.request_start': expect.any(Number),
'http.request.secure_connection_start': expect.any(Number),
'http.request.worker_start': expect.any(Number),
'http.request.response_end': expect.any(Number),
'http.request.response_start': expect.any(Number),
'http.request.time_to_first_byte': expect.any(Number),
'network.protocol.name': '',
'network.protocol.version': 'unknown',
'sentry.op': 'resource.script',
Expand All @@ -136,6 +183,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
'url.same_origin': false,
'url.scheme': 'https',
...(!isWebkitRun && {
'http.response.status_code': expect.any(Number),
'resource.render_blocking_status': 'non-blocking',
'http.response_delivery_type': '',
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/

export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils';

export { resourceTimingToSpanAttributes } from './metrics/resourceTiming';

export type { FetchHint, NetworkMetaWarning, XhrHint } from './types';
93 changes: 51 additions & 42 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,8 @@ import {
addTtfbInstrumentationHandler,
} from './instrument';
import { trackLcpAsStandaloneSpan } from './lcp';
import {
extractNetworkProtocol,
getBrowserPerformanceAPI,
isMeasurementValue,
msToSec,
startAndEndSpan,
} from './utils';
import { resourceTimingToSpanAttributes } from './resourceTiming';
import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils';
import { getActivationStart } from './web-vitals/lib/getActivationStart';
import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry';
import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher';
Expand Down Expand Up @@ -637,7 +632,7 @@ export function _addResourceSpans(
startTime: number,
duration: number,
timeOrigin: number,
ignoreResourceSpans?: Array<string>,
ignoredResourceSpanOps?: Array<string>,
): void {
// we already instrument based on fetch and xhr, so we don't need to
// duplicate spans here.
Expand All @@ -646,31 +641,15 @@ export function _addResourceSpans(
}

const op = entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource.other';
if (ignoreResourceSpans?.includes(op)) {
if (ignoredResourceSpanOps?.includes(op)) {
return;
}

const parsedUrl = parseUrl(resourceUrl);

const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
};
setResourceEntrySizeData(attributes, entry, 'transferSize', 'http.response_transfer_size');
setResourceEntrySizeData(attributes, entry, 'encodedBodySize', 'http.response_content_length');
setResourceEntrySizeData(attributes, entry, 'decodedBodySize', 'http.decoded_response_content_length');

// `deliveryType` is experimental and does not exist everywhere
const deliveryType = (entry as { deliveryType?: 'cache' | 'navigational-prefetch' | '' }).deliveryType;
if (deliveryType != null) {
attributes['http.response_delivery_type'] = deliveryType;
}

// Types do not reflect this property yet
const renderBlockingStatus = (entry as { renderBlockingStatus?: 'render-blocking' | 'non-render-blocking' })
.renderBlockingStatus;
if (renderBlockingStatus) {
attributes['resource.render_blocking_status'] = renderBlockingStatus;
}
const parsedUrl = parseUrl(resourceUrl);

if (parsedUrl.protocol) {
attributes['url.scheme'] = parsedUrl.protocol.split(':').pop(); // the protocol returned by parseUrl includes a :, but OTEL spec does not, so we remove it.
Expand All @@ -682,21 +661,30 @@ export function _addResourceSpans(

attributes['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin);

// Checking for only `undefined` and `null` is intentional because it's
// valid for `nextHopProtocol` to be an empty string.
if (entry.nextHopProtocol != null) {
const { name, version } = extractNetworkProtocol(entry.nextHopProtocol);
attributes['network.protocol.name'] = name;
attributes['network.protocol.version'] = version;
}
_setResourceRequestAttributes(entry, attributes, [
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
['responseStatus', 'http.response.status_code'],

['transferSize', 'http.response_transfer_size'],
['encodedBodySize', 'http.response_content_length'],
['decodedBodySize', 'http.decoded_response_content_length'],

// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/renderBlockingStatus
['renderBlockingStatus', 'resource.render_blocking_status'],

// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/deliveryType
['deliveryType', 'http.response_delivery_type'],
]);

const attributesWithResourceTiming: SpanAttributes = { ...attributes, ...resourceTimingToSpanAttributes(entry) };

const startTimestamp = timeOrigin + startTime;
const endTimestamp = startTimestamp + duration;

startAndEndSpan(span, startTimestamp, endTimestamp, {
name: resourceUrl.replace(WINDOW.location.origin, ''),
op,
attributes,
attributes: attributesWithResourceTiming,
});
}

Expand Down Expand Up @@ -776,16 +764,37 @@ function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOption
}
}

function setResourceEntrySizeData(
type ExperimentalResourceTimingProperty =
| 'renderBlockingStatus'
| 'deliveryType'
// For some reason, TS during build, errors on `responseStatus` not being a property of
// PerformanceResourceTiming while it actually is. Hence, we're adding it here.
// Perhaps because response status is not yet available in Webkit/Safari.
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
| 'responseStatus';

/**
* Use this to set any attributes we can take directly form the PerformanceResourceTiming entry.
*
* This is just a mapping function for entry->attribute to keep bundle-size minimal.
* Experimental properties are also accepted (see {@link ExperimentalResourceTimingProperty}).
* Assumes that all entry properties might be undefined for browser-specific differences.
* Only accepts string and number values for now and also sets 0-values.
*/
export function _setResourceRequestAttributes(
entry: Partial<PerformanceResourceTiming> & Partial<Record<ExperimentalResourceTimingProperty, number | string>>,
attributes: SpanAttributes,
entry: PerformanceResourceTiming,
key: keyof Pick<PerformanceResourceTiming, 'transferSize' | 'encodedBodySize' | 'decodedBodySize'>,
dataKey: 'http.response_transfer_size' | 'http.response_content_length' | 'http.decoded_response_content_length',
properties: [keyof PerformanceResourceTiming | ExperimentalResourceTimingProperty, string][],
): void {
const entryVal = entry[key];
if (entryVal != null && entryVal < MAX_INT_AS_BYTES) {
attributes[dataKey] = entryVal;
}
properties.forEach(([entryKey, attributeKey]) => {
const entryVal = entry[entryKey];
if (
entryVal != null &&
((typeof entryVal === 'number' && entryVal < MAX_INT_AS_BYTES) || typeof entryVal === 'string')
) {
attributes[attributeKey] = entryVal;
}
});
}

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/browser-utils/src/metrics/resourceTiming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { SpanAttributes } from '@sentry/core';
import { browserPerformanceTimeOrigin } from '@sentry/core';
import { extractNetworkProtocol, getBrowserPerformanceAPI } from './utils';

function getAbsoluteTime(time = 0): number {
return ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000;
}

/**
* Converts a PerformanceResourceTiming entry to span data for the resource span. Most importantly,
* it converts the timing values from timestamps relative to the `performance.timeOrigin` to absolute timestamps
* in seconds.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#timestamps
*
* @param resourceTiming
* @returns An array where the first element is the attribute name and the second element is the attribute value.
*/
export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResourceTiming): SpanAttributes {
const timingSpanData: SpanAttributes = {};
// Checking for only `undefined` and `null` is intentional because it's
// valid for `nextHopProtocol` to be an empty string.
if (resourceTiming.nextHopProtocol != undefined) {
const { name, version } = extractNetworkProtocol(resourceTiming.nextHopProtocol);
timingSpanData['network.protocol.version'] = version;
timingSpanData['network.protocol.name'] = name;
}

if (!(browserPerformanceTimeOrigin() || getBrowserPerformanceAPI()?.timeOrigin)) {
return timingSpanData;
}

return {
...timingSpanData,

'http.request.redirect_start': getAbsoluteTime(resourceTiming.redirectStart),
'http.request.redirect_end': getAbsoluteTime(resourceTiming.redirectEnd),

'http.request.worker_start': getAbsoluteTime(resourceTiming.workerStart),

'http.request.fetch_start': getAbsoluteTime(resourceTiming.fetchStart),

'http.request.domain_lookup_start': getAbsoluteTime(resourceTiming.domainLookupStart),
'http.request.domain_lookup_end': getAbsoluteTime(resourceTiming.domainLookupEnd),

'http.request.connect_start': getAbsoluteTime(resourceTiming.connectStart),
'http.request.secure_connection_start': getAbsoluteTime(resourceTiming.secureConnectionStart),
'http.request.connection_end': getAbsoluteTime(resourceTiming.connectEnd),

'http.request.request_start': getAbsoluteTime(resourceTiming.requestStart),

'http.request.response_start': getAbsoluteTime(resourceTiming.responseStart),
'http.request.response_end': getAbsoluteTime(resourceTiming.responseEnd),

// For TTFB we actually want the relative time from timeOrigin to responseStart
// This way, TTFB always measures the "first page load" experience.
// see: https://web.dev/articles/ttfb#measure-resource-requests
'http.request.time_to_first_byte': (resourceTiming.responseStart ?? 0) / 1000,
Comment on lines +55 to +58
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious on reviewers' opinions on this one: I decided to convert this value to seconds to stick with us mostly sending seconds-based values. Happy to leave at ms if reviewers prefer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of keeping the standard of seconds I would keep seconds here. It's a decimal number, right?

};
}
Loading