diff --git a/MIGRATION.md b/MIGRATION.md index c8726d67c16e..d9ef3046ee30 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -909,6 +909,7 @@ The following span names were adjusted: | `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 | | `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | | `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | +| `http.client`, `http.client.stream` | The request method and sanitized URL (`GET https://api.example.com/users/123`) | The request method and the domain (`GET api.example.com`), or just the method if there is no domain (`GET`) | | `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` 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`) | | `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) | @@ -930,6 +931,10 @@ Resource spans now also carry a `url.domain` attribute holding that domain. The `http.server` requests that resolve to a route are **unchanged** — those names were already low cardinality. Only requests the SDK cannot parameterize are affected. +Outgoing requests never resolve to a route, so **every** `http.client` name changes: the path, query and fragment are dropped and only the domain is kept. The full URL remains available on `url.full`, and outgoing request spans now also carry a `url.domain` attribute holding that domain. + +A request with no domain to fall back on — a data URL, or a relative URL that the SDK cannot resolve against a page origin — is named after the method alone. + Some consequences to be aware of: The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. @@ -940,6 +945,8 @@ For the same reason, `useOperationNameForRootSpan` no longer renames the enclosi Resource URIs are unbounded, so they are no longer part of an `mcp.server` span name. The URI remains available on the `mcp.resource.uri` attribute. +Because the URL path is gone from `http.client` names, `graphqlClientIntegration` no longer appends the operation to the outgoing request span name (`POST https://api.example.com/graphql (query GetUser)` becomes `POST api.example.com`). Outgoing GraphQL request spans now carry the operation on the `graphql.operation.name` and `graphql.operation.type` attributes instead, and it also stays on the request breadcrumb's `graphql.operation` data. + Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`. Messaging span names now read ` ` in every integration. The amqplib, kafkajs and NestJS BullMQ integrations used their own word order or verb, so their names change: `my-queue process` became `process my-queue`, amqplib's `publish` became `send`, and the kafkajs batch span's `poll` became `receive`. Cloudflare Queues and the kafkajs producer already matched the conventions, so their names are the same in both trace lifecycles. The operation name an integration reports upstream stays on `messaging.operation.name`. diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts index 30e32621edbd..7c02a63fc41c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -32,9 +32,10 @@ sentryTest( expect(pageloadSpan).toBeDefined(); expect(requestSpans).toHaveLength(3); - requestSpans?.forEach((span, index) => + requestSpans?.forEach(span => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // Streamed span names drop the high-cardinality URL path. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts index 3072734d2b79..81d41ada7ccc 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -24,8 +24,9 @@ sentryTest( const span = await spanPromise; - expect(span.name).toMatch(/^GET /); + expect(span.name).toBe('GET sentry-test-site.example'); expect(span.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); expect(span.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + expect(span.attributes['url.domain']).toEqual({ type: 'string', value: 'sentry-test-site.example' }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js new file mode 100644 index 000000000000..5ab240338c8c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-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()], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js new file mode 100644 index 000000000000..143b078692db --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js @@ -0,0 +1 @@ +fetch('/test-req/0').then(fetch('/test-req/1').then(fetch('/test-req/2'))); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts new file mode 100644 index 000000000000..820103bac06e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { sentryTest, TEST_HOST } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('names spans for relative fetch requests after the page domain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const requestSpans = (await spansPromise) + .filter(s => getSpanOp(s) === 'http.client') + .sort((a, b) => + (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), + ); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + // A relative URL has no domain of its own, so it resolves against the page origin. + name: 'GET sentry-test.io', + attributes: expect.objectContaining({ + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test.io' }, + 'server.address': { type: 'string', value: 'sentry-test.io' }, + type: { type: 'string', value: 'fetch' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index 8e332aa18a0b..04f0261beff4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -45,15 +45,17 @@ sentryTest( const [requestSpan, streamSpan] = await Promise.all([httpSpanPromise, streamSpanPromise]); expect(requestSpan).toMatchObject({ - name: 'GET http://sentry-test-site.example/delayed', + name: 'GET sentry-test-site.example', status: 'ok', }); + // `http.client.stream` follows the same name rules as `http.client`. expect(streamSpan).toMatchObject({ - name: 'GET http://sentry-test-site.example/delayed', + name: 'GET sentry-test-site.example', attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: 'http://sentry-test-site.example/delayed' }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'fetch' }, }), }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts index 9702763ce3f1..484a7151de1f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -29,7 +29,8 @@ sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) requestSpans.forEach((span, index) => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // Streamed span names drop the high-cardinality URL path. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), @@ -38,6 +39,7 @@ sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, 'server.address': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'fetch' }, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js new file mode 100644 index 000000000000..5ab240338c8c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-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()], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js new file mode 100644 index 000000000000..75155522ab50 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js @@ -0,0 +1,11 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', '/test-req/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', '/test-req/1'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', '/test-req/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts new file mode 100644 index 000000000000..31ab73597b88 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { sentryTest, TEST_HOST } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('names spans for relative XHR requests after the page domain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const requestSpans = (await spansPromise) + .filter(s => getSpanOp(s) === 'http.client') + .sort((a, b) => + (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), + ); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + // A relative URL has no domain of its own, so it resolves against the page origin. + name: 'GET sentry-test.io', + attributes: expect.objectContaining({ + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test.io' }, + 'server.address': { type: 'string', value: 'sentry-test.io' }, + type: { type: 'string', value: 'xhr' }, + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts index 7fb689dafc1e..2fd96eb8af58 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -29,7 +29,8 @@ sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) = requestSpans.forEach((span, index) => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // Streamed span names drop the high-cardinality URL path. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), @@ -38,6 +39,7 @@ sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) = attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, 'server.address': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'xhr' }, }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index 694368cac2ec..c7d55186fe86 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -74,6 +74,7 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru 'http.response.status_code': 200, type: 'fetch', 'url.full': 'http://localhost:3030/', + 'url.domain': 'localhost', 'server.address': 'localhost', 'server.port': 3030, 'sentry.op': 'http.client', diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index 1e477a9a2461..1ce28cd46a5d 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -1,4 +1,4 @@ -import { HTTP_REQUEST_METHOD, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SENTRY_OP, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import { HTTP_CLIENT_STREAM } from '@sentry/conventions/op'; import type { IntegrationFn, Span } from '@sentry/core'; import { @@ -6,12 +6,15 @@ import { addFetchInstrumentationHandler, defineIntegration, getSanitizedUrlStringFromUrlObject, + getUrlDomain, + hasSpanStreamingEnabled, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripDataUrlContent, filterCollectedUrl, startInactiveSpan, } from '@sentry/core/browser'; +import { WINDOW } from '../helpers'; const responseToStreamSpan = new WeakMap(); const responseToFallbackTimeout = new WeakMap>(); @@ -37,7 +40,7 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { return { name: 'FetchStreamPerformance' as const, - setup() { + setup(client) { // End the stream span when the response body finishes resolving addFetchEndInstrumentationHandler(handlerData => { if (handlerData.response) { @@ -78,11 +81,15 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url; + // `http.client.stream` follows the same name rules as `http.client`. + const domain = getUrlDomain(url, WINDOW.location?.origin); + const streamedName = domain ? `${method} ${domain}` : method; const streamSpan = startInactiveSpan({ - name: `${method} ${sanitizedUrl}`, + name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, startTime: handlerData.endTimestamp, attributes: { [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url)), + [URL_DOMAIN]: domain, [HTTP_REQUEST_METHOD]: method, type: 'fetch', [SENTRY_OP]: HTTP_CLIENT_STREAM, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 4399c41c0424..42f64ff02a66 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,6 +1,7 @@ import type { Client, IntegrationFn } from '@sentry/core/browser'; import { defineIntegration, + hasSpanStreamingEnabled, isObjectLike, isString, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, @@ -9,7 +10,14 @@ import { } from '@sentry/core/browser'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { getBodyString, getFetchRequestArgBody, SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; -import { GRAPHQL_DOCUMENT, HTTP_METHOD, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { + GRAPHQL_DOCUMENT, + GRAPHQL_OPERATION_NAME, + GRAPHQL_OPERATION_TYPE, + HTTP_METHOD, + SENTRY_OP, + URL_FULL, +} from '@sentry/conventions/attributes'; interface GraphQLClientOptions { endpoints: Array; @@ -83,8 +91,15 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const graphqlBody = getGraphQLRequestPayload(payload); if (graphqlBody) { - const operationInfo = _getGraphQLOperation(graphqlBody); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + // With span streaming the span already carries a low-cardinality name, so it must not be + // renamed back to one containing the URL. The operation stays reachable as an attribute. + if (!hasSpanStreamingEnabled(client)) { + span.updateName(`${httpMethod} ${httpUrl} (${_getGraphQLOperation(graphqlBody)})`); + } + + const { operationName, operationType } = _getGraphQLOperationDetails(graphqlBody); + span.setAttribute(GRAPHQL_OPERATION_NAME, operationName); + span.setAttribute(GRAPHQL_OPERATION_TYPE, operationType); // Handle standard requests - capture the query document when enabled via dataCollection (default true) if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { @@ -138,6 +153,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } +/** + * The operation name and type of a GraphQL request. Persisted operations carry no query document, so + * their type is unknown. + */ +function _getGraphQLOperationDetails(requestBody: GraphQLRequestPayload): GraphQLOperation { + if (isPersistedRequest(requestBody)) { + return { operationName: requestBody.operationName, operationType: undefined }; + } + + if (isStandardRequest(requestBody)) { + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + return { operationName, operationType }; + } + + return { operationName: undefined, operationType: undefined }; +} + /** * @param requestBody - GraphQL request * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' or 'persisted NAME' @@ -150,10 +183,8 @@ export function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string // Handle standard GraphQL requests if (isStandardRequest(requestBody)) { - const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; - const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); - const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; - return operationInfo; + const { operationName, operationType } = _getGraphQLOperationDetails(requestBody); + return operationName ? `${operationType} ${operationName}` : `${operationType}`; } // Fallback for unknown request types diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index bded6a5b036c..ffc548512a56 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -5,6 +5,7 @@ import { getActiveSpan, getClient, getTraceData, + getUrlDomain, getUrlFragment, getUrlQuery, hasSpansEnabled, @@ -33,11 +34,13 @@ import { SENTRY_XHR_DATA_KEY, } from '@sentry/browser-utils'; import type { BrowserClient } from '../client'; +import { WINDOW } from '../helpers'; import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils'; import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, + URL_DOMAIN, URL_FRAGMENT, URL_FULL, URL_QUERY, @@ -151,6 +154,8 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('location', { origin: 'https://app.example.com' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + /** Runs the integration's fetch handler for a streamed response and returns the `startInactiveSpan` spy. */ + function trackStreamedFetch(traceLifecycle: 'static' | 'stream', url: string) { + let fetchHandler: ((data: HandlerDataFetch) => void) | undefined; + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + return () => {}; + }); + vi.spyOn(utils, 'addFetchEndInstrumentationHandler').mockImplementation(() => () => {}); + const startInactiveSpanSpy = vi + .spyOn(utils, 'startInactiveSpan') + .mockReturnValue(new utils.SentryNonRecordingSpan()); + + fetchStreamPerformanceIntegration().setup?.({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client); + + // A streamed response is detected by a streaming content type and a missing content-length. + fetchHandler?.({ + fetchData: { url, method: 'GET' }, + args: [url], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1, + response: { headers: new Headers({ 'content-type': 'text/event-stream' }) }, + } as unknown as HandlerDataFetch); + + return startInactiveSpanSpy; + } + + it('drops the URL path but keeps the domain with span streaming enabled', () => { + expect(trackStreamedFetch('stream', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET api.example.com', + attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + }), + ); + }); + + it('resolves a relative URL against the page origin', () => { + expect(trackStreamedFetch('stream', '/v1/chat')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET app.example.com', + attributes: expect.objectContaining({ 'url.domain': 'app.example.com' }), + }), + ); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + expect(trackStreamedFetch('stream', 'data:text/event-stream,data: hi')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); + }); + + it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { + expect(trackStreamedFetch('static', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET https://api.example.com/v1/chat', + attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + }), + ); + }); +}); diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 8ac6ba3f8bce..97bc06f93449 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -317,6 +317,7 @@ describe('GraphqlClient', () => { function setupHandler( endpoints: Array, graphQLDocument = true, + traceLifecycle: 'static' | 'stream' = 'static', ): (span: SentrySpan, hint: FetchHint | XhrHint) => void { let capturedListener: ((span: SentrySpan, hint: FetchHint | XhrHint) => void) | undefined; const mockClient = { @@ -325,6 +326,7 @@ describe('GraphqlClient', () => { capturedListener = cb; } }, + getOptions: () => ({ traceLifecycle }), getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }), } as unknown as Client; @@ -370,6 +372,53 @@ describe('GraphqlClient', () => { const json = spanToJSON(span); expect(json.name).toBe('POST http://localhost:4000/graphql (query GetHello)'); expect(json.attributes['graphql.document']).toBe(requestBody.query); + expect(json.attributes['graphql.operation.name']).toBe('GetHello'); + expect(json.attributes['graphql.operation.type']).toBe('query'); + }); + + test('keeps the low-cardinality span name with span streaming enabled', () => { + const handler = setupHandler([/\/graphql$/], true, 'stream'); + const span = new SentrySpan({ + name: 'POST localhost:4000', + op: 'http.client', + attributes: { + 'http.method': 'POST', + [URL_FULL]: 'http://localhost:4000/graphql', + }, + }); + + handler(span, makeFetchHint('http://localhost:4000/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.name).toBe('POST localhost:4000'); + expect(json.attributes['graphql.document']).toBe(requestBody.query); + expect(json.attributes['graphql.operation.name']).toBe('GetHello'); + expect(json.attributes['graphql.operation.type']).toBe('query'); + }); + + test('records the operation on a persisted request, which has no query document', () => { + const handler = setupHandler([/\/graphql$/], true, 'stream'); + const span = new SentrySpan({ + name: 'POST localhost:4000', + op: 'http.client', + attributes: { + 'http.method': 'POST', + [URL_FULL]: 'http://localhost:4000/graphql', + }, + }); + + handler( + span, + makeFetchHint('http://localhost:4000/graphql', { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { persistedQuery: { version: 1, sha256Hash: 'abc123' } }, + }), + ); + + const json = spanToJSON(span); + expect(json.attributes['graphql.operation.name']).toBe('GetUser'); + expect(json.attributes['graphql.operation.type']).toBeUndefined(); }); test('enriches http.client span when only url.full is present', () => { diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 853682641691..de6481751c36 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -85,7 +85,7 @@ describe('instrumentOutgoingRequests', () => { expect(fetchHandler).toBeDefined(); expect(requestSpan).toBeDefined(); const requestSpanJson = utils.spanToJSON(requestSpan!); - expect(requestSpanJson.name).toBe('QUERY https://example.com/rest/v1/users'); + expect(requestSpanJson.name).toBe('QUERY example.com'); expect(requestSpanJson.attributes[HTTP_REQUEST_METHOD]).toBe('QUERY'); }); @@ -122,10 +122,211 @@ describe('instrumentOutgoingRequests', () => { expect(xhrHandler).toBeDefined(); expect(requestSpan).toBeDefined(); const requestSpanJson = utils.spanToJSON(requestSpan!); - expect(requestSpanJson.name).toBe('QUERY https://example.com/rest/v1/users'); + expect(requestSpanJson.name).toBe('QUERY example.com'); expect(requestSpanJson.attributes[HTTP_REQUEST_METHOD]).toBe('QUERY'); }); + it('keeps the sanitized URL in the fetch span name with `traceLifecycle: "static"`', () => { + let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + const tracingClient = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' }), + ); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceXHR: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + fetchHandler?.({ + fetchData: { method: 'QUERY', url: 'https://example.com/rest/v1/users?select=id' }, + args: ['https://example.com/rest/v1/users?select=id'], + startTimestamp: Date.now(), + }); + + expect(utils.spanToJSON(requestSpan!).name).toBe('QUERY https://example.com/rest/v1/users'); + }); + + it('keeps the sanitized URL in the XHR span name with `traceLifecycle: "static"`', () => { + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' }), + ); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'QUERY', + url: 'https://example.com/rest/v1/users?select=id', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + expect(utils.spanToJSON(requestSpan!).name).toBe('QUERY https://example.com/rest/v1/users'); + }); + + it('strips userinfo and the port from the streamed XHR span name and `url.domain`', () => { + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'GET', + url: 'https://user:pass@example.com:8443/rest/v1/users', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('example.com'); + expect(requestSpanJson.attributes['server.address']).toBe('example.com:8443'); + }); + + it('resolves a relative fetch URL against the page origin for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com' }); + + let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceXHR: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + fetchHandler?.({ + fetchData: { method: 'GET', url: '/rest/v1/users?select=id' }, + args: ['/rest/v1/users?select=id'], + startTimestamp: Date.now(), + }); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET app.example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + + vi.unstubAllGlobals(); + }); + + it('resolves a relative XHR URL against the page origin for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com' }); + + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'GET', + url: '/rest/v1/users?select=id', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET app.example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + + vi.unstubAllGlobals(); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceXHR: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + fetchHandler?.({ + fetchData: { method: 'GET', url: 'data:text/plain,hello' }, + args: ['data:text/plain,hello'], + startTimestamp: Date.now(), + }); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET'); + expect(requestSpanJson.attributes['url.domain']).toBeUndefined(); + }); + describe('XHR trace header span', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 65a2335c7a3b..aaf83ef25d82 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -5,6 +5,7 @@ import { SENTRY_OP, SERVER_ADDRESS, SERVER_PORT, + URL_DOMAIN, URL_FRAGMENT, URL_FULL, URL_QUERY, @@ -29,6 +30,7 @@ import { getActiveSpan } from './utils/spanUtils'; import { getTraceData } from './utils/traceData'; import { getSanitizedUrlStringFromUrlObject, + getUrlDomain, getUrlFragment, getUrlQuery, isURLObjectRelative, @@ -50,6 +52,8 @@ interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; + /** Base URL for relative request URLs. Browsers pass the page origin; server runtimes have none. */ + urlBase?: string; } /** @@ -92,7 +96,11 @@ export function instrumentFetchRequest( return undefined; } - const { spanOrigin = 'auto.http.browser', propagateTraceparent = false } = instrumentFetchRequestOptions ?? {}; + const { + spanOrigin = 'auto.http.browser', + propagateTraceparent = false, + urlBase, + } = instrumentFetchRequestOptions ?? {}; const client = getClient(); const hasParent = !!getActiveSpan(); @@ -101,7 +109,7 @@ export function instrumentFetchRequest( const span = shouldCreateSpanResult && shouldEmitSpan - ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin, client)) + ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin, client, urlBase)) : new SentryNonRecordingSpan(); const spanForTraceHeaders = spanIsIgnored(span) && hasParent ? undefined : span; @@ -337,7 +345,13 @@ function getSpanStartOptions( method: string, spanOrigin: SpanOrigin, client: Client | undefined, + urlBase: string | undefined, ): Parameters[0] { + // With span streaming, span names have to be low cardinality, so only the domain is kept. Outgoing + // requests have no route to fall back on, so one without a domain is named after the method alone. + const isStreamed = !!client && hasSpanStreamingEnabled(client); + const domain = getUrlDomain(url, urlBase); + // Data URLs need special handling because parseStringToURLObject treats them as "relative" // (no "://"), causing getSanitizedUrlStringFromUrlObject to return just the pathname // without the "data:" prefix, making later stripDataUrlContent calls ineffective. @@ -345,16 +359,16 @@ function getSpanStartOptions( if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); return { - name: `${method} ${sanitizedUrl}`, - attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin, client), + name: isStreamed ? method : `${method} ${sanitizedUrl}`, + attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin, client, domain), }; } const parsedUrl = parseStringToURLObject(url); const sanitizedUrl = parsedUrl ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url; return { - name: `${method} ${sanitizedUrl}`, - attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin, client), + name: isStreamed ? (domain ? `${method} ${domain}` : method) : `${method} ${sanitizedUrl}`, + attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin, client, domain), }; } @@ -364,6 +378,7 @@ function getFetchSpanAttributes( method: string, spanOrigin: SpanOrigin, client: Client | undefined, + domain: string | undefined, ): SpanAttributes { const attributes: SpanAttributes = { [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url), client), @@ -372,6 +387,7 @@ function getFetchSpanAttributes( [HTTP_REQUEST_METHOD]: method, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SENTRY_OP]: HTTP_CLIENT, + [URL_DOMAIN]: domain, }; if (parsedUrl) { if (!isURLObjectRelative(parsedUrl)) { diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 31e4995db775..82ae31c5cdb9 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -327,6 +327,7 @@ export { stripDataUrlContent, getUrlQuery, getUrlFragment, + getUrlDomain, } from './utils/url'; export { eventFromMessage, diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 46d3b912b694..45be6373bf29 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -148,6 +148,20 @@ export function getUrlFragment(fragment: string | undefined): string | undefined return fragment?.replace(/^#/, '') || undefined; } +/** + * The domain a request goes to, for the `url.domain` attribute and low-cardinality span names. + * + * Relative URLs need a `base` to resolve against — browsers have the page origin, server runtimes do + * not. URLs with no domain at all, such as data URLs, return `undefined`. + */ +export function getUrlDomain(url: string, base?: string): string | undefined { + try { + return new URL(url, base).hostname || undefined; + } catch { + return undefined; + } +} + type PartialRequest = { method?: string; }; diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index ac6a8ab66041..57c10d16243b 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,10 +1,11 @@ import { URL_FULL } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { HandlerDataFetch } from '../../src'; +import type { Client, HandlerDataFetch } from '../../src'; import { _INTERNAL_getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch'; import { SentryNonRecordingSpan } from '../../src/tracing/sentryNonRecordingSpan'; import type { Span } from '../../src/types/span'; import * as tracing from '../../src/tracing/trace'; +import * as currentScopes from '../../src/currentScopes'; import * as spanUtils from '../../src/utils/spanUtils'; import * as traceData from '../../src/utils/traceData'; @@ -494,6 +495,7 @@ describe('instrumentFetchRequest', () => { 'sentry.op': 'http.client', [URL_FULL]: url, 'server.address': 'api.example.com', + 'url.domain': 'api.example.com', 'url.query': 'include=profile', 'url.fragment': 'bio', }, @@ -501,6 +503,79 @@ describe('instrumentFetchRequest', () => { }); }); + describe('span name', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function startFetchSpan( + traceLifecycle: 'static' | 'stream', + url = 'https://api.example.com/users/42?include=profile', + urlBase?: string, + ): ReturnType { + hasSpansEnabled.mockReturnValue(true); + vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(new SentryNonRecordingSpan()); + vi.spyOn(currentScopes, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + emit: () => {}, + } as unknown as Client); + const startInactiveSpanSpy = vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(new SentryNonRecordingSpan()); + + instrumentFetchRequest( + { fetchData: { url, method: 'GET' }, args: [url], startTimestamp: Date.now() }, + () => true, + () => false, + {}, + { spanOrigin: 'auto.http.fetch', urlBase }, + ); + + return startInactiveSpanSpy; + } + + it('drops the URL path but keeps the domain with span streaming enabled', () => { + expect(startFetchSpan('stream')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET api.example.com', + attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + }), + ); + }); + + it('falls back to the request method for a relative URL when there is no base to resolve it against', () => { + expect(startFetchSpan('stream', '/users/42')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); + }); + + it('resolves a relative URL against `urlBase`', () => { + expect(startFetchSpan('stream', '/users/42', 'https://app.example.com')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET app.example.com', + attributes: expect.objectContaining({ 'url.domain': 'app.example.com' }), + }), + ); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + expect(startFetchSpan('stream', 'data:text/plain,hello', 'https://app.example.com')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); + }); + + it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { + expect(startFetchSpan('static')).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GET https://api.example.com/users/42' }), + ); + }); + }); + describe('trace header span', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/test/lib/utils/url.test.ts b/packages/core/test/lib/utils/url.test.ts index ad073e49a393..e045366955fe 100644 --- a/packages/core/test/lib/utils/url.test.ts +++ b/packages/core/test/lib/utils/url.test.ts @@ -3,6 +3,7 @@ import { getHttpSpanDetailsFromUrlObject, getSanitizedUrlString, getSanitizedUrlStringFromUrlObject, + getUrlDomain, getUrlFragment, getUrlQuery, isURLObjectRelative, @@ -308,6 +309,20 @@ describe('getUrlQuery', () => { }); }); +describe('getUrlDomain', () => { + it.each([ + ['https://somedomain.com/path?a=b#c', undefined, 'somedomain.com'], + ['https://user:pass@somedomain.com:8443/path', undefined, 'somedomain.com'], + ['/path/to/happiness', undefined, undefined], + ['/path/to/happiness', 'https://somedomain.com', 'somedomain.com'], + ['https://otherdomain.com/path', 'https://somedomain.com', 'otherdomain.com'], + ['data:text/plain,hello', 'https://somedomain.com', undefined], + ['', undefined, undefined], + ])('resolves %s against %s', (url, base, expected) => { + expect(getUrlDomain(url, base)).toBe(expected); + }); +}); + describe('getUrlFragment', () => { it.each([ ['#section', 'section'],