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 2343d77f55c7..9478b8f833f7 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 @@ -1,24 +1,44 @@ import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { Event } from '@sentry/core'; +import { type Event, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; -sentryTest('should add resource spans to pageload transaction', async ({ getLocalTestUrl, page }) => { +sentryTest('should add resource spans to pageload transaction', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } + const isWebkitRun = browserName === 'webkit'; + // Intercepting asset requests to avoid network-related flakiness and random retries (on Firefox). await page.route('https://example.com/path/to/image.svg', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/image.svg` }), + route.fulfill({ + path: `${__dirname}/assets/image.svg`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'image/svg+xml', + }, + }), ); await page.route('https://example.com/path/to/script.js', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/script.js` }), + route.fulfill({ + path: `${__dirname}/assets/script.js`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'application/javascript', + }, + }), ); await page.route('https://example.com/path/to/style.css', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/style.css` }), + route.fulfill({ + path: `${__dirname}/assets/style.css`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'text/css', + }, + }), ); const url = await getLocalTestUrl({ testDir: __dirname }); @@ -27,11 +47,14 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca const resourceSpans = eventData.spans?.filter(({ op }) => op?.startsWith('resource')); const scriptSpans = resourceSpans?.filter(({ op }) => op === 'resource.script'); - const linkSpans = resourceSpans?.filter(({ op }) => op === 'resource.link'); - const imgSpans = resourceSpans?.filter(({ op }) => op === 'resource.img'); + const linkSpan = resourceSpans?.filter(({ op }) => op === 'resource.link')[0]; + const imgSpan = resourceSpans?.filter(({ op }) => op === 'resource.img')[0]; + + const spanId = eventData.contexts?.trace?.span_id; + const traceId = eventData.contexts?.trace?.trace_id; - expect(imgSpans).toHaveLength(1); - expect(linkSpans).toHaveLength(1); + expect(spanId).toBeDefined(); + expect(traceId).toBeDefined(); const hasCdnBundle = (process.env.PW_BUNDLE || '').startsWith('bundle'); @@ -41,11 +64,90 @@ sentryTest('should add resource spans to pageload transaction', async ({ getLoca } expect(scriptSpans?.map(({ description }) => description).sort()).toEqual(expectedScripts); + expect(scriptSpans?.map(({ parent_span_id }) => parent_span_id)).toEqual(expectedScripts.map(() => spanId)); - const spanId = eventData.contexts?.trace?.span_id; + const customScriptSpan = scriptSpans?.find( + ({ description }) => description === 'https://example.com/path/to/script.js', + ); - expect(spanId).toBeDefined(); - expect(imgSpans?.[0].parent_span_id).toBe(spanId); - expect(linkSpans?.[0].parent_span_id).toBe(spanId); - expect(scriptSpans?.map(({ parent_span_id }) => parent_span_id)).toEqual(expectedScripts.map(() => spanId)); + expect(imgSpan).toEqual({ + data: { + 'http.decoded_response_content_length': expect.any(Number), + 'http.response_content_length': expect.any(Number), + 'http.response_transfer_size': expect.any(Number), + 'network.protocol.name': '', + 'network.protocol.version': 'unknown', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'server.address': 'example.com', + 'url.same_origin': false, + 'url.scheme': 'https', + ...(!isWebkitRun && { + 'resource.render_blocking_status': 'non-blocking', + 'http.response_delivery_type': '', + }), + }, + description: 'https://example.com/path/to/image.svg', + op: 'resource.img', + origin: 'auto.resource.browser.metrics', + parent_span_id: spanId, + span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + + expect(linkSpan).toEqual({ + data: { + 'http.decoded_response_content_length': expect.any(Number), + 'http.response_content_length': expect.any(Number), + 'http.response_transfer_size': expect.any(Number), + 'network.protocol.name': '', + 'network.protocol.version': 'unknown', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + 'server.address': 'example.com', + 'url.same_origin': false, + 'url.scheme': 'https', + ...(!isWebkitRun && { + 'resource.render_blocking_status': 'non-blocking', + 'http.response_delivery_type': '', + }), + }, + description: 'https://example.com/path/to/style.css', + op: 'resource.link', + origin: 'auto.resource.browser.metrics', + parent_span_id: spanId, + span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); + + expect(customScriptSpan).toEqual({ + data: { + 'http.decoded_response_content_length': expect.any(Number), + 'http.response_content_length': expect.any(Number), + 'http.response_transfer_size': expect.any(Number), + 'network.protocol.name': '', + 'network.protocol.version': 'unknown', + 'sentry.op': 'resource.script', + 'sentry.origin': 'auto.resource.browser.metrics', + 'server.address': 'example.com', + 'url.same_origin': false, + 'url.scheme': 'https', + ...(!isWebkitRun && { + 'resource.render_blocking_status': 'non-blocking', + 'http.response_delivery_type': '', + }), + }, + description: 'https://example.com/path/to/script.js', + op: 'resource.script', + origin: 'auto.resource.browser.metrics', + parent_span_id: spanId, + span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: traceId, + }); }); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index c71b2d70e31d..30bc3a29888e 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -17,6 +17,8 @@ export { registerInpInteractionListener, } from './metrics/browserMetrics'; +export { extractNetworkProtocol } from './metrics/utils'; + export { addClickKeypressInstrumentationHandler } from './instrument/dom'; export { addHistoryInstrumentationHandler } from './instrument/history'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 1d2b6b47c87e..794faa197ad5 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -20,7 +20,13 @@ import { addPerformanceInstrumentationHandler, addTtfbInstrumentationHandler, } from './instrument'; -import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils'; +import { + extractNetworkProtocol, + 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'; @@ -596,6 +602,10 @@ export function _addResourceSpans( attributes['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin); + const { name, version } = extractNetworkProtocol(entry.nextHopProtocol); + attributes['network.protocol.name'] = name; + attributes['network.protocol.version'] = version; + const startTimestamp = timeOrigin + startTime; const endTimestamp = startTimestamp + duration; diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 91aefa8a8918..aef40d4cf613 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -137,3 +137,34 @@ export function getBrowserPerformanceAPI(): Performance | undefined { export function msToSec(time: number): number { return time / 1000; } + +/** + * Converts ALPN protocol ids to name and version. + * + * (https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids) + * @param nextHopProtocol PerformanceResourceTiming.nextHopProtocol + */ +export function extractNetworkProtocol(nextHopProtocol: string): { name: string; version: string } { + let name = 'unknown'; + let version = 'unknown'; + let _name = ''; + for (const char of nextHopProtocol) { + // http/1.1 etc. + if (char === '/') { + [name, version] = nextHopProtocol.split('/') as [string, string]; + break; + } + // h2, h3 etc. + if (!isNaN(Number(char))) { + name = _name === 'h' ? 'http' : _name; + version = nextHopProtocol.split(_name)[1] as string; + break; + } + _name += char; + } + if (_name === nextHopProtocol) { + // webrtc, ftp, etc. + name = _name; + } + return { name, version }; +} diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 2ff1c2df209a..98a3bb375c00 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -131,6 +131,7 @@ describe('_addResourceSpans', () => { encodedBodySize: 256, decodedBodySize: 256, renderBlockingStatus: 'non-blocking', + nextHopProtocol: 'http/1.1', }); _addResourceSpans(span, entry, resourceEntryName, 123, 456, 100); @@ -150,6 +151,7 @@ describe('_addResourceSpans', () => { encodedBodySize: 256, decodedBodySize: 256, renderBlockingStatus: 'non-blocking', + nextHopProtocol: 'http/1.1', }); _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 456, 100); @@ -169,6 +171,7 @@ describe('_addResourceSpans', () => { encodedBodySize: 456, decodedBodySize: 593, renderBlockingStatus: 'non-blocking', + nextHopProtocol: 'http/1.1', }); const timeOrigin = 100; @@ -195,6 +198,8 @@ describe('_addResourceSpans', () => { ['url.scheme']: 'https', ['server.address']: 'example.com', ['url.same_origin']: true, + ['network.protocol.name']: 'http', + ['network.protocol.version']: '1.1', }, }), ); @@ -233,6 +238,7 @@ describe('_addResourceSpans', () => { const { initiatorType, op } = table[i]!; const entry = mockPerformanceResourceTiming({ initiatorType, + nextHopProtocol: 'http/1.1', }); _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 234, 465); @@ -254,6 +260,7 @@ describe('_addResourceSpans', () => { encodedBodySize: 0, decodedBodySize: 0, renderBlockingStatus: 'non-blocking', + nextHopProtocol: 'h2', }); _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345); @@ -271,6 +278,8 @@ describe('_addResourceSpans', () => { ['url.scheme']: 'https', ['server.address']: 'example.com', ['url.same_origin']: true, + ['network.protocol.name']: 'http', + ['network.protocol.version']: '2', }, }), ); @@ -288,6 +297,7 @@ describe('_addResourceSpans', () => { transferSize: 2147483647, encodedBodySize: 2147483647, decodedBodySize: 2147483647, + nextHopProtocol: 'h3', }); _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345); @@ -301,6 +311,8 @@ describe('_addResourceSpans', () => { 'server.address': 'example.com', 'url.same_origin': true, 'url.scheme': 'https', + ['network.protocol.name']: 'http', + ['network.protocol.version']: '3', }, description: '/assets/to/css', timestamp: 468, @@ -325,6 +337,7 @@ describe('_addResourceSpans', () => { transferSize: null, encodedBodySize: null, decodedBodySize: null, + nextHopProtocol: 'h3', } as unknown as PerformanceResourceTiming; _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345); @@ -338,6 +351,8 @@ describe('_addResourceSpans', () => { 'server.address': 'example.com', 'url.same_origin': true, 'url.scheme': 'https', + ['network.protocol.name']: 'http', + ['network.protocol.version']: '3', }, description: '/assets/to/css', timestamp: 468, @@ -365,6 +380,7 @@ describe('_addResourceSpans', () => { encodedBodySize: 0, decodedBodySize: 0, deliveryType, + nextHopProtocol: 'h3', }); _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345); diff --git a/packages/browser-utils/test/browser/utils.test.ts b/packages/browser-utils/test/browser/utils.test.ts index bb7a757e4b6a..01fb5da605c4 100644 --- a/packages/browser-utils/test/browser/utils.test.ts +++ b/packages/browser-utils/test/browser/utils.test.ts @@ -1,5 +1,5 @@ import { SentrySpan, getCurrentScope, getIsolationScope, setCurrentClient, spanToJSON } from '@sentry/core'; -import { startAndEndSpan } from '../../src/metrics/utils'; +import { extractNetworkProtocol, startAndEndSpan } from '../../src/metrics/utils'; import { TestClient, getDefaultClientOptions } from '../utils/TestClient'; describe('startAndEndSpan()', () => { @@ -54,3 +54,44 @@ describe('startAndEndSpan()', () => { expect(spanToJSON(parentSpan).start_timestamp).toEqual(123); }); }); + +describe('HTTPTimings', () => { + test.each([ + ['http/0.9', { name: 'http', version: '0.9' }], + ['http/1.0', { name: 'http', version: '1.0' }], + ['http/1.1', { name: 'http', version: '1.1' }], + ['spdy/1', { name: 'spdy', version: '1' }], + ['spdy/2', { name: 'spdy', version: '2' }], + ['spdy/3', { name: 'spdy', version: '3' }], + ['stun.turn', { name: 'stun.turn', version: 'unknown' }], + ['stun.nat-discovery', { name: 'stun.nat-discovery', version: 'unknown' }], + ['h2', { name: 'http', version: '2' }], + ['h2c', { name: 'http', version: '2c' }], + ['webrtc', { name: 'webrtc', version: 'unknown' }], + ['c-webrtc', { name: 'c-webrtc', version: 'unknown' }], + ['ftp', { name: 'ftp', version: 'unknown' }], + ['imap', { name: 'imap', version: 'unknown' }], + ['pop3', { name: 'pop', version: '3' }], + ['managesieve', { name: 'managesieve', version: 'unknown' }], + ['coap', { name: 'coap', version: 'unknown' }], + ['xmpp-client', { name: 'xmpp-client', version: 'unknown' }], + ['xmpp-server', { name: 'xmpp-server', version: 'unknown' }], + ['acme-tls/1', { name: 'acme-tls', version: '1' }], + ['mqtt', { name: 'mqtt', version: 'unknown' }], + ['dot', { name: 'dot', version: 'unknown' }], + ['ntske/1', { name: 'ntske', version: '1' }], + ['sunrpc', { name: 'sunrpc', version: 'unknown' }], + ['h3', { name: 'http', version: '3' }], + ['smb', { name: 'smb', version: 'unknown' }], + ['irc', { name: 'irc', version: 'unknown' }], + ['nntp', { name: 'nntp', version: 'unknown' }], + ['nnsp', { name: 'nnsp', version: 'unknown' }], + ['doq', { name: 'doq', version: 'unknown' }], + ['sip/2', { name: 'sip', version: '2' }], + ['tds/8.0', { name: 'tds', version: '8.0' }], + ['dicom', { name: 'dicom', version: 'unknown' }], + ['', { name: '', version: 'unknown' }], + ])('Extracting version from ALPN protocol %s', (protocol, expected) => { + expect(extractNetworkProtocol(protocol)).toMatchObject(expected); + }); +}); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 92a8f2924084..368ea450b0d0 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -2,6 +2,7 @@ import { SENTRY_XHR_DATA_KEY, addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, + extractNetworkProtocol, } from '@sentry-internal/browser-utils'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { @@ -228,37 +229,6 @@ function addHTTPTimings(span: Span): void { }); } -/** - * Converts ALPN protocol ids to name and version. - * - * (https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids) - * @param nextHopProtocol PerformanceResourceTiming.nextHopProtocol - */ -export function extractNetworkProtocol(nextHopProtocol: string): { name: string; version: string } { - let name = 'unknown'; - let version = 'unknown'; - let _name = ''; - for (const char of nextHopProtocol) { - // http/1.1 etc. - if (char === '/') { - [name, version] = nextHopProtocol.split('/') as [string, string]; - break; - } - // h2, h3 etc. - if (!isNaN(Number(char))) { - name = _name === 'h' ? 'http' : _name; - version = nextHopProtocol.split(_name)[1] as string; - break; - } - _name += char; - } - if (_name === nextHopProtocol) { - // webrtc, ftp, etc. - name = _name; - } - return { name, version }; -} - function getAbsoluteTime(time: number = 0): number { return ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000; } diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index b262053190eb..67cd96ee6717 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -5,7 +5,7 @@ import * as browserUtils from '@sentry-internal/browser-utils'; import * as utils from '@sentry/core'; import type { Client } from '@sentry/core'; -import { extractNetworkProtocol, instrumentOutgoingRequests, shouldAttachHeaders } from '../../src/tracing/request'; +import { instrumentOutgoingRequests, shouldAttachHeaders } from '../../src/tracing/request'; beforeAll(() => { // @ts-expect-error need to override global Request because it's not in the vi environment (even with an @@ -64,57 +64,6 @@ describe('instrumentOutgoingRequests', () => { }); }); -interface ProtocolInfo { - name: string; - version: string; -} - -describe('HTTPTimings', () => { - test('Extracting version from ALPN protocol', () => { - const nextHopToNetworkVersion: Record = { - 'http/0.9': { name: 'http', version: '0.9' }, - 'http/1.0': { name: 'http', version: '1.0' }, - 'http/1.1': { name: 'http', version: '1.1' }, - 'spdy/1': { name: 'spdy', version: '1' }, - 'spdy/2': { name: 'spdy', version: '2' }, - 'spdy/3': { name: 'spdy', version: '3' }, - 'stun.turn': { name: 'stun.turn', version: 'unknown' }, - 'stun.nat-discovery': { name: 'stun.nat-discovery', version: 'unknown' }, - h2: { name: 'http', version: '2' }, - h2c: { name: 'http', version: '2c' }, - webrtc: { name: 'webrtc', version: 'unknown' }, - 'c-webrtc': { name: 'c-webrtc', version: 'unknown' }, - ftp: { name: 'ftp', version: 'unknown' }, - imap: { name: 'imap', version: 'unknown' }, - pop3: { name: 'pop', version: '3' }, - managesieve: { name: 'managesieve', version: 'unknown' }, - coap: { name: 'coap', version: 'unknown' }, - 'xmpp-client': { name: 'xmpp-client', version: 'unknown' }, - 'xmpp-server': { name: 'xmpp-server', version: 'unknown' }, - 'acme-tls/1': { name: 'acme-tls', version: '1' }, - mqtt: { name: 'mqtt', version: 'unknown' }, - dot: { name: 'dot', version: 'unknown' }, - 'ntske/1': { name: 'ntske', version: '1' }, - sunrpc: { name: 'sunrpc', version: 'unknown' }, - h3: { name: 'http', version: '3' }, - smb: { name: 'smb', version: 'unknown' }, - irc: { name: 'irc', version: 'unknown' }, - nntp: { name: 'nntp', version: 'unknown' }, - nnsp: { name: 'nnsp', version: 'unknown' }, - doq: { name: 'doq', version: 'unknown' }, - 'sip/2': { name: 'sip', version: '2' }, - 'tds/8.0': { name: 'tds', version: '8.0' }, - dicom: { name: 'dicom', version: 'unknown' }, - }; - - const protocols = Object.keys(nextHopToNetworkVersion); - for (const protocol of protocols) { - const expected = nextHopToNetworkVersion[protocol]!; - expect(extractNetworkProtocol(protocol)).toMatchObject(expected); - } - }); -}); - describe('shouldAttachHeaders', () => { describe('should prefer `tracePropagationTargets` over defaults', () => { it('should return `true` if the url matches the new tracePropagationTargets', () => {