diff --git a/.size-limit.js b/.size-limit.js index 8e1f3e79ee2f..d688ea688fe2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -212,7 +212,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '46.5 KB', + limit: '47 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -226,7 +226,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', disablePlugins: ['@size-limit/esbuild'], }, { @@ -240,14 +240,14 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '83.5 KB', + limit: '84 KB', disablePlugins: ['@size-limit/esbuild'], }, { 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', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '139 KB', + limit: '140 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -294,7 +294,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '142 KB', + limit: '143 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -310,7 +310,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '256 KB', + limit: '257 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '260 KB', + limit: '260.5 KB', disablePlugins: ['@size-limit/esbuild'], }, { 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..e41a9cfdf484 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, @@ -51,6 +53,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + // 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 all hooks and beforeSendSpan so that user callbacks can see and override inferred values. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(spanJSON, spanKind); + if (spanJSON.is_segment) { applyScopeToSegmentSpan(spanJSON, finalScopeData); // Allow hook subscribers to mutate the segment span JSON @@ -150,3 +160,119 @@ 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. + */ +/** Exported only for tests. */ +export 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']; + const opIsCache = + typeof attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'string' && + `${attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]}`.startsWith('cache.'); + if (dbSystem && !opIsCache) { + 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 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; + } + + // 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' }); + } else { + // Fallback: set source to 'url' for HTTP spans without a route. + // The spec requires sentry.span.source on segment spans, and the non-streamed exporter + // always sets this — so we need to ensure it's present for streamed spans too. + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); + } +} + +function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); + + // 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; + } + + const statement = attributes['db.statement']; + if (statement) { + spanJSON.name = `${statement}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task' }); + } +} diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index d429d50714a2..56b039d56b67 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -21,7 +21,7 @@ import { withScope, withStreamedSpan, } from '../../../../src'; -import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { @@ -483,3 +483,158 @@ describe('safeSetSpanJSONAttributes', () => { expect(spanJSON.attributes).toEqual({}); }); }); + +describe('inferSpanDataFromOtelAttributes', () => { + function makeSpanJSON(name: string, attributes: Record): StreamedSpanJSON { + return { + name, + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes, + }; + } + + describe('http spans', () => { + it('infers http.client op for CLIENT kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); // SPAN_KIND_CLIENT + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + + it('infers http.server op for SERVER kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); // SPAN_KIND_SERVER + expect(spanJSON.attributes?.['sentry.op']).toBe('http.server'); + }); + + it('infers http op when kind is unknown', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + + it('appends prefetch to op', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.http.prefetch': true }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.prefetch'); + }); + + it('sets name and source from http.route', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'http.route': '/users/:id' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('GET /users/:id'); + expect(spanJSON.attributes?.['sentry.source']).toBe('route'); + }); + + it('does not overwrite name when no http.route but sets source to url', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.name).toBe('GET'); + expect(spanJSON.attributes?.['sentry.source']).toBe('url'); + }); + + it('does not overwrite sentry.op if already set', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.op': 'http.client.custom' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.custom'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten-by-otel', { + 'http.request.method': 'GET', + 'sentry.custom_span_name': 'my-custom-name', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-custom-name'); + }); + + it('does not overwrite name when sentry.source is custom', () => { + const spanJSON = makeSpanJSON('my-name', { + 'http.request.method': 'GET', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-name'); + }); + + it('supports legacy http.method attribute', () => { + const spanJSON = makeSpanJSON('GET', { 'http.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + }); + + describe('db spans', () => { + it('infers db op', () => { + const spanJSON = makeSpanJSON('redis', { 'db.system': 'redis' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('db'); + }); + + it('sets name from db.statement', () => { + const spanJSON = makeSpanJSON('mysql', { 'db.system': 'mysql', 'db.statement': 'SELECT * FROM users' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('SELECT * FROM users'); + expect(spanJSON.attributes?.['sentry.source']).toBe('task'); + }); + + it('skips db inference for cache spans', () => { + const spanJSON = makeSpanJSON('cache-get', { 'db.system': 'redis', 'sentry.op': 'cache.get_item' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('cache.get_item'); + expect(spanJSON.name).toBe('cache-get'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten', { + 'db.system': 'mysql', + 'db.statement': 'SELECT 1', + 'sentry.custom_span_name': 'my-db-span', + 'sentry.source': 'custom', + }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('my-db-span'); + }); + }); + + describe('other span types', () => { + it('infers rpc op', () => { + const spanJSON = makeSpanJSON('grpc', { 'rpc.service': 'UserService' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('rpc'); + }); + + it('infers message op', () => { + const spanJSON = makeSpanJSON('kafka', { 'messaging.system': 'kafka' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('message'); + }); + + it('infers faas op from trigger', () => { + const spanJSON = makeSpanJSON('lambda', { 'faas.trigger': 'http' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + }); + + it('does nothing when attributes are missing', () => { + const spanJSON = makeSpanJSON('test', undefined as unknown as Record); + spanJSON.attributes = undefined; + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes).toBeUndefined(); + }); + + it('does nothing for spans without recognizable attributes', () => { + const spanJSON = makeSpanJSON('test', { 'custom.attr': 'value' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBeUndefined(); + expect(spanJSON.name).toBe('test'); + }); +});