From ee3c49dd3ce2699e1ea5ec2a50c06af5fa1f3fc7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 12:02:11 +0200 Subject: [PATCH 1/5] 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 10068d133ae644c779b8355cd138422ea8f50190 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 12:09:15 +0200 Subject: [PATCH 2/5] 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 3ce22c74cccf2ddc88ceaa6a56604377aabc46d5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 26 Jun 2026 16:54:55 +0200 Subject: [PATCH 3/5] 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 fab2dd050aeb..b91b85dfe8a9 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'); @@ -116,6 +116,7 @@ describe('mysql auto instrumentation (streamed)', () => { type: 'string', value: 'SELECT NOW()', }, + ...override?.attributes, }, name: 'SELECT NOW()', ...COMMON_SPAN_PROPS, @@ -126,7 +127,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(); }); }); }); @@ -142,7 +153,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(); }); }); }); From adb3478032e8713d4dd09556f86a784433922651 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 29 Jun 2026 12:16:10 +0200 Subject: [PATCH 4/5] check docs --- packages/core/src/utils/spanUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index edd5309cee41..b773668aeb40 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -345,8 +345,7 @@ export function getSimpleStatus(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. + * An explicitly set attribute is never overwritten. */ export function addStatusMessageAttribute( attributes: SpanAttributes, From 118c27dd1beecf95129a08fedb26f92f346382a1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 29 Jun 2026 12:26:38 +0200 Subject: [PATCH 5/5] fix node 18 integration tests --- .../suites/tracing/mysql-streamed/test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 b91b85dfe8a9..5c50636e213c 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 @@ -3,6 +3,7 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses } from '../../../utils/runner'; import { createCjsTests } from '../../../utils/runner/createEsmAndCjsTests'; +import { NODE_VERSION } from '@sentry/node'; describe('mysql auto instrumentation (streamed)', () => { afterAll(() => { @@ -19,6 +20,8 @@ describe('mysql auto instrumentation (streamed)', () => { expect(dbSpans.length).toBe(2); + const isNode18 = NODE_VERSION.major === 18; + const COMMON_ATTRIBUTES = { 'db.connection_string': { type: 'string', @@ -84,6 +87,9 @@ describe('mysql auto instrumentation (streamed)', () => { type: 'string', value: 'task', }, + ...(isNode18 && { + 'sentry.status.message': { type: 'string', value: expect.stringMatching(/^connect ECONNREFUSED/) }, + }), }; const COMMON_SPAN_PROPS = {