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) { 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..05d381fcd606 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,20 @@ 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, + }); + } + } + return { ...streamedSpanJsonToSerializedSpan(processedSpan), _segmentSpan: segmentSpan, @@ -150,3 +165,39 @@ 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 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>, + spanKind?: number, +): string | undefined { + if (!attributes) { + return undefined; + } + + const httpMethod = attributes['http.request.method'] || attributes['http.method']; + if (httpMethod) { + 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']; + if (dbSystem) { + return 'db'; + } + + return undefined; +}