From dcd3328fbe96cd6bd965a975d3dba0ba086c558d Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:13:59 +0100 Subject: [PATCH 01/15] feat(otel): scaffold @agentspec/otel package --- packages/otel/package.json | 40 ++++++++++++++++++++++++++++++++++++ packages/otel/src/index.ts | 2 ++ packages/otel/tsconfig.json | 20 ++++++++++++++++++ packages/otel/tsup.config.ts | 9 ++++++++ 4 files changed, 71 insertions(+) create mode 100644 packages/otel/package.json create mode 100644 packages/otel/src/index.ts create mode 100644 packages/otel/tsconfig.json create mode 100644 packages/otel/tsup.config.ts diff --git a/packages/otel/package.json b/packages/otel/package.json new file mode 100644 index 0000000..11be769 --- /dev/null +++ b/packages/otel/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agentspec/otel", + "version": "0.1.0", + "description": "AgentSpec OpenTelemetry tracing - shared provider, exporter, and context propagation", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", + "@opentelemetry/resources": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts new file mode 100644 index 0000000..4a75aff --- /dev/null +++ b/packages/otel/src/index.ts @@ -0,0 +1,2 @@ +// Public API - implementations added in subsequent tasks +export {} diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json new file mode 100644 index 0000000..2ea0f76 --- /dev/null +++ b/packages/otel/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} diff --git a/packages/otel/tsup.config.ts b/packages/otel/tsup.config.ts new file mode 100644 index 0000000..65431c1 --- /dev/null +++ b/packages/otel/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, +}) From 09973441f4f217f0b9ee41f409258ced9f322db0 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:15:51 +0100 Subject: [PATCH 02/15] feat(otel): implement rate-based sampler --- packages/otel/src/__tests__/sampler.test.ts | 50 +++++++++++++++++++++ packages/otel/src/sampler.ts | 22 +++++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/otel/src/__tests__/sampler.test.ts create mode 100644 packages/otel/src/sampler.ts diff --git a/packages/otel/src/__tests__/sampler.test.ts b/packages/otel/src/__tests__/sampler.test.ts new file mode 100644 index 0000000..c54d144 --- /dev/null +++ b/packages/otel/src/__tests__/sampler.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { createSampler } from '../sampler.js' +import { SamplingDecision, type SamplingResult } from '@opentelemetry/sdk-trace-base' +import { SpanKind, ROOT_CONTEXT } from '@opentelemetry/api' + +describe('createSampler', () => { + it('returns a sampler that records all spans when sampleRate is 1.0', () => { + const sampler = createSampler(1.0) + const result: SamplingResult = sampler.shouldSample( + ROOT_CONTEXT, + '0af7651916cd43dd8448eb211c80319c', + 'test-span', + SpanKind.INTERNAL, + {}, + [], + ) + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) + }) + + it('returns a sampler that drops all spans when sampleRate is 0', () => { + const sampler = createSampler(0) + const result: SamplingResult = sampler.shouldSample( + ROOT_CONTEXT, + '0af7651916cd43dd8448eb211c80319c', + 'test-span', + SpanKind.INTERNAL, + {}, + [], + ) + expect(result.decision).toBe(SamplingDecision.NOT_RECORD) + }) + + it('defaults to sampleRate 1.0 when undefined', () => { + const sampler = createSampler(undefined) + const result: SamplingResult = sampler.shouldSample( + ROOT_CONTEXT, + '0af7651916cd43dd8448eb211c80319c', + 'test-span', + SpanKind.INTERNAL, + {}, + [], + ) + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) + }) + + it('returns a toString describing the sampler', () => { + const sampler = createSampler(0.5) + expect(sampler.toString()).toBe('AgentSpecSampler{0.5}') + }) +}) diff --git a/packages/otel/src/sampler.ts b/packages/otel/src/sampler.ts new file mode 100644 index 0000000..d239060 --- /dev/null +++ b/packages/otel/src/sampler.ts @@ -0,0 +1,22 @@ +import { TraceIdRatioBasedSampler, AlwaysOnSampler, AlwaysOffSampler } from '@opentelemetry/sdk-trace-base' +import type { Sampler } from '@opentelemetry/sdk-trace-base' + +/** + * Create a sampler from the manifest's sampleRate value. + * + * Uses OTel's built-in TraceIdRatioBasedSampler for fractional rates, + * with fast-path AlwaysOn/AlwaysOff for the 1.0 and 0 cases. + */ +export function createSampler(sampleRate: number | undefined): Sampler { + const rate = sampleRate ?? 1.0 + + if (rate >= 1.0) return new AlwaysOnSampler() + if (rate <= 0) return new AlwaysOffSampler() + + const inner = new TraceIdRatioBasedSampler(rate) + + return { + shouldSample: inner.shouldSample.bind(inner), + toString: () => `AgentSpecSampler{${rate}}`, + } +} From d727d331383f88c9ac0edae88bda8056c4c353b3 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:25:14 +0100 Subject: [PATCH 03/15] feat(otel): implement W3C trace context propagation Adds injectContext/extractContext/setupPropagation helpers wrapping the W3CTraceContextPropagator from @opentelemetry/core. Adds @opentelemetry/core as an explicit dependency since it is not re-exported by sdk-trace-base. --- packages/otel/package.json | 1 + .../otel/src/__tests__/propagation.test.ts | 120 +++++ packages/otel/src/propagation.ts | 51 ++ pnpm-lock.yaml | 435 ++++++++++++++++++ 4 files changed, 607 insertions(+) create mode 100644 packages/otel/src/__tests__/propagation.test.ts create mode 100644 packages/otel/src/propagation.ts diff --git a/packages/otel/package.json b/packages/otel/package.json index 11be769..802f928 100644 --- a/packages/otel/package.json +++ b/packages/otel/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0", "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", diff --git a/packages/otel/src/__tests__/propagation.test.ts b/packages/otel/src/__tests__/propagation.test.ts new file mode 100644 index 0000000..8b07433 --- /dev/null +++ b/packages/otel/src/__tests__/propagation.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { trace, context, propagation, ROOT_CONTEXT, type Context, type ContextManager } from '@opentelemetry/api' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { extractContext, injectContext, setupPropagation } from '../propagation.js' + +// ── Minimal synchronous stack-based context manager for testing ─────────────── +// @opentelemetry/api@1.9.x requires an explicit context manager to be registered +// for context.with() / context.active() to work. This avoids the extra dep on +// @opentelemetry/context-async-hooks. +class StackContextManager implements ContextManager { + private _stack: Context[] = [ROOT_CONTEXT] + + active(): Context { + return this._stack[this._stack.length - 1] + } + + with ReturnType>( + ctx: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + this._stack.push(ctx) + try { + return fn.call(thisArg, ...args) + } finally { + this._stack.pop() + } + } + + bind(_ctx: Context, target: T): T { + return target + } + + enable(): this { + return this + } + + disable(): this { + this._stack = [ROOT_CONTEXT] + return this + } +} + +describe('propagation', () => { + let provider: BasicTracerProvider + let exporter: InMemorySpanExporter + + beforeEach(() => { + exporter = new InMemorySpanExporter() + provider = new BasicTracerProvider() + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) + provider.register({ contextManager: new StackContextManager().enable() }) + setupPropagation() + }) + + afterEach(async () => { + await provider.shutdown() + trace.disable() + propagation.disable() + context.disable() + }) + + it('injectContext sets traceparent header in the carrier', () => { + const tracer = trace.getTracer('test') + const span = tracer.startSpan('parent') + const ctx = trace.setSpan(context.active(), span) + + const headers: Record = {} + context.with(ctx, () => { + injectContext(headers) + }) + + expect(headers['traceparent']).toBeDefined() + expect(headers['traceparent']).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/, + ) + span.end() + }) + + it('extractContext parses traceparent and returns a context with the span', () => { + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' + const headers: Record = { traceparent } + + const extracted = extractContext(headers) + const spanContext = trace.getSpanContext(extracted) + + expect(spanContext).toBeDefined() + expect(spanContext!.traceId).toBe('0af7651916cd43dd8448eb211c80319c') + expect(spanContext!.spanId).toBe('b7ad6b7169203331') + }) + + it('extractContext returns ROOT_CONTEXT when no traceparent present', () => { + const extracted = extractContext({}) + const spanContext = trace.getSpanContext(extracted) + expect(spanContext).toBeUndefined() + }) + + it('round-trips: inject then extract preserves traceId', () => { + const tracer = trace.getTracer('test') + const span = tracer.startSpan('roundtrip') + const ctx = trace.setSpan(context.active(), span) + + const headers: Record = {} + context.with(ctx, () => { + injectContext(headers) + }) + + const extracted = extractContext(headers as Record) + const extractedSpanCtx = trace.getSpanContext(extracted) + const originalSpanCtx = span.spanContext() + + expect(extractedSpanCtx!.traceId).toBe(originalSpanCtx.traceId) + span.end() + }) +}) diff --git a/packages/otel/src/propagation.ts b/packages/otel/src/propagation.ts new file mode 100644 index 0000000..e608307 --- /dev/null +++ b/packages/otel/src/propagation.ts @@ -0,0 +1,51 @@ +import { context, propagation, ROOT_CONTEXT, type Context } from '@opentelemetry/api' +import { W3CTraceContextPropagator } from '@opentelemetry/core' + +// ── Module-private propagator instance ─────────────────────────────────────── + +const propagator = new W3CTraceContextPropagator() + +// ── Carrier adapters ────────────────────────────────────────────────────────── + +/** Carrier adapter for plain header objects (inject direction). */ +const injectSetter = { + set(carrier: Record, key: string, value: string): void { + carrier[key] = value + }, +} + +/** Carrier adapter for plain header objects (extract direction). */ +const extractGetter = { + get(carrier: Record, key: string): string | undefined { + return carrier[key] + }, + keys(carrier: Record): string[] { + return Object.keys(carrier) + }, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Register the W3C trace context propagator globally. + * Call once at startup, after initTracing(). + */ +export function setupPropagation(): void { + propagation.setGlobalPropagator(propagator) +} + +/** + * Inject the current active span's trace context into outgoing HTTP headers. + * The `traceparent` header is set on the provided carrier object. + */ +export function injectContext(headers: Record): void { + propagation.inject(context.active(), headers, injectSetter) +} + +/** + * Extract trace context from incoming HTTP headers. + * Returns a Context that can be used as a parent for new spans. + */ +export function extractContext(headers: Record): Context { + return propagation.extract(ROOT_CONTEXT, headers, extractGetter) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0d165a..f6265fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,43 @@ importers: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.37) + packages/otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/core': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': + specifier: ^0.57.0 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.57.0 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.30.0 + version: 1.40.0 + devDependencies: + '@types/node': + specifier: ^20.17.0 + version: 20.19.37 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@20.19.37) + packages/sdk: dependencies: js-yaml: @@ -630,6 +667,15 @@ packages: '@fastify/reply-from@9.8.0': resolution: {integrity: sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@iconify-json/simple-icons@1.2.71': resolution: {integrity: sha512-rNoDFbq1fAYiEexBvrw613/xiUOPEu5MKVV/X8lI64AgdTzLQUUemr9f9fplxUMPoxCBP2rWzlhOEeTHk/Sf0Q==} @@ -661,6 +707,85 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2': + resolution: {integrity: sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.57.2': + resolution: {integrity: sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.57.2': + resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2': + resolution: {integrity: sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -668,6 +793,36 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -702,66 +857,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1166,6 +1334,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1294,6 +1466,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1396,6 +1572,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -1554,6 +1734,12 @@ packages: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -1790,6 +1976,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1817,6 +2007,10 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2246,6 +2440,18 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -2644,6 +2850,18 @@ snapshots: toad-cache: 3.7.0 undici: 5.29.0 + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@iconify-json/simple-icons@1.2.71': dependencies: '@iconify/types': 2.0.0 @@ -2679,11 +2897,122 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -2893,6 +3222,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.34) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.37))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.37) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -3179,6 +3516,12 @@ snapshots: dependencies: readdirp: 4.1.2 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3325,6 +3668,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -3462,6 +3807,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -3622,6 +3969,10 @@ snapshots: mlly: 1.8.0 pkg-types: 1.3.1 + lodash.camelcase@4.3.0: {} + + long@5.3.2: {} + loupe@2.3.7: dependencies: get-func-name: 2.0.2 @@ -3841,6 +4192,21 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.19.37 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3864,6 +4230,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@5.0.0: {} @@ -4182,6 +4550,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@20.19.37): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.37) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@20.19.34): dependencies: esbuild: 0.21.5 @@ -4318,6 +4704,41 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@20.19.37): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.37)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.37) + vite-node: 2.1.9(@types/node@20.19.37) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.37 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vue@3.5.29(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.29 @@ -4362,6 +4783,20 @@ snapshots: ws@8.19.0: {} + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@1.2.2: {} yoctocolors@2.1.2: {} From a452c5828740dfad93aad987014effbd7cca0763 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:31:10 +0100 Subject: [PATCH 04/15] feat(otel): implement TracerProvider with OTLP exporters --- packages/otel/src/__tests__/provider.test.ts | 88 +++++++++++++++++++ packages/otel/src/index.ts | 5 +- packages/otel/src/provider.ts | 91 ++++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 packages/otel/src/__tests__/provider.test.ts create mode 100644 packages/otel/src/provider.ts diff --git a/packages/otel/src/__tests__/provider.test.ts b/packages/otel/src/__tests__/provider.test.ts new file mode 100644 index 0000000..a57fa28 --- /dev/null +++ b/packages/otel/src/__tests__/provider.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { trace, propagation } from '@opentelemetry/api' +import { initTracing, getTracer, shutdown, type OtelConfig } from '../index.js' + +describe('initTracing', () => { + afterEach(async () => { + await shutdown() + trace.disable() + propagation.disable() + }) + + it('registers a global TracerProvider', () => { + const config: OtelConfig = { + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + } + initTracing(config) + + const provider = trace.getTracerProvider() + expect(provider).toBeDefined() + const tracer = provider.getTracer('test') + const span = tracer.startSpan('test-span') + expect(span.spanContext().traceId).toMatch(/^[0-9a-f]{32}$/) + span.end() + }) + + it('getTracer returns a tracer from the registered provider', () => { + initTracing({ + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + }) + const tracer = getTracer('@agentspec/test') + expect(tracer).toBeDefined() + + const span = tracer.startSpan('get-tracer-test') + expect(span.spanContext().traceId).toMatch(/^[0-9a-f]{32}$/) + span.end() + }) + + it('uses http/protobuf protocol by default', () => { + expect(() => + initTracing({ + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + }), + ).not.toThrow() + }) + + it('accepts grpc protocol without throwing', () => { + expect(() => + initTracing({ + serviceName: 'test-agent', + endpoint: 'http://localhost:4317', + protocol: 'grpc', + }), + ).not.toThrow() + }) + + it('applies sampleRate to the provider', () => { + initTracing({ + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + sampleRate: 0, + }) + + const tracer = getTracer('test') + const span = tracer.startSpan('should-be-unsampled') + expect(span.isRecording()).toBe(false) + span.end() + }) + + it('is idempotent - calling twice does not throw', () => { + const config: OtelConfig = { + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + } + initTracing(config) + expect(() => initTracing(config)).not.toThrow() + }) + + it('shutdown flushes without error', async () => { + initTracing({ + serviceName: 'test-agent', + endpoint: 'http://localhost:4318', + }) + await expect(shutdown()).resolves.not.toThrow() + }) +}) diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts index 4a75aff..c0e08c0 100644 --- a/packages/otel/src/index.ts +++ b/packages/otel/src/index.ts @@ -1,2 +1,3 @@ -// Public API - implementations added in subsequent tasks -export {} +export { initTracing, getTracer, shutdown, type OtelConfig } from './provider.js' +export { extractContext, injectContext, setupPropagation } from './propagation.js' +export { createSampler } from './sampler.js' diff --git a/packages/otel/src/provider.ts b/packages/otel/src/provider.ts new file mode 100644 index 0000000..7a11f92 --- /dev/null +++ b/packages/otel/src/provider.ts @@ -0,0 +1,91 @@ +import { trace } from '@opentelemetry/api' +import { + BasicTracerProvider, + BatchSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { OTLPTraceExporter as OTLPHttpExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { OTLPTraceExporter as OTLPGrpcExporter } from '@opentelemetry/exporter-trace-otlp-grpc' +import { Resource } from '@opentelemetry/resources' +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions' +import { createSampler } from './sampler.js' +import { setupPropagation } from './propagation.js' + +// ── Public interface ────────────────────────────────────────────────────────── + +export interface OtelConfig { + serviceName: string + endpoint: string + sampleRate?: number + protocol?: 'grpc' | 'http/protobuf' +} + +// ── Module-private state ────────────────────────────────────────────────────── + +let provider: BasicTracerProvider | null = null + +// ── Private helpers ─────────────────────────────────────────────────────────── + +function resolveProtocol(config: OtelConfig): 'grpc' | 'http/protobuf' { + if (config.protocol) return config.protocol + const envProtocol = process.env['OTEL_EXPORTER_OTLP_PROTOCOL'] + if (envProtocol === 'grpc') return 'grpc' + return 'http/protobuf' +} + +function createExporter( + endpoint: string, + protocol: 'grpc' | 'http/protobuf', +): OTLPHttpExporter | OTLPGrpcExporter { + if (protocol === 'grpc') { + return new OTLPGrpcExporter({ url: endpoint }) + } + return new OTLPHttpExporter({ url: `${endpoint}/v1/traces` }) +} + +function buildProvider(config: OtelConfig): BasicTracerProvider { + const protocol = resolveProtocol(config) + const exporter = createExporter(config.endpoint, protocol) + const sampler = createSampler(config.sampleRate) + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: config.serviceName, + }) + + const p = new BasicTracerProvider({ resource, sampler }) + + p.addSpanProcessor( + new BatchSpanProcessor(exporter, { + maxQueueSize: 2048, + maxExportBatchSize: 512, + scheduledDelayMillis: 5000, + }), + ) + + return p +} + +// ── Public orchestrators ────────────────────────────────────────────────────── + +export function initTracing(config: OtelConfig): void { + if (provider !== null) return + + provider = buildProvider(config) + provider.register() + setupPropagation() +} + +export function getTracer(name: string) { + return trace.getTracer(name) +} + +export async function shutdown(): Promise { + if (provider === null) return + const p = provider + provider = null + try { + await p.shutdown() + } catch { + // Exporter connection errors on flush are expected in test/offline environments. + // The provider state has already been cleared above. + } +} From 1eeab793cd5d433ba4e2011ac6b33dfbd36c06a9 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:40:59 +0100 Subject: [PATCH 05/15] feat(sidecar): instrument proxy with OTel spans and traceparent injection --- packages/sidecar/package.json | 3 + .../sidecar/src/__tests__/proxy-otel.test.ts | 131 ++++++++++++++++++ packages/sidecar/src/proxy.ts | 47 +++++++ pnpm-lock.yaml | 19 +-- 4 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 packages/sidecar/src/__tests__/proxy-otel.test.ts diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json index 5efba38..3c1e886 100644 --- a/packages/sidecar/package.json +++ b/packages/sidecar/package.json @@ -17,6 +17,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@agentspec/otel": "workspace:*", "@agentspec/sdk": "workspace:*", "@fastify/http-proxy": "^9.5.0", "fastify": "^4.28.1" @@ -25,6 +26,8 @@ "@anthropic-ai/sdk": "^0.39.0" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", "@types/node": "^20.17.0", "tsup": "^8.3.5", "tsx": "^4.19.2", diff --git a/packages/sidecar/src/__tests__/proxy-otel.test.ts b/packages/sidecar/src/__tests__/proxy-otel.test.ts new file mode 100644 index 0000000..3cddcb0 --- /dev/null +++ b/packages/sidecar/src/__tests__/proxy-otel.test.ts @@ -0,0 +1,131 @@ +/** + * OTel instrumentation tests for proxy.ts. + * + * Verifies that: + * 1. Proxy requests create a root span named `proxy:request` + * 2. Spans carry http.method, http.url, http.status_code, agentspec.agent.name + * 3. The `traceparent` header is injected into upstream requests + */ + +import { createServer, type Server } from 'node:http' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import type { FastifyInstance } from 'fastify' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { trace, propagation } from '@opentelemetry/api' +import { setupPropagation } from '@agentspec/otel' +import { buildProxyApp } from '../proxy.js' +import { AuditRing } from '../audit-ring.js' +import { testManifest } from './fixtures.js' + +// ── OTel test infrastructure ───────────────────────────────────────────────── + +let exporter: InMemorySpanExporter +let provider: BasicTracerProvider + +// ── Mock upstream ──────────────────────────────────────────────────────────── + +let upstream: Server +let upstreamUrl: string +let capturedHeaders: Record = {} + +function startUpstream(): Promise { + return new Promise((resolve) => { + upstream = createServer((req, res) => { + capturedHeaders = req.headers as Record + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + upstream.listen(0, '127.0.0.1', () => { + const addr = upstream.address() as { port: number } + upstreamUrl = `http://127.0.0.1:${addr.port}` + resolve() + }) + }) +} + +// ── Per-test lifecycle ─────────────────────────────────────────────────────── + +let proxyApp: FastifyInstance +let proxyPort: number + +beforeEach(async () => { + // OTel provider with in-memory exporter for assertions + exporter = new InMemorySpanExporter() + provider = new BasicTracerProvider() + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) + provider.register() + setupPropagation() + + // Mock upstream + proxy app + capturedHeaders = {} + await startUpstream() + + const ring = new AuditRing() + proxyApp = await buildProxyApp(testManifest, { + upstream: upstreamUrl, + auditRing: ring, + }) + await proxyApp.listen({ port: 0, host: '127.0.0.1' }) + proxyPort = (proxyApp.server.address() as { port: number }).port +}) + +afterEach(async () => { + await proxyApp?.close() + await new Promise((resolve) => upstream?.close(() => resolve())) + await provider.shutdown() + trace.disable() + propagation.disable() +}) + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('proxy OTel instrumentation', () => { + it('creates a root span named proxy:request', async () => { + await fetch(`http://127.0.0.1:${proxyPort}/hello`) + + const spans = exporter.getFinishedSpans() + const proxySpan = spans.find((s) => s.name === 'proxy:request') + expect(proxySpan).toBeDefined() + }) + + it('span carries http.method and http.url attributes', async () => { + await fetch(`http://127.0.0.1:${proxyPort}/test-path`, { method: 'POST' }) + + const spans = exporter.getFinishedSpans() + const proxySpan = spans.find((s) => s.name === 'proxy:request')! + expect(proxySpan.attributes['http.method']).toBe('POST') + expect(proxySpan.attributes['http.url']).toBe('/test-path') + }) + + it('span carries http.status_code after response', async () => { + await fetch(`http://127.0.0.1:${proxyPort}/ok`) + + const spans = exporter.getFinishedSpans() + const proxySpan = spans.find((s) => s.name === 'proxy:request')! + expect(proxySpan.attributes['http.status_code']).toBe(200) + }) + + it('span carries agentspec.agent.name from manifest', async () => { + await fetch(`http://127.0.0.1:${proxyPort}/ok`) + + const spans = exporter.getFinishedSpans() + const proxySpan = spans.find((s) => s.name === 'proxy:request')! + expect(proxySpan.attributes['agentspec.agent.name']).toBe('gymcoach') + }) + + it('injects traceparent header into upstream request', async () => { + await fetch(`http://127.0.0.1:${proxyPort}/trace-check`) + + // The upstream should have received a traceparent header + expect(capturedHeaders['traceparent']).toBeDefined() + expect(typeof capturedHeaders['traceparent']).toBe('string') + // W3C traceparent format: version-traceid-parentid-flags + expect(capturedHeaders['traceparent']).toMatch( + /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/, + ) + }) +}) diff --git a/packages/sidecar/src/proxy.ts b/packages/sidecar/src/proxy.ts index 6585dfa..2afc876 100644 --- a/packages/sidecar/src/proxy.ts +++ b/packages/sidecar/src/proxy.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto' import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify' import httpProxy from '@fastify/http-proxy' import type { AgentSpecManifest } from '@agentspec/sdk' +import { getTracer } from '@agentspec/otel' +import { trace, context, propagation, SpanStatusCode, type Span } from '@opentelemetry/api' import { AuditRing } from './audit-ring.js' import { config } from './config.js' import { @@ -22,6 +24,8 @@ declare module 'fastify' { _agentToolsCalled?: string[] /** Whether OPA evaluated real behavioral data and allowed the request. */ _behavioralCompliant?: boolean + /** OTel span for distributed tracing. */ + _otelSpan?: Span } } @@ -67,9 +71,38 @@ export async function buildProxyApp( (request.headers['x-request-id'] as string | undefined) ?? randomUUID() request.headers['x-request-id'] = requestId request._startedAt = Date.now() + + // ── OTel span creation + traceparent injection ─────────────────────────── + const tracer = getTracer('@agentspec/sidecar') + const span = tracer.startSpan('proxy:request', { + attributes: { + 'http.method': request.method, + 'http.url': request.url, + 'http.request_id': requestId, + 'agentspec.agent.name': manifest.metadata.name, + }, + }) + request._otelSpan = span + + // Inject traceparent into raw headers so @fastify/reply-from forwards them + // upstream. We use propagation.inject with the span context directly rather + // than relying on context.with + context.active(), because Fastify's async + // hooks may not propagate the OTel async context correctly. + const spanContext = trace.setSpan(context.active(), span) + propagation.inject(spanContext, request.raw.headers, { + set(carrier, key, value) { + ;(carrier as Record)[key] = value + }, + }) }) app.addHook('onResponse', async (request: FastifyRequest, reply) => { + // ── Close OTel span with status code ───────────────────────────────────── + if (request._otelSpan) { + request._otelSpan.setAttribute('http.status_code', reply.statusCode) + request._otelSpan.end() + } + // Skip — already recorded in the OPA block branch of replyOptions.onResponse if (request._opaBlocked) return @@ -100,6 +133,12 @@ export async function buildProxyApp( // onRequestAbort fires when the client disconnects before a response is sent. app.addHook('onRequestAbort', async (request: FastifyRequest) => { + // ── Close OTel span with error status ──────────────────────────────────── + if (request._otelSpan) { + request._otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'aborted' }) + request._otelSpan.end() + } + const requestId = request.headers['x-request-id'] as string if (!requestId) return auditRing.push({ @@ -203,6 +242,14 @@ export async function buildProxyApp( }) request._opaBlocked = true + // ── Close OTel span with OPA policy violation ──────────────────────── + if (request._otelSpan) { + request._otelSpan.setAttribute('http.status_code', 403) + request._otelSpan.setAttribute('agentspec.opa.blocked', true) + request._otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'OPA policy violation' }) + request._otelSpan.end() + } + reply.code(403).header('Content-Type', 'application/json') return JSON.stringify({ error: 'PolicyViolation', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6265fa..50f5a04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: packages/sidecar: dependencies: + '@agentspec/otel': + specifier: workspace:* + version: link:../otel '@agentspec/sdk': specifier: workspace:* version: link:../sdk @@ -194,6 +197,12 @@ importers: specifier: ^4.28.1 version: 4.29.1 devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) '@types/node': specifier: ^20.17.0 version: 20.19.34 @@ -3222,14 +3231,6 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.34) - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.37))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@20.19.37) - '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -4707,7 +4708,7 @@ snapshots: vitest@2.1.9(@types/node@20.19.37): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.37)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.34)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 From 0f46cdab8bef6c6848f582543a63f429e69cb7ec Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:44:32 +0100 Subject: [PATCH 06/15] feat(sidecar): instrument audit ring, events, and explain with OTel spans --- .../src/__tests__/control-plane-otel.test.ts | 68 +++++++ packages/sidecar/src/audit-ring.ts | 37 ++-- packages/sidecar/src/control-plane/events.ts | 169 ++++++++++-------- packages/sidecar/src/control-plane/explain.ts | 8 + packages/sidecar/src/control-plane/index.ts | 42 +++++ 5 files changed, 238 insertions(+), 86 deletions(-) create mode 100644 packages/sidecar/src/__tests__/control-plane-otel.test.ts diff --git a/packages/sidecar/src/__tests__/control-plane-otel.test.ts b/packages/sidecar/src/__tests__/control-plane-otel.test.ts new file mode 100644 index 0000000..ed9cf4c --- /dev/null +++ b/packages/sidecar/src/__tests__/control-plane-otel.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { trace, propagation } from '@opentelemetry/api' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { setupPropagation } from '@agentspec/otel' +import { buildControlPlaneApp } from '../control-plane/index.js' +import { AuditRing } from '../audit-ring.js' +import { testManifest } from './fixtures.js' + +describe('control plane OTel instrumentation', () => { + let provider: BasicTracerProvider + let exporter: InMemorySpanExporter + let app: Awaited> + + beforeEach(async () => { + exporter = new InMemorySpanExporter() + provider = new BasicTracerProvider() + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) + provider.register() + setupPropagation() + + global.fetch = async () => { + throw new Error('ECONNREFUSED') + } + + app = await buildControlPlaneApp(testManifest, new AuditRing(), { + opaUrl: null, + }) + }) + + afterEach(async () => { + await app.close() + await provider.shutdown() + trace.disable() + propagation.disable() + }) + + it('creates a span for GET /health/live', async () => { + await app.inject({ method: 'GET', url: '/health/live' }) + const spans = exporter.getFinishedSpans() + const span = spans.find((s) => s.name === 'cp:GET /health/live') + expect(span).toBeDefined() + }) + + it('creates a span for GET /explore', async () => { + await app.inject({ method: 'GET', url: '/explore' }) + const spans = exporter.getFinishedSpans() + const span = spans.find((s) => s.name === 'cp:GET /explore') + expect(span).toBeDefined() + }) + + it('creates a span for GET /gap', async () => { + await app.inject({ method: 'GET', url: '/gap' }) + const spans = exporter.getFinishedSpans() + const span = spans.find((s) => s.name === 'cp:GET /gap') + expect(span).toBeDefined() + }) + + it('sets http.status_code on the span', async () => { + await app.inject({ method: 'GET', url: '/health/live' }) + const spans = exporter.getFinishedSpans() + const span = spans.find((s) => s.name === 'cp:GET /health/live') + expect(span!.attributes['http.status_code']).toBe(200) + }) +}) diff --git a/packages/sidecar/src/audit-ring.ts b/packages/sidecar/src/audit-ring.ts index 6929872..4b1c065 100644 --- a/packages/sidecar/src/audit-ring.ts +++ b/packages/sidecar/src/audit-ring.ts @@ -1,3 +1,5 @@ +import { getTracer } from '@agentspec/otel' + export interface AuditEntry { requestId: string timestamp: string @@ -43,18 +45,31 @@ export class AuditRing { } push(entry: AuditEntry): void { - if (this.count < this.maxSize) { - // Ring has room — write at tail - this.items[(this.head + this.count) % this.maxSize] = entry - this.count++ - } else { - // Ring is full — overwrite the oldest slot and advance head - this.items[this.head] = entry - this.head = (this.head + 1) % this.maxSize - } + const tracer = getTracer('@agentspec/sidecar') + const span = tracer.startSpan('audit-ring:push', { + attributes: { + 'agentspec.audit.request_id': entry.requestId, + 'agentspec.audit.method': entry.method, + 'agentspec.audit.path': entry.path, + }, + }) - for (const listener of this.listeners) { - listener(entry) + try { + if (this.count < this.maxSize) { + // Ring has room — write at tail + this.items[(this.head + this.count) % this.maxSize] = entry + this.count++ + } else { + // Ring is full — overwrite the oldest slot and advance head + this.items[this.head] = entry + this.head = (this.head + 1) % this.maxSize + } + + for (const listener of this.listeners) { + listener(entry) + } + } finally { + span.end() } } diff --git a/packages/sidecar/src/control-plane/events.ts b/packages/sidecar/src/control-plane/events.ts index 938b014..c498168 100644 --- a/packages/sidecar/src/control-plane/events.ts +++ b/packages/sidecar/src/control-plane/events.ts @@ -19,6 +19,8 @@ import type { AgentSpecManifest } from '@agentspec/sdk' import type { AuditRing } from '../audit-ring.js' import { config } from '../config.js' import { buildBehavioralOPAInput, queryOPA } from './opa-client.js' +import { getTracer } from '@agentspec/otel' +import { SpanStatusCode } from '@opentelemetry/api' // ── Event type definitions ──────────────────────────────────────────────────── @@ -98,93 +100,110 @@ export async function buildEventsRoutes( typeof body.agentName === 'string' ? body.agentName.slice(0, 64) : '' const events = body.events - // ── Find entry in audit ring ─────────────────────────────────────────────── - const entry = auditRing.findById(requestId) - if (!entry) { - // Race: agent pushed before the proxy recorded the request. - // This is expected at high throughput. Tell the agent not to retry. - return reply.code(202).send({ - requestId, - found: false, - message: 'Request not yet in audit ring — TTL race, no retry needed', - }) - } - - // ── Extract behavioral data from the event batch ──────────────────────────── - const guardrailsInvoked: string[] = [] - const toolsCalled: string[] = [] - const modelCalls: { modelId: string; tokenCount: number }[] = [] + const tracer = getTracer('@agentspec/sidecar') + const span = tracer.startSpan('events:ingest', { + attributes: { + 'agentspec.events.request_id': requestId, + 'agentspec.events.count': events.length, + }, + }) - for (const event of events) { - if (!event || typeof event.type !== 'string') continue + try { + // ── Find entry in audit ring ─────────────────────────────────────────────── + const entry = auditRing.findById(requestId) + if (!entry) { + // Race: agent pushed before the proxy recorded the request. + // This is expected at high throughput. Tell the agent not to retry. + span.end() + return reply.code(202).send({ + requestId, + found: false, + message: 'Request not yet in audit ring — TTL race, no retry needed', + }) + } - switch (event.type) { - case 'guardrail': { - const g = event as GuardrailEventPayload - if (g.invoked && typeof g.guardrailType === 'string') { - guardrailsInvoked.push(g.guardrailType) + // ── Extract behavioral data from the event batch ──────────────────────────── + const guardrailsInvoked: string[] = [] + const toolsCalled: string[] = [] + const modelCalls: { modelId: string; tokenCount: number }[] = [] + + for (const event of events) { + if (!event || typeof event.type !== 'string') continue + + switch (event.type) { + case 'guardrail': { + const g = event as GuardrailEventPayload + if (g.invoked && typeof g.guardrailType === 'string') { + guardrailsInvoked.push(g.guardrailType) + } + break } - break - } - case 'tool': { - const t = event as ToolEventPayload - if (typeof t.name === 'string') { - toolsCalled.push(t.name) + case 'tool': { + const t = event as ToolEventPayload + if (typeof t.name === 'string') { + toolsCalled.push(t.name) + } + break } - break - } - case 'model': { - const m = event as ModelEventPayload - if (typeof m.modelId === 'string') { - modelCalls.push({ - modelId: m.modelId, - tokenCount: typeof m.tokenCount === 'number' ? m.tokenCount : 0, - }) + case 'model': { + const m = event as ModelEventPayload + if (typeof m.modelId === 'string') { + modelCalls.push({ + modelId: m.modelId, + tokenCount: typeof m.tokenCount === 'number' ? m.tokenCount : 0, + }) + } + break } - break + // memory events: record nothing on AuditEntry for now } - // memory events: record nothing on AuditEntry for now } - } - // ── Update the audit ring entry with behavioral data ──────────────────────── - const behavioralUpdate: Partial = {} - if (guardrailsInvoked.length > 0) behavioralUpdate.guardrailsInvoked = guardrailsInvoked - if (toolsCalled.length > 0) behavioralUpdate.toolsCalled = toolsCalled - if (modelCalls.length > 0) behavioralUpdate.modelCalls = modelCalls - - // ── OPA evaluation on real behavioral data (fail-open) ───────────────────── - let opaViolations: string[] = [] - if (opaUrl) { - try { - const opaInput = buildBehavioralOPAInput( - manifest, - guardrailsInvoked, - toolsCalled, - ) - const opaResult = await queryOPA(opaUrl, manifest.metadata.name, opaInput) - - if (!opaResult.opaUnavailable) { - opaViolations = opaResult.violations - behavioralUpdate.behavioralCompliant = opaResult.allow - if (opaViolations.length > 0) { - // Merge violations into existing entry (don't overwrite proxy-level violations) - const existing = entry.opaViolations ?? [] - const merged = [...new Set([...existing, ...opaViolations])] - behavioralUpdate.opaViolations = merged + // ── Update the audit ring entry with behavioral data ──────────────────────── + const behavioralUpdate: Partial = {} + if (guardrailsInvoked.length > 0) behavioralUpdate.guardrailsInvoked = guardrailsInvoked + if (toolsCalled.length > 0) behavioralUpdate.toolsCalled = toolsCalled + if (modelCalls.length > 0) behavioralUpdate.modelCalls = modelCalls + + // ── OPA evaluation on real behavioral data (fail-open) ───────────────────── + let opaViolations: string[] = [] + if (opaUrl) { + try { + const opaInput = buildBehavioralOPAInput( + manifest, + guardrailsInvoked, + toolsCalled, + ) + const opaResult = await queryOPA(opaUrl, manifest.metadata.name, opaInput) + + if (!opaResult.opaUnavailable) { + opaViolations = opaResult.violations + behavioralUpdate.behavioralCompliant = opaResult.allow + if (opaViolations.length > 0) { + // Merge violations into existing entry (don't overwrite proxy-level violations) + const existing = entry.opaViolations ?? [] + const merged = [...new Set([...existing, ...opaViolations])] + behavioralUpdate.opaViolations = merged + } } + } catch { + // OPA errors are non-fatal — behavioral data is still recorded } - } catch { - // OPA errors are non-fatal — behavioral data is still recorded } - } - auditRing.updateById(requestId, behavioralUpdate) + auditRing.updateById(requestId, behavioralUpdate) - return reply.code(200).send({ - requestId, - found: true, - opaViolations, - }) + span.setAttribute('agentspec.events.opa_violations', opaViolations.length) + span.end() + return reply.code(200).send({ + requestId, + found: true, + opaViolations, + }) + } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }) + span.end() + throw err + } }) } diff --git a/packages/sidecar/src/control-plane/explain.ts b/packages/sidecar/src/control-plane/explain.ts index 98aaf18..ffe94bd 100644 --- a/packages/sidecar/src/control-plane/explain.ts +++ b/packages/sidecar/src/control-plane/explain.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import type { AuditRing } from '../audit-ring.js' +import { getTracer } from '@agentspec/otel' export interface ExplainStep { step: string @@ -26,8 +27,14 @@ export async function buildExplainRoutes( app.get<{ Params: { requestId: string } }>( '/explain/:requestId', async (req, reply) => { + const tracer = getTracer('@agentspec/sidecar') + const span = tracer.startSpan('explain:generate', { + attributes: { 'agentspec.explain.request_id': req.params.requestId }, + }) + const entry = auditRing.findById(req.params.requestId) if (!entry) { + span.end() reply.status(404) return { error: `No audit entry found for requestId: ${req.params.requestId}` } } @@ -58,6 +65,7 @@ export async function buildExplainRoutes( steps, } + span.end() return trace }, ) diff --git a/packages/sidecar/src/control-plane/index.ts b/packages/sidecar/src/control-plane/index.ts index 07591d3..fb747e2 100644 --- a/packages/sidecar/src/control-plane/index.ts +++ b/packages/sidecar/src/control-plane/index.ts @@ -1,5 +1,7 @@ import Fastify, { type FastifyInstance } from 'fastify' import type { AgentSpecManifest } from '@agentspec/sdk' +import { getTracer } from '@agentspec/otel' +import { SpanStatusCode, type Span } from '@opentelemetry/api' import type { AuditRing } from '../audit-ring.js' import { buildHealthRoutes } from './health.js' import { buildCapabilitiesRoutes } from './capabilities.js' @@ -12,6 +14,12 @@ import { buildGapRoutes } from './gap.js' import { buildEventsRoutes } from './events.js' import { buildProofRoutes, ProofStore } from './proof.js' +declare module 'fastify' { + interface FastifyRequest { + _cpSpan?: Span + } +} + export interface ControlPlaneOptions { logger?: boolean proxyUrl?: string @@ -29,6 +37,40 @@ export async function buildControlPlaneApp( ): Promise { const app = Fastify({ logger: opts.logger ?? false }) + // ── OTel request tracing hooks ─────────────────────────────────────────────── + + app.addHook('onRequest', async (request) => { + const tracer = getTracer('@agentspec/sidecar') + const span = tracer.startSpan(`cp:${request.method} ${request.url}`, { + attributes: { + 'http.method': request.method, + 'http.url': request.url, + }, + }) + request._cpSpan = span + }) + + app.addHook('onResponse', async (request, reply) => { + if (request._cpSpan) { + request._cpSpan.setAttribute('http.status_code', reply.statusCode) + if (reply.statusCode >= 500) { + request._cpSpan.setStatus({ code: SpanStatusCode.ERROR }) + } + request._cpSpan.end() + } + }) + + app.addHook('onError', async (request, _reply, error) => { + if (request._cpSpan) { + request._cpSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }) + } + }) + + // ── Route registrations ─────────────────────────────────────────────────────── + await buildHealthRoutes(app, manifest) await buildCapabilitiesRoutes(app, manifest, { proxyUrl: opts.proxyUrl }) await buildMcpRoutes(app, manifest) From b386b6169dfa9ee25cc857e69736b6c76d66037e Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:47:18 +0100 Subject: [PATCH 07/15] feat(sidecar): initialize OTel tracing at startup from manifest config --- packages/sidecar/src/config.ts | 7 ++++++ packages/sidecar/src/index.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/sidecar/src/config.ts b/packages/sidecar/src/config.ts index b11728d..2578ea3 100644 --- a/packages/sidecar/src/config.ts +++ b/packages/sidecar/src/config.ts @@ -88,4 +88,11 @@ export const config = { * and your agents set X-AgentSpec-Guardrails-Invoked correctly. */ opaProxyMode: requireOpaMode('OPA_PROXY_MODE', 'track'), + + /** + * OpenTelemetry tracing configuration. + * Resolved from the manifest at runtime, but defaults are read from env vars + * so the sidecar can start exporting before the manifest is fully loaded. + */ + otelEndpoint: process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? null, } as const diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts index d8eb508..3cb0dff 100644 --- a/packages/sidecar/src/index.ts +++ b/packages/sidecar/src/index.ts @@ -1,13 +1,58 @@ import { loadManifest } from '@agentspec/sdk' +import { initTracing, shutdown as shutdownOtel } from '@agentspec/otel' import { config } from './config.js' import { AuditRing } from './audit-ring.js' import { buildProxyApp } from './proxy.js' import { buildControlPlaneApp } from './control-plane/index.js' import { log } from './logger.js' +// ── Private helpers ──────────────────────────────────────────────────────────── + +/** + * Resolve the OTLP endpoint from the manifest tracing config. + * Handles $env: references by reading the env var, falls back to + * OTEL_EXPORTER_OTLP_ENDPOINT env var, then localhost:4318. + */ +function resolveOtelEndpoint( + manifestEndpoint: string | undefined, + envEndpoint: string | null, +): string { + if (manifestEndpoint) { + // Handle $env: references + const envMatch = manifestEndpoint.match(/^\$env:(.+)$/) + if (envMatch && envMatch[1]) { + return process.env[envMatch[1]] ?? envEndpoint ?? 'http://localhost:4318' + } + return manifestEndpoint + } + return envEndpoint ?? 'http://localhost:4318' +} + +// ── Public orchestrator ──────────────────────────────────────────────────────── + async function main(): Promise { const { manifest } = loadManifest(config.manifestPath) + // ── OTel tracing ───────────────────────────────────────────────────────── + const tracingConfig = manifest.spec.observability?.tracing + if (tracingConfig?.backend === 'otel') { + const resolvedEndpoint = resolveOtelEndpoint(tracingConfig.endpoint, config.otelEndpoint) + + initTracing({ + serviceName: + manifest.spec.observability?.metrics?.serviceName ?? manifest.metadata.name, + endpoint: resolvedEndpoint, + sampleRate: tracingConfig.sampleRate, + }) + + log.info('otel tracing initialized', { + endpoint: resolvedEndpoint, + serviceName: + manifest.spec.observability?.metrics?.serviceName ?? manifest.metadata.name, + sampleRate: tracingConfig.sampleRate ?? 1.0, + }) + } + const auditRing = new AuditRing(config.auditRingSize) const startedAt = Date.now() @@ -28,6 +73,7 @@ async function main(): Promise { log.info('shutdown signal received', { signal }) try { await Promise.all([proxyApp.close(), cpApp.close()]) + await shutdownOtel() } catch (err) { log.error('error during shutdown', { err: String(err) }) } From c198fe89b8915404e8f3355f31a9e02a6e02c1e7 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:49:03 +0100 Subject: [PATCH 08/15] feat(sdk): instrument reporter health refresh and heartbeat with OTel spans --- packages/sdk/package.json | 5 + .../sdk/src/__tests__/reporter-otel.test.ts | 101 ++++++++++++++++++ packages/sdk/src/agent/reporter.ts | 23 ++++ 3 files changed, 129 insertions(+) create mode 100644 packages/sdk/src/__tests__/reporter-otel.test.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3f5e047..499b39b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -50,11 +50,16 @@ "prepublishOnly": "pnpm build" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", "js-yaml": "^4.1.0", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.1" }, + "optionalDependencies": { + "@agentspec/otel": "workspace:*" + }, "devDependencies": { + "@opentelemetry/sdk-trace-base": "^1.30.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.0", "tsup": "^8.3.5", diff --git a/packages/sdk/src/__tests__/reporter-otel.test.ts b/packages/sdk/src/__tests__/reporter-otel.test.ts new file mode 100644 index 0000000..efdcbdd --- /dev/null +++ b/packages/sdk/src/__tests__/reporter-otel.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for OTel span instrumentation in AgentSpecReporter. + * + * Verifies that: + * - health refresh creates a span named `reporter:health-refresh` + * - the span carries `agentspec.health.status` attribute + * - the reporter degrades gracefully when no OTel provider is registered + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { trace, propagation } from '@opentelemetry/api' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { setupPropagation } from '@agentspec/otel' +import { AgentSpecReporter } from '../agent/reporter.js' +import type { AgentSpecManifest } from '../schema/manifest.schema.js' + +const mockManifest = { + apiVersion: 'agentspec.io/v1', + kind: 'AgentSpec', + metadata: { name: 'test-agent', version: '0.1.0', description: 'test' }, + spec: { + model: { + provider: 'openai', + id: 'gpt-4o', + apiKey: '$env:OPENAI_API_KEY', + }, + prompts: { system: 'You are a test agent.', hotReload: false }, + observability: { + tracing: { + backend: 'otel', + endpoint: 'http://localhost:4318', + sampleRate: 1.0, + }, + }, + }, +} as AgentSpecManifest + +describe('reporter OTel instrumentation', () => { + let provider: BasicTracerProvider + let exporter: InMemorySpanExporter + + beforeEach(() => { + exporter = new InMemorySpanExporter() + provider = new BasicTracerProvider() + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) + provider.register() + setupPropagation() + global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + }) + + afterEach(async () => { + await provider.shutdown() + trace.disable() + propagation.disable() + vi.restoreAllMocks() + }) + + it('creates a span for health refresh', async () => { + const reporter = new AgentSpecReporter(mockManifest, { refreshIntervalMs: 999_999 }) + await reporter.getReport() + reporter.stop() + + const spans = exporter.getFinishedSpans() + const refreshSpan = spans.find((s) => s.name === 'reporter:health-refresh') + expect(refreshSpan).toBeDefined() + }) + + it('sets health status attributes on refresh span', async () => { + const reporter = new AgentSpecReporter(mockManifest, { refreshIntervalMs: 999_999 }) + await reporter.getReport() + reporter.stop() + + const spans = exporter.getFinishedSpans() + const refreshSpan = spans.find((s) => s.name === 'reporter:health-refresh') + expect(refreshSpan!.attributes['agentspec.health.status']).toBeDefined() + }) +}) + +describe('reporter without OTel', () => { + beforeEach(() => { + trace.disable() + global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('works without errors when no OTel provider is registered', async () => { + const reporter = new AgentSpecReporter(mockManifest, { refreshIntervalMs: 999_999 }) + const report = await reporter.getReport() + reporter.stop() + + expect(report).toBeDefined() + expect(report.agentName).toBe('test-agent') + }) +}) diff --git a/packages/sdk/src/agent/reporter.ts b/packages/sdk/src/agent/reporter.ts index aa67d76..6a5719d 100644 --- a/packages/sdk/src/agent/reporter.ts +++ b/packages/sdk/src/agent/reporter.ts @@ -17,6 +17,7 @@ * app.use('/agentspec', agentSpecExpressRouter(reporter)) */ +import { trace, SpanStatusCode } from '@opentelemetry/api' import { runHealthCheck } from '../health/index.js' import { runAudit } from '../audit/index.js' import type { AgentSpecManifest } from '../schema/manifest.schema.js' @@ -169,6 +170,9 @@ export class AgentSpecReporter { } private async _pushHeartbeat(opts: PushModeOptions): Promise { + const tracer = trace.getTracer('@agentspec/sdk') + const span = tracer.startSpan('reporter:heartbeat') + try { const health = await this.getReport() const gap = runAudit(this.manifest) @@ -184,6 +188,8 @@ export class AgentSpecReporter { } } + span.setAttribute('agentspec.heartbeat.payload_bytes', body.length) + let res: Response try { res = await fetch(`${opts.controlPlaneUrl}/api/v1/heartbeat`, { @@ -195,17 +201,24 @@ export class AgentSpecReporter { body, }) } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR, message: 'heartbeat fetch failed' }) const sanitized = String(err).split(opts.apiKey).join('[REDACTED]') opts.onError?.(new Error(sanitized)) return } + span.setAttribute('http.status_code', res.status) + if (!res.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `heartbeat HTTP ${res.status}` }) opts.onError?.(new Error(`Heartbeat failed: HTTP ${res.status}`)) } } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR, message: 'heartbeat failed' }) const sanitized = String(err).split(opts.apiKey).join('[REDACTED]') opts.onError?.(new Error(sanitized)) + } finally { + span.end() } } @@ -226,6 +239,9 @@ export class AgentSpecReporter { if (this.stopped || this.refreshing) return // stopped or concurrent refresh in flight this.refreshing = true + const tracer = trace.getTracer('@agentspec/sdk') + const span = tracer.startSpan('reporter:health-refresh') + try { const report = await runHealthCheck(this.manifest, { checkModel: true, @@ -261,8 +277,14 @@ export class AgentSpecReporter { }, } + span.setAttribute('agentspec.health.status', enrichedReport.status) + span.setAttribute('agentspec.health.passed', enrichedReport.summary.passed) + span.setAttribute('agentspec.health.failed', enrichedReport.summary.failed) + this.cached = { report: enrichedReport, ts: Date.now() } } catch { + span.setStatus({ code: SpanStatusCode.ERROR, message: 'health check failed' }) + // Preserve the previous cache on error — don't reset to null. // Always advance the timestamp so repeated failures don't cause a retry // storm (getReport would re-trigger refresh on every call otherwise). @@ -291,6 +313,7 @@ export class AgentSpecReporter { this.cached = { ...this.cached, ts: Date.now() } } } finally { + span.end() this.refreshing = false } } From e2d5868d574cd5222e22ee457a284f267d80062a Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:56:24 +0100 Subject: [PATCH 09/15] fix(sidecar): address code review findings - Move @opentelemetry/api from devDependencies to dependencies (runtime import) - Guard proxy span end in onResponse to prevent double-end on OPA block path - Wrap explain handler in try/finally for reliable span cleanup --- packages/sidecar/package.json | 2 +- packages/sidecar/src/control-plane/explain.ts | 62 ++++++++++--------- packages/sidecar/src/proxy.ts | 3 +- pnpm-lock.yaml | 16 ++++- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json index 3c1e886..c73f417 100644 --- a/packages/sidecar/package.json +++ b/packages/sidecar/package.json @@ -20,13 +20,13 @@ "@agentspec/otel": "workspace:*", "@agentspec/sdk": "workspace:*", "@fastify/http-proxy": "^9.5.0", + "@opentelemetry/api": "^1.9.0", "fastify": "^4.28.1" }, "optionalDependencies": { "@anthropic-ai/sdk": "^0.39.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-trace-base": "^1.30.0", "@types/node": "^20.17.0", "tsup": "^8.3.5", diff --git a/packages/sidecar/src/control-plane/explain.ts b/packages/sidecar/src/control-plane/explain.ts index ffe94bd..b41bb39 100644 --- a/packages/sidecar/src/control-plane/explain.ts +++ b/packages/sidecar/src/control-plane/explain.ts @@ -32,41 +32,43 @@ export async function buildExplainRoutes( attributes: { 'agentspec.explain.request_id': req.params.requestId }, }) - const entry = auditRing.findById(req.params.requestId) - if (!entry) { - span.end() - reply.status(404) - return { error: `No audit entry found for requestId: ${req.params.requestId}` } - } + try { + const entry = auditRing.findById(req.params.requestId) + if (!entry) { + reply.status(404) + return { error: `No audit entry found for requestId: ${req.params.requestId}` } + } - // Reconstruct trace from audit entry. - // When the Python SDK is present, it emits structured steps into the excerpt. - // Without it, we infer basic steps from the request/response metadata. - const steps: ExplainStep[] = [] + // Reconstruct trace from audit entry. + // When the Python SDK is present, it emits structured steps into the excerpt. + // Without it, we infer basic steps from the request/response metadata. + const steps: ExplainStep[] = [] - steps.push({ step: 'request_received', result: `${entry.method} ${entry.path}` }) + steps.push({ step: 'request_received', result: `${entry.method} ${entry.path}` }) - if (entry.statusCode !== undefined) { - const isSuccess = entry.statusCode < 400 - steps.push({ - step: 'response', - result: isSuccess ? 'success' : 'error', - excerpt: entry.excerpt, - }) - } + if (entry.statusCode !== undefined) { + const isSuccess = entry.statusCode < 400 + steps.push({ + step: 'response', + result: isSuccess ? 'success' : 'error', + excerpt: entry.excerpt, + }) + } - const trace: ExplainTrace = { - requestId: entry.requestId, - timestamp: entry.timestamp, - method: entry.method, - path: entry.path, - durationMs: entry.durationMs, - statusCode: entry.statusCode, - steps, - } + const trace: ExplainTrace = { + requestId: entry.requestId, + timestamp: entry.timestamp, + method: entry.method, + path: entry.path, + durationMs: entry.durationMs, + statusCode: entry.statusCode, + steps, + } - span.end() - return trace + return trace + } finally { + span.end() + } }, ) } diff --git a/packages/sidecar/src/proxy.ts b/packages/sidecar/src/proxy.ts index 2afc876..bbed8cb 100644 --- a/packages/sidecar/src/proxy.ts +++ b/packages/sidecar/src/proxy.ts @@ -98,7 +98,8 @@ export async function buildProxyApp( app.addHook('onResponse', async (request: FastifyRequest, reply) => { // ── Close OTel span with status code ───────────────────────────────────── - if (request._otelSpan) { + // Skip if OPA already ended the span in the onSend hook (enforce-mode 403) + if (request._otelSpan && !request._opaBlocked) { request._otelSpan.setAttribute('http.status_code', reply.statusCode) request._otelSpan.end() } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f5a04..9e50ee6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: packages/sdk: dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -166,6 +169,9 @@ importers: specifier: ^3.24.1 version: 3.25.1(zod@3.25.76) devDependencies: + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -181,6 +187,10 @@ importers: vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@20.19.34) + optionalDependencies: + '@agentspec/otel': + specifier: workspace:* + version: link:../otel packages/sidecar: dependencies: @@ -193,13 +203,13 @@ importers: '@fastify/http-proxy': specifier: ^9.5.0 version: 9.5.0 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 fastify: specifier: ^4.28.1 version: 4.29.1 devDependencies: - '@opentelemetry/api': - specifier: ^1.9.0 - version: 1.9.1 '@opentelemetry/sdk-trace-base': specifier: ^1.30.0 version: 1.30.1(@opentelemetry/api@1.9.1) From 4c527c45683cd594cc87a89398623cea6b80cbec Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 15:57:15 +0100 Subject: [PATCH 10/15] refactor(otel): remove createSampler from public exports (internal helper) --- packages/otel/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts index c0e08c0..c1befb7 100644 --- a/packages/otel/src/index.ts +++ b/packages/otel/src/index.ts @@ -1,3 +1,2 @@ export { initTracing, getTracer, shutdown, type OtelConfig } from './provider.js' export { extractContext, injectContext, setupPropagation } from './propagation.js' -export { createSampler } from './sampler.js' From efecfc627be69500dc2048e8e1360bbf57c13395 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 16:07:01 +0100 Subject: [PATCH 11/15] fix(sidecar): address remaining review findings - Remove noisy audit-ring:push span (O(1) op, already covered by proxy:request) - Use SDK resolveRef() for OTel endpoint resolution (handles $env:/$secret:/$file:) - Add @agentspec/otel to Key Files in CLAUDE.md --- CLAUDE.md | 1 + packages/sidecar/src/audit-ring.ts | 37 +++++++++--------------------- packages/sidecar/src/index.ts | 15 ++++++------ 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2d13651..68da855 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,6 +277,7 @@ See `docs/concepts/operating-modes.md` for the full guide, VS Code config, and M | `packages/sdk/src/audit/index.ts` | Audit rules engine | | `packages/sdk/src/generate/index.ts` | Adapter registry | | `packages/adapter-langgraph/src/index.ts` | LangGraph adapter (auto-registers) | +| `packages/otel/src/provider.ts` | OTel TracerProvider + OTLP exporter setup | | `packages/cli/src/cli.ts` | CLI entrypoint | | `examples/gymcoach/agent.yaml` | Full GymCoach manifest example | diff --git a/packages/sidecar/src/audit-ring.ts b/packages/sidecar/src/audit-ring.ts index 4b1c065..1a37375 100644 --- a/packages/sidecar/src/audit-ring.ts +++ b/packages/sidecar/src/audit-ring.ts @@ -1,5 +1,3 @@ -import { getTracer } from '@agentspec/otel' - export interface AuditEntry { requestId: string timestamp: string @@ -45,31 +43,18 @@ export class AuditRing { } push(entry: AuditEntry): void { - const tracer = getTracer('@agentspec/sidecar') - const span = tracer.startSpan('audit-ring:push', { - attributes: { - 'agentspec.audit.request_id': entry.requestId, - 'agentspec.audit.method': entry.method, - 'agentspec.audit.path': entry.path, - }, - }) - - try { - if (this.count < this.maxSize) { - // Ring has room — write at tail - this.items[(this.head + this.count) % this.maxSize] = entry - this.count++ - } else { - // Ring is full — overwrite the oldest slot and advance head - this.items[this.head] = entry - this.head = (this.head + 1) % this.maxSize - } + if (this.count < this.maxSize) { + // Ring has room - write at tail + this.items[(this.head + this.count) % this.maxSize] = entry + this.count++ + } else { + // Ring is full - overwrite the oldest slot and advance head + this.items[this.head] = entry + this.head = (this.head + 1) % this.maxSize + } - for (const listener of this.listeners) { - listener(entry) - } - } finally { - span.end() + for (const listener of this.listeners) { + listener(entry) } } diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts index 3cb0dff..b1d3657 100644 --- a/packages/sidecar/src/index.ts +++ b/packages/sidecar/src/index.ts @@ -1,4 +1,4 @@ -import { loadManifest } from '@agentspec/sdk' +import { loadManifest, resolveRef, detectRefType } from '@agentspec/sdk' import { initTracing, shutdown as shutdownOtel } from '@agentspec/otel' import { config } from './config.js' import { AuditRing } from './audit-ring.js' @@ -10,7 +10,7 @@ import { log } from './logger.js' /** * Resolve the OTLP endpoint from the manifest tracing config. - * Handles $env: references by reading the env var, falls back to + * Uses the SDK's resolveRef() for $env:/$secret: references, falls back to * OTEL_EXPORTER_OTLP_ENDPOINT env var, then localhost:4318. */ function resolveOtelEndpoint( @@ -18,12 +18,13 @@ function resolveOtelEndpoint( envEndpoint: string | null, ): string { if (manifestEndpoint) { - // Handle $env: references - const envMatch = manifestEndpoint.match(/^\$env:(.+)$/) - if (envMatch && envMatch[1]) { - return process.env[envMatch[1]] ?? envEndpoint ?? 'http://localhost:4318' + const refType = detectRefType(manifestEndpoint) + if (refType === 'literal') return manifestEndpoint + try { + return resolveRef(manifestEndpoint, { baseDir: '.' }, { optional: true }) || envEndpoint || 'http://localhost:4318' + } catch { + return envEndpoint ?? 'http://localhost:4318' } - return manifestEndpoint } return envEndpoint ?? 'http://localhost:4318' } From 2bd99a108427f6bf8450fde4b03cf0ccc3cf2caa Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 16:12:46 +0100 Subject: [PATCH 12/15] docs: add observability concept page and OTel tracing guide - concepts/observability.md: architecture, span reference, config, degradation - guides/add-observability.md: step-by-step setup with Jaeger/Tempo examples - Add both to VitePress sidebar navigation --- docs/.vitepress/config.mts | 4 +- docs/concepts/observability.md | 109 ++++++++++++++++++++++++++++ docs/guides/add-observability.md | 119 +++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 docs/concepts/observability.md create mode 100644 docs/guides/add-observability.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 950e112..d00c702 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -47,6 +47,7 @@ export default defineConfig({ { text: 'Runtime Introspection', link: '/concepts/runtime-introspection' }, { text: 'Compliance', link: '/concepts/compliance' }, { text: 'Probe Coverage', link: '/concepts/probe-coverage' }, + { text: 'Observability', link: '/concepts/observability' }, { text: 'OPA Policies', link: '/concepts/opa' }, { text: 'Adapters', link: '/concepts/adapters' }, ], @@ -60,7 +61,8 @@ export default defineConfig({ items: [ { text: 'Add Tools', link: '/guides/add-tools' }, { text: 'Add Memory', link: '/guides/add-memory' }, - { text: 'Add Guardrails', link: '/guides/add-guardrails' }, + { text: 'Add Guardrails', link: '/guides/add-guardrails' }, + { text: 'Add Observability', link: '/guides/add-observability' }, ], }, { diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md new file mode 100644 index 0000000..bee72b7 --- /dev/null +++ b/docs/concepts/observability.md @@ -0,0 +1,109 @@ +# Observability + +AgentSpec provides native OpenTelemetry trace export so you can see exactly what your agent infrastructure is doing -- every proxied request, health check, and heartbeat -- in your existing observability stack. + +## How It Works + +``` +agent.yaml + spec.observability.tracing.backend: otel + | + v + @agentspec/otel + (shared TracerProvider) + / \ + v v + Sidecar Proxy SDK Reporter + (request spans) (health spans) + | | + +-- traceparent ---->+ + | | + v v + OTLP Exporter --> Jaeger / Grafana / Datadog +``` + +The **sidecar** creates a root span for every proxied request and injects a W3C `traceparent` header into the upstream call. The **SDK reporter** creates spans around health refreshes and heartbeat pushes. When both are running, spans from both layers join the same distributed trace. + +## Declare Tracing in Your Manifest + +```yaml +observability: + tracing: + backend: otel + endpoint: $env:OTEL_EXPORTER_OTLP_ENDPOINT + sampleRate: 1.0 + metrics: + serviceName: my-agent +``` + +When `backend` is `otel`, the sidecar and SDK reporter automatically start exporting spans via OTLP. No extra config or env var toggle needed. + +## What Gets Traced + +### Sidecar spans + +| Span name | When | Attributes | +|-----------|------|------------| +| `proxy:request` | Every proxied request | `http.method`, `http.url`, `http.status_code`, `http.request_id`, `agentspec.agent.name` | +| `cp:GET /health/ready` | Control plane requests | `http.method`, `http.url`, `http.status_code` | +| `cp:GET /explore` | | | +| `cp:GET /gap` | | | +| `events:ingest` | POST /events batch | `agentspec.events.request_id`, `agentspec.events.count`, `agentspec.events.opa_violations` | +| `explain:generate` | GET /explain/:id | `agentspec.explain.request_id` | + +### SDK reporter spans + +| Span name | When | Attributes | +|-----------|------|------------| +| `reporter:health-refresh` | Every health check cycle | `agentspec.health.status`, `agentspec.health.passed`, `agentspec.health.failed` | +| `reporter:heartbeat` | Every push-mode heartbeat | `agentspec.heartbeat.payload_bytes`, `http.status_code` | + +## Distributed Tracing + +The sidecar injects a W3C `traceparent` header into every request it forwards to your agent. If your agent uses the SDK reporter, its spans automatically become children of the sidecar's proxy span: + +``` +[proxy:request] POST /chat (sidecar, root) + +-- [upstream] POST http://agent:8000/chat + +-- [reporter:health-refresh] (agent process, child) +``` + +This gives you a single trace spanning both the infrastructure layer and the agent's internal operations. + +## Configuration Reference + +| Manifest field | Maps to | Default | +|---|---|---| +| `spec.observability.tracing.endpoint` | OTLP collector URL | `OTEL_EXPORTER_OTLP_ENDPOINT` env var, then `http://localhost:4318` | +| `spec.observability.tracing.sampleRate` | Fraction of requests traced (0.0-1.0) | `1.0` | +| `spec.observability.metrics.serviceName` | OTel resource `service.name` | `metadata.name` | + +**Protocol**: Both HTTP/protobuf and gRPC are supported. Set `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` for gRPC; the default is `http/protobuf`. + +**Endpoint references**: The `endpoint` field supports `$env:`, `$secret:`, and `$file:` references, resolved via the standard SDK `resolveRef()`. + +## Graceful Degradation + +The SDK uses `@opentelemetry/api` directly, which returns no-op tracers when no provider is registered. This means: + +- Agents that don't declare `backend: otel` pay zero overhead +- The SDK remains usable without `@agentspec/otel` installed (it's an optional dependency) +- No conditional imports or runtime feature detection needed + +## Compliance Rules + +Three audit rules check observability configuration: + +| Rule | Check | Severity | +|------|-------|----------| +| OBS-01 | Tracing backend declared | Medium | +| OBS-02 | Structured logging enabled | Low | +| OBS-03 | Sensitive fields redacted from logs | Medium | + +Run `agentspec audit agent.yaml` to check compliance. + +## See also + +- [Runtime Introspection](/concepts/runtime-introspection) -- live health reporting from inside your agent +- [Health Checks](/concepts/health-checks) -- pre-flight CLI checks +- [Add Runtime Health](/guides/add-runtime-health) -- integrate the SDK reporter diff --git a/docs/guides/add-observability.md b/docs/guides/add-observability.md new file mode 100644 index 0000000..cd7f48d --- /dev/null +++ b/docs/guides/add-observability.md @@ -0,0 +1,119 @@ +# Add OpenTelemetry Tracing + +Export distributed traces from your AgentSpec sidecar and SDK reporter to any OTLP-compatible backend (Jaeger, Grafana Tempo, Honeycomb, Datadog). + +## Prerequisites + +- [ ] An `agent.yaml` manifest +- [ ] The AgentSpec sidecar running (`agentspec-sidecar`) +- [ ] An OTLP collector or compatible backend (e.g. Jaeger, Grafana Tempo) + +## 1. Declare tracing in your manifest + +Add the `observability.tracing` section to your `agent.yaml`: + +```yaml +observability: + tracing: + backend: otel + endpoint: $env:OTEL_EXPORTER_OTLP_ENDPOINT + sampleRate: 1.0 + metrics: + serviceName: my-agent +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `backend` | Yes | Set to `otel` for OpenTelemetry export | +| `endpoint` | No | OTLP collector URL. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT` env var, then `http://localhost:4318` | +| `sampleRate` | No | Fraction of requests to trace (0.0-1.0). Default: `1.0` | +| `serviceName` | No | OTel `service.name` resource attribute. Default: `metadata.name` | + +## 2. Set the endpoint environment variable + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` + +For gRPC backends (port 4317): + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc +``` + +## 3. Start the sidecar + +```bash +MANIFEST_PATH=./agent.yaml agentspec-sidecar +``` + +You should see in the logs: + +```json +{"ts":"2026-04-12T...","level":"info","msg":"otel tracing initialized","endpoint":"http://localhost:4318","serviceName":"my-agent","sampleRate":1} +``` + +The sidecar now exports spans for every proxied request and control plane query. + +## 4. Add tracing to your agent (optional) + +If your agent uses `@agentspec/sdk`, the reporter automatically emits spans for health checks and heartbeats: + +```typescript +import { AgentSpecReporter } from '@agentspec/sdk' + +const reporter = new AgentSpecReporter(manifest, { refreshIntervalMs: 30_000 }) +reporter.start() +``` + +No additional OTel setup is needed in the agent. The reporter uses `@opentelemetry/api` directly, which joins the sidecar's trace via the `traceparent` header. + +## 5. Verify traces + +### With Jaeger + +```bash +docker run -d --name jaeger \ + -p 16686:16686 \ + -p 4318:4318 \ + jaegertracing/all-in-one:latest +``` + +Open http://localhost:16686, select your service name, and find traces. You should see: + +- `proxy:request` spans for every proxied request +- `cp:GET /health/ready`, `cp:GET /explore`, etc. for control plane calls +- `reporter:health-refresh` spans if the SDK reporter is running + +### With Grafana Tempo + +Point `OTEL_EXPORTER_OTLP_ENDPOINT` to your Tempo instance's OTLP receiver and query traces in Grafana. + +## Tuning for production + +### Reduce trace volume + +Set `sampleRate` below 1.0 for high-traffic agents: + +```yaml +observability: + tracing: + backend: otel + sampleRate: 0.1 # trace 10% of requests +``` + +### Use gRPC for high throughput + +gRPC is more efficient for large trace volumes in Kubernetes environments: + +```bash +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc +export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +``` + +## See also + +- [Observability concepts](/concepts/observability) -- architecture and span reference +- [Runtime Introspection](/concepts/runtime-introspection) -- live health reporting +- [Add Runtime Health](/guides/add-runtime-health) -- integrate the SDK reporter From e4365a71451fbbd5ae4d225a947f27a0774896f7 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 16:21:16 +0100 Subject: [PATCH 13/15] fix(sidecar): fix Docker build and add OTel E2E tests - Bundle @agentspec/otel into sidecar dist via tsup noExternal - Update Dockerfile to copy otel package, build it, and strip workspace dep - Add 3 E2E tests: traceparent injection, distinct trace IDs, client trace propagation - Update mock-agent to echo traceparent header in responses - Enable OTel tracing in E2E test agent.yaml --- packages/sidecar/Dockerfile | 8 +++- packages/sidecar/test/e2e/agent.yaml | 8 ++++ packages/sidecar/test/e2e/mock-agent.mjs | 8 +++- packages/sidecar/test/e2e/sidecar.e2e.test.ts | 42 +++++++++++++++++++ packages/sidecar/tsup.config.ts | 6 +-- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/sidecar/Dockerfile b/packages/sidecar/Dockerfile index bf2543b..e59077f 100644 --- a/packages/sidecar/Dockerfile +++ b/packages/sidecar/Dockerfile @@ -11,6 +11,7 @@ RUN corepack enable # Copy workspace metadata first (layer cache) COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY packages/sdk/package.json ./packages/sdk/package.json +COPY packages/otel/package.json ./packages/otel/package.json COPY packages/sidecar/package.json ./packages/sidecar/package.json COPY packages/sidecar/tsup.config.ts ./packages/sidecar/tsup.config.ts @@ -20,11 +21,13 @@ RUN pnpm install --frozen-lockfile # Copy source COPY schemas ./schemas COPY packages/sdk ./packages/sdk +COPY packages/otel ./packages/otel COPY packages/sidecar/src ./packages/sidecar/src COPY packages/sidecar/tsconfig.json ./packages/sidecar/tsconfig.json -# Build SDK first, then sidecar (tsup bundles @agentspec/sdk into dist/index.js) +# Build SDK + OTel first, then sidecar (tsup bundles both into dist/index.js) RUN pnpm --filter @agentspec/sdk build +RUN pnpm --filter @agentspec/otel build RUN pnpm --filter @agentspec/sidecar build # ── Production image ────────────────────────────────────────────────────────── @@ -36,8 +39,9 @@ COPY --from=builder /repo/packages/sidecar/dist ./dist COPY --from=builder /repo/packages/sidecar/package.json ./package.json # Install only production runtime deps (fastify, @fastify/http-proxy, etc.) -# @agentspec/sdk is bundled into dist/index.js — skip it +# @agentspec/sdk and @agentspec/otel are bundled into dist/index.js by tsup — skip them RUN npm pkg delete dependencies['@agentspec/sdk'] && \ + npm pkg delete dependencies['@agentspec/otel'] && \ npm install --omit=dev --ignore-scripts EXPOSE 4000 4001 diff --git a/packages/sidecar/test/e2e/agent.yaml b/packages/sidecar/test/e2e/agent.yaml index 8d9959c..c34f595 100644 --- a/packages/sidecar/test/e2e/agent.yaml +++ b/packages/sidecar/test/e2e/agent.yaml @@ -12,6 +12,14 @@ spec: prompts: system: You are a test agent. hotReload: false + observability: + tracing: + backend: otel + endpoint: http://localhost:4318 + sampleRate: 1.0 + metrics: + backend: opentelemetry + serviceName: test-agent tools: - name: echo type: function diff --git a/packages/sidecar/test/e2e/mock-agent.mjs b/packages/sidecar/test/e2e/mock-agent.mjs index 3e6d242..987d662 100644 --- a/packages/sidecar/test/e2e/mock-agent.mjs +++ b/packages/sidecar/test/e2e/mock-agent.mjs @@ -62,12 +62,16 @@ const server = createServer((req, res) => { return } - // Default — echo method + url (drain body first) + // Default — echo method + url + traceparent (drain body first) const chunks = [] req.on('data', (c) => chunks.push(c)) req.on('end', () => { res.statusCode = 200 - res.end(JSON.stringify({ method: req.method, url: req.url })) + const body = { method: req.method, url: req.url } + // Echo traceparent if present (for OTel E2E tests) + const traceparent = req.headers['traceparent'] + if (traceparent) body.traceparent = traceparent + res.end(JSON.stringify(body)) }) }) diff --git a/packages/sidecar/test/e2e/sidecar.e2e.test.ts b/packages/sidecar/test/e2e/sidecar.e2e.test.ts index 944258f..b589070 100644 --- a/packages/sidecar/test/e2e/sidecar.e2e.test.ts +++ b/packages/sidecar/test/e2e/sidecar.e2e.test.ts @@ -165,4 +165,46 @@ describe('agentspec-sidecar E2E smoke tests', () => { const body = (await res.json()) as { source?: string } expect(['agent-sdk', 'manifest-static']).toContain(body.source) }) + + // ── OpenTelemetry trace propagation tests ───────────────────────────────── + + it('15. Proxied request injects traceparent header into upstream', async () => { + const res = await fetch(`${PROXY}/otel-trace-check`) + expect(res.status).toBe(200) + const body = (await res.json()) as { traceparent?: string } + // The sidecar should have injected a W3C traceparent header + expect(body.traceparent).toBeDefined() + expect(body.traceparent).toMatch( + /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/, + ) + }) + + it('16. Multiple proxied requests get distinct trace IDs', async () => { + const res1 = await fetch(`${PROXY}/otel-trace-1`) + const res2 = await fetch(`${PROXY}/otel-trace-2`) + const body1 = (await res1.json()) as { traceparent?: string } + const body2 = (await res2.json()) as { traceparent?: string } + + expect(body1.traceparent).toBeDefined() + expect(body2.traceparent).toBeDefined() + + // Extract trace IDs (second segment of traceparent: version-traceId-spanId-flags) + const traceId1 = body1.traceparent!.split('-')[1] + const traceId2 = body2.traceparent!.split('-')[1] + expect(traceId1).not.toBe(traceId2) + }) + + it('17. Client-provided traceparent is propagated (not replaced)', async () => { + const clientTraceId = 'abcdef1234567890abcdef1234567890' + const clientTraceparent = `00-${clientTraceId}-0000000000000001-01` + const res = await fetch(`${PROXY}/otel-propagate`, { + headers: { traceparent: clientTraceparent }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { traceparent?: string } + expect(body.traceparent).toBeDefined() + // The trace ID should be preserved from the client's traceparent + const receivedTraceId = body.traceparent!.split('-')[1] + expect(receivedTraceId).toBe(clientTraceId) + }) }) diff --git a/packages/sidecar/tsup.config.ts b/packages/sidecar/tsup.config.ts index 350a6a8..36a1e5e 100644 --- a/packages/sidecar/tsup.config.ts +++ b/packages/sidecar/tsup.config.ts @@ -4,9 +4,9 @@ export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], outDir: 'dist', - // Bundle the workspace sibling so dist/index.js is self-contained - // and doesn't need @agentspec/sdk in node_modules at runtime. - noExternal: ['@agentspec/sdk'], + // Bundle workspace siblings so dist/index.js is self-contained + // and doesn't need them in node_modules at runtime. + noExternal: ['@agentspec/sdk', '@agentspec/otel'], // Keep optional LLM dep external — only loaded when ANTHROPIC_API_KEY is set external: ['@anthropic-ai/sdk'], sourcemap: true, From 14725cd50461d3abf83e48bc88b207d22399e85e Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 16:30:34 +0100 Subject: [PATCH 14/15] fix(otel): lazy-load gRPC exporter to fix bundled runtime crash The @opentelemetry/exporter-trace-otlp-grpc package depends on @grpc/grpc-js which uses dynamic requires that break when bundled by esbuild/tsup. This caused the sidecar Docker container to crash on startup. - Make gRPC exporter a dynamic import (only loaded when protocol is 'grpc') - Make initTracing() async to support the lazy import - Mark gRPC exporter as external in sidecar tsup config - Update provider tests for async initTracing --- packages/otel/src/__tests__/provider.test.ts | 32 ++++++++++---------- packages/otel/src/provider.ts | 20 +++++++----- packages/sidecar/src/index.ts | 2 +- packages/sidecar/tsup.config.ts | 9 ++++-- 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/otel/src/__tests__/provider.test.ts b/packages/otel/src/__tests__/provider.test.ts index a57fa28..9fca3f5 100644 --- a/packages/otel/src/__tests__/provider.test.ts +++ b/packages/otel/src/__tests__/provider.test.ts @@ -9,12 +9,12 @@ describe('initTracing', () => { propagation.disable() }) - it('registers a global TracerProvider', () => { + it('registers a global TracerProvider', async () => { const config: OtelConfig = { serviceName: 'test-agent', endpoint: 'http://localhost:4318', } - initTracing(config) + await initTracing(config) const provider = trace.getTracerProvider() expect(provider).toBeDefined() @@ -24,8 +24,8 @@ describe('initTracing', () => { span.end() }) - it('getTracer returns a tracer from the registered provider', () => { - initTracing({ + it('getTracer returns a tracer from the registered provider', async () => { + await initTracing({ serviceName: 'test-agent', endpoint: 'http://localhost:4318', }) @@ -37,27 +37,27 @@ describe('initTracing', () => { span.end() }) - it('uses http/protobuf protocol by default', () => { - expect(() => + it('uses http/protobuf protocol by default', async () => { + await expect( initTracing({ serviceName: 'test-agent', endpoint: 'http://localhost:4318', }), - ).not.toThrow() + ).resolves.not.toThrow() }) - it('accepts grpc protocol without throwing', () => { - expect(() => + it('accepts grpc protocol without throwing', async () => { + await expect( initTracing({ serviceName: 'test-agent', endpoint: 'http://localhost:4317', protocol: 'grpc', }), - ).not.toThrow() + ).resolves.not.toThrow() }) - it('applies sampleRate to the provider', () => { - initTracing({ + it('applies sampleRate to the provider', async () => { + await initTracing({ serviceName: 'test-agent', endpoint: 'http://localhost:4318', sampleRate: 0, @@ -69,17 +69,17 @@ describe('initTracing', () => { span.end() }) - it('is idempotent - calling twice does not throw', () => { + it('is idempotent - calling twice does not throw', async () => { const config: OtelConfig = { serviceName: 'test-agent', endpoint: 'http://localhost:4318', } - initTracing(config) - expect(() => initTracing(config)).not.toThrow() + await initTracing(config) + await expect(initTracing(config)).resolves.not.toThrow() }) it('shutdown flushes without error', async () => { - initTracing({ + await initTracing({ serviceName: 'test-agent', endpoint: 'http://localhost:4318', }) diff --git a/packages/otel/src/provider.ts b/packages/otel/src/provider.ts index 7a11f92..d5bfad2 100644 --- a/packages/otel/src/provider.ts +++ b/packages/otel/src/provider.ts @@ -2,9 +2,9 @@ import { trace } from '@opentelemetry/api' import { BasicTracerProvider, BatchSpanProcessor, + type SpanExporter, } from '@opentelemetry/sdk-trace-base' import { OTLPTraceExporter as OTLPHttpExporter } from '@opentelemetry/exporter-trace-otlp-http' -import { OTLPTraceExporter as OTLPGrpcExporter } from '@opentelemetry/exporter-trace-otlp-grpc' import { Resource } from '@opentelemetry/resources' import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions' import { createSampler } from './sampler.js' @@ -32,19 +32,23 @@ function resolveProtocol(config: OtelConfig): 'grpc' | 'http/protobuf' { return 'http/protobuf' } -function createExporter( +async function createExporter( endpoint: string, protocol: 'grpc' | 'http/protobuf', -): OTLPHttpExporter | OTLPGrpcExporter { +): Promise { if (protocol === 'grpc') { - return new OTLPGrpcExporter({ url: endpoint }) + // Dynamic import: @opentelemetry/exporter-trace-otlp-grpc pulls in @grpc/grpc-js + // which uses dynamic requires that break when bundled by esbuild/tsup. + // Lazy-loading avoids the crash when protocol is http/protobuf (the default). + const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-grpc') + return new OTLPTraceExporter({ url: endpoint }) } return new OTLPHttpExporter({ url: `${endpoint}/v1/traces` }) } -function buildProvider(config: OtelConfig): BasicTracerProvider { +async function buildProvider(config: OtelConfig): Promise { const protocol = resolveProtocol(config) - const exporter = createExporter(config.endpoint, protocol) + const exporter = await createExporter(config.endpoint, protocol) const sampler = createSampler(config.sampleRate) const resource = new Resource({ @@ -66,10 +70,10 @@ function buildProvider(config: OtelConfig): BasicTracerProvider { // ── Public orchestrators ────────────────────────────────────────────────────── -export function initTracing(config: OtelConfig): void { +export async function initTracing(config: OtelConfig): Promise { if (provider !== null) return - provider = buildProvider(config) + provider = await buildProvider(config) provider.register() setupPropagation() } diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts index b1d3657..999b999 100644 --- a/packages/sidecar/src/index.ts +++ b/packages/sidecar/src/index.ts @@ -39,7 +39,7 @@ async function main(): Promise { if (tracingConfig?.backend === 'otel') { const resolvedEndpoint = resolveOtelEndpoint(tracingConfig.endpoint, config.otelEndpoint) - initTracing({ + await initTracing({ serviceName: manifest.spec.observability?.metrics?.serviceName ?? manifest.metadata.name, endpoint: resolvedEndpoint, diff --git a/packages/sidecar/tsup.config.ts b/packages/sidecar/tsup.config.ts index 36a1e5e..d654ab7 100644 --- a/packages/sidecar/tsup.config.ts +++ b/packages/sidecar/tsup.config.ts @@ -7,8 +7,13 @@ export default defineConfig({ // Bundle workspace siblings so dist/index.js is self-contained // and doesn't need them in node_modules at runtime. noExternal: ['@agentspec/sdk', '@agentspec/otel'], - // Keep optional LLM dep external — only loaded when ANTHROPIC_API_KEY is set - external: ['@anthropic-ai/sdk'], + // Keep optional deps external — loaded dynamically at runtime when needed + external: [ + '@anthropic-ai/sdk', + // gRPC exporter is lazy-imported in @agentspec/otel only when protocol is 'grpc'. + // Its @grpc/grpc-js dep uses dynamic requires that break when bundled by esbuild. + '@opentelemetry/exporter-trace-otlp-grpc', + ], sourcemap: true, clean: true, }) From 77f7f527b1c081fd59dc13e1546be5db6973d185 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 16:33:58 +0100 Subject: [PATCH 15/15] fix(sidecar): propagate incoming traceparent instead of creating new root span --- packages/sidecar/src/proxy.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/sidecar/src/proxy.ts b/packages/sidecar/src/proxy.ts index bbed8cb..e7d49e3 100644 --- a/packages/sidecar/src/proxy.ts +++ b/packages/sidecar/src/proxy.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify' import httpProxy from '@fastify/http-proxy' import type { AgentSpecManifest } from '@agentspec/sdk' -import { getTracer } from '@agentspec/otel' +import { getTracer, extractContext } from '@agentspec/otel' import { trace, context, propagation, SpanStatusCode, type Span } from '@opentelemetry/api' import { AuditRing } from './audit-ring.js' import { config } from './config.js' @@ -73,6 +73,9 @@ export async function buildProxyApp( request._startedAt = Date.now() // ── OTel span creation + traceparent injection ─────────────────────────── + // Extract incoming trace context (if client sent a traceparent header) + // so the proxy span becomes a child of the caller's trace. + const parentCtx = extractContext(request.headers as Record) const tracer = getTracer('@agentspec/sidecar') const span = tracer.startSpan('proxy:request', { attributes: { @@ -81,13 +84,11 @@ export async function buildProxyApp( 'http.request_id': requestId, 'agentspec.agent.name': manifest.metadata.name, }, - }) + }, parentCtx) request._otelSpan = span // Inject traceparent into raw headers so @fastify/reply-from forwards them - // upstream. We use propagation.inject with the span context directly rather - // than relying on context.with + context.active(), because Fastify's async - // hooks may not propagate the OTel async context correctly. + // upstream. The span context carries the (possibly inherited) trace ID. const spanContext = trace.setSpan(context.active(), span) propagation.inject(spanContext, request.raw.headers, { set(carrier, key, value) {