From a8b6c133b61120f0c02a187fd65e74700aadfc39 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Apr 2026 16:58:34 +0200 Subject: [PATCH 1/9] maybe this --- .../fetch-basic-streamed/scenario.ts | 20 ++++++++++ .../fetch-basic-streamed/test.ts | 30 +++++++++++++++ .../core/src/tracing/spans/captureSpan.ts | 38 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts new file mode 100644 index 000000000000..3fe49e76fb35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +async function run(): Promise { + await Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + }); + + await Sentry.flush(); +} + +void run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts new file mode 100644 index 000000000000..d30dd29df2b5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -0,0 +1,30 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('captures streamed spans with sentry.op for outgoing fetch requests', async () => { + expect.assertions(2); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const httpClientSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && item.attributes['sentry.op'].value === 'http.client', + ); + + expect(httpClientSpan).toBeDefined(); + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index fe8bc31fcae7..44aae573709b 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -3,6 +3,7 @@ import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -79,6 +80,17 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }); } + // Backfill sentry.op from span attributes when not explicitly set. + // OTel-originated spans don't have sentry.op set — we infer it from semantic conventions. + if (!processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]) { + const inferredOp = inferOpFromAttributes(processedSpan.attributes); + if (inferredOp) { + safeSetSpanJSONAttributes(processedSpan, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: inferredOp, + }); + } + } + return { ...streamedSpanJsonToSerializedSpan(processedSpan), _segmentSpan: segmentSpan, @@ -150,3 +162,29 @@ export function safeSetSpanJSONAttributes( } }); } + +/** + * Infer `sentry.op` from span attributes based on OTel semantic conventions. + * This is needed because OTel-originated spans don't set `sentry.op` — the non-streamed + * path infers it in the `SentrySpanExporter`, but streamed spans skip the exporter entirely. + */ +function inferOpFromAttributes(attributes?: RawAttributes>): string | undefined { + if (!attributes) { + return undefined; + } + + const httpMethod = attributes['http.request.method'] || attributes['http.method']; + if (httpMethod) { + // Determine client vs server from the span's parent: + // - Spans with a server address are outgoing (client) requests + // - The `sentry.origin` attribute can also indicate the direction + return attributes['server.address'] || attributes['net.peer.name'] ? 'http.client' : 'http.server'; + } + + const dbSystem = attributes['db.system.name'] || attributes['db.system']; + if (dbSystem) { + return 'db'; + } + + return undefined; +} From f23e7d19ccefc11cd62cd569bf54e3ba600fc527 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Apr 2026 17:55:32 +0200 Subject: [PATCH 2/9] spankind detection --- .../core/src/tracing/spans/captureSpan.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 44aae573709b..05d381fcd606 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -82,8 +82,11 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW // Backfill sentry.op from span attributes when not explicitly set. // OTel-originated spans don't have sentry.op set — we infer it from semantic conventions. + // The non-streamed path infers this in the SentrySpanExporter, but streamed spans skip the exporter. if (!processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]) { - const inferredOp = inferOpFromAttributes(processedSpan.attributes); + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type + const spanKind = (span as { kind?: number }).kind; + const inferredOp = inferOpFromAttributes(processedSpan.attributes, spanKind); if (inferredOp) { safeSetSpanJSONAttributes(processedSpan, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: inferredOp, @@ -163,22 +166,32 @@ export function safeSetSpanJSONAttributes( }); } +// OTel SpanKind values (we use the numeric values to avoid importing from @opentelemetry/api) +const SPAN_KIND_CLIENT = 2; +const SPAN_KIND_SERVER = 1; + /** - * Infer `sentry.op` from span attributes based on OTel semantic conventions. + * Infer `sentry.op` from span attributes and kind based on OTel semantic conventions. * This is needed because OTel-originated spans don't set `sentry.op` — the non-streamed * path infers it in the `SentrySpanExporter`, but streamed spans skip the exporter entirely. */ -function inferOpFromAttributes(attributes?: RawAttributes>): string | undefined { +function inferOpFromAttributes( + attributes?: RawAttributes>, + spanKind?: number, +): string | undefined { if (!attributes) { return undefined; } const httpMethod = attributes['http.request.method'] || attributes['http.method']; if (httpMethod) { - // Determine client vs server from the span's parent: - // - Spans with a server address are outgoing (client) requests - // - The `sentry.origin` attribute can also indicate the direction - return attributes['server.address'] || attributes['net.peer.name'] ? 'http.client' : 'http.server'; + if (spanKind === SPAN_KIND_CLIENT) { + return 'http.client'; + } + if (spanKind === SPAN_KIND_SERVER) { + return 'http.server'; + } + return 'http'; } const dbSystem = attributes['db.system.name'] || attributes['db.system']; From 49564b39486e9d61da210113f1a57dfaf497358e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 11:54:25 +0200 Subject: [PATCH 3/9] backfill all ops --- .../httpIntegration-streamed/instrument.mjs | 10 +++ .../httpIntegration-streamed/server.mjs | 16 ++++ .../tracing/httpIntegration-streamed/test.ts | 31 +++++++ .../core/src/tracing/spans/captureSpan.ts | 81 ++++++++++++------- 4 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs new file mode 100644 index 000000000000..4b86f31cb860 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts new file mode 100644 index 000000000000..7a9e2f5c3d08 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -0,0 +1,31 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('httpIntegration-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('infers sentry.op http.server on streamed server spans', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && + item.attributes['sentry.op'].value === 'http.server', + ); + + expect(serverSpan).toBeDefined(); + expect(serverSpan?.is_segment).toBe(true); + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 05d381fcd606..8f7e70d24927 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -80,19 +80,12 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }); } - // Backfill sentry.op from span attributes when not explicitly set. - // OTel-originated spans don't have sentry.op set — we infer it from semantic conventions. - // The non-streamed path infers this in the SentrySpanExporter, but streamed spans skip the exporter. - if (!processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]) { - // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type - const spanKind = (span as { kind?: number }).kind; - const inferredOp = inferOpFromAttributes(processedSpan.attributes, spanKind); - if (inferredOp) { - safeSetSpanJSONAttributes(processedSpan, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: inferredOp, - }); - } - } + // Backfill span data from OTel semantic conventions when not explicitly set. + // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path + // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(processedSpan, spanKind); return { ...streamedSpanJsonToSerializedSpan(processedSpan), @@ -166,38 +159,68 @@ export function safeSetSpanJSONAttributes( }); } -// OTel SpanKind values (we use the numeric values to avoid importing from @opentelemetry/api) -const SPAN_KIND_CLIENT = 2; +// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) const SPAN_KIND_SERVER = 1; +const SPAN_KIND_CLIENT = 2; /** - * Infer `sentry.op` from span attributes and kind based on OTel semantic conventions. - * This is needed because OTel-originated spans don't set `sentry.op` — the non-streamed - * path infers it in the `SentrySpanExporter`, but streamed spans skip the exporter entirely. + * Infer and backfill span data from OTel semantic conventions. + * This mirrors what the `SentrySpanExporter` does for non-streamed spans via `getSpanData`/`inferSpanData`. + * Streamed spans skip the exporter, so we do the inference here during capture. + * + * Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten. */ -function inferOpFromAttributes( - attributes?: RawAttributes>, - spanKind?: number, -): string | undefined { +function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void { + const attributes = spanJSON.attributes; if (!attributes) { - return undefined; + return; } const httpMethod = attributes['http.request.method'] || attributes['http.method']; if (httpMethod) { + const opParts = ['http']; if (spanKind === SPAN_KIND_CLIENT) { - return 'http.client'; + opParts.push('client'); + } else if (spanKind === SPAN_KIND_SERVER) { + opParts.push('server'); } - if (spanKind === SPAN_KIND_SERVER) { - return 'http.server'; + + if (attributes['sentry.http.prefetch']) { + opParts.push('prefetch'); } - return 'http'; + + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.'), + }); + return; } const dbSystem = attributes['db.system.name'] || attributes['db.system']; if (dbSystem) { - return 'db'; + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + }); + return; } - return undefined; + if (attributes['rpc.service']) { + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc', + }); + return; + } + + if (attributes['messaging.system']) { + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message', + }); + return; + } + + const faasTrigger = attributes['faas.trigger']; + if (faasTrigger) { + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}`, + }); + } } From f994eb1387bc655f86b075f1ca09a9af89ab0be7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 12:15:44 +0200 Subject: [PATCH 4/9] backfill source --- .../fetch-basic-streamed/test.ts | 7 +- .../tracing/httpIntegration-streamed/test.ts | 4 +- .../core/src/tracing/spans/captureSpan.ts | 114 ++++++++++++++---- 3 files changed, 95 insertions(+), 30 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts index d30dd29df2b5..080dd0a285cc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -2,12 +2,11 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -test('captures streamed spans with sentry.op for outgoing fetch requests', async () => { - expect.assertions(2); +test('infers sentry.op, name, and source for streamed outgoing fetch spans', async () => { + expect.assertions(4); const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', () => { - // Just ensure we're called expect(true).toBe(true); }) .start(); @@ -22,6 +21,8 @@ test('captures streamed spans with sentry.op for outgoing fetch requests', async ); expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toMatch(/^GET /); + expect(httpClientSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'url' }); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts index 7a9e2f5c3d08..df86ef752026 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -7,7 +7,7 @@ describe('httpIntegration-streamed', () => { }); createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { - test('infers sentry.op http.server on streamed server spans', async () => { + test('infers sentry.op, name, and source for streamed server spans', async () => { const runner = createRunner() .expect({ span: container => { @@ -19,6 +19,8 @@ describe('httpIntegration-streamed', () => { expect(serverSpan).toBeDefined(); expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.name).toBe('GET /test'); + expect(serverSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'route' }); }, }) .start(); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 8f7e70d24927..12299889ec70 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -2,8 +2,10 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; import { + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -15,6 +17,7 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; +import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { @@ -168,6 +171,7 @@ const SPAN_KIND_CLIENT = 2; * This mirrors what the `SentrySpanExporter` does for non-streamed spans via `getSpanData`/`inferSpanData`. * Streamed spans skip the exporter, so we do the inference here during capture. * + * Backfills: `sentry.op`, `sentry.source`, and `name` (description). * Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten. */ function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void { @@ -178,49 +182,107 @@ function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: const httpMethod = attributes['http.request.method'] || attributes['http.method']; if (httpMethod) { - const opParts = ['http']; - if (spanKind === SPAN_KIND_CLIENT) { - opParts.push('client'); - } else if (spanKind === SPAN_KIND_SERVER) { - opParts.push('server'); - } - - if (attributes['sentry.http.prefetch']) { - opParts.push('prefetch'); - } - - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.'), - }); + inferHttpSpanData(spanJSON, attributes, spanKind, httpMethod); return; } const dbSystem = attributes['db.system.name'] || attributes['db.system']; if (dbSystem) { - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - }); + inferDbSpanData(spanJSON, attributes); return; } if (attributes['rpc.service']) { - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc', - }); + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc' }); return; } if (attributes['messaging.system']) { - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message', - }); + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message' }); return; } const faasTrigger = attributes['faas.trigger']; if (faasTrigger) { - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}`, - }); + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}` }); + } +} + +function inferHttpSpanData( + spanJSON: StreamedSpanJSON, + attributes: RawAttributes>, + spanKind: number | undefined, + httpMethod: unknown, +): void { + // Infer op: http.client, http.server, or just http + const opParts = ['http']; + if (spanKind === SPAN_KIND_CLIENT) { + opParts.push('client'); + } else if (spanKind === SPAN_KIND_SERVER) { + opParts.push('server'); + } + if (attributes['sentry.http.prefetch']) { + opParts.push('prefetch'); + } + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.') }); + + // If the user already set a custom name or source, don't overwrite + if ( + attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] || + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' + ) { + return; + } + + // Infer name and source from URL attributes + const httpRoute = attributes['http.route']; + const httpTarget = attributes['http.target']; + const httpUrl = attributes['url.full'] || attributes['http.url']; + const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined; + const sanitizedUrl = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined; + + let urlPath: string | undefined; + let source: string | undefined; + + if (typeof httpRoute === 'string') { + urlPath = httpRoute; + source = 'route'; + } else if (spanKind === SPAN_KIND_SERVER && typeof httpTarget === 'string') { + urlPath = stripUrlQueryAndFragment(httpTarget); + source = 'url'; + } else if (sanitizedUrl) { + urlPath = sanitizedUrl; + source = 'url'; + } else if (typeof httpTarget === 'string') { + urlPath = stripUrlQueryAndFragment(httpTarget); + source = 'url'; + } + + if (urlPath) { + const isClientOrServer = spanKind === SPAN_KIND_CLIENT || spanKind === SPAN_KIND_SERVER; + const origin = attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] || 'manual'; + const isAutoSpan = `${origin}`.startsWith('auto'); + + if (isClientOrServer || isAutoSpan) { + spanJSON.name = `${httpMethod} ${urlPath}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source }); + } + } +} + +function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); + + if ( + attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] || + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' + ) { + return; + } + + const statement = attributes['db.statement']; + if (statement) { + spanJSON.name = `${statement}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task' }); } } From d60220375a5e092a12be49c9e6a9f1cd67d06702 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 13:58:46 +0200 Subject: [PATCH 5/9] simplify --- .size-limit.js | 24 ++++++------ .../fetch-basic-streamed/test.ts | 6 +-- .../core/src/tracing/spans/captureSpan.ts | 38 +++---------------- 3 files changed, 19 insertions(+), 49 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index e9e760d91526..7268d3a3e001 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -191,7 +191,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '46.5 KB', + limit: '47 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics)', @@ -203,7 +203,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '47.5 KB', + limit: '48 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', @@ -215,25 +215,25 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '83.5 KB', + limit: '84 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '84.5 KB', + limit: '85 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '89 KB', + limit: '89.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '90 KB', + limit: '90.5 KB', }, // browser CDN bundles (non-gzipped) { @@ -248,7 +248,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '138 KB', + limit: '140 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '141.5 KB', + limit: '143 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -276,28 +276,28 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '255.5 KB', + limit: '257 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '259 KB', + limit: '260.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '269 KB', + limit: '270 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '272 KB', + limit: '273.5 KB', }, // Next.js SDK (ESM) { diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts index 080dd0a285cc..c943957c8ae6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -2,8 +2,8 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -test('infers sentry.op, name, and source for streamed outgoing fetch spans', async () => { - expect.assertions(4); +test('infers sentry.op for streamed outgoing fetch spans', async () => { + expect.assertions(2); const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', () => { @@ -21,8 +21,6 @@ test('infers sentry.op, name, and source for streamed outgoing fetch spans', asy ); expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan?.name).toMatch(/^GET /); - expect(httpClientSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'url' }); }, }) .start() diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 12299889ec70..148484a18513 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -5,7 +5,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -17,7 +16,6 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { @@ -234,39 +232,13 @@ function inferHttpSpanData( return; } - // Infer name and source from URL attributes + // Only overwrite the span name when we have an explicit http.route — it's more specific than + // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), + // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). const httpRoute = attributes['http.route']; - const httpTarget = attributes['http.target']; - const httpUrl = attributes['url.full'] || attributes['http.url']; - const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined; - const sanitizedUrl = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined; - - let urlPath: string | undefined; - let source: string | undefined; - if (typeof httpRoute === 'string') { - urlPath = httpRoute; - source = 'route'; - } else if (spanKind === SPAN_KIND_SERVER && typeof httpTarget === 'string') { - urlPath = stripUrlQueryAndFragment(httpTarget); - source = 'url'; - } else if (sanitizedUrl) { - urlPath = sanitizedUrl; - source = 'url'; - } else if (typeof httpTarget === 'string') { - urlPath = stripUrlQueryAndFragment(httpTarget); - source = 'url'; - } - - if (urlPath) { - const isClientOrServer = spanKind === SPAN_KIND_CLIENT || spanKind === SPAN_KIND_SERVER; - const origin = attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] || 'manual'; - const isAutoSpan = `${origin}`.startsWith('auto'); - - if (isClientOrServer || isAutoSpan) { - spanJSON.name = `${httpMethod} ${urlPath}`; - safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source }); - } + spanJSON.name = `${httpMethod} ${httpRoute}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); } } From 623ad14fef7ba8f2ec8a3d81110aa62d90f6e6b5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 16:19:32 +0200 Subject: [PATCH 6/9] fix --- .../tracing/httpIntegration-streamed/test.ts | 1 + packages/core/src/tracing/spans/captureSpan.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts index df86ef752026..7ebd70673b96 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -21,6 +21,7 @@ describe('httpIntegration-streamed', () => { expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.name).toBe('GET /test'); expect(serverSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'route' }); + expect(serverSpan?.attributes?.['sentry.span.source']).toEqual({ type: 'string', value: 'route' }); }, }) .start(); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 148484a18513..fa1413742a9b 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -70,6 +70,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; + // Backfill span data from OTel semantic conventions when not explicitly set. + // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path + // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. + // This must run before the sentry.span.source backfill below, so that inferred sentry.source is picked up. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(processedSpan, spanKind); + // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; @@ -81,13 +89,6 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }); } - // Backfill span data from OTel semantic conventions when not explicitly set. - // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path - // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. - // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. - const spanKind = (span as { kind?: number }).kind; - inferSpanDataFromOtelAttributes(processedSpan, spanKind); - return { ...streamedSpanJsonToSerializedSpan(processedSpan), _segmentSpan: segmentSpan, From ccc0c2c8eab906f9b876465b040b73644df9756c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 16:28:18 +0200 Subject: [PATCH 7/9] order --- packages/core/src/tracing/spans/captureSpan.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index fa1413742a9b..56c00d33f445 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -60,6 +60,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW client.emit('processSegmentSpan', spanJSON); } + // Backfill span data from OTel semantic conventions when not explicitly set. + // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path + // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. + // This must run before hooks and beforeSendSpan so that user callbacks can see and override inferred values. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(spanJSON, spanKind); + // This allows hook subscribers to mutate the span JSON // This also invokes the `processSpan` hook of all integrations client.emit('processSpan', spanJSON); @@ -70,14 +78,6 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; - // Backfill span data from OTel semantic conventions when not explicitly set. - // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path - // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. - // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. - // This must run before the sentry.span.source backfill below, so that inferred sentry.source is picked up. - const spanKind = (span as { kind?: number }).kind; - inferSpanDataFromOtelAttributes(processedSpan, spanKind); - // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry. // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; From c2e1043f92f19539dbbbd2fd30a8ba8251f5c10f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 17:09:48 +0200 Subject: [PATCH 8/9] . --- .size-limit.js | 2 +- packages/core/src/tracing/spans/captureSpan.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 61e68d0818b1..59915b2d3bd3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -297,7 +297,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '273 KB', + limit: '273.5 KB', }, // Next.js SDK (ESM) { diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 56c00d33f445..855eb587dfdd 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -186,7 +186,10 @@ function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: } const dbSystem = attributes['db.system.name'] || attributes['db.system']; - if (dbSystem) { + const opIsCache = + typeof attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'string' && + `${attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]}`.startsWith('cache.'); + if (dbSystem && !opIsCache) { inferDbSpanData(spanJSON, attributes); return; } From f6306962f41f79945e19cc1a9fb3eeca70805617 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 24 Apr 2026 20:19:23 +0200 Subject: [PATCH 9/9] custom span name --- .../core/src/tracing/spans/captureSpan.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 855eb587dfdd..8278bd5bcacd 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -228,11 +228,15 @@ function inferHttpSpanData( } safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.') }); - // If the user already set a custom name or source, don't overwrite - if ( - attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] || - attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' - ) { + // If the user set a custom span name via updateSpanName(), apply it — OTel instrumentation + // may have overwritten span.name after the user set it, so we restore from the attribute. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { return; } @@ -249,10 +253,14 @@ function inferHttpSpanData( function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); - if ( - attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] || - attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' - ) { + // If the user set a custom span name via updateSpanName(), apply it. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { return; }