From a80c9986bd0b2866b3011f152ef5065f88857fe9 Mon Sep 17 00:00:00 2001 From: Agent Harness Date: Sat, 31 Oct 2026 06:19:17 +0000 Subject: [PATCH] build(deps): move OpenTelemetry to the 3.x SDK and 0.300 exporters Supersedes the dependabot bumps of sdk-logs and resources, which could not land on their own: 3.x peers on api >=1.10 <1.11 and the stable packages only pair with the 0.300 exporters. sdk-trace-base and sdk-trace-node have no 3.x release (deprecated for sdk-trace), so tracing moves to sdk-trace plus context-async-hooks. NodeTracerProvider.register() is gone, so the global provider and context manager are set explicitly. Batch and simple span processors now take an options object. Co-Authored-By: Claude Sonnet 5.5 --- packages/server/package.json | 8 +- packages/server/src/tracing.ts | 28 +++-- packages/server/test/command-tracing.test.ts | 12 +- packages/server/test/request-tracing.test.ts | 10 +- packages/server/test/tracing.test.ts | 70 ++++++++++++ pnpm-lock.yaml | 109 +++---------------- 6 files changed, 121 insertions(+), 116 deletions(-) create mode 100644 packages/server/test/tracing.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index f800b15..75be5e5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,12 +28,12 @@ "@grpc/grpc-js": "^1.14.6", "@opentelemetry/api": "^1.10.0", "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/context-async-hooks": "^3.0.0", "@opentelemetry/exporter-logs-otlp-http": "^0.300.0", "@opentelemetry/exporter-trace-otlp-http": "^0.300.0", - "@opentelemetry/resources": "^2.12.0", - "@opentelemetry/sdk-logs": "^0.223.0", - "@opentelemetry/sdk-trace-base": "^2.12.0", - "@opentelemetry/sdk-trace-node": "^2.12.0", + "@opentelemetry/resources": "^3.0.0", + "@opentelemetry/sdk-logs": "^3.0.0", + "@opentelemetry/sdk-trace": "^3.0.0", "@opentelemetry/semantic-conventions": "^1.43.0", "@remote-sql-agent/protocol": "workspace:*", "argon2": "^0.45.1", diff --git a/packages/server/src/tracing.ts b/packages/server/src/tracing.ts index 3f55d22..69c09ff 100644 --- a/packages/server/src/tracing.ts +++ b/packages/server/src/tracing.ts @@ -1,6 +1,10 @@ -import { trace, type Tracer } from '@opentelemetry/api'; -import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'; -import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base'; +import { context, trace, type Tracer } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { + BatchSpanProcessor, + TraceIdRatioBasedSampler, + TracerProvider, +} from '@opentelemetry/sdk-trace'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; @@ -32,7 +36,8 @@ import type { ServerConfig } from './config.js'; const SERVICE_VERSION = '0.2.0'; const TRACER_NAME = 'rsagent.server'; -let provider: NodeTracerProvider | null = null; +let provider: TracerProvider | null = null; +let contextManager: AsyncLocalStorageContextManager | null = null; export function tracingEnabled(config: ServerConfig): boolean { return config.trace.otlpEnabled && Boolean(config.trace.otlpEndpoint); @@ -58,12 +63,12 @@ export function startTracing(config: ServerConfig, logger: Logger): void { headers: config.trace.otlpHeaders, }); - provider = new NodeTracerProvider({ + provider = new TracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: config.trace.serviceName, [ATTR_SERVICE_VERSION]: SERVICE_VERSION, }), - spanProcessors: [new BatchSpanProcessor(exporter)], + spanProcessors: [new BatchSpanProcessor({ exporter })], // Below 1 lets a large estate cap trace volume without touching the // collector; the default of 1 (every request and command traced) is right // for the traffic this product actually sees — a handful of operators, @@ -73,7 +78,12 @@ export function startTracing(config: ServerConfig, logger: Logger): void { ? new TraceIdRatioBasedSampler(config.trace.sampleRatio) : undefined, }); - provider.register(); + // sdk-trace 3.x dropped NodeTracerProvider.register(): the provider no longer + // wires itself into the API, so the global provider and the async-local + // context manager (which keeps the active span across awaits) are set here. + contextManager = new AsyncLocalStorageContextManager().enable(); + context.setGlobalContextManager(contextManager); + trace.setGlobalTracerProvider(provider); logger.info( { endpoint: config.trace.otlpEndpoint, sampleRatio: config.trace.sampleRatio }, @@ -85,6 +95,10 @@ export async function stopTracing(): Promise { await provider?.forceFlush().catch(() => undefined); await provider?.shutdown().catch(() => undefined); provider = null; + contextManager?.disable(); + contextManager = null; + trace.disable(); + context.disable(); } export function getTracer(): Tracer { diff --git a/packages/server/test/command-tracing.test.ts b/packages/server/test/command-tracing.test.ts index 7420914..ead1286 100644 --- a/packages/server/test/command-tracing.test.ts +++ b/packages/server/test/command-tracing.test.ts @@ -4,11 +4,11 @@ import { eq } from 'drizzle-orm'; import { generateCommandSigningKeyPair } from '@remote-sql-agent/protocol'; import { trace, SpanStatusCode } from '@opentelemetry/api'; import { - NodeTracerProvider, + TracerProvider, InMemorySpanExporter, SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-node'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +} from '@opentelemetry/sdk-trace'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; import type { Database } from '../src/db/client.js'; import { loadConfig } from '../src/config.js'; import { CommandService } from '../src/domain/commands.js'; @@ -40,10 +40,10 @@ const signingKeyPem = generateCommandSigningKeyPair().privateKeyPem; beforeAll(async () => { ({ db, close } = await setupTestDatabase('command_tracing')); - const provider = new NodeTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], + const provider = new TracerProvider({ + spanProcessors: [new SimpleSpanProcessor({ exporter })], }); - provider.register(); + trace.setGlobalTracerProvider(provider); }); afterAll(async () => { diff --git a/packages/server/test/request-tracing.test.ts b/packages/server/test/request-tracing.test.ts index 4dcaf2a..f39c485 100644 --- a/packages/server/test/request-tracing.test.ts +++ b/packages/server/test/request-tracing.test.ts @@ -3,10 +3,10 @@ import { pino } from 'pino'; import { generateKeyPairSync } from 'node:crypto'; import { trace, SpanStatusCode } from '@opentelemetry/api'; import { - NodeTracerProvider, + TracerProvider, InMemorySpanExporter, SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-node'; +} from '@opentelemetry/sdk-trace'; import type { Database } from '../src/db/client.js'; import { loadConfig } from '../src/config.js'; import { createApp } from '../src/api/app.js'; @@ -34,10 +34,10 @@ const signingKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toStri beforeAll(async () => { ({ db, close } = await setupTestDatabase('request_tracing')); - const provider = new NodeTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], + const provider = new TracerProvider({ + spanProcessors: [new SimpleSpanProcessor({ exporter })], }); - provider.register(); + trace.setGlobalTracerProvider(provider); }); afterAll(async () => { diff --git a/packages/server/test/tracing.test.ts b/packages/server/test/tracing.test.ts new file mode 100644 index 0000000..040d45b --- /dev/null +++ b/packages/server/test/tracing.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { pino } from 'pino'; +import { context, trace } from '@opentelemetry/api'; +import type { ServerConfig } from '../src/config.js'; +import { getTracer, startTracing, stopTracing, tracingEnabled } from '../src/tracing.js'; + +/** + * startTracing wires the OpenTelemetry 3.x SDK into the API by hand (the SDK's + * own `register()` is gone), so this checks the wiring itself: a real global + * provider, and a context manager that keeps the active span across awaits. + * No database or collector is needed; nothing is exported (the endpoint is + * never contacted before shutdown, which swallows the flush failure). + */ +function config(trace: Partial = {}): ServerConfig { + return { + trace: { + otlpEnabled: true, + otlpEndpoint: 'http://127.0.0.1:9/v1/traces', + otlpHeaders: {}, + serviceName: 'rsagent-test', + sampleRatio: 1, + ...trace, + }, + } as ServerConfig; +} + +const logger = pino({ level: 'silent' }); + +afterEach(async () => { + await stopTracing(); +}); + +describe('tracing', () => { + it('is disabled without an endpoint', () => { + expect(tracingEnabled(config({ otlpEndpoint: undefined }))).toBe(false); + expect(tracingEnabled(config({ otlpEnabled: false }))).toBe(false); + expect(tracingEnabled(config())).toBe(true); + }); + + it('leaves the API no-op when disabled', () => { + startTracing(config({ otlpEnabled: false }), logger); + expect(getTracer().startSpan('x').isRecording()).toBe(false); + }); + + it('records spans and keeps the active span across awaits once started', async () => { + startTracing(config(), logger); + const tracer = getTracer(); + + await tracer.startActiveSpan('outer', async (outer) => { + expect(outer.isRecording()).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(trace.getSpan(context.active())).toBe(outer); + const child = tracer.startSpan('inner'); + expect(child.spanContext().traceId).toBe(outer.spanContext().traceId); + child.end(); + outer.end(); + }); + }); + + it('honours a sample ratio of 0', () => { + startTracing(config({ sampleRatio: 0 }), logger); + expect(getTracer().startSpan('dropped').isRecording()).toBe(false); + }); + + it('is a no-op again after stopTracing', async () => { + startTracing(config(), logger); + await stopTracing(); + expect(getTracer().startSpan('after').isRecording()).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f28558a..88c10d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@opentelemetry/api-logs': specifier: ^0.223.0 version: 0.223.0 + '@opentelemetry/context-async-hooks': + specifier: ^3.0.0 + version: 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.300.0 version: 0.300.0(@opentelemetry/api@1.10.0) @@ -151,17 +154,14 @@ importers: specifier: ^0.300.0 version: 0.300.0(@opentelemetry/api@1.10.0) '@opentelemetry/resources': - specifier: ^2.12.0 - version: 2.12.0(@opentelemetry/api@1.10.0) + specifier: ^3.0.0 + version: 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/sdk-logs': - specifier: ^0.223.0 - version: 0.223.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace-base': - specifier: ^2.12.0 - version: 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace-node': - specifier: ^2.12.0 - version: 2.12.0(@opentelemetry/api@1.10.0) + specifier: ^3.0.0 + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: ^3.0.0 + version: 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/semantic-conventions': specifier: ^1.43.0 version: 1.43.0 @@ -1137,17 +1137,11 @@ packages: resolution: {integrity: sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@2.12.0': - resolution: {integrity: sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@2.12.0': - resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} - engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/context-async-hooks@3.0.0': + resolution: {integrity: sha512-qDZeKSgTT+qTaL99eRbpvWO5+oZ0MKvMH4pqvzi6xYKyhiMTDBxBeTLI6BpdeMt58T5TpxHJvFl9P52i6CDWGA==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/api': '>=1.0.0 <1.11.0' '@opentelemetry/core@3.0.0': resolution: {integrity: sha512-whOLlww0R/RLtCWGBXhK1fdvL9t2/TL0fqdjZvcaoUUuNilRPjXecqQdzyVwV68s6XEDA5oYbm7/bZT62GXF4A==} @@ -1179,24 +1173,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/resources@2.12.0': - resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@3.0.0': resolution: {integrity: sha512-4762KVmlDroRIt+4Ra8AnwcQqdXlkAR55jDrhzna8bzhJx1mUzvHW/Lh/OemFuY9+ouM4xaDc6CbUmoJmM6BFg==} engines: {node: '>=22.15.0'} peerDependencies: '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/sdk-logs@0.223.0': - resolution: {integrity: sha512-ApiAfUiUh6fmJt1GbsBuoeDn8D8IL42UvGyPUr8o1Z89ZeIiOCE1rZ78KkIUjEX3OV8Oorl9ez5Qjo43ZynDyw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-logs@3.0.0': resolution: {integrity: sha512-IysNZWrBk2PqYw4qU9PZcC7pjrZLa+KLk7vcLmPPNBUr97MgeamWhX0DUHsmxwJv3dnjYM6mLRmP8mw69ld+dA==} engines: {node: '>=22.15.0'} @@ -1209,26 +1191,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/sdk-trace-base@2.12.0': - resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} - engines: {node: ^18.19.0 || >=20.6.0} - deprecated: Use @opentelemetry/sdk-trace package - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-trace-node@2.12.0': - resolution: {integrity: sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==} - engines: {node: ^18.19.0 || >=20.6.0} - deprecated: Use @opentelemetry/sdk-trace package - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/sdk-trace@2.12.0': - resolution: {integrity: sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace@3.0.0': resolution: {integrity: sha512-4T0DbDGdtsjNmYK5/lNalHro4fsMZ0UZ3JE+C8fcqYP25xaJC22FHg8kdJNJ21srjJY5XoT7WNFCtj88NNTl4Q==} engines: {node: '>=22.15.0'} @@ -3690,15 +3652,10 @@ snapshots: '@opentelemetry/api@1.10.0': {} - '@opentelemetry/context-async-hooks@2.12.0(@opentelemetry/api@1.10.0)': + '@opentelemetry/context-async-hooks@3.0.0(@opentelemetry/api@1.10.0)': dependencies: '@opentelemetry/api': 1.10.0 - '@opentelemetry/core@2.12.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/core@3.0.0(@opentelemetry/api@1.10.0)': dependencies: '@opentelemetry/api': 1.10.0 @@ -3733,26 +3690,12 @@ snapshots: '@opentelemetry/sdk-metrics': 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/sdk-trace': 3.0.0(@opentelemetry/api@1.10.0) - '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/resources@3.0.0(@opentelemetry/api@1.10.0)': dependencies: '@opentelemetry/api': 1.10.0 '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-logs@0.223.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/api-logs': 0.223.0 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-logs@3.0.0(@opentelemetry/api@1.10.0)': dependencies: '@opentelemetry/api': 1.10.0 @@ -3766,28 +3709,6 @@ snapshots: '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/resources': 3.0.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/semantic-conventions': 1.43.0 - - '@opentelemetry/sdk-trace-node@2.12.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/context-async-hooks': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.10.0) - - '@opentelemetry/sdk-trace@2.12.0(@opentelemetry/api@1.10.0)': - dependencies: - '@opentelemetry/api': 1.10.0 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.10.0) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace@3.0.0(@opentelemetry/api@1.10.0)': dependencies: '@opentelemetry/api': 1.10.0