From 9d41a8cc827b67dc15dcd99133858d377963105f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 23 Jul 2026 09:19:11 +0200 Subject: [PATCH 1/4] ref(core): Ensure error span status is always valid --- packages/core/src/tracing/index.ts | 10 ++++- packages/core/src/tracing/spanstatus.ts | 7 +++- packages/core/src/types/spanStatus.ts | 39 ++++++++++--------- packages/core/src/utils/spanUtils.ts | 4 +- .../opentelemetry/src/applyOtelSpanData.ts | 3 +- packages/opentelemetry/src/utils/mapStatus.ts | 6 +-- 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index c06373d61f56..b71f13471c19 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -13,8 +13,14 @@ export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; export { _INTERNAL_setDeferSegmentSpanCapture } from './deferSegmentSpanCapture'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; -export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; -export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; +export { + setHttpStatus, + getSpanStatusFromHttpCode, + isStatusErrorMessageValid, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + SPAN_STATUS_UNSET, +} from './spanstatus'; export { startSpan, startInactiveSpan, diff --git a/packages/core/src/tracing/spanstatus.ts b/packages/core/src/tracing/spanstatus.ts index 60074a60f44f..fbd8ef0f23f5 100644 --- a/packages/core/src/tracing/spanstatus.ts +++ b/packages/core/src/tracing/spanstatus.ts @@ -1,10 +1,15 @@ import type { Span } from '../types/span'; -import type { SpanStatus } from '../types/spanStatus'; +import type { SpanStatusType } from '../types/spanStatus'; +import { SPAN_STATUS_TYPES, type SpanStatus } from '../types/spanStatus'; export const SPAN_STATUS_UNSET = 0; export const SPAN_STATUS_OK = 1; export const SPAN_STATUS_ERROR = 2; +export function isStatusErrorMessageValid(message: string): boolean { + return SPAN_STATUS_TYPES.includes(message as SpanStatusType); +} + /** * Converts a HTTP status code into a sentry status with a message. * diff --git a/packages/core/src/types/spanStatus.ts b/packages/core/src/types/spanStatus.ts index 151530b5cb69..e1df5e563460 100644 --- a/packages/core/src/types/spanStatus.ts +++ b/packages/core/src/types/spanStatus.ts @@ -1,38 +1,41 @@ -export type SpanStatusType = +export const SPAN_STATUS_TYPES = [ /** The operation completed successfully. */ - | 'ok' + 'ok', /** Deadline expired before operation could complete. */ - | 'deadline_exceeded' + 'deadline_exceeded', /** 401 Unauthorized (actually does mean unauthenticated according to RFC 7235) */ - | 'unauthenticated' + 'unauthenticated', /** 403 Forbidden */ - | 'permission_denied' + 'permission_denied', /** 404 Not Found. Some requested entity (file or directory) was not found. */ - | 'not_found' + 'not_found', /** 429 Too Many Requests */ - | 'resource_exhausted' + 'resource_exhausted', /** Client specified an invalid argument. 4xx. */ - | 'invalid_argument' + 'invalid_argument', /** 501 Not Implemented */ - | 'unimplemented' + 'unimplemented', /** 503 Service Unavailable */ - | 'unavailable' + 'unavailable', /** Other/generic 5xx. */ - | 'internal_error' + 'internal_error', /** Unknown. Any non-standard HTTP status code. */ - | 'unknown_error' + 'unknown_error', /** The operation was cancelled (typically by the user). */ - | 'cancelled' + 'cancelled', /** Already exists (409) */ - | 'already_exists' + 'already_exists', /** Operation was rejected because the system is not in a state required for the operation's */ - | 'failed_precondition' + 'failed_precondition', /** The operation was aborted, typically due to a concurrency issue. */ - | 'aborted' + 'aborted', /** Operation was attempted past the valid range. */ - | 'out_of_range' + 'out_of_range', /** Unrecoverable data loss or corruption */ - | 'data_loss'; + 'data_loss', +] as const; + +export type SpanStatusType = (typeof SPAN_STATUS_TYPES)[number]; // These are aligned with OpenTelemetry span status codes const SPAN_STATUS_UNSET = 0; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 3bcea3e4c64a..170334da27d3 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -12,7 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; -import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; +import { isStatusErrorMessageValid, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; import type { TraceContext } from '../types/context'; import type { SpanLink, SpanLinkJSON } from '../types/link'; @@ -327,7 +327,7 @@ export function getStatusMessage(status: SpanStatus | undefined): string { return 'ok'; } - return status.message || 'internal_error'; + return status.message && isStatusErrorMessageValid(status.message) ? status.message : 'internal_error'; } /** diff --git a/packages/opentelemetry/src/applyOtelSpanData.ts b/packages/opentelemetry/src/applyOtelSpanData.ts index af1d621bcb64..1f08b4e3280a 100644 --- a/packages/opentelemetry/src/applyOtelSpanData.ts +++ b/packages/opentelemetry/src/applyOtelSpanData.ts @@ -10,9 +10,10 @@ import { spanToJSON, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + isStatusErrorMessageValid, } from '@sentry/core'; import type { Span, SpanAttributes } from '@sentry/core'; -import { inferStatusFromAttributes, isStatusErrorMessageValid } from './utils/mapStatus'; +import { inferStatusFromAttributes } from './utils/mapStatus'; import { inferSpanData } from './utils/parseSpanDescription'; /** diff --git a/packages/opentelemetry/src/utils/mapStatus.ts b/packages/opentelemetry/src/utils/mapStatus.ts index 5ebd31c912c9..53a0311eee3e 100644 --- a/packages/opentelemetry/src/utils/mapStatus.ts +++ b/packages/opentelemetry/src/utils/mapStatus.ts @@ -1,7 +1,7 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { HTTP_RESPONSE_STATUS_CODE, HTTP_STATUS_CODE, RPC_GRPC_STATUS_CODE } from '@sentry/conventions/attributes'; import type { SpanAttributes, SpanStatus } from '@sentry/core'; -import { getSpanStatusFromHttpCode, SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { getSpanStatusFromHttpCode, isStatusErrorMessageValid, SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; import type { AbstractSpan } from '../types'; import { spanHasAttributes, spanHasStatus } from './spanTypes'; @@ -25,10 +25,6 @@ const canonicalGrpcErrorCodesMap: Record = { '16': 'unauthenticated', } as const; -export const isStatusErrorMessageValid = (message: string): boolean => { - return Object.values(canonicalGrpcErrorCodesMap).includes(message as SpanStatus['message']); -}; - /** * Get a Sentry span status from an otel span. */ From 33fad20c58e9c8ae98f27871d83350bd8fa3077b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 23 Jul 2026 10:08:44 +0200 Subject: [PATCH 2/4] fix statuses --- packages/core/test/lib/tracing/sentrySpan.test.ts | 6 +++--- packages/deno/test/deno-redis.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 5f2c57a35988..5a32da39fed0 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -143,7 +143,7 @@ describe('SentrySpan', () => { describe('tracer-provider span sealing', () => { it('seals a tracer-provider span against all mutation after it ends', () => { const span = new SentrySpan({ name: 'original', startTimestamp: 1, attributes: { key: 'before' } }); - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'before' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'permission_denied' }); span.addEvent('measurement', { [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', @@ -156,7 +156,7 @@ describe('SentrySpan', () => { // Every mutator must no-op on a tracer-provider span once it has ended, mirroring OTel SDK spans. span.setAttribute('key', 'after'); span.setAttributes({ key2: 'after' }); - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'after' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'already_exists' }); span.updateName('after'); span.updateStartTime(999); span.addLink({ context: linked.spanContext() }); @@ -169,7 +169,7 @@ describe('SentrySpan', () => { const json = spanToJSON(span); expect(json.data?.['key']).toBe('before'); expect(json.data?.['key2']).toBeUndefined(); - expect(json.status).toBe('before'); + expect(json.status).toBe('permission_denied'); expect(json.description).toBe('original'); expect(json.start_timestamp).toBe(1); expect(json.links).toBeUndefined(); diff --git a/packages/deno/test/deno-redis.test.ts b/packages/deno/test/deno-redis.test.ts index cd4b59e86ec2..c16d9283b8b9 100644 --- a/packages/deno/test/deno-redis.test.ts +++ b/packages/deno/test/deno-redis.test.ts @@ -132,7 +132,7 @@ Deno.test('denoRedisIntegration: errors on the command channel set span status', // as `status: 'X'` (the message takes the slot). Both "not ok" and the // forwarded message confirm the error path fired. assert(redisSpan!.status && redisSpan!.status !== 'ok', `expected error-shaped status, got ${redisSpan!.status}`); - assertEquals(redisSpan!.status, 'ECONNREFUSED'); + assertEquals(redisSpan!.status, 'internal_error'); }); Deno.test('denoRedisIntegration: ioredis:command channel produces a db.redis child span', async () => { From 188a6741aa242e3abcd55391f34860b582d51612 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 23 Jul 2026 11:09:49 +0200 Subject: [PATCH 3/4] fix tests --- .../opentelemetry/test/tracerProvider.test.ts | 9 ++++-- .../test/mysql2/mysql2-dc-subscriber.test.ts | 2 +- .../server-utils/test/tracing-channel.test.ts | 31 +++++++++++-------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts index 1564d786adb4..27e074bbd85a 100644 --- a/packages/opentelemetry/test/tracerProvider.test.ts +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -5,6 +5,7 @@ import { getCapturedScopesOnSpan, getRootSpan, spanToJSON, + spanToStreamedSpanJSON, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpanManual, @@ -194,15 +195,17 @@ describe('SentryTracerProvider', () => { it('preserves a non-canonical error status message under span streaming', () => { // Under streaming the streamed serializer surfaces the raw message as `sentry.status.message`, so - // finalizing must not normalize it to `internal_error` the way it does for the non-streamed - // transaction status field. Without streaming, `finalizes span statuses` covers the `internal_error` case. + // finalizing must not overwrite the live span status. The transaction `status` field is always + // normalized to a valid value (`internal_error`), but the raw message survives on the streamed span. initTestClient({ tracesSampleRate: 1, traceLifecycle: 'stream' }); const span = trace.getTracer('test').startSpan('db-error'); span.setStatus({ code: SPAN_STATUS_ERROR, message: 'Cannot enqueue Query after fatal error.' }); applyOtelSpanData(span as Span, { finalizeStatus: true }); - expect(spanToJSON(span as Span).status).toBe('Cannot enqueue Query after fatal error.'); + const streamed = spanToStreamedSpanJSON(span as Span); + expect(streamed.status).toBe('error'); + expect(streamed.attributes?.['sentry.status.message']).toBe('Cannot enqueue Query after fatal error.'); }); it('infers route source, op, and name for HTTP server spans', () => { diff --git a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts index aecbdcd2870d..3ce29b95b065 100644 --- a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts @@ -236,7 +236,7 @@ describe('subscribeMysql2DiagnosticChannels', () => { { error: new Error('table missing') }, ); - expect(spanToJSON(span!).status).toBe('table missing'); + expect(spanToJSON(span!).status).toBe('internal_error'); expect(spanToJSON(span!).timestamp).toBeDefined(); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 488ece8bb60f..dd3ddc2267cb 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -16,6 +16,7 @@ import { resolvedSyncPromise, setAsyncContextStrategy, spanToJSON, + spanToStreamedSpanJSON, startInactiveSpan, startSpan, } from '@sentry/core'; @@ -292,7 +293,7 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('sync-throw'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); @@ -361,7 +362,7 @@ describe('bindTracingChannelToSpan', () => { await expect(promise).rejects.toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('async-reject'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); @@ -379,7 +380,7 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('promise-sync-throw'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); @@ -421,7 +422,7 @@ describe('bindTracingChannelToSpan', () => { }); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('callback-error'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); @@ -442,7 +443,7 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('callback-sync-throw'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); @@ -459,8 +460,11 @@ describe('bindTracingChannelToSpan', () => { ), ).toThrow('bad input'); + // The transaction status field is normalized to a valid value; the raw message survives on + // the streamed span as `sentry.status.message`. const { status, data } = spanToJSON(span); - expect(status).toBe('bad input'); + expect(status).toBe('internal_error'); + expect(spanToStreamedSpanJSON(span).attributes?.['sentry.status.message']).toBe('bad input'); expect(data['error.type']).toBe('TypeError'); }); @@ -477,7 +481,8 @@ describe('bindTracingChannelToSpan', () => { ).toThrow('plain failure'); const { status, data } = spanToJSON(span); - expect(status).toBe('plain failure'); + expect(status).toBe('internal_error'); + expect(spanToStreamedSpanJSON(span).attributes?.['sentry.status.message']).toBe('plain failure'); expect(data['error.type']).toBe('unknown'); }); @@ -546,7 +551,7 @@ describe('bindTracingChannelToSpan', () => { ).rejects.toThrow(error); expect(captureExceptionSpy).not.toHaveBeenCalled(); - expect(spanToJSON(span).status).toBe('db-down'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(spanToJSON(span).timestamp).toBeDefined(); }); @@ -576,7 +581,7 @@ describe('bindTracingChannelToSpan', () => { expect(captureExceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false }, }); - expect(spanToJSON(span).status).toBe('boom'); + expect(spanToJSON(span).status).toBe('internal_error'); }); it('captures the exception on the synchronous error path when `captureError` is true', () => { @@ -605,7 +610,7 @@ describe('bindTracingChannelToSpan', () => { expect(captureExceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false }, }); - expect(spanToJSON(span).status).toBe('sync-boom'); + expect(spanToJSON(span).status).toBe('internal_error'); }); it('captures the exception on the callback error path when `captureError` is true', async () => { @@ -637,7 +642,7 @@ describe('bindTracingChannelToSpan', () => { expect(captureExceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false }, }); - expect(spanToJSON(span).status).toBe('callback-boom'); + expect(spanToJSON(span).status).toBe('internal_error'); }); it('captures the exception with the hint returned by a `captureError` function, passing it the thrown error', async () => { @@ -669,7 +674,7 @@ describe('bindTracingChannelToSpan', () => { expect(captureExceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.http.custom', handled: false }, }); - expect(spanToJSON(span).status).toBe('boom'); + expect(spanToJSON(span).status).toBe('internal_error'); }); it('uses the default mechanism when `captureError` is a function on the synchronous error path', () => { @@ -826,7 +831,7 @@ describe('bindTracingChannelToSpan', () => { it('`end(error)` sets error status and the `error.type` attribute, then ends', () => { const { span, endSpy, end } = setupDeferred('test:defer:error'); end(new TypeError('stream blew up')); - expect(spanToJSON(span).status).toBe('stream blew up'); + expect(spanToJSON(span).status).toBe('internal_error'); expect(spanToJSON(span).data['error.type']).toBe('TypeError'); expect(endSpy).toHaveBeenCalledTimes(1); }); From 60f96733e2f698bf497bce76466b1424fe20ddeb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 Jul 2026 09:14:21 +0200 Subject: [PATCH 4/4] ensure span status is not ok --- packages/core/src/tracing/spanstatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tracing/spanstatus.ts b/packages/core/src/tracing/spanstatus.ts index fbd8ef0f23f5..9e0b1d95521c 100644 --- a/packages/core/src/tracing/spanstatus.ts +++ b/packages/core/src/tracing/spanstatus.ts @@ -7,7 +7,7 @@ export const SPAN_STATUS_OK = 1; export const SPAN_STATUS_ERROR = 2; export function isStatusErrorMessageValid(message: string): boolean { - return SPAN_STATUS_TYPES.includes(message as SpanStatusType); + return message !== 'ok' && SPAN_STATUS_TYPES.includes(message as SpanStatusType); } /**