From 6c4a3e52ac9394b666df83ba7b06b2114a7b893a Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 14:22:53 +0200 Subject: [PATCH] ref(opentelemetry): Vendor minimal `TraceState` implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `TraceState` import from `@opentelemetry/core` with a small in-tree implementation under `packages/opentelemetry/src/utils/`. The SDK only ever calls `new TraceState()` and chains `.set()`/`.get()`/ `.serialize()` on it, so the vendored version drops raw-string parsing, key/value validation, and the W3C length/item caps — none of which apply to keys we control. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opentelemetry/src/sampler.ts | 2 +- .../opentelemetry/src/utils/TraceState.ts | 71 +++++++++++++++++++ .../opentelemetry/src/utils/makeTraceState.ts | 2 +- .../opentelemetry/test/contextManager.test.ts | 2 +- .../test/integration/transactions.test.ts | 2 +- packages/opentelemetry/test/sampler.test.ts | 2 +- .../test/utils/TraceState.test.ts | 44 ++++++++++++ 7 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/opentelemetry/src/utils/TraceState.ts create mode 100644 packages/opentelemetry/test/utils/TraceState.test.ts diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 05dc0758458b..be873aca3d51 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ import type { Context, Span, TraceState as TraceStateInterface } from '@opentelemetry/api'; import { isSpanContextValid, SpanKind, trace } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; +import { TraceState } from './utils/TraceState'; import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base'; import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; import { diff --git a/packages/opentelemetry/src/utils/TraceState.ts b/packages/opentelemetry/src/utils/TraceState.ts new file mode 100644 index 000000000000..326371f03093 --- /dev/null +++ b/packages/opentelemetry/src/utils/TraceState.ts @@ -0,0 +1,71 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Minimal vendored implementation of `TraceState` from `@opentelemetry/core` + * to avoid pulling in that dependency for a single class. + * - Drops raw-string parsing and key/value validation, neither of which are + * used by the SDK — the W3C `tracestate` header is parsed by OTel's own + * propagators (which use their own `TraceState`), and every key we `set` + * is a known constant. + */ +import type { TraceState as TraceStateInterface } from '@opentelemetry/api'; + +/** + * Minimal implementation of the W3C `tracestate` field as a `@opentelemetry/api` + * `TraceState`. New entries are inserted at the front of the list, and updating + * an existing key moves it to the front. + * + * See https://www.w3.org/TR/trace-context/#tracestate-field for the field spec. + */ +export class TraceState implements TraceStateInterface { + private _internalState: Map = new Map(); + + /** @inheritDoc */ + public set(key: string, value: string): TraceState { + const next = this._clone(); + if (next._internalState.has(key)) { + next._internalState.delete(key); + } + next._internalState.set(key, value); + return next; + } + + /** @inheritDoc */ + public unset(key: string): TraceState { + const next = this._clone(); + next._internalState.delete(key); + return next; + } + + /** @inheritDoc */ + public get(key: string): string | undefined { + return this._internalState.get(key); + } + + /** @inheritDoc */ + public serialize(): string { + return Array.from(this._internalState.keys()) + .reverse() + .map(key => `${key}=${this._internalState.get(key)}`) + .join(','); + } + + private _clone(): TraceState { + const next = new TraceState(); + next._internalState = new Map(this._internalState); + return next; + } +} diff --git a/packages/opentelemetry/src/utils/makeTraceState.ts b/packages/opentelemetry/src/utils/makeTraceState.ts index 5d7d6248de56..e88d7e882657 100644 --- a/packages/opentelemetry/src/utils/makeTraceState.ts +++ b/packages/opentelemetry/src/utils/makeTraceState.ts @@ -1,7 +1,7 @@ -import { TraceState } from '@opentelemetry/core'; import type { DynamicSamplingContext } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/core'; import { SENTRY_TRACE_STATE_DSC, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../constants'; +import { TraceState } from './TraceState'; /** * Generate a TraceState for the given data. diff --git a/packages/opentelemetry/test/contextManager.test.ts b/packages/opentelemetry/test/contextManager.test.ts index 4b871ac3e7bb..795c017dd456 100644 --- a/packages/opentelemetry/test/contextManager.test.ts +++ b/packages/opentelemetry/test/contextManager.test.ts @@ -1,5 +1,5 @@ import { context, trace, TraceFlags } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; +import { TraceState } from '../src/utils/TraceState'; import { afterEach, describe, expect, it } from 'vitest'; import { SENTRY_TRACE_STATE_CHILD_IGNORED } from '../src/constants'; import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index 570df4a86aa8..aae975d8d8a9 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -1,6 +1,6 @@ import type { SpanContext } from '@opentelemetry/api'; import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; +import { TraceState } from '../../src/utils/TraceState'; import type { Event, TransactionEvent } from '@sentry/core'; import { addBreadcrumb, diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 55c3cff8ac32..37c444a7b8d4 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -1,5 +1,5 @@ import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; +import { TraceState } from '../src/utils/TraceState'; import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; import { generateSpanId, generateTraceId } from '@sentry/core'; diff --git a/packages/opentelemetry/test/utils/TraceState.test.ts b/packages/opentelemetry/test/utils/TraceState.test.ts new file mode 100644 index 000000000000..779b4604e8c3 --- /dev/null +++ b/packages/opentelemetry/test/utils/TraceState.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { TraceState } from '../../src/utils/TraceState'; + +describe('TraceState', () => { + it('returns undefined for unknown keys', () => { + expect(new TraceState().get('missing')).toBeUndefined(); + }); + + it('set returns a new instance and leaves the original unchanged', () => { + const original = new TraceState(); + const next = original.set('a', '1'); + + expect(next).not.toBe(original); + expect(original.get('a')).toBeUndefined(); + expect(next.get('a')).toBe('1'); + }); + + it('moves an updated key to the front of the serialized list', () => { + const state = new TraceState().set('a', '1').set('b', '2').set('a', '3'); + + expect(state.get('a')).toBe('3'); + expect(state.serialize()).toBe('a=3,b=2'); + }); + + it('serializes newest entries first', () => { + const state = new TraceState().set('a', '1').set('b', '2').set('c', '3'); + + expect(state.serialize()).toBe('c=3,b=2,a=1'); + }); + + it('unset removes the key and returns a new instance', () => { + const state = new TraceState().set('a', '1').set('b', '2'); + const next = state.unset('a'); + + expect(next).not.toBe(state); + expect(state.get('a')).toBe('1'); + expect(next.get('a')).toBeUndefined(); + expect(next.serialize()).toBe('b=2'); + }); + + it('serializes an empty state to an empty string', () => { + expect(new TraceState().serialize()).toBe(''); + }); +});