Skip to content

Commit 4c053b6

Browse files
authored
feat(core): Backfill otel attributes on streamed spans (#20439)
The streaming path skips the `SentrySpanExporter`, which is why we backfill span data (`sentry.op`, `name`, `sentry.source`) in `captureSpan` now. Some of the inference logic is duplicated from the otel package — which we can likely drop once we move away from otel. closes #20425
1 parent 4932714 commit 4c053b6

8 files changed

Lines changed: 399 additions & 9 deletions

File tree

.size-limit.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ module.exports = [
212212
name: 'CDN Bundle (incl. Tracing)',
213213
path: createCDNPath('bundle.tracing.min.js'),
214214
gzip: true,
215-
limit: '46.5 KB',
215+
limit: '47 KB',
216216
disablePlugins: ['@size-limit/esbuild'],
217217
},
218218
{
@@ -226,7 +226,7 @@ module.exports = [
226226
name: 'CDN Bundle (incl. Tracing, Logs, Metrics)',
227227
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
228228
gzip: true,
229-
limit: '47.5 KB',
229+
limit: '48 KB',
230230
disablePlugins: ['@size-limit/esbuild'],
231231
},
232232
{
@@ -240,14 +240,14 @@ module.exports = [
240240
name: 'CDN Bundle (incl. Tracing, Replay)',
241241
path: createCDNPath('bundle.tracing.replay.min.js'),
242242
gzip: true,
243-
limit: '83.5 KB',
243+
limit: '84 KB',
244244
disablePlugins: ['@size-limit/esbuild'],
245245
},
246246
{
247247
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)',
248248
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
249249
gzip: true,
250-
limit: '84.5 KB',
250+
limit: '85 KB',
251251
disablePlugins: ['@size-limit/esbuild'],
252252
},
253253
{
@@ -278,7 +278,7 @@ module.exports = [
278278
path: createCDNPath('bundle.tracing.min.js'),
279279
gzip: false,
280280
brotli: false,
281-
limit: '139 KB',
281+
limit: '140 KB',
282282
disablePlugins: ['@size-limit/esbuild'],
283283
},
284284
{
@@ -294,7 +294,7 @@ module.exports = [
294294
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
295295
gzip: false,
296296
brotli: false,
297-
limit: '142 KB',
297+
limit: '143 KB',
298298
disablePlugins: ['@size-limit/esbuild'],
299299
},
300300
{
@@ -310,15 +310,15 @@ module.exports = [
310310
path: createCDNPath('bundle.tracing.replay.min.js'),
311311
gzip: false,
312312
brotli: false,
313-
limit: '256 KB',
313+
limit: '257 KB',
314314
disablePlugins: ['@size-limit/esbuild'],
315315
},
316316
{
317317
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
318318
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
319319
gzip: false,
320320
brotli: false,
321-
limit: '260 KB',
321+
limit: '260.5 KB',
322322
disablePlugins: ['@size-limit/esbuild'],
323323
},
324324
{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
traceLifecycle: 'stream',
9+
transport: loggingTransport,
10+
});
11+
12+
async function run(): Promise<void> {
13+
await Sentry.startSpan({ name: 'test_transaction' }, async () => {
14+
await fetch(`${process.env.SERVER_URL}/api/v0`);
15+
});
16+
17+
await Sentry.flush();
18+
}
19+
20+
void run();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createTestServer } from '@sentry-internal/test-utils';
2+
import { expect, test } from 'vitest';
3+
import { createRunner } from '../../../../utils/runner';
4+
5+
test('infers sentry.op for streamed outgoing fetch spans', async () => {
6+
expect.assertions(2);
7+
8+
const [SERVER_URL, closeTestServer] = await createTestServer()
9+
.get('/api/v0', () => {
10+
expect(true).toBe(true);
11+
})
12+
.start();
13+
14+
await createRunner(__dirname, 'scenario.ts')
15+
.withEnv({ SERVER_URL })
16+
.expect({
17+
span: container => {
18+
const httpClientSpan = container.items.find(
19+
item =>
20+
item.attributes?.['sentry.op']?.type === 'string' && item.attributes['sentry.op'].value === 'http.client',
21+
);
22+
23+
expect(httpClientSpan).toBeDefined();
24+
},
25+
})
26+
.start()
27+
.completed();
28+
closeTestServer();
29+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
traceLifecycle: 'stream',
10+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/node';
2+
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
3+
import cors from 'cors';
4+
import express from 'express';
5+
6+
const app = express();
7+
8+
app.use(cors());
9+
10+
app.get('/test', (_req, res) => {
11+
res.send({ response: 'ok' });
12+
});
13+
14+
Sentry.setupExpressErrorHandler(app);
15+
16+
startExpressServerAndSendPortToRunner(app);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
3+
4+
describe('httpIntegration-streamed', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('infers sentry.op, name, and source for streamed server spans', async () => {
11+
const runner = createRunner()
12+
.expect({
13+
span: container => {
14+
const serverSpan = container.items.find(
15+
item =>
16+
item.attributes?.['sentry.op']?.type === 'string' &&
17+
item.attributes['sentry.op'].value === 'http.server',
18+
);
19+
20+
expect(serverSpan).toBeDefined();
21+
expect(serverSpan?.is_segment).toBe(true);
22+
expect(serverSpan?.name).toBe('GET /test');
23+
expect(serverSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'route' });
24+
expect(serverSpan?.attributes?.['sentry.span.source']).toEqual({ type: 'string', value: 'route' });
25+
},
26+
})
27+
.start();
28+
29+
await runner.makeRequest('get', '/test');
30+
31+
await runner.completed();
32+
});
33+
});
34+
});

packages/core/src/tracing/spans/captureSpan.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { RawAttributes } from '../../attributes';
22
import type { Client } from '../../client';
33
import type { ScopeData } from '../../scope';
44
import {
5+
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
56
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
7+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
68
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
79
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
810
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
@@ -51,6 +53,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW
5153

5254
applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData);
5355

56+
// Backfill span data from OTel semantic conventions when not explicitly set.
57+
// OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path
58+
// infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely.
59+
// Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type.
60+
// This must run before all hooks and beforeSendSpan so that user callbacks can see and override inferred values.
61+
const spanKind = (span as { kind?: number }).kind;
62+
inferSpanDataFromOtelAttributes(spanJSON, spanKind);
63+
5464
if (spanJSON.is_segment) {
5565
applyScopeToSegmentSpan(spanJSON, finalScopeData);
5666
// Allow hook subscribers to mutate the segment span JSON
@@ -150,3 +160,119 @@ export function safeSetSpanJSONAttributes(
150160
}
151161
});
152162
}
163+
164+
// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api)
165+
const SPAN_KIND_SERVER = 1;
166+
const SPAN_KIND_CLIENT = 2;
167+
168+
/**
169+
* Infer and backfill span data from OTel semantic conventions.
170+
* This mirrors what the `SentrySpanExporter` does for non-streamed spans via `getSpanData`/`inferSpanData`.
171+
* Streamed spans skip the exporter, so we do the inference here during capture.
172+
*
173+
* Backfills: `sentry.op`, `sentry.source`, and `name` (description).
174+
* Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten.
175+
*/
176+
/** Exported only for tests. */
177+
export function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void {
178+
const attributes = spanJSON.attributes;
179+
if (!attributes) {
180+
return;
181+
}
182+
183+
const httpMethod = attributes['http.request.method'] || attributes['http.method'];
184+
if (httpMethod) {
185+
inferHttpSpanData(spanJSON, attributes, spanKind, httpMethod);
186+
return;
187+
}
188+
189+
const dbSystem = attributes['db.system.name'] || attributes['db.system'];
190+
const opIsCache =
191+
typeof attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'string' &&
192+
`${attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]}`.startsWith('cache.');
193+
if (dbSystem && !opIsCache) {
194+
inferDbSpanData(spanJSON, attributes);
195+
return;
196+
}
197+
198+
if (attributes['rpc.service']) {
199+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc' });
200+
return;
201+
}
202+
203+
if (attributes['messaging.system']) {
204+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message' });
205+
return;
206+
}
207+
208+
const faasTrigger = attributes['faas.trigger'];
209+
if (faasTrigger) {
210+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}` });
211+
}
212+
}
213+
214+
function inferHttpSpanData(
215+
spanJSON: StreamedSpanJSON,
216+
attributes: RawAttributes<Record<string, unknown>>,
217+
spanKind: number | undefined,
218+
httpMethod: unknown,
219+
): void {
220+
// Infer op: http.client, http.server, or just http
221+
const opParts = ['http'];
222+
if (spanKind === SPAN_KIND_CLIENT) {
223+
opParts.push('client');
224+
} else if (spanKind === SPAN_KIND_SERVER) {
225+
opParts.push('server');
226+
}
227+
if (attributes['sentry.http.prefetch']) {
228+
opParts.push('prefetch');
229+
}
230+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.') });
231+
232+
// If the user set a custom span name via updateSpanName(), apply it — OTel instrumentation
233+
// may have overwritten span.name after the user set it, so we restore from the attribute.
234+
const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME];
235+
if (typeof customName === 'string') {
236+
spanJSON.name = customName;
237+
return;
238+
}
239+
240+
if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') {
241+
return;
242+
}
243+
244+
// Only overwrite the span name when we have an explicit http.route — it's more specific than
245+
// what OTel instrumentation sets as the span name. For all other cases (url.full, http.target),
246+
// the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL).
247+
const httpRoute = attributes['http.route'];
248+
if (typeof httpRoute === 'string') {
249+
spanJSON.name = `${httpMethod} ${httpRoute}`;
250+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' });
251+
} else {
252+
// Fallback: set source to 'url' for HTTP spans without a route.
253+
// The spec requires sentry.span.source on segment spans, and the non-streamed exporter
254+
// always sets this — so we need to ensure it's present for streamed spans too.
255+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' });
256+
}
257+
}
258+
259+
function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes<Record<string, unknown>>): void {
260+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' });
261+
262+
// If the user set a custom span name via updateSpanName(), apply it.
263+
const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME];
264+
if (typeof customName === 'string') {
265+
spanJSON.name = customName;
266+
return;
267+
}
268+
269+
if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') {
270+
return;
271+
}
272+
273+
const statement = attributes['db.statement'];
274+
if (statement) {
275+
spanJSON.name = `${statement}`;
276+
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task' });
277+
}
278+
}

0 commit comments

Comments
 (0)