From ae0c9db4a9ba03b8263bb1c274e3d6a9dd2aeba3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Apr 2026 16:58:34 +0200 Subject: [PATCH 1/3] 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 a169b595e03c917b52eefbcfc077bc335c909292 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Apr 2026 17:12:21 +0200 Subject: [PATCH 2/3] size --- .size-limit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index cad516a0a49a..e4ea335b0b85 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -283,21 +283,21 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '258.5 KB', + limit: '259 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '268 KB', + limit: '269 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: '271.5 KB', + limit: '272 KB', }, // Next.js SDK (ESM) { From ef30a41f2e4d2066f1b4c9b6c20330152b4ae4cd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Apr 2026 17:55:32 +0200 Subject: [PATCH 3/3] 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'];