From 1fea9e23968f0f8791e6d302f2592775b387693c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 12:02:11 +0200 Subject: [PATCH 1/3] fix(core): Serialize streamed span status message to `sentry.status.message` attribute --- packages/core/src/semanticAttributes.ts | 10 +++ packages/core/src/tracing/sentrySpan.ts | 7 +- packages/core/src/utils/spanUtils.ts | 25 +++++- .../core/test/lib/utils/spanUtils.test.ts | 80 +++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index fd3b0e0c4022..b0067f3c2a41 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -31,6 +31,16 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_OP = 'sentry.op'; */ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; +/** + * Holds the human-readable span status message (e.g. set via + * `span.setStatus({ code, message })`). + * + * Streamed (v2) span statuses are reduced to `ok`/`error`, so we preserve the + * message as an attribute instead of dropping it. This mirrors the attribute + * Sentry's OTLP ingestion uses for the same purpose. + */ +export const SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE = 'sentry.status.message'; + /** The reason why an idle span finished. */ export const SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON = 'sentry.idle_span_finish_reason'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8387c788d5fd..ce9e7b9f6b7d 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -29,9 +29,10 @@ import type { TimedEvent } from '../types/timedEvent'; import { debug } from '../utils/debug-logger'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { + addStatusMessageAttribute, convertSpanLinksForEnvelope, getRootSpan, - getSimpleStatusMessage, + getSimpleStatus, getSpanDescendants, getStatusMessage, getStreamedSpanLinks, @@ -271,8 +272,8 @@ export class SentrySpan implements Span { // just in case _endTime is not set, we use the start time (i.e. duration 0) end_timestamp: this._endTime ?? this._startTime, is_segment: this._isStandaloneSpan || this === getRootSpan(this), - status: getSimpleStatusMessage(this._status), - attributes: this._attributes, + status: getSimpleStatus(this._status), + attributes: addStatusMessageAttribute(this._attributes, this._status), links: getStreamedSpanLinks(this._links), }; } diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 9f495ef7b30e..9f61e285fe94 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -8,6 +8,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; @@ -228,8 +229,8 @@ export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON { start_timestamp: spanTimeInputToSeconds(startTime), end_timestamp: spanTimeInputToSeconds(endTime), is_segment: span === INTERNAL_getSegmentSpan(span), - status: getSimpleStatusMessage(status), - attributes, + status: getSimpleStatus(status), + attributes: addStatusMessageAttribute(attributes, status), links: getStreamedSpanLinks(links), }; } @@ -330,7 +331,7 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef /** * Convert the various statuses to the simple ones expected by Sentry for streamed spans ('ok' is default). */ -export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { +export function getSimpleStatus(status: SpanStatus | undefined): 'ok' | 'error' { return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET || @@ -339,6 +340,24 @@ export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | ' : 'error'; } +/** + * Returns the span's attributes with the SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE attribute added + * if the span has an error status message worth preserving. + * + * An explicitly set attribute is never overwritten, and the original attributes + * reference is returned untouched when there is no message to add. + */ +export function addStatusMessageAttribute( + attributes: SpanAttributes, + status: SpanStatus | undefined, +): RawAttributes> { + const statusMessage = getSimpleStatus(status) === 'error' ? status?.message : undefined; + return { + ...(statusMessage && { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: statusMessage }), + ...attributes, + }; +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index f810bb414741..d8b0009cffee 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -4,6 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentrySpan, setCurrentClient, @@ -493,6 +494,52 @@ describe('spanToJSON', () => { ], }); }); + it('preserves an error status message as the sentry.status.message attribute', () => { + const span = new SentrySpan({ name: 'test name' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'Connection Refused' }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('error'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + }); + + it('does not set a status message for ok spans', () => { + const span = new SentrySpan({ name: 'test name' }); + span.setStatus({ code: SPAN_STATUS_OK }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('ok'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + }); + + it('does not set a status message for error spans without a message', () => { + const span = new SentrySpan({ name: 'test name' }); + span.setStatus({ code: SPAN_STATUS_ERROR }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('error'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + }); + + it('treats a cancelled status as ok and does not set a status message', () => { + const span = new SentrySpan({ name: 'test name' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('ok'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + }); + + it('does not overwrite an explicitly set sentry.status.message attribute', () => { + const span = new SentrySpan({ + name: 'test name', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'explicit message' }, + }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'Connection Refused' }); + + const json = spanToStreamedSpanJSON(span); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('explicit message'); + }); }); describe('OpenTelemetry Span', () => { it('converts a simple span', () => { @@ -562,6 +609,7 @@ describe('spanToJSON', () => { attr2: 2, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'unknown_error', }, links: [ { @@ -575,6 +623,38 @@ describe('spanToJSON', () => { ], }); }); + + it('preserves a custom error status message as the sentry.status.message attribute', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + startTime: 123, + endTime: 456, + attributes: {}, + status: { code: SPAN_STATUS_ERROR, message: 'Connection Refused' }, + }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('error'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + }); + + it('does not set a status message for ok/unset spans', () => { + const span = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + startTime: 123, + endTime: 456, + attributes: {}, + status: { code: SPAN_STATUS_UNSET }, + }); + + const json = spanToStreamedSpanJSON(span); + expect(json.status).toBe('ok'); + expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + }); }); }); From c18bd3ec73855270960eefa0f7be9290152eb909 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 12:09:15 +0200 Subject: [PATCH 2/3] lint --- packages/core/src/utils/spanUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 9f61e285fe94..edd5309cee41 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -1,3 +1,4 @@ +// oxlint-disable max-lines import { getAsyncContextStrategy } from '../asyncContext'; import type { RawAttributes } from '../attributes'; import { serializeAttributes } from '../attributes'; From b1a19b95b0534ff1fd6d4f40c9d8480a9982b4e5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 16:54:55 +0200 Subject: [PATCH 3/3] integration tests --- .../public-api/startSpan/streamed/subject.js | 4 ++- .../public-api/startSpan/streamed/test.ts | 7 ++++- .../suites/tracing/mysql-streamed/test.ts | 27 ++++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js index 7e4395e06708..afe2527d8c7e 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js @@ -7,7 +7,9 @@ Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { inactiveSpan.end(); Sentry.startSpanManual({ name: 'test-manual-span' }, span => { - // noop + // 2 = SPAN_STATUS_ERROR. The message must be preserved as the `sentry.status.message` + // attribute on the streamed span, since v2 statuses are reduced to ok/error. + span.setStatus({ code: 2, message: 'Connection Refused' }); span.end(); }); }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 39febca888ee..0b6af5fb420d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -11,6 +11,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -171,6 +172,10 @@ sentryTest( type: 'string', value: 'production', }, + [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: { + type: 'string', + value: 'Connection Refused', + }, }, end_timestamp: expect.any(Number), is_segment: false, @@ -178,7 +183,7 @@ sentryTest( parent_span_id: segmentSpanId, span_id: expect.stringMatching(/^[\da-f]{16}$/), start_timestamp: expect.any(Number), - status: 'ok', + status: 'error', trace_id: traceId, }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql-streamed/test.ts index 61015776e09b..59b611d96a95 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql-streamed/test.ts @@ -9,7 +9,7 @@ describe('mysql auto instrumentation (streamed)', () => { cleanupChildProcesses(); }); - const assertMysqlSpans = (container: SerializedStreamedSpanContainer): void => { + const assertMysqlSpans = (container: SerializedStreamedSpanContainer, override?: Record): void => { const segmentSpan = container.items.find(item => item.is_segment); expect(segmentSpan?.name).toBe('Test Transaction'); @@ -112,6 +112,7 @@ describe('mysql auto instrumentation (streamed)', () => { type: 'string', value: 'SELECT NOW()', }, + ...override?.attributes, }, name: 'SELECT NOW()', ...COMMON_SPAN_PROPS, @@ -122,7 +123,17 @@ describe('mysql auto instrumentation (streamed)', () => { describe('with connection.connect()', () => { createCjsTests(__dirname, 'scenario-withConnect.mjs', 'instrument.mjs', (createTestRunner, test) => { test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createTestRunner().expect({ span: assertMysqlSpans }).start().completed(); + await createTestRunner() + .expect({ + span: container => + assertMysqlSpans(container, { + attributes: { + 'sentry.status.message': { type: 'string', value: 'Cannot enqueue Query after fatal error.' }, + }, + }), + }) + .start() + .completed(); }); }); }); @@ -138,7 +149,17 @@ describe('mysql auto instrumentation (streamed)', () => { describe('without connection.connect()', () => { createCjsTests(__dirname, 'scenario-withoutConnect.mjs', 'instrument.mjs', (createTestRunner, test) => { test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createTestRunner().expect({ span: assertMysqlSpans }).start().completed(); + await createTestRunner() + .expect({ + span: container => + assertMysqlSpans(container, { + attributes: { + 'sentry.status.message': { type: 'string', value: 'Cannot enqueue Query after fatal error.' }, + }, + }), + }) + .start() + .completed(); }); }); });