diff --git a/.size-limit.js b/.size-limit.js index 6075311aaa01..61e68d0818b1 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,19 +215,19 @@ 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)', @@ -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,21 +276,21 @@ 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', 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..c943957c8ae6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -0,0 +1,29 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('infers sentry.op for streamed outgoing fetch spans', async () => { + expect.assertions(2); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + 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/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..7ebd70673b96 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -0,0 +1,34 @@ +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, name, and source for 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); + 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(); + + 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 fe8bc31fcae7..56c00d33f445 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -2,7 +2,9 @@ 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_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -58,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); @@ -150,3 +160,102 @@ export function safeSetSpanJSONAttributes( } }); } + +// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) +const SPAN_KIND_SERVER = 1; +const SPAN_KIND_CLIENT = 2; + +/** + * 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. + * + * Backfills: `sentry.op`, `sentry.source`, and `name` (description). + * Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten. + */ +function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void { + const attributes = spanJSON.attributes; + if (!attributes) { + return; + } + + const httpMethod = attributes['http.request.method'] || attributes['http.method']; + if (httpMethod) { + inferHttpSpanData(spanJSON, attributes, spanKind, httpMethod); + return; + } + + const dbSystem = attributes['db.system.name'] || attributes['db.system']; + if (dbSystem) { + inferDbSpanData(spanJSON, attributes); + return; + } + + 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}` }); + } +} + +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; + } + + // 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']; + if (typeof httpRoute === 'string') { + spanJSON.name = `${httpMethod} ${httpRoute}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); + } +} + +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' }); + } +}