From 8899d62afe203662cf42c9f84d47c44c05194e05 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 15:37:46 +0200 Subject: [PATCH 1/2] feat: Emit low-cardinality http.client span names for node:http and undici With span streaming, `http.client` spans are named `{method} {url.domain}` instead of `{method} {sanitized-url}`, falling back to the method alone when there is no domain. Covers outgoing `node:http`/`https` requests, undici, and `googleCloudHttpIntegration`. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/spans.test.ts | 14 +++++- .../tracing/http-client-span-streamed/test.ts | 3 +- .../http/get-outgoing-span-data.ts | 23 +++++---- .../http/get-outgoing-span-data.test.ts | 35 +++++++++++++- .../src/integrations/google-cloud-http.ts | 20 ++++++-- .../integrations/google-cloud-http.test.ts | 38 ++++++++++++--- packages/node/src/integrations/http/index.ts | 16 ++++++- .../node-fetch/undici-instrumentation.ts | 24 +++++++--- .../node/test/integrations/undici.test.ts | 48 ++++++++++++++++++- 9 files changed, 187 insertions(+), 34 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts index 1d879f20c32e..a54201c86ded 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts @@ -79,7 +79,12 @@ test('Sends streamed spans for an errored route', async ({ baseURL }) => { test('Outgoing fetch spans are streamed', async ({ baseURL }) => { const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { - return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + // Streamed `http.client` names are the request method alone, so match on `url.full` instead. + return ( + getSpanOp(span) === 'http.client' && + !span.is_segment && + String(span.attributes['url.full']?.value ?? '').includes('localhost:3030/test-success') + ); }); await fetch(`${baseURL}/test-outgoing-fetch`); @@ -96,7 +101,12 @@ test.skip('Outgoing fetch spans include response headers when headersToSpanAttri baseURL, }) => { const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { - return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + // Streamed `http.client` names are the request method alone, so match on `url.full` instead. + return ( + getSpanOp(span) === 'http.client' && + !span.is_segment && + String(span.attributes['url.full']?.value ?? '').includes('localhost:3030/test-success') + ); }); await fetch(`${baseURL}/test-outgoing-fetch`); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts index eee1e85933f6..4905d9fe8abe 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -18,7 +18,8 @@ describe('http.client span with streaming enabled', () => { ); expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan?.name).toMatch(/^GET .*\/external$/); + // The URL path is high cardinality, so a streamed span name keeps only the domain. + expect(httpClientSpan?.name).toBe('GET localhost'); }, }) .start(); diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts index f345b5052b53..8760ad8388eb 100644 --- a/packages/core/src/integrations/http/get-outgoing-span-data.ts +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -1,8 +1,11 @@ import type { Span, SpanAttributes } from '../../types/span'; +import { getClient } from '../../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes'; +import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled'; +import { HTTP_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames'; import { filterCollectedUrl } from '../../utils/data-collection/filterCollectedUrl'; import { getContentLengthFromHeaders } from '../../utils/request'; -import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url'; +import { getHttpSpanDetailsFromUrlObject, isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; import type { HttpClientRequest, HttpIncomingMessage } from './types'; import { getRequestUrlFromClientRequest } from './get-request-url'; import type { StartSpanOptions } from '../../types/startSpanOptions'; @@ -29,17 +32,21 @@ import { */ export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpanOptions { const url = getRequestUrlFromClientRequest(request); - const [name, attributes] = getHttpSpanDetailsFromUrlObject( - parseStringToURLObject(url), - 'client', - 'auto.http.client', - request, - ); + const urlObject = parseStringToURLObject(url); + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'client', 'auto.http.client', request); const userAgent = request.getHeader('user-agent'); + // With span streaming, span names have to be low cardinality, so the URL path is dropped and only the + // domain is kept. Outgoing requests have no route to parameterize. + const client = getClient(); + const method = request.method?.toUpperCase(); + const domain = urlObject && !isURLObjectRelative(urlObject) ? urlObject.hostname : undefined; + const streamedName = method ? (domain ? `${method} ${domain}` : method) : HTTP_SPAN_NAME_FALLBACK; + const spanName = !!client && hasSpanStreamingEnabled(client) ? streamedName : name; + return { - name, + name: spanName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', [SENTRY_KIND]: 'client', diff --git a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts index 5d3f7417a5b4..c025944abf33 100644 --- a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts +++ b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Client } from '../../../../src/client'; +import * as currentScopes from '../../../../src/currentScopes'; import { getOutgoingRequestSpanData, setIncomingResponseSpanData, @@ -76,6 +78,37 @@ describe('getOutgoingRequestSpanData', () => { expect(result.name).toMatch(/^POST /); }); + describe('with span streaming enabled', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockStreamingClient(): void { + vi.spyOn(currentScopes, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client); + } + + it('drops the URL path but keeps the domain', () => { + mockStreamingClient(); + const result = getOutgoingRequestSpanData(makeMockRequest({ method: 'post' })); + expect(result.name).toBe('POST example.com'); + }); + + it('falls back to `HTTP` when the request has no method', () => { + mockStreamingClient(); + const result = getOutgoingRequestSpanData(makeMockRequest({ method: undefined })); + expect(result.name).toBe('HTTP'); + }); + + it('still records the URL on `url.full`', () => { + mockStreamingClient(); + const result = getOutgoingRequestSpanData(makeMockRequest()); + expect(result.attributes![URL_FULL]).toBe('http://example.com/api/test'); + }); + }); + it('includes URL_FULL, HTTP_REQUEST_METHOD, URL_PATH, and server endpoint attributes', () => { const result = getOutgoingRequestSpanData(makeMockRequest()); expect(result.attributes).toMatchObject({ diff --git a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts index 50392a42a63d..91e99e031840 100644 --- a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts +++ b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts @@ -1,11 +1,12 @@ import type * as common from '@google-cloud/common'; -import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import { HTTP_CLIENT } from '@sentry/conventions/op'; import type { Client, IntegrationFn } from '@sentry/core'; import { defineIntegration, fill, getClient, + hasSpanStreamingEnabled, isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -56,16 +57,25 @@ export const googleCloudHttpIntegration = defineIntegration(_googleCloudHttpInte function wrapRequestFunction(orig: RequestFunction): RequestFunction { return function (this: common.Service, reqOpts: RequestOptions, callback: ResponseCallback): void { const httpMethod = reqOpts.method || 'GET'; - const span = SETUP_CLIENTS.has(getClient() as Client) + const client = getClient(); + const serverAddress = getServerAddress(this.apiEndpoint); + // Span names must not contain a query string, and callers can pass any URI they want. With span + // streaming they have to be low cardinality on top of that, so the URI is dropped entirely and only + // the API endpoint is kept — `reqOpts.uri` is a path with no route to parameterize. + const streamedName = serverAddress ? `${httpMethod} ${serverAddress}` : httpMethod; + const span = SETUP_CLIENTS.has(client as Client) ? startInactiveSpan({ - // Span names must not contain a query string, and callers can pass any URI they want. - name: `${httpMethod} ${stripUrlQueryAndFragment(reqOpts.uri)}`, + name: + !!client && hasSpanStreamingEnabled(client) + ? streamedName + : `${httpMethod} ${stripUrlQueryAndFragment(reqOpts.uri)}`, onlyIfParent: true, attributes: { [SENTRY_OP]: HTTP_CLIENT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless', [HTTP_REQUEST_METHOD]: httpMethod, - [SERVER_ADDRESS]: getServerAddress(this.apiEndpoint), + [SERVER_ADDRESS]: serverAddress, + [URL_DOMAIN]: serverAddress, [URL_FULL]: filterCollectedUrl(reqOpts.uri), }, }) diff --git a/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts b/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts index 72f2642c8d46..3a0859914daf 100644 --- a/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts +++ b/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts @@ -1,5 +1,5 @@ import { BigQuery } from '@google-cloud/bigquery'; -import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import { HTTP_CLIENT } from '@sentry/conventions/op'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { createTransport, NodeClient, setCurrentClient } from '@sentry/node'; @@ -34,8 +34,19 @@ describe('GoogleCloudHttp tracing', () => { stackParser: () => [], }); + // `traceLifecycle` defaults to `'stream'`, so `mockClient` exercises the low-cardinality names. + const staticClient = new NodeClient({ + tracesSampleRate: 1.0, + integrations: [], + traceLifecycle: 'static', + dsn: 'https://withAWSServices@domain/123', + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); + const integration = googleCloudHttpIntegration(); mockClient.addIntegration(integration); + staticClient.addIntegration(googleCloudHttpIntegration()); beforeEach(() => { nock('https://www.googleapis.com') @@ -78,32 +89,32 @@ describe('GoogleCloudHttp tracing', () => { const resp = await bigquery.query('SELECT true AS foo'); expect(resp).toEqual([[{ foo: true }]]); expect(mockStartInactiveSpan).toBeCalledWith({ - name: 'POST /jobs', + name: 'POST bigquery.googleapis.com', onlyIfParent: true, attributes: { [SENTRY_OP]: HTTP_CLIENT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless', [HTTP_REQUEST_METHOD]: 'POST', [SERVER_ADDRESS]: 'bigquery.googleapis.com', + [URL_DOMAIN]: 'bigquery.googleapis.com', [URL_FULL]: '/jobs', }, }); expect(mockStartInactiveSpan).toBeCalledWith({ - name: expect.stringMatching(/^GET \/queries\/.+/), + name: 'GET bigquery.googleapis.com', onlyIfParent: true, attributes: { [SENTRY_OP]: HTTP_CLIENT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless', [HTTP_REQUEST_METHOD]: 'GET', [SERVER_ADDRESS]: 'bigquery.googleapis.com', + [URL_DOMAIN]: 'bigquery.googleapis.com', [URL_FULL]: expect.stringMatching(/^\/queries\/.+/), }, }); }); - // Span names follow `METHOD scheme://host/path`, so a query string must never reach the name, - // whatever the caller passes as `uri`. - test('strips the query string from the span name', async () => { + async function requestDatasetsWithQueryString(): Promise { nock('https://bigquery.googleapis.com') .get('/bigquery/v2/projects/project-id/datasets') .query(true) @@ -115,6 +126,21 @@ describe('GoogleCloudHttp tracing', () => { (err: unknown) => (err ? reject(err) : resolve()), ); }); + } + + // With span streaming the URI does not reach the name at all, so neither can the query string. + test('names the span after the method and the API endpoint', async () => { + await requestDatasetsWithQueryString(); + + expect(mockStartInactiveSpan).toBeCalledWith(expect.objectContaining({ name: 'GET bigquery.googleapis.com' })); + }); + + // Span names follow `METHOD scheme://host/path`, so a query string must never reach the name, + // whatever the caller passes as `uri`. + test('strips the query string from the span name with `traceLifecycle: "static"`', async () => { + setCurrentClient(staticClient); + + await requestDatasetsWithQueryString(); expect(mockStartInactiveSpan).toBeCalledWith(expect.objectContaining({ name: 'GET /datasets' })); const names = mockStartInactiveSpan.mock.calls.map(([args]) => (args as { name: string }).name); diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 52c38231d0a0..57a7b013c4a5 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -1,7 +1,14 @@ import type { ClientRequest, RequestOptions } from 'node:http'; import type { Span } from '@sentry/core'; import { URL_FULL } from '@sentry/conventions/attributes'; -import { defineIntegration, getRequestUrlFromClientRequest, hasSpansEnabled, stripDataUrlContent } from '@sentry/core'; +import { + defineIntegration, + getClient, + getRequestUrlFromClientRequest, + hasSpansEnabled, + hasSpanStreamingEnabled, + stripDataUrlContent, +} from '@sentry/core'; import type { NodeClient } from '../../sdk/client'; import type { HttpServerIntegrationOptions } from './httpServerIntegration'; import { httpServerIntegration } from './httpServerIntegration'; @@ -109,7 +116,12 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => const url = getRequestUrlFromClientRequest(request); if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); - span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); + // With span streaming the span already carries a low-cardinality name, so it must not be + // renamed back to something containing the URL. + const client = getClient(); + if (!client || !hasSpanStreamingEnabled(client)) { + span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); + } span.setAttributes({ [URL_FULL]: sanitizedUrl, }); diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 322a82dba495..39f3cd8c2a43 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -26,6 +26,7 @@ import { getSanitizedUrlString, getSpanStatusFromHttpCode, hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, isTracingSuppressed, LRUMap, parseUrl, @@ -49,6 +50,7 @@ import { SENTRY_OP, SERVER_ADDRESS, SERVER_PORT, + URL_DOMAIN, URL_FRAGMENT, URL_FULL, URL_PATH, @@ -220,6 +222,7 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage) [HTTP_REQUEST_METHOD]: requestMethod, [ATTR_HTTP_REQUEST_METHOD_ORIGINAL]: request.method, [URL_FULL]: filterCollectedUrl(requestUrl.toString()), + [URL_DOMAIN]: requestUrl.hostname || undefined, [URL_PATH]: requestUrl.pathname, [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(requestUrl.search)), [URL_FRAGMENT]: getUrlFragment(requestUrl.hash), @@ -262,14 +265,21 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage) // when an OpenTelemetry SDK tracer provider is set up, so we enforce it here too, which covers // SDKs that don't use an OpenTelemetry tracer provider at all. const isDataUrl = url.startsWith('data:'); - const spanName = - requestMethod === '_OTHER' - ? 'HTTP' - : isDataUrl - ? `${request.method || 'GET'} ${stripDataUrlContent(url)}` - : `${requestMethod} ${getSanitizedUrlString(parseUrl(requestUrl.toString()))}`; - const client = getClient(); + + let spanName: string; + if (requestMethod === '_OTHER') { + spanName = HTTP_SPAN_NAME_FALLBACK; + } else if (!!client && hasSpanStreamingEnabled(client)) { + // With span streaming, span names have to be low cardinality, so the URL path is dropped and only + // the domain is kept. Outgoing requests have no route to parameterize, and data URLs have no domain. + spanName = requestUrl.hostname ? `${requestMethod} ${requestUrl.hostname}` : requestMethod; + } else if (isDataUrl) { + spanName = `${request.method || 'GET'} ${stripDataUrlContent(url)}`; + } else { + spanName = `${requestMethod} ${getSanitizedUrlString(parseUrl(requestUrl.toString()))}`; + } + const span = startInactiveSpan({ name: spanName, attributes, diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index f48539fb0119..f278ccc726d7 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -5,6 +5,7 @@ import { SERVER_ADDRESS, SERVER_PORT, URL_FRAGMENT, + URL_DOMAIN, URL_FULL, URL_PATH, URL_QUERY, @@ -12,15 +13,20 @@ import { } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { channel } from 'node:diagnostics_channel'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { UndiciRequest } from '../../src/integrations/node-fetch/types'; -const { span, startInactiveSpan } = vi.hoisted(() => ({ span: {}, startInactiveSpan: vi.fn() })); +const { span, startInactiveSpan, getClient } = vi.hoisted(() => ({ + span: {}, + startInactiveSpan: vi.fn(), + getClient: vi.fn(), +})); vi.mock('@sentry/core', async () => { const actual = (await vi.importActual('@sentry/core')) as Record; return { ...actual, + getClient, startInactiveSpan: startInactiveSpan.mockReturnValue(span), }; }); @@ -36,6 +42,11 @@ describe('instrumentUndici', () => { instrumentUndici({ spans: true }); }); + beforeEach(() => { + startInactiveSpan.mockClear(); + getClient.mockReturnValue(undefined); + }); + it.each(['QUERY', 'query'])('normalizes %s as QUERY in client span metadata', method => { const request = { method, @@ -54,6 +65,7 @@ describe('instrumentUndici', () => { [HTTP_REQUEST_METHOD]: 'QUERY', 'http.request.method_original': method, [URL_FULL]: 'https://api.example.com/resources?limit=10', + [URL_DOMAIN]: 'api.example.com', [URL_PATH]: '/resources', [URL_QUERY]: 'limit=10', [URL_FRAGMENT]: undefined, @@ -65,4 +77,36 @@ describe('instrumentUndici', () => { onlyIfParent: true, }); }); + + it.each([true, false])('falls back to `HTTP` for an unknown method (span streaming: %s)', streaming => { + getClient.mockReturnValue(streaming ? { getOptions: () => ({ traceLifecycle: 'stream' }) } : undefined); + + const request = { + method: 'FROBNICATE', + origin: 'https://api.example.com', + path: '/resources', + headers: [], + } as unknown as UndiciRequest; + + channel('undici:request:create').publish({ request }); + + expect(startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ name: 'HTTP' })); + }); + + it('drops the URL path but keeps the domain with span streaming enabled', () => { + getClient.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); + + const request = { + method: 'QUERY', + origin: 'https://api.example.com', + path: '/resources?limit=10', + headers: [], + } as unknown as UndiciRequest; + + channel('undici:request:create').publish({ request }); + + expect(startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ name: 'QUERY api.example.com', onlyIfParent: false }), + ); + }); }); From 1c6aa6946bf5b34d442ae8ef44016cf203adc16b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 17:26:53 +0200 Subject: [PATCH 2/2] test(e2e): Expect url.domain on the nextjs-16 middleware fetch span The outgoing request span now carries `url.domain`, and the assertion compares `data` exactly. Co-Authored-By: Claude Opus 5 (1M context) --- .../test-applications/nextjs-16/tests/middleware.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 23fc112a9a72..41944552619a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -127,6 +127,7 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru 'sentry.origin': 'auto.http.node_fetch', 'server.address': 'localhost', 'server.port': 3030, + 'url.domain': 'localhost', 'url.full': 'http://localhost:3030/', 'url.path': '/', 'url.scheme': 'http',