Skip to content

Commit 7c41f03

Browse files
authored
feat(browser): Add timing and status atttributes to resource spans (#17562)
Adds a few attributes to `resource.*` spans for request timing and status information. Most importantly: - `http.request.time_to_first_byte` [from `responseStart`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart) which can be [interpreted as TTFB](https://web.dev/articles/ttfb#measure-resource-requests) for resource requests. This was requested via via getsentry/sentry#63739. - `http.response.status_code` the status code of the resource request. Requested in #16805 and #10995 To get these attributes, I adjusted the already existing `resourceTimingToSpanAttributes` a bit: - Moved it from browser to browser-utils because resource spans logic is located in browser-utils (this is safe, it never was exported from browser) - Changed the signature to return an object instead of an array of attributes. This is more ergonomical to use in both callsites and should reduce bundle size slightly - Added `http.request.redirect_end`, `http.request.worker_start` attributes which are stable timing values available on `PerformanceResourceTiming` but were previously missing - Also added to conventions: getsentry/sentry-conventions#130 - Added `http.request.time_to_first_byte`. Decided to add this attribute because `http.request.response_start` cannot be directly used as TTFB as its value is an absolute time stamp of `responseStart`. Instead the TTFB attribute is the relative response start attribute converted to seconds (happy to change to ms if reviewers prefer). - Also added to conventions: getsentry/sentry-conventions#131 Other consequences: - `http.client` spans now also have the three additional attributes Remarks: - Not super happy about us always defaulting to `0` in case a value is not present. But decided to leave this as-is to avoid any behaviour change for http.client spans.
1 parent cd706ef commit 7c41f03

File tree

12 files changed

+434
-231
lines changed

12 files changed

+434
-231
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ module.exports = [
135135
path: 'packages/vue/build/esm/index.js',
136136
import: createImport('init', 'browserTracingIntegration'),
137137
gzip: true,
138-
limit: '42 KB',
138+
limit: '43 KB',
139139
},
140140
// Svelte SDK (ESM)
141141
{

dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Event } from '@sentry/core';
33
import { sentryTest } from '../../../../utils/fixtures';
44
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
55

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

99
if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
@@ -40,6 +40,8 @@ sentryTest('should create fetch spans with http timing @firefox', async ({ brows
4040
trace_id: tracingEvent.contexts?.trace?.trace_id,
4141
data: expect.objectContaining({
4242
'http.request.redirect_start': expect.any(Number),
43+
'http.request.redirect_end': expect.any(Number),
44+
'http.request.worker_start': expect.any(Number),
4345
'http.request.fetch_start': expect.any(Number),
4446
'http.request.domain_lookup_start': expect.any(Number),
4547
'http.request.domain_lookup_end': expect.any(Number),
@@ -49,6 +51,7 @@ sentryTest('should create fetch spans with http timing @firefox', async ({ brows
4951
'http.request.request_start': expect.any(Number),
5052
'http.request.response_start': expect.any(Number),
5153
'http.request.response_end': expect.any(Number),
54+
'http.request.time_to_first_byte': expect.any(Number),
5255
'network.protocol.version': expect.any(String),
5356
}),
5457
}),

dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type Event, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORI
44
import { sentryTest } from '../../../../utils/fixtures';
55
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
66

7-
sentryTest('should add resource spans to pageload transaction', async ({ getLocalTestUrl, page, browserName }) => {
7+
sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestUrl, page, browserName }) => {
88
if (shouldSkipTracingTest()) {
99
sentryTest.skip();
1010
}
@@ -74,6 +74,19 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
7474
'http.decoded_response_content_length': expect.any(Number),
7575
'http.response_content_length': expect.any(Number),
7676
'http.response_transfer_size': expect.any(Number),
77+
'http.request.connect_start': expect.any(Number),
78+
'http.request.connection_end': expect.any(Number),
79+
'http.request.domain_lookup_end': expect.any(Number),
80+
'http.request.domain_lookup_start': expect.any(Number),
81+
'http.request.fetch_start': expect.any(Number),
82+
'http.request.redirect_end': expect.any(Number),
83+
'http.request.redirect_start': expect.any(Number),
84+
'http.request.request_start': expect.any(Number),
85+
'http.request.secure_connection_start': expect.any(Number),
86+
'http.request.worker_start': expect.any(Number),
87+
'http.request.response_end': expect.any(Number),
88+
'http.request.response_start': expect.any(Number),
89+
'http.request.time_to_first_byte': expect.any(Number),
7790
'network.protocol.name': '',
7891
'network.protocol.version': 'unknown',
7992
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img',
@@ -82,6 +95,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
8295
'url.same_origin': false,
8396
'url.scheme': 'https',
8497
...(!isWebkitRun && {
98+
'http.response.status_code': expect.any(Number),
8599
'resource.render_blocking_status': 'non-blocking',
86100
'http.response_delivery_type': '',
87101
}),
@@ -96,11 +110,30 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
96110
trace_id: traceId,
97111
});
98112

113+
// range check: TTFB must be >0 (at least in this case) and it's reasonable to
114+
// assume <10 seconds. This also tests that we're reporting TTFB in seconds.
115+
const imgSpanTtfb = imgSpan?.data['http.request.time_to_first_byte'];
116+
expect(imgSpanTtfb).toBeGreaterThan(0);
117+
expect(imgSpanTtfb).toBeLessThan(10);
118+
99119
expect(linkSpan).toEqual({
100120
data: {
101121
'http.decoded_response_content_length': expect.any(Number),
102122
'http.response_content_length': expect.any(Number),
103123
'http.response_transfer_size': expect.any(Number),
124+
'http.request.connect_start': expect.any(Number),
125+
'http.request.connection_end': expect.any(Number),
126+
'http.request.domain_lookup_end': expect.any(Number),
127+
'http.request.domain_lookup_start': expect.any(Number),
128+
'http.request.fetch_start': expect.any(Number),
129+
'http.request.redirect_end': expect.any(Number),
130+
'http.request.redirect_start': expect.any(Number),
131+
'http.request.request_start': expect.any(Number),
132+
'http.request.secure_connection_start': expect.any(Number),
133+
'http.request.worker_start': expect.any(Number),
134+
'http.request.response_end': expect.any(Number),
135+
'http.request.response_start': expect.any(Number),
136+
'http.request.time_to_first_byte': expect.any(Number),
104137
'network.protocol.name': '',
105138
'network.protocol.version': 'unknown',
106139
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link',
@@ -109,6 +142,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
109142
'url.same_origin': false,
110143
'url.scheme': 'https',
111144
...(!isWebkitRun && {
145+
'http.response.status_code': expect.any(Number),
112146
'resource.render_blocking_status': 'non-blocking',
113147
'http.response_delivery_type': '',
114148
}),
@@ -128,6 +162,19 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
128162
'http.decoded_response_content_length': expect.any(Number),
129163
'http.response_content_length': expect.any(Number),
130164
'http.response_transfer_size': expect.any(Number),
165+
'http.request.connection_end': expect.any(Number),
166+
'http.request.connect_start': expect.any(Number),
167+
'http.request.domain_lookup_end': expect.any(Number),
168+
'http.request.domain_lookup_start': expect.any(Number),
169+
'http.request.fetch_start': expect.any(Number),
170+
'http.request.redirect_end': expect.any(Number),
171+
'http.request.redirect_start': expect.any(Number),
172+
'http.request.request_start': expect.any(Number),
173+
'http.request.secure_connection_start': expect.any(Number),
174+
'http.request.worker_start': expect.any(Number),
175+
'http.request.response_end': expect.any(Number),
176+
'http.request.response_start': expect.any(Number),
177+
'http.request.time_to_first_byte': expect.any(Number),
131178
'network.protocol.name': '',
132179
'network.protocol.version': 'unknown',
133180
'sentry.op': 'resource.script',
@@ -136,6 +183,7 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca
136183
'url.same_origin': false,
137184
'url.scheme': 'https',
138185
...(!isWebkitRun && {
186+
'http.response.status_code': expect.any(Number),
139187
'resource.render_blocking_status': 'non-blocking',
140188
'http.response_delivery_type': '',
141189
}),

packages/browser-utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/
3030

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

33+
export { resourceTimingToSpanAttributes } from './metrics/resourceTiming';
34+
3335
export type { FetchHint, NetworkMetaWarning, XhrHint } from './types';

packages/browser-utils/src/metrics/browserMetrics.ts

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,8 @@ import {
2222
addTtfbInstrumentationHandler,
2323
} from './instrument';
2424
import { trackLcpAsStandaloneSpan } from './lcp';
25-
import {
26-
extractNetworkProtocol,
27-
getBrowserPerformanceAPI,
28-
isMeasurementValue,
29-
msToSec,
30-
startAndEndSpan,
31-
} from './utils';
25+
import { resourceTimingToSpanAttributes } from './resourceTiming';
26+
import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils';
3227
import { getActivationStart } from './web-vitals/lib/getActivationStart';
3328
import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry';
3429
import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher';
@@ -637,7 +632,7 @@ export function _addResourceSpans(
637632
startTime: number,
638633
duration: number,
639634
timeOrigin: number,
640-
ignoreResourceSpans?: Array<string>,
635+
ignoredResourceSpanOps?: Array<string>,
641636
): void {
642637
// we already instrument based on fetch and xhr, so we don't need to
643638
// duplicate spans here.
@@ -646,31 +641,15 @@ export function _addResourceSpans(
646641
}
647642

648643
const op = entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource.other';
649-
if (ignoreResourceSpans?.includes(op)) {
644+
if (ignoredResourceSpanOps?.includes(op)) {
650645
return;
651646
}
652647

653-
const parsedUrl = parseUrl(resourceUrl);
654-
655648
const attributes: SpanAttributes = {
656649
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
657650
};
658-
setResourceEntrySizeData(attributes, entry, 'transferSize', 'http.response_transfer_size');
659-
setResourceEntrySizeData(attributes, entry, 'encodedBodySize', 'http.response_content_length');
660-
setResourceEntrySizeData(attributes, entry, 'decodedBodySize', 'http.decoded_response_content_length');
661-
662-
// `deliveryType` is experimental and does not exist everywhere
663-
const deliveryType = (entry as { deliveryType?: 'cache' | 'navigational-prefetch' | '' }).deliveryType;
664-
if (deliveryType != null) {
665-
attributes['http.response_delivery_type'] = deliveryType;
666-
}
667651

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

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

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

685-
// Checking for only `undefined` and `null` is intentional because it's
686-
// valid for `nextHopProtocol` to be an empty string.
687-
if (entry.nextHopProtocol != null) {
688-
const { name, version } = extractNetworkProtocol(entry.nextHopProtocol);
689-
attributes['network.protocol.name'] = name;
690-
attributes['network.protocol.version'] = version;
691-
}
664+
_setResourceRequestAttributes(entry, attributes, [
665+
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
666+
['responseStatus', 'http.response.status_code'],
667+
668+
['transferSize', 'http.response_transfer_size'],
669+
['encodedBodySize', 'http.response_content_length'],
670+
['decodedBodySize', 'http.decoded_response_content_length'],
671+
672+
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/renderBlockingStatus
673+
['renderBlockingStatus', 'resource.render_blocking_status'],
674+
675+
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/deliveryType
676+
['deliveryType', 'http.response_delivery_type'],
677+
]);
678+
679+
const attributesWithResourceTiming: SpanAttributes = { ...attributes, ...resourceTimingToSpanAttributes(entry) };
692680

693681
const startTimestamp = timeOrigin + startTime;
694682
const endTimestamp = startTimestamp + duration;
695683

696684
startAndEndSpan(span, startTimestamp, endTimestamp, {
697685
name: resourceUrl.replace(WINDOW.location.origin, ''),
698686
op,
699-
attributes,
687+
attributes: attributesWithResourceTiming,
700688
});
701689
}
702690

@@ -776,16 +764,37 @@ function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOption
776764
}
777765
}
778766

779-
function setResourceEntrySizeData(
767+
type ExperimentalResourceTimingProperty =
768+
| 'renderBlockingStatus'
769+
| 'deliveryType'
770+
// For some reason, TS during build, errors on `responseStatus` not being a property of
771+
// PerformanceResourceTiming while it actually is. Hence, we're adding it here.
772+
// Perhaps because response status is not yet available in Webkit/Safari.
773+
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
774+
| 'responseStatus';
775+
776+
/**
777+
* Use this to set any attributes we can take directly form the PerformanceResourceTiming entry.
778+
*
779+
* This is just a mapping function for entry->attribute to keep bundle-size minimal.
780+
* Experimental properties are also accepted (see {@link ExperimentalResourceTimingProperty}).
781+
* Assumes that all entry properties might be undefined for browser-specific differences.
782+
* Only accepts string and number values for now and also sets 0-values.
783+
*/
784+
export function _setResourceRequestAttributes(
785+
entry: Partial<PerformanceResourceTiming> & Partial<Record<ExperimentalResourceTimingProperty, number | string>>,
780786
attributes: SpanAttributes,
781-
entry: PerformanceResourceTiming,
782-
key: keyof Pick<PerformanceResourceTiming, 'transferSize' | 'encodedBodySize' | 'decodedBodySize'>,
783-
dataKey: 'http.response_transfer_size' | 'http.response_content_length' | 'http.decoded_response_content_length',
787+
properties: [keyof PerformanceResourceTiming | ExperimentalResourceTimingProperty, string][],
784788
): void {
785-
const entryVal = entry[key];
786-
if (entryVal != null && entryVal < MAX_INT_AS_BYTES) {
787-
attributes[dataKey] = entryVal;
788-
}
789+
properties.forEach(([entryKey, attributeKey]) => {
790+
const entryVal = entry[entryKey];
791+
if (
792+
entryVal != null &&
793+
((typeof entryVal === 'number' && entryVal < MAX_INT_AS_BYTES) || typeof entryVal === 'string')
794+
) {
795+
attributes[attributeKey] = entryVal;
796+
}
797+
});
789798
}
790799

791800
/**
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { SpanAttributes } from '@sentry/core';
2+
import { browserPerformanceTimeOrigin } from '@sentry/core';
3+
import { extractNetworkProtocol, getBrowserPerformanceAPI } from './utils';
4+
5+
function getAbsoluteTime(time = 0): number {
6+
return ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000;
7+
}
8+
9+
/**
10+
* Converts a PerformanceResourceTiming entry to span data for the resource span. Most importantly,
11+
* it converts the timing values from timestamps relative to the `performance.timeOrigin` to absolute timestamps
12+
* in seconds.
13+
*
14+
* @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#timestamps
15+
*
16+
* @param resourceTiming
17+
* @returns An array where the first element is the attribute name and the second element is the attribute value.
18+
*/
19+
export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResourceTiming): SpanAttributes {
20+
const timingSpanData: SpanAttributes = {};
21+
// Checking for only `undefined` and `null` is intentional because it's
22+
// valid for `nextHopProtocol` to be an empty string.
23+
if (resourceTiming.nextHopProtocol != undefined) {
24+
const { name, version } = extractNetworkProtocol(resourceTiming.nextHopProtocol);
25+
timingSpanData['network.protocol.version'] = version;
26+
timingSpanData['network.protocol.name'] = name;
27+
}
28+
29+
if (!(browserPerformanceTimeOrigin() || getBrowserPerformanceAPI()?.timeOrigin)) {
30+
return timingSpanData;
31+
}
32+
33+
return {
34+
...timingSpanData,
35+
36+
'http.request.redirect_start': getAbsoluteTime(resourceTiming.redirectStart),
37+
'http.request.redirect_end': getAbsoluteTime(resourceTiming.redirectEnd),
38+
39+
'http.request.worker_start': getAbsoluteTime(resourceTiming.workerStart),
40+
41+
'http.request.fetch_start': getAbsoluteTime(resourceTiming.fetchStart),
42+
43+
'http.request.domain_lookup_start': getAbsoluteTime(resourceTiming.domainLookupStart),
44+
'http.request.domain_lookup_end': getAbsoluteTime(resourceTiming.domainLookupEnd),
45+
46+
'http.request.connect_start': getAbsoluteTime(resourceTiming.connectStart),
47+
'http.request.secure_connection_start': getAbsoluteTime(resourceTiming.secureConnectionStart),
48+
'http.request.connection_end': getAbsoluteTime(resourceTiming.connectEnd),
49+
50+
'http.request.request_start': getAbsoluteTime(resourceTiming.requestStart),
51+
52+
'http.request.response_start': getAbsoluteTime(resourceTiming.responseStart),
53+
'http.request.response_end': getAbsoluteTime(resourceTiming.responseEnd),
54+
55+
// For TTFB we actually want the relative time from timeOrigin to responseStart
56+
// This way, TTFB always measures the "first page load" experience.
57+
// see: https://web.dev/articles/ttfb#measure-resource-requests
58+
'http.request.time_to_first_byte': (resourceTiming.responseStart ?? 0) / 1000,
59+
};
60+
}

0 commit comments

Comments
 (0)