From 0a38dc67ed4ed0356f3c69357060c58d98c12e21 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 8 Sep 2025 16:30:32 +0200 Subject: [PATCH 01/12] feat(browser): Add timing and status atttributes to resource spans --- .../src/metrics/browserMetrics.ts | 99 ++++++++++++++----- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 870558ada39d..f2d06d9171c1 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -637,7 +637,7 @@ export function _addResourceSpans( startTime: number, duration: number, timeOrigin: number, - ignoreResourceSpans?: Array, + ignoredResourceSpanOps?: Array, ): void { // we already instrument based on fetch and xhr, so we don't need to // duplicate spans here. @@ -646,31 +646,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. @@ -690,6 +674,49 @@ export function _addResourceSpans( attributes['network.protocol.version'] = version; } + setResourceRequestAttributes(entry, attributes, [ + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus + ['responseStatus', 'http.request.response_status'], + + // Timing attributes (request/response lifecycle) + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#timestamps + ['redirectStart', 'http.request.redirect_start'], + ['redirectEnd', 'http.request.redirect_end'], + + ['workerStart', 'http.request.worker_start'], + + ['fetchStart', 'http.request.fetch_start'], + + ['domainLookupStart', 'http.request.domain_lookup_start'], + ['domainLookupEnd', 'http.request.domain_lookup_end'], + + ['connectStart', 'http.request.connect_start'], + ['secureConnectionStart', 'http.request.secure_connection_start'], + ['connectEnd', 'http.request.connect_end'], + + ['requestStart', 'http.request.request_start'], + ['firstInterimResponseStart', 'http.response.first_interim_response_start'], + ['finalResponseHeadersStart', 'http.response.final_response_headers_start'], + + // responseStart can also be interpreted as TTFB for resource requests: https://web.dev/articles/ttfb + ['responseStart', 'http.response.response_start'], + ['responseEnd', 'http.response.response_end'], + + // Size attributes: + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/transferSize + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/encodedBodySize + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/decodedBodySize + ['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 startTimestamp = timeOrigin + startTime; const endTimestamp = startTimestamp + duration; @@ -776,16 +803,34 @@ function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOption } } -function setResourceEntrySizeData( +type ExperimentalResourceTimingProperty = + | 'firstInterimResponseStart' + | 'finalResponseHeadersStart' + | 'renderBlockingStatus' + | 'deliveryType'; + +/** + * 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. + */ +function setResourceRequestAttributes( + entry: Partial & Partial>, attributes: SpanAttributes, - entry: PerformanceResourceTiming, - key: keyof Pick, - 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; + } + }); } /** From ace4f382858a629f59b47bf08b76be61dc3cf967 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 8 Sep 2025 17:11:18 +0200 Subject: [PATCH 02/12] adjust attribute names --- packages/browser-utils/src/metrics/browserMetrics.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index f2d06d9171c1..dc06cc6e9611 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -675,8 +675,8 @@ export function _addResourceSpans( } setResourceRequestAttributes(entry, attributes, [ - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus - ['responseStatus', 'http.request.response_status'], + // Resource request response status + ['responseStatus', 'http.response.status_code'], // Timing attributes (request/response lifecycle) // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#timestamps @@ -695,12 +695,13 @@ export function _addResourceSpans( ['connectEnd', 'http.request.connect_end'], ['requestStart', 'http.request.request_start'], + ['firstInterimResponseStart', 'http.response.first_interim_response_start'], ['finalResponseHeadersStart', 'http.response.final_response_headers_start'], - // responseStart can also be interpreted as TTFB for resource requests: https://web.dev/articles/ttfb - ['responseStart', 'http.response.response_start'], - ['responseEnd', 'http.response.response_end'], + // ResponseStart can also be interpreted as TTFB for resource requests: https://web.dev/articles/ttfb + ['responseStart', 'http.response.start'], + ['responseEnd', 'http.response.end'], // Size attributes: // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/transferSize From 0da56f53a4a4351aa96c7e61d02bd35a78d726f7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 8 Sep 2025 17:20:36 +0200 Subject: [PATCH 03/12] add unit tests for attribute extraction --- .../src/metrics/browserMetrics.ts | 6 +- .../test/browser/browserMetrics.test.ts | 78 ++++++++++++++++++- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index dc06cc6e9611..481554f9412b 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -674,7 +674,7 @@ export function _addResourceSpans( attributes['network.protocol.version'] = version; } - setResourceRequestAttributes(entry, attributes, [ + _setResourceRequestAttributes(entry, attributes, [ // Resource request response status ['responseStatus', 'http.response.status_code'], @@ -818,8 +818,8 @@ type 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. */ -function setResourceRequestAttributes( - entry: Partial & Partial>, +export function _setResourceRequestAttributes( + entry: Partial & Partial>, attributes: SpanAttributes, properties: [keyof PerformanceResourceTiming | ExperimentalResourceTimingProperty, string][], ): void { diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 50dcfd65a528..e15590557753 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -1,4 +1,4 @@ -import type { Span } from '@sentry/core'; +import type { Span, SpanAttributes } from '@sentry/core'; import { getClient, getCurrentScope, @@ -10,7 +10,12 @@ import { spanToJSON, } from '@sentry/core'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { _addMeasureSpans, _addNavigationSpans, _addResourceSpans } from '../../src/metrics/browserMetrics'; +import { + _addMeasureSpans, + _addNavigationSpans, + _addResourceSpans, + _setResourceRequestAttributes, +} from '../../src/metrics/browserMetrics'; import { WINDOW } from '../../src/types'; import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; @@ -709,6 +714,75 @@ describe('_addNavigationSpans', () => { }); }); +describe('_setResourceRequestAttributes', () => { + it('sets resource request attributes', () => { + const attributes: SpanAttributes = {}; + + const entry = mockPerformanceResourceTiming({ + transferSize: 0, + deliveryType: 'cache', + renderBlockingStatus: 'non-blocking', + responseStatus: 200, + redirectStart: 100, + responseStart: 200, + }); + + _setResourceRequestAttributes(entry, attributes, [ + ['transferSize', 'http.response_transfer_size'], + ['deliveryType', 'http.response_delivery_type'], + ['renderBlockingStatus', 'resource.render_blocking_status'], + ['responseStatus', 'http.response.status_code'], + ['redirectStart', 'http.request.redirect_start'], + ['responseStart', 'http.response.start'], + ]); + + expect(attributes).toEqual({ + 'http.response_transfer_size': 0, + 'http.request.redirect_start': 100, + 'http.response.start': 200, + 'http.response.status_code': 200, + 'http.response_delivery_type': 'cache', + 'resource.render_blocking_status': 'non-blocking', + }); + }); + + it("doesn't set other attributes", () => { + const attributes: SpanAttributes = {}; + + const entry = mockPerformanceResourceTiming({ + transferSize: 0, + deliveryType: 'cache', + renderBlockingStatus: 'non-blocking', + }); + + _setResourceRequestAttributes(entry, attributes, [['transferSize', 'http.response_transfer_size']]); + + expect(attributes).toEqual({ + 'http.response_transfer_size': 0, + }); + }); + + it("doesn't set non-primitive or undefined values", () => { + const attributes: SpanAttributes = {}; + + const entry = mockPerformanceResourceTiming({ + transferSize: undefined, + // @ts-expect-error null is invalid but let's test it anyway + deliveryType: null, + // @ts-expect-error object is invalid but let's test it anyway + renderBlockingStatus: { blocking: 'non-blocking' }, + }); + + _setResourceRequestAttributes(entry, attributes, [ + ['transferSize', 'http.response_transfer_size'], + ['deliveryType', 'http.response_delivery_type'], + ['renderBlockingStatus', 'resource.render_blocking_status'], + ]); + + expect(attributes).toEqual({}); + }); +}); + const setGlobalLocation = (location: Location) => { // @ts-expect-error need to delete this in order to set to new value delete WINDOW.location; From fe24c43ab4f413e7946a9328007f8e06d7623db0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 8 Sep 2025 18:03:33 +0200 Subject: [PATCH 04/12] adjust integration test --- .../metrics/pageload-resource-spans/test.ts | 47 ++++++++++++++++++- .../src/metrics/browserMetrics.ts | 5 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts index 38c7e61ff541..18a2ca1a5e3c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts @@ -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(); } @@ -74,6 +74,21 @@ 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_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.response.end': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), + 'http.response.start': expect.any(Number), + 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img', @@ -101,6 +116,21 @@ 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_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.response.end': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), + 'http.response.start': expect.any(Number), + 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link', @@ -128,6 +158,21 @@ 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_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.response.end': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), + 'http.response.start': expect.any(Number), + 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', 'sentry.op': 'resource.script', diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 481554f9412b..53e766365d66 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -808,7 +808,10 @@ type ExperimentalResourceTimingProperty = | 'firstInterimResponseStart' | 'finalResponseHeadersStart' | 'renderBlockingStatus' - | 'deliveryType'; + | 'deliveryType' + // for some reason, TS during build things this is not a property of PerformanceResourceTiming + // while it actually is. Hence, we're adding it here + | 'responseStatus'; /** * Use this to set any attributes we can take directly form the PerformanceResourceTiming entry. From 6d59bb0dc0c3553b3e26af555e69706617306bb5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 10:12:19 +0200 Subject: [PATCH 05/12] fix webkit test --- .../metrics/pageload-resource-spans/test.ts | 18 +++++++++--------- .../src/metrics/browserMetrics.ts | 8 +++++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts index 18a2ca1a5e3c..b3f885b63774 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts @@ -85,10 +85,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), 'http.response.end': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'http.response.start': expect.any(Number), - 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img', @@ -97,6 +94,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.same_origin': false, 'url.scheme': 'https', ...(!isWebkitRun && { + 'http.response.status_code': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), @@ -127,10 +127,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), 'http.response.end': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'http.response.start': expect.any(Number), - 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link', @@ -139,6 +136,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.same_origin': false, 'url.scheme': 'https', ...(!isWebkitRun && { + 'http.response.status_code': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), @@ -169,10 +169,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), 'http.response.end': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'http.response.start': expect.any(Number), - 'http.response.status_code': expect.any(Number), 'network.protocol.name': '', 'network.protocol.version': 'unknown', 'sentry.op': 'resource.script', @@ -181,6 +178,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.same_origin': false, 'url.scheme': 'https', ...(!isWebkitRun && { + 'http.response.status_code': expect.any(Number), + 'http.response.final_response_headers_start': expect.any(Number), + 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 53e766365d66..938f0abde58b 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -675,7 +675,7 @@ export function _addResourceSpans( } _setResourceRequestAttributes(entry, attributes, [ - // Resource request response status + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus ['responseStatus', 'http.response.status_code'], // Timing attributes (request/response lifecycle) @@ -809,8 +809,10 @@ type ExperimentalResourceTimingProperty = | 'finalResponseHeadersStart' | 'renderBlockingStatus' | 'deliveryType' - // for some reason, TS during build things this is not a property of PerformanceResourceTiming - // while it actually is. Hence, we're adding it here + // 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'; /** From 37edc3e6f03b3a706295d2d493e484c31ec2f309 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 10:15:18 +0200 Subject: [PATCH 06/12] size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 8eef4950f00d..32d5d19e1495 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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) { From ce5c8e63eea2173d9669f515fd40c94800b1c5d9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 12:36:11 +0200 Subject: [PATCH 07/12] rework implementation to use resourceTimingToSpanAttributes --- .../metrics/pageload-resource-spans/test.ts | 27 ++++----- packages/browser-utils/src/index.ts | 2 + .../src/metrics/browserMetrics.ts | 42 ++----------- .../src/metrics/resourceTiming.ts | 60 +++++++++++++++++++ .../metrics/elementTiming.test.ts | 6 +- .../{instrument => }/metrics/inpt.test.ts | 6 +- .../test/metrics/resourceTiming.test.ts} | 57 ++++++++++++------ packages/browser/src/tracing/request.ts | 5 +- .../browser/src/tracing/resource-timing.ts | 41 ------------- 9 files changed, 126 insertions(+), 120 deletions(-) create mode 100644 packages/browser-utils/src/metrics/resourceTiming.ts rename packages/browser-utils/test/{instrument => }/metrics/elementTiming.test.ts (98%) rename packages/browser-utils/test/{instrument => }/metrics/inpt.test.ts (95%) rename packages/{browser/test/tracing/resource-timing.test.ts => browser-utils/test/metrics/resourceTiming.test.ts} (87%) delete mode 100644 packages/browser/src/tracing/resource-timing.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts index b3f885b63774..ff8cbe5675f3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts @@ -74,8 +74,8 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU '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_end': 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), @@ -84,8 +84,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.request_start': expect.any(Number), 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), - 'http.response.end': expect.any(Number), - 'http.response.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.img', @@ -95,8 +96,6 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.scheme': 'https', ...(!isWebkitRun && { 'http.response.status_code': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), @@ -116,8 +115,8 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU '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_end': 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), @@ -126,8 +125,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.request_start': expect.any(Number), 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), - 'http.response.end': expect.any(Number), - 'http.response.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', @@ -137,8 +137,6 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.scheme': 'https', ...(!isWebkitRun && { 'http.response.status_code': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), @@ -158,7 +156,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU '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_end': 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), @@ -168,8 +166,9 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'http.request.request_start': expect.any(Number), 'http.request.secure_connection_start': expect.any(Number), 'http.request.worker_start': expect.any(Number), - 'http.response.end': expect.any(Number), - 'http.response.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', @@ -179,8 +178,6 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'url.scheme': 'https', ...(!isWebkitRun && { 'http.response.status_code': expect.any(Number), - 'http.response.final_response_headers_start': expect.any(Number), - 'http.response.first_interim_response_start': expect.any(Number), 'resource.render_blocking_status': 'non-blocking', 'http.response_delivery_type': '', }), diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index bf6605f3f399..accf3cb3a278 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -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'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 938f0abde58b..b2a48b03359c 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -22,6 +22,7 @@ import { addTtfbInstrumentationHandler, } from './instrument'; import { trackLcpAsStandaloneSpan } from './lcp'; +import { resourceTimingToSpanAttributes } from './resourceTiming'; import { extractNetworkProtocol, getBrowserPerformanceAPI, @@ -666,47 +667,10 @@ 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'], - // Timing attributes (request/response lifecycle) - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#timestamps - ['redirectStart', 'http.request.redirect_start'], - ['redirectEnd', 'http.request.redirect_end'], - - ['workerStart', 'http.request.worker_start'], - - ['fetchStart', 'http.request.fetch_start'], - - ['domainLookupStart', 'http.request.domain_lookup_start'], - ['domainLookupEnd', 'http.request.domain_lookup_end'], - - ['connectStart', 'http.request.connect_start'], - ['secureConnectionStart', 'http.request.secure_connection_start'], - ['connectEnd', 'http.request.connect_end'], - - ['requestStart', 'http.request.request_start'], - - ['firstInterimResponseStart', 'http.response.first_interim_response_start'], - ['finalResponseHeadersStart', 'http.response.final_response_headers_start'], - - // ResponseStart can also be interpreted as TTFB for resource requests: https://web.dev/articles/ttfb - ['responseStart', 'http.response.start'], - ['responseEnd', 'http.response.end'], - - // Size attributes: - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/transferSize - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/encodedBodySize - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/decodedBodySize ['transferSize', 'http.response_transfer_size'], ['encodedBodySize', 'http.response_content_length'], ['decodedBodySize', 'http.decoded_response_content_length'], @@ -718,13 +682,15 @@ export function _addResourceSpans( ['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, }); } diff --git a/packages/browser-utils/src/metrics/resourceTiming.ts b/packages/browser-utils/src/metrics/resourceTiming.ts new file mode 100644 index 000000000000..fe613355c55d --- /dev/null +++ b/packages/browser-utils/src/metrics/resourceTiming.ts @@ -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, + }; +} diff --git a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts similarity index 98% rename from packages/browser-utils/test/instrument/metrics/elementTiming.test.ts rename to packages/browser-utils/test/metrics/elementTiming.test.ts index 04456ceadc44..14431415873b 100644 --- a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -1,8 +1,8 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { _onElementTiming, startTrackingElementTiming } from '../../../src/metrics/elementTiming'; -import * as browserMetricsInstrumentation from '../../../src/metrics/instrument'; -import * as browserMetricsUtils from '../../../src/metrics/utils'; +import { _onElementTiming, startTrackingElementTiming } from '../../src/metrics/elementTiming'; +import * as browserMetricsInstrumentation from '../../src/metrics/instrument'; +import * as browserMetricsUtils from '../../src/metrics/utils'; describe('_onElementTiming', () => { const spanEndSpy = vi.fn(); diff --git a/packages/browser-utils/test/instrument/metrics/inpt.test.ts b/packages/browser-utils/test/metrics/inpt.test.ts similarity index 95% rename from packages/browser-utils/test/instrument/metrics/inpt.test.ts rename to packages/browser-utils/test/metrics/inpt.test.ts index 437ae650d0fe..bfa44b17a5b4 100644 --- a/packages/browser-utils/test/instrument/metrics/inpt.test.ts +++ b/packages/browser-utils/test/metrics/inpt.test.ts @@ -1,8 +1,8 @@ import { afterEach } from 'node:test'; import { describe, expect, it, vi } from 'vitest'; -import { _onInp, _trackINP } from '../../../src/metrics/inp'; -import * as instrument from '../../../src/metrics/instrument'; -import * as utils from '../../../src/metrics/utils'; +import { _onInp, _trackINP } from '../../src/metrics/inp'; +import * as instrument from '../../src/metrics/instrument'; +import * as utils from '../../src/metrics/utils'; describe('_trackINP', () => { const addInpInstrumentationHandler = vi.spyOn(instrument, 'addInpInstrumentationHandler'); diff --git a/packages/browser/test/tracing/resource-timing.test.ts b/packages/browser-utils/test/metrics/resourceTiming.test.ts similarity index 87% rename from packages/browser/test/tracing/resource-timing.test.ts rename to packages/browser-utils/test/metrics/resourceTiming.test.ts index c3ad8b9c0c85..3cf4723e006f 100644 --- a/packages/browser/test/tracing/resource-timing.test.ts +++ b/packages/browser-utils/test/metrics/resourceTiming.test.ts @@ -1,8 +1,8 @@ import * as utils from '@sentry/core'; -import * as browserUtils from '@sentry-internal/browser-utils'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { resourceTimingToSpanAttributes } from '../../src/tracing/resource-timing'; +import { resourceTimingToSpanAttributes } from '../../src/metrics/resourceTiming'; +import * as browserMetricsUtils from '../../src/metrics/utils'; describe('resourceTimingToSpanAttributes', () => { let browserPerformanceTimeOriginSpy: MockInstance; @@ -11,7 +11,7 @@ describe('resourceTimingToSpanAttributes', () => { beforeEach(() => { vi.clearAllMocks(); browserPerformanceTimeOriginSpy = vi.spyOn(utils, 'browserPerformanceTimeOrigin'); - extractNetworkProtocolSpy = vi.spyOn(browserUtils, 'extractNetworkProtocol'); + extractNetworkProtocolSpy = vi.spyOn(browserMetricsUtils, 'extractNetworkProtocol'); }); afterEach(() => { @@ -48,7 +48,7 @@ describe('resourceTimingToSpanAttributes', () => { }; describe('with network protocol information', () => { - it('should extract network protocol when nextHopProtocol is available', () => { + it('extracts network protocol when nextHopProtocol is available', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: 'h2', }); @@ -79,7 +79,7 @@ describe('resourceTimingToSpanAttributes', () => { global.performance = originalPerformance; }); - it('should handle different network protocols', () => { + it('handles different network protocols', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: 'http/1.1', }); @@ -110,7 +110,7 @@ describe('resourceTimingToSpanAttributes', () => { global.performance = originalPerformance; }); - it('should extract network protocol even when nextHopProtocol is empty', () => { + it('extracts network protocol even when nextHopProtocol is empty', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: '', }); @@ -141,7 +141,7 @@ describe('resourceTimingToSpanAttributes', () => { global.performance = originalPerformance; }); - it('should not extract network protocol when nextHopProtocol is undefined', () => { + it("doesn't extract network protocol when nextHopProtocol is undefined", () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: undefined as any, }); @@ -166,7 +166,7 @@ describe('resourceTimingToSpanAttributes', () => { }); describe('without browserPerformanceTimeOrigin', () => { - it('should return only network protocol data when browserPerformanceTimeOrigin is not available', () => { + it('returns only network protocol data when browserPerformanceTimeOrigin is not available', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: 'h2', }); @@ -196,7 +196,7 @@ describe('resourceTimingToSpanAttributes', () => { global.performance = originalPerformance; }); - it('should return network protocol attributes even when empty string and no browserPerformanceTimeOrigin', () => { + it('returns network protocol attributes even when empty string and no browserPerformanceTimeOrigin', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: '', }); @@ -232,10 +232,12 @@ describe('resourceTimingToSpanAttributes', () => { browserPerformanceTimeOriginSpy.mockReturnValue(1000000); // 1 second in milliseconds }); - it('should include all timing attributes when browserPerformanceTimeOrigin is available', () => { + it('includes all timing attributes when browserPerformanceTimeOrigin is available', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: 'h2', redirectStart: 10, + redirectEnd: 20, + workerStart: 22, fetchStart: 25, domainLookupStart: 30, domainLookupEnd: 35, @@ -258,6 +260,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', '2.0'], ['network.protocol.name', 'http'], ['http.request.redirect_start', 1000.01], // (1000000 + 10) / 1000 + ['http.request.redirect_end', 1000.02], // (1000000 + 20) / 1000 + ['http.request.worker_start', 1000.022], // (1000000 + 22) / 1000 ['http.request.fetch_start', 1000.025], // (1000000 + 25) / 1000 ['http.request.domain_lookup_start', 1000.03], // (1000000 + 30) / 1000 ['http.request.domain_lookup_end', 1000.035], // (1000000 + 35) / 1000 @@ -267,10 +271,11 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 1000.055], // (1000000 + 55) / 1000 ['http.request.response_start', 1000.15], // (1000000 + 150) / 1000 ['http.request.response_end', 1000.2], // (1000000 + 200) / 1000 + ['http.request.time_to_first_byte', 0.15], // 150 / 1000 ]); }); - it('should handle zero timing values', () => { + it('handles zero timing values', () => { extractNetworkProtocolSpy.mockReturnValue({ name: '', version: 'unknown', @@ -296,6 +301,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', 'unknown'], ['network.protocol.name', ''], ['http.request.redirect_start', 1000], // (1000000 + 0) / 1000 + ['http.request.redirect_end', 1000.02], + ['http.request.worker_start', 1000], ['http.request.fetch_start', 1000], ['http.request.domain_lookup_start', 1000], ['http.request.domain_lookup_end', 1000], @@ -305,10 +312,11 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 1000], ['http.request.response_start', 1000], ['http.request.response_end', 1000], + ['http.request.time_to_first_byte', 0], ]); }); - it('should combine network protocol and timing attributes', () => { + it('combines network protocol and timing attributes', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: 'http/1.1', redirectStart: 5, @@ -334,6 +342,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', '1.1'], ['network.protocol.name', 'http'], ['http.request.redirect_start', 1000.005], + ['http.request.redirect_end', 1000.02], + ['http.request.worker_start', 1000], ['http.request.fetch_start', 1000.01], ['http.request.domain_lookup_start', 1000.015], ['http.request.domain_lookup_end', 1000.02], @@ -343,12 +353,13 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 1000.04], ['http.request.response_start', 1000.08], ['http.request.response_end', 1000.1], + ['http.request.time_to_first_byte', 0.08], ]); }); }); describe('fallback to performance.timeOrigin', () => { - it('should use performance.timeOrigin when browserPerformanceTimeOrigin returns null', () => { + it('uses performance.timeOrigin when browserPerformanceTimeOrigin returns null', () => { // Mock browserPerformanceTimeOrigin to return null for the main check browserPerformanceTimeOriginSpy.mockReturnValue(null); @@ -380,7 +391,7 @@ describe('resourceTimingToSpanAttributes', () => { ]); }); - it('should use performance.timeOrigin fallback in getAbsoluteTime when available', () => { + it('uses performance.timeOrigin fallback in getAbsoluteTime when available', () => { // Mock browserPerformanceTimeOrigin to return 500000 for the main check browserPerformanceTimeOriginSpy.mockReturnValue(500000); @@ -392,6 +403,8 @@ describe('resourceTimingToSpanAttributes', () => { const mockResourceTiming = createMockResourceTiming({ nextHopProtocol: '', redirectStart: 20, + redirectEnd: 30, + workerStart: 35, fetchStart: 40, domainLookupStart: 60, domainLookupEnd: 80, @@ -409,6 +422,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', 'unknown'], ['network.protocol.name', ''], ['http.request.redirect_start', 500.02], // (500000 + 20) / 1000 + ['http.request.redirect_end', 500.03], // (500000 + 30) / 1000 + ['http.request.worker_start', 500.035], // (500000 + 35) / 1000 ['http.request.fetch_start', 500.04], // (500000 + 40) / 1000 ['http.request.domain_lookup_start', 500.06], // (500000 + 60) / 1000 ['http.request.domain_lookup_end', 500.08], // (500000 + 80) / 1000 @@ -418,10 +433,11 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 500.16], // (500000 + 160) / 1000 ['http.request.response_start', 500.3], // (500000 + 300) / 1000 ['http.request.response_end', 500.4], // (500000 + 400) / 1000 + ['http.request.time_to_first_byte', 0.3], // 300 / 1000 ]); }); - it('should handle case when neither browserPerformanceTimeOrigin nor performance.timeOrigin is available', () => { + it('handles case when neither browserPerformanceTimeOrigin nor performance.timeOrigin is available', () => { browserPerformanceTimeOriginSpy.mockReturnValue(null); extractNetworkProtocolSpy.mockReturnValue({ @@ -454,7 +470,7 @@ describe('resourceTimingToSpanAttributes', () => { }); describe('edge cases', () => { - it('should handle undefined timing values', () => { + it('handles undefined timing values', () => { browserPerformanceTimeOriginSpy.mockReturnValue(1000000); extractNetworkProtocolSpy.mockReturnValue({ @@ -466,6 +482,7 @@ describe('resourceTimingToSpanAttributes', () => { nextHopProtocol: '', redirectStart: undefined as any, fetchStart: undefined as any, + workerStart: undefined as any, domainLookupStart: undefined as any, domainLookupEnd: undefined as any, connectStart: undefined as any, @@ -482,6 +499,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', 'unknown'], ['network.protocol.name', ''], ['http.request.redirect_start', 1000], // (1000000 + 0) / 1000 + ['http.request.redirect_end', 1000.02], + ['http.request.worker_start', 1000], ['http.request.fetch_start', 1000], ['http.request.domain_lookup_start', 1000], ['http.request.domain_lookup_end', 1000], @@ -491,10 +510,11 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 1000], ['http.request.response_start', 1000], ['http.request.response_end', 1000], + ['http.request.time_to_first_byte', 0], ]); }); - it('should handle very large timing values', () => { + it('handles very large timing values', () => { browserPerformanceTimeOriginSpy.mockReturnValue(1000000); extractNetworkProtocolSpy.mockReturnValue({ @@ -522,6 +542,8 @@ describe('resourceTimingToSpanAttributes', () => { ['network.protocol.version', 'unknown'], ['network.protocol.name', ''], ['http.request.redirect_start', 1999.999], // (1000000 + 999999) / 1000 + ['http.request.redirect_end', 1000.02], + ['http.request.worker_start', 1000], ['http.request.fetch_start', 1999.999], ['http.request.domain_lookup_start', 1999.999], ['http.request.domain_lookup_end', 1999.999], @@ -531,6 +553,7 @@ describe('resourceTimingToSpanAttributes', () => { ['http.request.request_start', 1999.999], ['http.request.response_start', 1999.999], ['http.request.response_end', 1999.999], + ['http.request.time_to_first_byte', 999.999], ]); }); }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index d046793b42a1..1216c93ec9d3 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -22,11 +22,11 @@ import type { XhrHint } from '@sentry-internal/browser-utils'; import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, + resourceTimingToSpanAttributes, SENTRY_XHR_DATA_KEY, } from '@sentry-internal/browser-utils'; import type { BrowserClient } from '../client'; import { WINDOW } from '../helpers'; -import { resourceTimingToSpanAttributes } from './resource-timing'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -249,8 +249,7 @@ function addHTTPTimings(span: Span): void { const cleanup = addPerformanceInstrumentationHandler('resource', ({ entries }) => { entries.forEach(entry => { if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) { - const spanAttributes = resourceTimingToSpanAttributes(entry); - spanAttributes.forEach(attributeArray => span.setAttribute(...attributeArray)); + span.setAttributes(resourceTimingToSpanAttributes(entry)); // In the next tick, clean this handler up // We have to wait here because otherwise this cleans itself up before it is fully done setTimeout(cleanup); diff --git a/packages/browser/src/tracing/resource-timing.ts b/packages/browser/src/tracing/resource-timing.ts deleted file mode 100644 index c741a7e91016..000000000000 --- a/packages/browser/src/tracing/resource-timing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Span } from '@sentry/core'; -import { browserPerformanceTimeOrigin } from '@sentry/core'; -import { extractNetworkProtocol } from '@sentry-internal/browser-utils'; - -function getAbsoluteTime(time: number = 0): number { - return ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000; -} - -/** - * Converts a PerformanceResourceTiming entry to span data for the resource span. - * - * @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, -): Array> { - const timingSpanData: Array> = []; - // 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.push(['network.protocol.version', version], ['network.protocol.name', name]); - } - if (!browserPerformanceTimeOrigin()) { - return timingSpanData; - } - return [ - ...timingSpanData, - ['http.request.redirect_start', getAbsoluteTime(resourceTiming.redirectStart)], - ['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)], - ]; -} From c608dab70eec46677f6e9cb9b11626a65ff38e53 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 12:39:25 +0200 Subject: [PATCH 08/12] add new attributes to http timing test --- .../tracing/browserTracingIntegration/http-timings/test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts index f47552f5e9b6..633be5f570b5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts @@ -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)) { @@ -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), @@ -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), }), }), From 4ebf1f7904234a92c6e2eaaf26e0cef721a5495d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 12:56:49 +0200 Subject: [PATCH 09/12] lint --- packages/browser-utils/src/metrics/browserMetrics.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index b2a48b03359c..3e5f7682446b 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -23,13 +23,7 @@ import { } from './instrument'; import { trackLcpAsStandaloneSpan } from './lcp'; import { resourceTimingToSpanAttributes } from './resourceTiming'; -import { - extractNetworkProtocol, - getBrowserPerformanceAPI, - isMeasurementValue, - msToSec, - startAndEndSpan, -} from './utils'; +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'; From 993c23baf82b4d061a48e8936a3290cc2ee16e31 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 13:05:43 +0200 Subject: [PATCH 10/12] fix browser utils unit tests --- .../test/browser/browserMetrics.test.ts | 34 ++- .../test/metrics/resourceTiming.test.ts | 262 +++++++++--------- 2 files changed, 161 insertions(+), 135 deletions(-) diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index e15590557753..c717bb81ca0b 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -294,6 +294,19 @@ describe('_addResourceSpans', () => { ['url.same_origin']: true, ['network.protocol.name']: 'http', ['network.protocol.version']: '1.1', + '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.response_end': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'http.request.secure_connection_start': expect.any(Number), + 'http.request.time_to_first_byte': 0, + 'http.request.worker_start': expect.any(Number), }, }), ); @@ -409,7 +422,7 @@ describe('_addResourceSpans', () => { expect(spans).toHaveLength(1); expect(spanToJSON(spans[0]!)).toEqual( expect.objectContaining({ - data: { + data: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.css', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', ['http.decoded_response_content_length']: entry.decodedBodySize, @@ -421,7 +434,7 @@ describe('_addResourceSpans', () => { ['url.same_origin']: true, ['network.protocol.name']: 'http', ['network.protocol.version']: '2', - }, + }), }), ); }); @@ -446,7 +459,7 @@ describe('_addResourceSpans', () => { expect(spans).toHaveLength(1); expect(spanToJSON(spans[0]!)).toEqual( expect.objectContaining({ - data: { + data: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.css', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', 'server.address': 'example.com', @@ -454,7 +467,7 @@ describe('_addResourceSpans', () => { 'url.scheme': 'https', ['network.protocol.name']: 'http', ['network.protocol.version']: '3', - }, + }), description: '/assets/to/css', timestamp: 468, op: 'resource.css', @@ -494,6 +507,19 @@ describe('_addResourceSpans', () => { 'url.scheme': 'https', ['network.protocol.name']: 'http', ['network.protocol.version']: '3', + '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.response_end': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'http.request.secure_connection_start': expect.any(Number), + 'http.request.time_to_first_byte': 0, + 'http.request.worker_start': expect.any(Number), }, description: '/assets/to/css', timestamp: 468, diff --git a/packages/browser-utils/test/metrics/resourceTiming.test.ts b/packages/browser-utils/test/metrics/resourceTiming.test.ts index 3cf4723e006f..881a7075441e 100644 --- a/packages/browser-utils/test/metrics/resourceTiming.test.ts +++ b/packages/browser-utils/test/metrics/resourceTiming.test.ts @@ -70,10 +70,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); expect(extractNetworkProtocolSpy).toHaveBeenCalledWith('h2'); - expect(result).toEqual([ - ['network.protocol.version', '2.0'], - ['network.protocol.name', 'http'], - ]); + expect(result).toEqual({ + 'network.protocol.version': '2.0', + 'network.protocol.name': 'http', + }); // Restore global performance global.performance = originalPerformance; @@ -101,10 +101,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); expect(extractNetworkProtocolSpy).toHaveBeenCalledWith('http/1.1'); - expect(result).toEqual([ - ['network.protocol.version', '1.1'], - ['network.protocol.name', 'http'], - ]); + expect(result).toEqual({ + 'network.protocol.version': '1.1', + 'network.protocol.name': 'http', + }); // Restore global performance global.performance = originalPerformance; @@ -132,10 +132,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); expect(extractNetworkProtocolSpy).toHaveBeenCalledWith(''); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + }); // Restore global performance global.performance = originalPerformance; @@ -158,7 +158,7 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); expect(extractNetworkProtocolSpy).not.toHaveBeenCalled(); - expect(result).toEqual([]); + expect(result).toEqual({}); // Restore global performance global.performance = originalPerformance; @@ -187,10 +187,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', '2.0'], - ['network.protocol.name', 'http'], - ]); + expect(result).toEqual({ + 'network.protocol.version': '2.0', + 'network.protocol.name': 'http', + }); // Restore global performance global.performance = originalPerformance; @@ -217,10 +217,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + }); // Restore global performance global.performance = originalPerformance; @@ -256,23 +256,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', '2.0'], - ['network.protocol.name', 'http'], - ['http.request.redirect_start', 1000.01], // (1000000 + 10) / 1000 - ['http.request.redirect_end', 1000.02], // (1000000 + 20) / 1000 - ['http.request.worker_start', 1000.022], // (1000000 + 22) / 1000 - ['http.request.fetch_start', 1000.025], // (1000000 + 25) / 1000 - ['http.request.domain_lookup_start', 1000.03], // (1000000 + 30) / 1000 - ['http.request.domain_lookup_end', 1000.035], // (1000000 + 35) / 1000 - ['http.request.connect_start', 1000.04], // (1000000 + 40) / 1000 - ['http.request.secure_connection_start', 1000.045], // (1000000 + 45) / 1000 - ['http.request.connection_end', 1000.05], // (1000000 + 50) / 1000 - ['http.request.request_start', 1000.055], // (1000000 + 55) / 1000 - ['http.request.response_start', 1000.15], // (1000000 + 150) / 1000 - ['http.request.response_end', 1000.2], // (1000000 + 200) / 1000 - ['http.request.time_to_first_byte', 0.15], // 150 / 1000 - ]); + expect(result).toEqual({ + 'network.protocol.version': '2.0', + 'network.protocol.name': 'http', + 'http.request.redirect_start': 1000.01, // (1000000 + 10) / 1000 + 'http.request.redirect_end': 1000.02, // (1000000 + 20) / 1000 + 'http.request.worker_start': 1000.022, // (1000000 + 22) / 1000 + 'http.request.fetch_start': 1000.025, // (1000000 + 25) / 1000 + 'http.request.domain_lookup_start': 1000.03, // (1000000 + 30) / 1000 + 'http.request.domain_lookup_end': 1000.035, // (1000000 + 35) / 1000 + 'http.request.connect_start': 1000.04, // (1000000 + 40) / 1000 + 'http.request.secure_connection_start': 1000.045, // (1000000 + 45) / 1000 + 'http.request.connection_end': 1000.05, // (1000000 + 50) / 1000 + 'http.request.request_start': 1000.055, // (1000000 + 55) / 1000 + 'http.request.response_start': 1000.15, // (1000000 + 150) / 1000 + 'http.request.response_end': 1000.2, // (1000000 + 200) / 1000 + 'http.request.time_to_first_byte': 0.15, // 150 / 1000 + }); }); it('handles zero timing values', () => { @@ -297,23 +297,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ['http.request.redirect_start', 1000], // (1000000 + 0) / 1000 - ['http.request.redirect_end', 1000.02], - ['http.request.worker_start', 1000], - ['http.request.fetch_start', 1000], - ['http.request.domain_lookup_start', 1000], - ['http.request.domain_lookup_end', 1000], - ['http.request.connect_start', 1000], - ['http.request.secure_connection_start', 1000], - ['http.request.connection_end', 1000], - ['http.request.request_start', 1000], - ['http.request.response_start', 1000], - ['http.request.response_end', 1000], - ['http.request.time_to_first_byte', 0], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + 'http.request.redirect_start': 1000, // (1000000 + 0) / 1000 + 'http.request.redirect_end': 1000.02, + 'http.request.worker_start': 1000, + 'http.request.fetch_start': 1000, + 'http.request.domain_lookup_start': 1000, + 'http.request.domain_lookup_end': 1000, + 'http.request.connect_start': 1000, + 'http.request.secure_connection_start': 1000, + 'http.request.connection_end': 1000, + 'http.request.request_start': 1000, + 'http.request.response_start': 1000, + 'http.request.response_end': 1000, + 'http.request.time_to_first_byte': 0, + }); }); it('combines network protocol and timing attributes', () => { @@ -338,23 +338,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', '1.1'], - ['network.protocol.name', 'http'], - ['http.request.redirect_start', 1000.005], - ['http.request.redirect_end', 1000.02], - ['http.request.worker_start', 1000], - ['http.request.fetch_start', 1000.01], - ['http.request.domain_lookup_start', 1000.015], - ['http.request.domain_lookup_end', 1000.02], - ['http.request.connect_start', 1000.025], - ['http.request.secure_connection_start', 1000.03], - ['http.request.connection_end', 1000.035], - ['http.request.request_start', 1000.04], - ['http.request.response_start', 1000.08], - ['http.request.response_end', 1000.1], - ['http.request.time_to_first_byte', 0.08], - ]); + expect(result).toEqual({ + 'network.protocol.version': '1.1', + 'network.protocol.name': 'http', + 'http.request.redirect_start': 1000.005, + 'http.request.redirect_end': 1000.02, + 'http.request.worker_start': 1000, + 'http.request.fetch_start': 1000.01, + 'http.request.domain_lookup_start': 1000.015, + 'http.request.domain_lookup_end': 1000.02, + 'http.request.connect_start': 1000.025, + 'http.request.secure_connection_start': 1000.03, + 'http.request.connection_end': 1000.035, + 'http.request.request_start': 1000.04, + 'http.request.response_start': 1000.08, + 'http.request.response_end': 1000.1, + 'http.request.time_to_first_byte': 0.08, + }); }); }); @@ -385,10 +385,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); // When browserPerformanceTimeOrigin returns null, function returns early with only network protocol attributes - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + }); }); it('uses performance.timeOrigin fallback in getAbsoluteTime when available', () => { @@ -418,23 +418,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ['http.request.redirect_start', 500.02], // (500000 + 20) / 1000 - ['http.request.redirect_end', 500.03], // (500000 + 30) / 1000 - ['http.request.worker_start', 500.035], // (500000 + 35) / 1000 - ['http.request.fetch_start', 500.04], // (500000 + 40) / 1000 - ['http.request.domain_lookup_start', 500.06], // (500000 + 60) / 1000 - ['http.request.domain_lookup_end', 500.08], // (500000 + 80) / 1000 - ['http.request.connect_start', 500.1], // (500000 + 100) / 1000 - ['http.request.secure_connection_start', 500.12], // (500000 + 120) / 1000 - ['http.request.connection_end', 500.14], // (500000 + 140) / 1000 - ['http.request.request_start', 500.16], // (500000 + 160) / 1000 - ['http.request.response_start', 500.3], // (500000 + 300) / 1000 - ['http.request.response_end', 500.4], // (500000 + 400) / 1000 - ['http.request.time_to_first_byte', 0.3], // 300 / 1000 - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + 'http.request.redirect_start': 500.02, // (500000 + 20) / 1000 + 'http.request.redirect_end': 500.03, // (500000 + 30) / 1000 + 'http.request.worker_start': 500.035, // (500000 + 35) / 1000 + 'http.request.fetch_start': 500.04, // (500000 + 40) / 1000 + 'http.request.domain_lookup_start': 500.06, // (500000 + 60) / 1000 + 'http.request.domain_lookup_end': 500.08, // (500000 + 80) / 1000 + 'http.request.connect_start': 500.1, // (500000 + 100) / 1000 + 'http.request.secure_connection_start': 500.12, // (500000 + 120) / 1000 + 'http.request.connection_end': 500.14, // (500000 + 140) / 1000 + 'http.request.request_start': 500.16, // (500000 + 160) / 1000 + 'http.request.response_start': 500.3, // (500000 + 300) / 1000 + 'http.request.response_end': 500.4, // (500000 + 400) / 1000 + 'http.request.time_to_first_byte': 0.3, // 300 / 1000 + }); }); it('handles case when neither browserPerformanceTimeOrigin nor performance.timeOrigin is available', () => { @@ -459,10 +459,10 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); // When neither timing source is available, should return network protocol attributes for empty string - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + }); // Restore global performance global.performance = originalPerformance; @@ -495,23 +495,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ['http.request.redirect_start', 1000], // (1000000 + 0) / 1000 - ['http.request.redirect_end', 1000.02], - ['http.request.worker_start', 1000], - ['http.request.fetch_start', 1000], - ['http.request.domain_lookup_start', 1000], - ['http.request.domain_lookup_end', 1000], - ['http.request.connect_start', 1000], - ['http.request.secure_connection_start', 1000], - ['http.request.connection_end', 1000], - ['http.request.request_start', 1000], - ['http.request.response_start', 1000], - ['http.request.response_end', 1000], - ['http.request.time_to_first_byte', 0], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + 'http.request.redirect_start': 1000, // (1000000 + 0) / 1000 + 'http.request.redirect_end': 1000.02, + 'http.request.worker_start': 1000, + 'http.request.fetch_start': 1000, + 'http.request.domain_lookup_start': 1000, + 'http.request.domain_lookup_end': 1000, + 'http.request.connect_start': 1000, + 'http.request.secure_connection_start': 1000, + 'http.request.connection_end': 1000, + 'http.request.request_start': 1000, + 'http.request.response_start': 1000, + 'http.request.response_end': 1000, + 'http.request.time_to_first_byte': 0, + }); }); it('handles very large timing values', () => { @@ -538,23 +538,23 @@ describe('resourceTimingToSpanAttributes', () => { const result = resourceTimingToSpanAttributes(mockResourceTiming); - expect(result).toEqual([ - ['network.protocol.version', 'unknown'], - ['network.protocol.name', ''], - ['http.request.redirect_start', 1999.999], // (1000000 + 999999) / 1000 - ['http.request.redirect_end', 1000.02], - ['http.request.worker_start', 1000], - ['http.request.fetch_start', 1999.999], - ['http.request.domain_lookup_start', 1999.999], - ['http.request.domain_lookup_end', 1999.999], - ['http.request.connect_start', 1999.999], - ['http.request.secure_connection_start', 1999.999], - ['http.request.connection_end', 1999.999], - ['http.request.request_start', 1999.999], - ['http.request.response_start', 1999.999], - ['http.request.response_end', 1999.999], - ['http.request.time_to_first_byte', 999.999], - ]); + expect(result).toEqual({ + 'network.protocol.version': 'unknown', + 'network.protocol.name': '', + 'http.request.redirect_start': 1999.999, // (1000000 + 999999) / 1000 + 'http.request.redirect_end': 1000.02, + 'http.request.worker_start': 1000, + 'http.request.fetch_start': 1999.999, + 'http.request.domain_lookup_start': 1999.999, + 'http.request.domain_lookup_end': 1999.999, + 'http.request.connect_start': 1999.999, + 'http.request.secure_connection_start': 1999.999, + 'http.request.connection_end': 1999.999, + 'http.request.request_start': 1999.999, + 'http.request.response_start': 1999.999, + 'http.request.response_end': 1999.999, + 'http.request.time_to_first_byte': 999.999, + }); }); }); }); From dc2085d3d82719fbe7ec51f40f503a7746dd0a43 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 9 Sep 2025 15:31:57 +0200 Subject: [PATCH 11/12] remove unused types --- packages/browser-utils/src/metrics/browserMetrics.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 3e5f7682446b..8b1592408e8a 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -765,8 +765,6 @@ function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOption } type ExperimentalResourceTimingProperty = - | 'firstInterimResponseStart' - | 'finalResponseHeadersStart' | 'renderBlockingStatus' | 'deliveryType' // For some reason, TS during build, errors on `responseStatus` not being a property of From 32b3717f4a3c577fffceff6ce69d7222b1c58c65 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 12 Sep 2025 16:43:43 +0200 Subject: [PATCH 12/12] check for ttfb in seconds range --- .../suites/tracing/metrics/pageload-resource-spans/test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts index ff8cbe5675f3..f748c339ce14 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts @@ -110,6 +110,12 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 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),