diff --git a/MIGRATION.md b/MIGRATION.md index 9a7fafc9f9c4..ede48b5f3db8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -619,15 +619,17 @@ Affected SDKs: All SDKs. With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/). -In v11, this affects `pageload` and `graphql` spans. Further ops will follow in future releases. If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged. The following span names were adjusted: -| Span op | Before | After | -| ---------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | -| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | +| Span op | Before | After | +| ------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | +| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none | + +Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`. Some consequences to be aware of: @@ -637,9 +639,13 @@ Because a low-cardinality name cannot say which part of request processing a spa For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before. -Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. +Child spans of a service or root span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. + +`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later. +`ignoreSpans` filters matching a URL path no longer apply to them. +Another example where filters might need adjustments are `resource.*` spans where their name now only includes the domain the resource was taken from. -`ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead: +Match on attributes instead: ```js Sentry.init({ diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/init.js new file mode 100644 index 000000000000..4deba4c452dd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/template.html new file mode 100644 index 000000000000..8491137339f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + Rendered + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/test.ts new file mode 100644 index 000000000000..a0a1e863137c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans-streamed/test.ts @@ -0,0 +1,68 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +const assetsDir = `${__dirname}/../pageload-resource-spans/assets`; + +sentryTest('names streamed resource spans after the resource domain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + // Intercepting asset requests to avoid network-related flakiness and random retries (on Firefox). + await page.route('https://sentry-test-site.example/path/to/image.svg', (route: Route) => + route.fulfill({ + path: `${assetsDir}/image.svg`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'image/svg+xml', + }, + }), + ); + await page.route('https://sentry-test-site.example/path/to/script.js', (route: Route) => + route.fulfill({ + path: `${assetsDir}/script.js`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'application/javascript', + }, + }), + ); + await page.route('https://sentry-test-site.example/path/to/style.css', (route: Route) => + route.fulfill({ + path: `${assetsDir}/style.css`, + headers: { + 'Timing-Allow-Origin': '*', + 'Content-Type': 'text/css', + }, + }), + ); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'resource.img'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = getSpansFromEnvelope(await spanEnvelopePromise); + + const imgSpan = spans.find(s => getSpanOp(s) === 'resource.img'); + const linkSpan = spans.find(s => getSpanOp(s) === 'resource.link'); + + expect(imgSpan?.name).toBe('sentry-test-site.example'); + expect(imgSpan?.attributes['url.domain']).toEqual({ type: 'string', value: 'sentry-test-site.example' }); + expect(imgSpan?.attributes['url.full']).toEqual({ + type: 'string', + value: 'https://sentry-test-site.example/path/to/image.svg', + }); + + expect(linkSpan?.name).toBe('sentry-test-site.example'); + + // Same-origin resources used to be named by their origin-relative path, they now carry the test host. + const sameOriginScriptSpan = spans.find( + s => getSpanOp(s) === 'resource.script' && s.name !== 'sentry-test-site.example', + ); + expect(sameOriginScriptSpan?.name).toBe('sentry-test.io'); +}); 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 7fccc4585982..2ae373d673e5 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 @@ -98,6 +98,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.img', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', 'server.address': 'sentry-test-site.example', + 'url.domain': 'sentry-test-site.example', 'url.same_origin': false, 'url.scheme': 'https', 'url.full': 'https://sentry-test-site.example/path/to/image.svg', @@ -147,6 +148,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.link', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', 'server.address': 'sentry-test-site.example', + 'url.domain': 'sentry-test-site.example', 'url.same_origin': false, 'url.scheme': 'https', 'url.full': 'https://sentry-test-site.example/path/to/style.css', @@ -190,6 +192,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU 'sentry.op': 'resource.script', 'sentry.origin': 'auto.resource.browser.metrics', 'server.address': 'sentry-test-site.example', + 'url.domain': 'sentry-test-site.example', 'url.same_origin': false, 'url.scheme': 'https', 'url.full': 'https://sentry-test-site.example/path/to/script.js', diff --git a/packages/browser-utils/src/performance/entries.ts b/packages/browser-utils/src/performance/entries.ts index c4d5b1cfdc4b..a630c91885a6 100644 --- a/packages/browser-utils/src/performance/entries.ts +++ b/packages/browser-utils/src/performance/entries.ts @@ -4,12 +4,13 @@ import { browserPerformanceTimeOrigin, getActiveSpan, parseUrl, + RESOURCE_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, filterCollectedUrl, } from '@sentry/core'; -import { CODE_FILE_PATH, CODE_FUNCTION_NAME, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { CODE_FILE_PATH, CODE_FUNCTION_NAME, SENTRY_OP, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import { BROWSER_PAINT, UI_LONG_ANIMATION_FRAME, UI_LONG_TASK } from '@sentry/conventions/op'; import { addPerformanceInstrumentationHandler, @@ -225,6 +226,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries duration, timeOrigin, ignoreResourceSpans, + spanStreamingEnabled, ); break; } @@ -366,6 +368,7 @@ export function _addResourceSpans( duration: number, timeOrigin: number, ignoredResourceSpanOps?: Array, + spanStreamingEnabled?: boolean, ): void { // we already instrument based on fetch and xhr, so we don't need to // duplicate spans here. @@ -388,8 +391,18 @@ export function _addResourceSpans( attributes['url.scheme'] = parsedUrl.protocol.split(':').pop(); // the protocol returned by parseUrl includes a :, but OTEL spec does not, so we remove it. } - if (parsedUrl.host) { - attributes['server.address'] = parsedUrl.host; + // `host` is the URL authority, so it can carry userinfo, which doesn't belong on either attribute. + const host = parsedUrl.host?.replace(/^.*@/, ''); + + if (host) { + attributes['server.address'] = host; + } + + // Unlike `server.address`, `url.domain` excludes the port. + const domain = host?.replace(/:\d+$/, ''); + + if (domain) { + attributes[URL_DOMAIN] = domain; } attributes['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin); @@ -417,7 +430,10 @@ export function _addResourceSpans( const endTimestamp = startTimestamp + duration; startAndEndSpan(span, startTimestamp, endTimestamp, { - name: resourceUrl.replace(WINDOW.location.origin, ''), + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: spanStreamingEnabled + ? domain || RESOURCE_SPAN_NAME_FALLBACK + : resourceUrl.replace(WINDOW.location.origin, ''), op, attributes: attributesWithResourceTiming, }); diff --git a/packages/browser-utils/test/performance/browserMetrics.test.ts b/packages/browser-utils/test/performance/browserMetrics.test.ts index fc825f66ba10..d94fe3c7b6f6 100644 --- a/packages/browser-utils/test/performance/browserMetrics.test.ts +++ b/packages/browser-utils/test/performance/browserMetrics.test.ts @@ -249,6 +249,7 @@ describe('_addResourceSpans', () => { ['resource.render_blocking_status']: entry.renderBlockingStatus, ['url.scheme']: 'https', ['server.address']: 'example.com', + ['url.domain']: 'example.com', ['url.same_origin']: true, ['url.full']: resourceEntryName, ['network.protocol.name']: 'http', @@ -431,6 +432,7 @@ describe('_addResourceSpans', () => { ['resource.render_blocking_status']: entry.renderBlockingStatus, ['url.scheme']: 'https', ['server.address']: 'example.com', + ['url.domain']: 'example.com', ['url.same_origin']: true, ['url.full']: resourceEntryName, ['network.protocol.name']: 'http', @@ -464,6 +466,7 @@ describe('_addResourceSpans', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.css', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', 'server.address': 'example.com', + 'url.domain': 'example.com', 'url.same_origin': true, 'url.scheme': 'https', 'url.full': resourceEntryName, @@ -515,6 +518,7 @@ describe('_addResourceSpans', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'resource.css', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', 'server.address': 'example.com', + 'url.domain': 'example.com', 'url.same_origin': true, 'url.scheme': 'https', 'url.full': resourceEntryName, @@ -569,6 +573,52 @@ describe('_addResourceSpans', () => { }); }, ); + + describe('with span streaming enabled', () => { + it.each([ + ['https://example.com/assets/to/css', 'example.com', 'example.com'], + ['https://cdn.example.org:8443/static/logo.png', 'cdn.example.org', 'cdn.example.org:8443'], + ['https://user:pass@example.com:8443/static/logo.png', 'example.com', 'example.com:8443'], + ])('names the span after the resource domain (%s)', (url, expectedName, expectedAddress) => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entry = mockPerformanceResourceTiming({ initiatorType: 'css', nextHopProtocol: 'h2' }); + + _addResourceSpans(span, entry, url, 100, 23, 345, undefined, true); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!)).toEqual( + expect.objectContaining({ + name: expectedName, + attributes: expect.objectContaining({ + 'url.domain': expectedName, + 'server.address': expectedAddress, + }), + }), + ); + }); + + it('falls back to a static name when the resource URL has no domain', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entry = mockPerformanceResourceTiming({ initiatorType: 'script', nextHopProtocol: 'h2' }); + + _addResourceSpans(span, entry, 'blob:0f6b3f0a-1e2d-4d1a-9c3f-2a5c1d7b8e90', 100, 23, 345, undefined, true); + + expect(spans).toHaveLength(1); + const spanJson = spanToJSON(spans[0]!); + expect(spanJson.name).toBe('Resource'); + expect(spanJson.attributes['url.domain']).toBeUndefined(); + }); + }); }); describe('_addNavigationSpans', () => {