From a50258dddf59856eb4f330232220555ad5c90d7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 18:02:47 +0000 Subject: [PATCH 01/12] feat(otel): Phase 1 OpenTelemetry plumbing in @a2a-wrapper/core Add optional peer @opentelemetry/api and core hooks so createA2AServer wraps executors with a2a.task.* spans. Hosts register TracerProvider / OTLP exporters; without them instrumentation is a no-op. Align factory A2AExecutor with executor/types so instrumentExecutor typechecks. Ship OSS docs/observability.md (not research notes). Co-authored-by: Shashi Kanth G S --- .changeset/otel-core-api.md | 10 + README.md | 2 +- a2a-antigravity/src/config/defaults.ts | 1 + a2a-claude/src/config/defaults.ts | 1 + a2a-codex/src/config/defaults.ts | 1 + a2a-copilot/src/config/defaults.ts | 4 +- a2a-opencode/src/config/defaults.ts | 4 +- docs/observability.md | 106 ++++++++++ package-lock.json | 17 ++ packages/core/README.md | 10 + packages/core/package.json | 7 + .../core/src/__tests__/telemetry/otel.test.ts | 194 ++++++++++++++++++ packages/core/src/config/types.ts | 24 +++ packages/core/src/events/transport.ts | 3 + packages/core/src/index.ts | 30 +++ packages/core/src/server/factory.ts | 50 ++--- packages/core/src/telemetry/api.ts | 83 ++++++++ packages/core/src/telemetry/attributes.ts | 51 +++++ packages/core/src/telemetry/context.ts | 94 +++++++++ packages/core/src/telemetry/emitter-bridge.ts | 76 +++++++ packages/core/src/telemetry/index.ts | 31 +++ packages/core/src/telemetry/instrument.ts | 107 ++++++++++ packages/core/src/telemetry/types.ts | 124 +++++++++++ 23 files changed, 1003 insertions(+), 27 deletions(-) create mode 100644 .changeset/otel-core-api.md create mode 100644 docs/observability.md create mode 100644 packages/core/src/__tests__/telemetry/otel.test.ts create mode 100644 packages/core/src/telemetry/api.ts create mode 100644 packages/core/src/telemetry/attributes.ts create mode 100644 packages/core/src/telemetry/context.ts create mode 100644 packages/core/src/telemetry/emitter-bridge.ts create mode 100644 packages/core/src/telemetry/index.ts create mode 100644 packages/core/src/telemetry/instrument.ts create mode 100644 packages/core/src/telemetry/types.ts diff --git a/.changeset/otel-core-api.md b/.changeset/otel-core-api.md new file mode 100644 index 0000000..8623446 --- /dev/null +++ b/.changeset/otel-core-api.md @@ -0,0 +1,10 @@ +--- +"@a2a-wrapper/core": minor +--- + +feat(otel): optional OpenTelemetry task spans via `@opentelemetry/api` + +- Optional peer `@opentelemetry/api` (no SDK/exporter in core) +- `instrumentExecutor` in `createA2AServer` for `a2a.task.execute` / cancel +- `AgentEventEmitter` respects emission policy (no duplicate tool spans when backend OTel is on) +- Exports: `setOtelTracer`, `withSpan`, `extractA2ATraceContext`, `getW3cTraceContext`, `resolveOtelEmissionPolicy`, … diff --git a/README.md b/README.md index 12aef71..374b9ab 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Wrappers run coding agents behind an HTTP endpoint. Read the [Security Guide](do ## Roadmap -- **OpenTelemetry observability** — optional distributed tracing/metrics for task lifecycle and HTTP requests, via `@opentelemetry/api` (no-op by default, zero cost unless a host app registers a real SDK/exporter). Not yet implemented. +- **OpenTelemetry observability** — optional distributed tracing for the A2A task lifecycle via `@opentelemetry/api` (optional peer; no-op until a host registers a TracerProvider / OTLP exporter). Core hooks ship in `@a2a-wrapper/core`; backend CLI passthrough (Copilot / Claude) is follow-up work. See [Observability](docs/observability.md). ## Contributing diff --git a/a2a-antigravity/src/config/defaults.ts b/a2a-antigravity/src/config/defaults.ts index a7348fb..983ecf6 100644 --- a/a2a-antigravity/src/config/defaults.ts +++ b/a2a-antigravity/src/config/defaults.ts @@ -56,5 +56,6 @@ export const DEFAULTS: Required = { }, memory: undefined as unknown as Required["memory"], subAgents: undefined as unknown as Required["subAgents"], + otel: undefined as unknown as Required["otel"], configDir: process.cwd(), }; diff --git a/a2a-claude/src/config/defaults.ts b/a2a-claude/src/config/defaults.ts index 4593702..1c9ef28 100644 --- a/a2a-claude/src/config/defaults.ts +++ b/a2a-claude/src/config/defaults.ts @@ -64,5 +64,6 @@ export const DEFAULTS: Required = { }, memory: undefined as unknown as Required["memory"], subAgents: undefined as unknown as Required["subAgents"], + otel: undefined as unknown as Required["otel"], configDir: process.cwd(), }; diff --git a/a2a-codex/src/config/defaults.ts b/a2a-codex/src/config/defaults.ts index 1757394..56bfb8c 100644 --- a/a2a-codex/src/config/defaults.ts +++ b/a2a-codex/src/config/defaults.ts @@ -58,5 +58,6 @@ export const DEFAULTS: Required = { }, memory: undefined as unknown as Required["memory"], subAgents: undefined as unknown as Required["subAgents"], + otel: undefined as unknown as Required["otel"], configDir: process.cwd(), }; diff --git a/a2a-copilot/src/config/defaults.ts b/a2a-copilot/src/config/defaults.ts index cf72ef0..8ac9354 100644 --- a/a2a-copilot/src/config/defaults.ts +++ b/a2a-copilot/src/config/defaults.ts @@ -13,10 +13,11 @@ import type { AgentConfig } from "./types.js"; * Fields that are genuinely optional at runtime (`memory`, `configDir`) are * typed as `undefined` here rather than hidden behind an unsafe cast. */ -export type DefaultAgentConfig = Omit, "memory" | "configDir" | "subAgents"> & { +export type DefaultAgentConfig = Omit, "memory" | "configDir" | "subAgents" | "otel"> & { memory: undefined; configDir: undefined; subAgents: undefined; + otel: undefined; }; /** Deep-frozen default config. Never mutate — always merge over. */ @@ -73,4 +74,5 @@ export const DEFAULTS: Readonly = Object.freeze({ memory: undefined, configDir: undefined, subAgents: undefined, + otel: undefined, }); diff --git a/a2a-opencode/src/config/defaults.ts b/a2a-opencode/src/config/defaults.ts index 919674e..900708f 100644 --- a/a2a-opencode/src/config/defaults.ts +++ b/a2a-opencode/src/config/defaults.ts @@ -14,10 +14,11 @@ import type { AgentConfig } from "./types.js"; * `subAgents`) are typed as `undefined` here rather than hidden behind an * unsafe cast. */ -export type DefaultAgentConfig = Omit, "memory" | "configDir" | "subAgents"> & { +export type DefaultAgentConfig = Omit, "memory" | "configDir" | "subAgents" | "otel"> & { memory: undefined; configDir: undefined; subAgents: undefined; + otel: undefined; }; /** Deep-frozen default config. Never mutate — always merge over. */ @@ -78,4 +79,5 @@ export const DEFAULTS: Readonly = Object.freeze({ memory: undefined, configDir: undefined, subAgents: undefined, + otel: undefined, }); diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..9b295e1 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,106 @@ +# Observability + +How to collect traces from `a2a-*` wrappers with [OpenTelemetry](https://opentelemetry.io/), and how that relates to A2A sideband `trace.*` artifacts. + +## Two channels (keep both) + +| Channel | Purpose | Transport | +|---|---|---| +| **A2A sideband** (`trace.mcp`, `trace.usage`, …) | Orchestrators and A2A clients | Event bus / optional HTTP event transport | +| **OpenTelemetry** | Platform collectors (Grafana Tempo, Jaeger, Langfuse, …) | OTLP | + +Sideband is unchanged when you enable OTel. OTel does **not** replace A2A artifacts. + +## Dependency model + +| Package | Role | +|---|---| +| `@opentelemetry/api` | Optional peer of `@a2a-wrapper/core` — create spans / propagate context | +| OpenTelemetry **SDK** + OTLP exporter | Owned by **your host app** or a wrapper CLI bootstrap — **not** a hard dependency of core | + +Without a registered `TracerProvider`, wrapper span calls are no-ops (zero overhead for most installs). + +### Register a tracer (library / host) + +```ts +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { trace } from "@opentelemetry/api"; +import { setOtelTracer } from "@a2a-wrapper/core"; + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter({ url: "http://127.0.0.1:4318/v1/traces" }), +}); +await sdk.start(); + +setOtelTracer(trace.getTracer("a2a-wrapper")); +``` + +Point your collector (or Grafana Alloy) at OTLP HTTP `:4318` and/or gRPC `:4317`. + +## Agent config + +```json +{ + "otel": { + "enabled": true, + "mirrorAgentEvents": false, + "backend": { + "copilot": { + "otlpEndpoint": "http://127.0.0.1:4318", + "exporterType": "otlp-http", + "propagateTraceContext": true + } + } + } +} +``` + +| Field | Meaning | +|---|---| +| `otel.enabled` | Emit wrapper spans such as `a2a.task.execute` | +| `otel.mirrorAgentEvents` | Also turn sideband tool events into wrapper OTel tool spans (default `false`) | +| `otel.backend.copilot` | Copilot CLI will export its own OTLP spans — wrapper **will not** duplicate tool spans | +| `otel.emitOverlappingBackendSpans` | Unsafe; allow duplicates (debug only) | + +### What appears in Tempo / Jaeger / Langfuse + +One **trace**, parent/child spans (not one merged span): + +``` +a2a.task.execute ← wrapper (Node), attributes include gen_ai.agent.name from agentCard.name + └─ Copilot / Claude … ← backend CLI/runtime (separate process), same collector +``` + +- **Copilot:** configure `telemetry.otlpEndpoint` (wired from `otel.backend.copilot` in a follow-up) and `onGetTraceContext` for parent linking. +- **Claude Agent SDK:** TypeScript host spawns Claude Code; enable runtime OTel via env (`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*`) and pass `TRACEPARENT` for parent linking. +- **Antigravity:** no backend OTel today — enable `otel.enabled` and set `mirrorAgentEvents: true` if you want tool detail in OTLP from the wrapper. + +Use the **same collector** for wrapper and backend exporters. Prefer OTLP HTTP `:4318` when mixing Copilot with the Node SDK. + +## Wrapper span attributes (selected) + +| Attribute | Source | +|---|---| +| `a2a.task.id` / `a2a.context.id` | A2A task / context | +| `gen_ai.agent.name` / `a2a.agent.name` | `agentCard.name` | +| `gen_ai.conversation.id` | A2A `contextId` | +| `a2a.wrapper.name` | Server option / package name | + +## Programmatic API + +```ts +import { + setOtelTracer, + withSpan, + extractA2ATraceContext, + getW3cTraceContext, + resolveOtelEmissionPolicy, +} from "@a2a-wrapper/core"; +``` + +`createA2AServer` automatically wraps the executor so every `a2a-*` provider gets `a2a.task.execute` / `a2a.task.cancel` when `otel.enabled` is true and a tracer is registered. + +## Privacy + +Do not enable backend content capture (`captureContent`, `OTEL_LOG_USER_PROMPTS`, etc.) unless you accept prompts and tool payloads in your collector. Wrapper instrumentation does not record prompt bodies by default. diff --git a/package-lock.json b/package-lock.json index cd334c0..7136cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1929,6 +1929,16 @@ "cross-spawn": "7.0.6" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -6513,6 +6523,7 @@ "license": "MIT", "devDependencies": { "@a2a-js/sdk": "^1.0.0", + "@opentelemetry/api": "^1.9.1", "@types/express": "^4.17.21", "@types/node": "^20.10.0", "@types/supertest": "^7.2.0", @@ -6529,8 +6540,14 @@ }, "peerDependencies": { "@a2a-js/sdk": "^1.0.0", + "@opentelemetry/api": ">=1.0.0 <1.10.0", "express": "^4.18.2", "uuid": "^9.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "packages/core/node_modules/@types/node": { diff --git a/packages/core/README.md b/packages/core/README.md index f33e7ab..7dd5dd4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -466,6 +466,16 @@ if (this.config.subAgents?.agents?.length) { } ``` +### OpenTelemetry + +Optional distributed tracing for `a2a.task.execute` / `a2a.task.cancel`. Core peers on `@opentelemetry/api` only (optional); register a real SDK exporter in your host. See the monorepo [Observability guide](../../docs/observability.md). + +```ts +import { setOtelTracer, withSpan, extractA2ATraceContext } from "@a2a-wrapper/core"; +``` + +`createA2AServer` wraps the executor automatically when `config.otel.enabled` is true **and** a tracer is registered (`setOtelTracer` or a global provider). + ### A2A SDK Re-exports | Export | Source | diff --git a/packages/core/package.json b/packages/core/package.json index ea6fa7d..712836d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,11 +22,18 @@ }, "peerDependencies": { "@a2a-js/sdk": "^1.0.0", + "@opentelemetry/api": ">=1.0.0 <1.10.0", "express": "^4.18.2", "uuid": "^9.0.0" }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + }, "devDependencies": { "@a2a-js/sdk": "^1.0.0", + "@opentelemetry/api": "^1.9.1", "@types/express": "^4.17.21", "@types/node": "^20.10.0", "@types/supertest": "^7.2.0", diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts new file mode 100644 index 0000000..37419f3 --- /dev/null +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + setOtelTracer, + withSpan, + resolveOtelEmissionPolicy, + extractA2ATraceContext, + instrumentExecutor, + observeAgentEvent, + runWithTaskOtelStore, + type OtelTracerLike, + type OtelSpanLike, + type A2AExecutor, +} from "../../index.js"; +import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; + +function fakeTracer() { + const ended: string[] = []; + const events: string[] = []; + const spans: OtelSpanLike[] = []; + const tracer: OtelTracerLike = { + startSpan(name) { + const span: OtelSpanLike = { + setAttribute() {}, + setStatus() {}, + recordException() {}, + addEvent(n) { + events.push(n); + }, + end() { + ended.push(name); + }, + }; + spans.push(span); + return span; + }, + }; + return { tracer, ended, events, spans }; +} + +describe("resolveOtelEmissionPolicy", () => { + it("disables tool spans when backend copilot OTel is configured", () => { + const p = resolveOtelEmissionPolicy({ + enabled: true, + mirrorAgentEvents: true, + backend: { copilot: { otlpEndpoint: "http://127.0.0.1:4318" } }, + }); + expect(p.enabled).toBe(true); + expect(p.backendOtelOn).toBe(true); + expect(p.emitToolSpans).toBe(false); + }); + + it("allows tool spans when backend OTel is off and mirror is on", () => { + const p = resolveOtelEmissionPolicy({ + enabled: true, + mirrorAgentEvents: true, + }); + expect(p.emitToolSpans).toBe(true); + }); + + it("allows overlap only with explicit unsafe flag", () => { + const p = resolveOtelEmissionPolicy({ + enabled: true, + mirrorAgentEvents: true, + emitOverlappingBackendSpans: true, + backend: { claude: { enableTelemetry: true } }, + }); + expect(p.emitToolSpans).toBe(true); + }); +}); + +describe("withSpan", () => { + afterEach(() => setOtelTracer(undefined)); + + it("is a no-op without a tracer", async () => { + setOtelTracer(undefined); + await expect(withSpan("x", {}, async () => 7)).resolves.toBe(7); + }); + + it("ends the span on success and on throw", async () => { + const { tracer, ended } = fakeTracer(); + setOtelTracer(tracer); + await withSpan("op", { a: 1 }, async () => "ok"); + expect(ended).toEqual(["op"]); + await expect( + withSpan("boom", {}, async () => { + throw new Error("nope"); + }), + ).rejects.toThrow("nope"); + expect(ended).toEqual(["op", "boom"]); + }); +}); + +describe("extractA2ATraceContext", () => { + it("reads trace_id and parent_agent_id from metadata", () => { + const ctx = { + taskId: "t1", + contextId: "ctx-1", + metadata: { trace_id: "orch-1", parent_agent_id: "parent-a" }, + } as unknown as RequestContext; + const t = extractA2ATraceContext(ctx); + expect(t.traceId).toBe("orch-1"); + expect(t.parentAgentId).toBe("parent-a"); + }); + + it("falls back to contextId", () => { + const ctx = { taskId: "t1", contextId: "ctx-99", metadata: {} } as unknown as RequestContext; + expect(extractA2ATraceContext(ctx).traceId).toBe("ctx-99"); + }); +}); + +describe("instrumentExecutor", () => { + afterEach(() => setOtelTracer(undefined)); + + it("wraps execute in a2a.task.execute when otel.enabled", async () => { + const { tracer, ended } = fakeTracer(); + setOtelTracer(tracer); + let ran = false; + const inner: A2AExecutor = { + async initialize() {}, + async shutdown() {}, + async execute() { + ran = true; + }, + }; + const ex = instrumentExecutor(inner, { + otel: { enabled: true }, + agentName: "Example Agent", + wrapperName: "a2a-copilot", + }); + await ex.execute( + { taskId: "t1", contextId: "c1" } as unknown as RequestContext, + {} as ExecutionEventBus, + ); + expect(ran).toBe(true); + expect(ended).toEqual(["a2a.task.execute"]); + }); +}); + +describe("observeAgentEvent de-dupe", () => { + beforeEach(() => setOtelTracer(undefined)); + afterEach(() => setOtelTracer(undefined)); + + it("does not open tool spans when backend OTel is on", async () => { + const { tracer, ended } = fakeTracer(); + setOtelTracer(tracer); + const policy = resolveOtelEmissionPolicy({ + enabled: true, + mirrorAgentEvents: true, + backend: { copilot: { otlpEndpoint: "http://localhost:4318" } }, + }); + await runWithTaskOtelStore( + { + policy, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + observeAgentEvent("tool_call_start", { tool: "Read", callId: "1" }); + observeAgentEvent("tool_call_end", { tool: "Read", callId: "1" }); + }, + ); + // only the manual task span from the test setup — no a2a.mcp.tool + expect(ended.filter((n) => n.startsWith("a2a.mcp.tool"))).toHaveLength(0); + }); + + it("opens tool spans for Antigravity-style fallback when mirror is on", async () => { + const { tracer, ended } = fakeTracer(); + setOtelTracer(tracer); + const policy = resolveOtelEmissionPolicy({ + enabled: true, + mirrorAgentEvents: true, + }); + await runWithTaskOtelStore( + { + policy, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + observeAgentEvent("tool_call_start", { tool: "Bash", callId: "9" }); + observeAgentEvent("tool_call_end", { tool: "Bash", callId: "9" }); + }, + ); + expect(ended).toContain("a2a.mcp.tool Bash"); + }); +}); diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index fb0d61d..b23e13d 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -508,4 +508,28 @@ export interface BaseAgentConfig> { * Defaults to process.cwd() when no config file is specified. */ configDir?: string; + + /** + * Optional OpenTelemetry settings for wrapper-owned spans. + * + * Core only uses `@opentelemetry/api` (optional peer). Register a real + * TracerProvider / OTLP exporter in the host app or wrapper CLI — see + * `docs/observability.md`. When omitted, instrumentation is a no-op. + * + * Typed loosely here to avoid a hard import cycle with the telemetry + * module; see {@link OtelConfig} in `@a2a-wrapper/core` exports. + */ + otel?: { + enabled?: boolean; + serviceName?: string; + tracerName?: string; + mirrorAgentEvents?: boolean; + emitOverlappingBackendSpans?: boolean; + backend?: { + copilot?: { otlpEndpoint?: string; exporterType?: string; propagateTraceContext?: boolean }; + claude?: { enableTelemetry?: boolean }; + codex?: Record; + opencode?: { openTelemetry?: boolean }; + }; + }; } diff --git a/packages/core/src/events/transport.ts b/packages/core/src/events/transport.ts index 568c11d..bb1422b 100644 --- a/packages/core/src/events/transport.ts +++ b/packages/core/src/events/transport.ts @@ -36,6 +36,7 @@ import { TaskArtifactUpdateEvent } from "@a2a-js/sdk"; import type { ExecutionEventBus } from "@a2a-js/sdk/server"; import type { EventsConfig } from "../config/types.js"; import { TRACE_EXTENSION_URI } from "../server/agent-card.js"; +import { observeAgentEvent } from "../telemetry/emitter-bridge.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -408,5 +409,7 @@ export class AgentEventEmitter { } catch (e) { console.warn(`[emitter] Failed to emit ${eventType}: ${(e as Error).message}`); } + // OTel annotation / optional tool spans — never affects sideband delivery. + observeAgentEvent(eventType, data); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2a56ce1..14a462d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,36 @@ export { export type { UsageCallRecord, UsageTelemetryData, ContextWindowSnapshot } from "./events/usage.js"; export { LlmUsageAccumulator } from "./events/usage.js"; +// ─── OpenTelemetry (optional @opentelemetry/api peer) ─────────────────────── + +export type { + OtelConfig, + OtelEmissionPolicy, + OtelTracerLike, + OtelSpanLike, + W3cTraceContext, + A2ATraceContext, + TaskOtelStore, + InstrumentExecutorOptions, +} from "./telemetry/index.js"; + +export { + resolveOtelEmissionPolicy, + setOtelTracer, + getOtelTracer, + tryGetGlobalTracer, + withSpan, + extractA2ATraceContext, + getW3cTraceContext, + getTaskOtelStore, + runWithTaskOtelStore, + buildTaskSpanAttributes, + slugAgentId, + agentIdentityFromCard, + instrumentExecutor, + observeAgentEvent, +} from "./telemetry/index.js"; + // ─── A2A SDK Type Re-exports ──────────────────────────────────────────────── // // Core-owned aliases for commonly used A2A SDK types. Wrapper projects diff --git a/packages/core/src/server/factory.ts b/packages/core/src/server/factory.ts index d235c14..dafe9d3 100644 --- a/packages/core/src/server/factory.ts +++ b/packages/core/src/server/factory.ts @@ -63,6 +63,8 @@ import { import type { AgentCardConfig } from "../config/types.js"; import type { EventTransport, EventTransportFn } from "../events/transport.js"; +import { instrumentExecutor } from "../telemetry/instrument.js"; +import type { OtelConfig } from "../telemetry/types.js"; import { buildAgentCard, buildAgentCardForUrls, buildLegacyAgentCard } from "./agent-card.js"; import type { BuildAgentCardInput } from "./agent-card.js"; @@ -103,31 +105,13 @@ function resolveAgentCardSigningFromConfig( // ─── A2AExecutor Interface ────────────────────────────────────────────────── /** - * Minimal executor contract required by the server factory. + * Full executor contract used by the server factory and OTel instrumentation. * - * Every wrapper project implements this interface with its backend-specific - * logic (e.g. `CopilotExecutor`, `OpenCodeExecutor`). The server factory - * calls {@link initialize} during startup and {@link shutdown} during - * graceful teardown. - * - * The `execute` and `cancelTask` methods are inherited from the SDK's - * `AgentExecutor` type and are invoked by {@link DefaultRequestHandler} - * when processing A2A JSON-RPC / REST requests. + * Re-exported from {@link ../executor/types.js} so factory typing stays aligned + * with `execute` / `cancelTask` (required for Hook A span wrapping). */ -export interface A2AExecutor { - /** - * Perform asynchronous startup logic (e.g. connect to backend, register - * MCP servers, warm caches). Called once before the HTTP server begins - * accepting requests. - */ - initialize(): Promise; - - /** - * Perform graceful cleanup (e.g. close backend connections, flush buffers). - * Called when the server is shutting down. - */ - shutdown(): Promise; -} +export type { A2AExecutor } from "../executor/types.js"; +import type { A2AExecutor } from "../executor/types.js"; // ─── ServerOptions ────────────────────────────────────────────────────────── @@ -205,6 +189,16 @@ export interface ServerOptions { * inside their `execute()` method to get the final transport for each request. */ eventTransport?: EventTransport | EventTransportFn; + + /** + * Wrapper package name for OTel resource/span attributes (e.g. `"a2a-copilot"`). + */ + wrapperName?: string; + + /** + * Wrapper package version for OTel attributes. + */ + wrapperVersion?: string; } // ─── ServerHandle ─────────────────────────────────────────────────────────── @@ -318,7 +312,15 @@ export async function createA2AServer( const protocolVersion = options?.protocolVersion ?? "1.0"; // ── 1. Executor ───────────────────────────────────────────────────────── - const executor = executorFactory(config); + const rawExecutor = executorFactory(config); + const otelConfig = (config as { otel?: OtelConfig }).otel; + const executor = instrumentExecutor(rawExecutor, { + otel: otelConfig, + wrapperName: options?.wrapperName, + wrapperVersion: options?.wrapperVersion, + protocolVersion: options?.protocolVersion ?? "1.0", + agentName: config.agentCard.name, + }); await executor.initialize(); // ── 2. Agent card (static, used for request-handler-internal version diff --git a/packages/core/src/telemetry/api.ts b/packages/core/src/telemetry/api.ts new file mode 100644 index 0000000..492aea2 --- /dev/null +++ b/packages/core/src/telemetry/api.ts @@ -0,0 +1,83 @@ +/** + * Optional OpenTelemetry tracer registry (skillmap-style). + * + * No hard dependency on `@opentelemetry/api`. Hosts that use the real API + * obtain a tracer from their SDK and register it here (or rely on + * {@link tryGetGlobalTracer} when the API package is installed). + * + * @module telemetry/api + */ + +import { createRequire } from "node:module"; +import type { OtelSpanLike, OtelTracerLike } from "./types.js"; + +const require = createRequire(import.meta.url); + +let _tracer: OtelTracerLike | undefined; +let _warnedMissingProvider = false; + +export function setOtelTracer(tracer: OtelTracerLike | undefined): void { + _tracer = tracer; +} + +export function getOtelTracer(): OtelTracerLike | undefined { + return _tracer ?? tryGetGlobalTracer(); +} + +/** + * Best-effort: if `@opentelemetry/api` is resolvable and a non-no-op provider + * is registered, return its tracer. Never throws. + */ +export function tryGetGlobalTracer(name = "a2a-wrapper", version?: string): OtelTracerLike | undefined { + try { + const api = require("@opentelemetry/api") as { + trace: { + getTracer: (n: string, v?: string) => OtelTracerLike; + getTracerProvider: () => { constructor?: { name?: string } }; + }; + }; + const provider = api.trace.getTracerProvider?.(); + const providerName = provider?.constructor?.name ?? ""; + if (!provider || providerName === "NoopTracerProvider") { + return undefined; + } + return api.trace.getTracer(name, version); + } catch { + return undefined; + } +} + +/** + * Run `block` under a span when a tracer is available; otherwise run plainly. + * Always ends the span (including on throw). + */ +export async function withSpan( + name: string, + attributes: Record, + block: (span: OtelSpanLike | undefined) => Promise, +): Promise { + const tracer = getOtelTracer(); + if (!tracer) { + if (!_warnedMissingProvider && process.env["A2A_OTEL_DEBUG"] === "1") { + _warnedMissingProvider = true; + console.warn("[a2a-wrapper/otel] no tracer registered; spans are no-ops"); + } + return block(undefined); + } + + const span = tracer.startSpan(name, { attributes }); + try { + return await block(span); + } catch (err) { + try { + span.recordException?.(err); + // SpanStatusCode.ERROR === 2 in @opentelemetry/api + span.setStatus?.({ code: 2, message: err instanceof Error ? err.message : String(err) }); + } catch { + /* ignore span status failures */ + } + throw err; + } finally { + span.end(); + } +} diff --git a/packages/core/src/telemetry/attributes.ts b/packages/core/src/telemetry/attributes.ts new file mode 100644 index 0000000..72a77ea --- /dev/null +++ b/packages/core/src/telemetry/attributes.ts @@ -0,0 +1,51 @@ +/** + * Attribute builders for wrapper-owned spans. + * + * @module telemetry/attributes + */ + +import type { A2ATraceContext } from "./context.js"; + +export function slugAgentId(agentName: string): string { + return agentName.toLowerCase().replace(/\s+/g, "-"); +} + +export function buildTaskSpanAttributes(opts: { + taskId: string; + contextId: string; + agentName: string; + agentId?: string; + wrapperName?: string; + wrapperVersion?: string; + protocolVersion?: string; + parentAgentId?: string | null; + orchestratorTraceId?: string; +}): Record { + const agentId = opts.agentId ?? slugAgentId(opts.agentName); + const attrs: Record = { + "a2a.task.id": opts.taskId, + "a2a.context.id": opts.contextId, + "a2a.agent.id": agentId, + "a2a.agent.name": opts.agentName, + "gen_ai.agent.name": opts.agentName, + "gen_ai.agent.id": agentId, + "gen_ai.conversation.id": opts.contextId, + }; + if (opts.wrapperName) attrs["a2a.wrapper.name"] = opts.wrapperName; + if (opts.wrapperVersion) attrs["a2a.wrapper.version"] = opts.wrapperVersion; + if (opts.protocolVersion) attrs["a2a.protocol.version"] = opts.protocolVersion; + if (opts.parentAgentId) attrs["a2a.parent_agent.id"] = opts.parentAgentId; + if (opts.orchestratorTraceId) attrs["a2a.orchestrator.trace_id"] = opts.orchestratorTraceId; + return attrs; +} + +export function agentIdentityFromCard(agentCard: { name: string }, trace?: A2ATraceContext) { + const agentName = agentCard.name; + const agentId = slugAgentId(agentName); + return { + agentId, + agentName, + parentAgentId: trace?.parentAgentId ?? null, + orchestratorTraceId: trace?.traceId, + }; +} diff --git a/packages/core/src/telemetry/context.ts b/packages/core/src/telemetry/context.ts new file mode 100644 index 0000000..7dcca32 --- /dev/null +++ b/packages/core/src/telemetry/context.ts @@ -0,0 +1,94 @@ +/** + * Per-task OpenTelemetry / A2A trace context (AsyncLocalStorage). + * + * @module telemetry/context + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { createRequire } from "node:module"; +import { v4 as uuidv4 } from "uuid"; +import type { RequestContext } from "@a2a-js/sdk/server"; +import type { OtelEmissionPolicy, OtelSpanLike, W3cTraceContext } from "./types.js"; + +const require = createRequire(import.meta.url); + +export interface A2ATraceContext { + traceId: string; + parentAgentId: string | null; + metadata: Record; +} + +export interface TaskOtelStore { + span?: OtelSpanLike; + policy: OtelEmissionPolicy; + taskId: string; + contextId: string; + agentId: string; + agentName: string; + /** Active tool spans keyed by call id / tool name (when emitToolSpans). */ + toolSpans: Map; +} + +const als = new AsyncLocalStorage(); + +export function getTaskOtelStore(): TaskOtelStore | undefined { + return als.getStore(); +} + +export function runWithTaskOtelStore(store: TaskOtelStore, fn: () => Promise): Promise { + return als.run(store, fn); +} + +/** + * Extract orchestrator-propagated trace metadata from an A2A RequestContext. + * Shared replacement for the Copilot/OpenCode private helpers. + */ +export function extractA2ATraceContext(ctx: RequestContext): A2ATraceContext { + const raw = ctx as unknown as Record; + const meta = + (raw.metadata as Record) || + ((raw.task as Record)?.metadata as Record) || + ((raw.task as Record)?.configuration as Record) || + {}; + + return { + traceId: + (meta.trace_id as string) || + (meta.traceId as string) || + ctx.contextId || + uuidv4(), + parentAgentId: + (meta.parent_agent_id as string) || + (meta.parentAgentId as string) || + null, + metadata: + (meta.propagated_metadata as Record) || + (meta.propagatedMetadata as Record) || + {}, + }; +} + +/** + * W3C carrier for backend passthrough. + * + * When `@opentelemetry/api` is installed and a real context is active, injects + * `traceparent` / `tracestate`. Otherwise returns `{}`. + */ +export function getW3cTraceContext(): W3cTraceContext { + try { + const api = require("@opentelemetry/api") as { + context: { active: () => unknown }; + propagation: { + inject: (ctx: unknown, carrier: Record) => void; + }; + }; + const carrier: Record = {}; + api.propagation.inject(api.context.active(), carrier); + const out: W3cTraceContext = {}; + if (carrier.traceparent) out.traceparent = carrier.traceparent; + if (carrier.tracestate) out.tracestate = carrier.tracestate; + return out; + } catch { + return {}; + } +} diff --git a/packages/core/src/telemetry/emitter-bridge.ts b/packages/core/src/telemetry/emitter-bridge.ts new file mode 100644 index 0000000..4fbad41 --- /dev/null +++ b/packages/core/src/telemetry/emitter-bridge.ts @@ -0,0 +1,76 @@ +/** + * Hook D — apply OTel policy when AgentEvents are emitted. + * Sideband transport is unchanged; this only annotates / opens spans. + * + * @module telemetry/emitter-bridge + */ + +import type { EventType } from "../events/transport.js"; +import { getOtelTracer } from "./api.js"; +import { getTaskOtelStore } from "./context.js"; + +function toolKey(data: Record): string { + return String(data.callId ?? data.toolCallId ?? data.tool_use_id ?? data.tool ?? data.name ?? "tool"); +} + +/** + * Called from {@link AgentEventEmitter.emit} after the transport send. + * Never throws to the caller. + */ +export function observeAgentEvent(eventType: EventType, data: Record): void { + try { + const store = getTaskOtelStore(); + if (!store?.policy.enabled) return; + + const span = store.span; + if (store.policy.annotateLifecycleOnTaskSpan && span) { + if ( + eventType === "agent_started" || + eventType === "agent_finished" || + eventType === "agent_error" || + eventType === "decision" || + eventType === "thinking" + ) { + span.addEvent?.(eventType, { + "a2a.event.type": eventType, + }); + } + if (eventType === "agent_error") { + span.setStatus?.({ code: 2, message: String(data.message ?? data.error ?? "agent_error") }); + } + } + + if (!store.policy.emitToolSpans) return; + + const tracer = getOtelTracer(); + if (!tracer) return; + + if (eventType === "tool_call_start") { + const key = toolKey(data); + const toolName = String(data.tool ?? data.name ?? key); + const child = tracer.startSpan(`a2a.mcp.tool ${toolName}`, { + attributes: { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": toolName, + "a2a.tool.name": toolName, + ...(data.toolKind ? { "a2a.tool.kind": String(data.toolKind) } : {}), + ...(data.server ? { "a2a.mcp.server": String(data.server) } : {}), + ...(data.delegation === true ? { "a2a.delegation": true } : {}), + }, + }); + store.toolSpans.set(key, child); + } else if (eventType === "tool_call_end") { + const key = toolKey(data); + const child = store.toolSpans.get(key); + if (child) { + if (data.error || data.success === false) { + child.setStatus?.({ code: 2, message: String(data.error ?? "tool_error") }); + } + child.end(); + store.toolSpans.delete(key); + } + } + } catch { + /* never break sideband emission */ + } +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts new file mode 100644 index 0000000..bb40bd9 --- /dev/null +++ b/packages/core/src/telemetry/index.ts @@ -0,0 +1,31 @@ +/** + * OpenTelemetry integration for `@a2a-wrapper/core`. + * + * @module telemetry + */ + +export type { + OtelConfig, + OtelEmissionPolicy, + OtelTracerLike, + OtelSpanLike, + W3cTraceContext, +} from "./types.js"; +export { resolveOtelEmissionPolicy } from "./types.js"; + +export { setOtelTracer, getOtelTracer, tryGetGlobalTracer, withSpan } from "./api.js"; + +export { + extractA2ATraceContext, + getW3cTraceContext, + getTaskOtelStore, + runWithTaskOtelStore, + type A2ATraceContext, + type TaskOtelStore, +} from "./context.js"; + +export { buildTaskSpanAttributes, slugAgentId, agentIdentityFromCard } from "./attributes.js"; + +export { instrumentExecutor, type InstrumentExecutorOptions } from "./instrument.js"; + +export { observeAgentEvent } from "./emitter-bridge.js"; diff --git a/packages/core/src/telemetry/instrument.ts b/packages/core/src/telemetry/instrument.ts new file mode 100644 index 0000000..2105e9d --- /dev/null +++ b/packages/core/src/telemetry/instrument.ts @@ -0,0 +1,107 @@ +/** + * Executor instrumentation — Hook A. + * + * Wraps `execute` / `cancelTask` once in {@link createA2AServer} so every + * provider gets `a2a.task.*` spans without duplicating OTel in wrappers. + * + * @module telemetry/instrument + */ + +import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; +import type { A2AExecutor } from "../executor/types.js"; +import { withSpan } from "./api.js"; +import { buildTaskSpanAttributes, slugAgentId } from "./attributes.js"; +import { extractA2ATraceContext, runWithTaskOtelStore, type TaskOtelStore } from "./context.js"; +import { resolveOtelEmissionPolicy, type OtelConfig } from "./types.js"; + +export interface InstrumentExecutorOptions { + /** Optional otel block from agent config. */ + otel?: OtelConfig | null; + /** e.g. "a2a-copilot" */ + wrapperName?: string; + wrapperVersion?: string; + protocolVersion?: string; + agentName: string; +} + +/** + * Return an executor whose execute/cancelTask are wrapped with task spans. + * Lifecycle methods are passed through unchanged. + */ +export function instrumentExecutor( + executor: A2AExecutor, + options: InstrumentExecutorOptions, +): A2AExecutor { + const policy = resolveOtelEmissionPolicy(options.otel); + const agentName = options.agentName; + const agentId = slugAgentId(agentName); + + const wrappedExecute = async (ctx: RequestContext, bus: ExecutionEventBus): Promise => { + const taskId = ctx.taskId; + const contextId = ctx.contextId; + const trace = extractA2ATraceContext(ctx); + const attrs = buildTaskSpanAttributes({ + taskId, + contextId, + agentName, + agentId, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + protocolVersion: options.protocolVersion, + parentAgentId: trace.parentAgentId, + orchestratorTraceId: trace.traceId, + }); + + if (!policy.enabled) { + // Still establish ALS with disabled policy so emitter hooks stay cheap/no-op. + const store: TaskOtelStore = { + policy, + taskId, + contextId, + agentId, + agentName, + toolSpans: new Map(), + }; + return runWithTaskOtelStore(store, () => executor.execute(ctx, bus)); + } + + return withSpan("a2a.task.execute", attrs, async (span) => { + const store: TaskOtelStore = { + span, + policy, + taskId, + contextId, + agentId, + agentName, + toolSpans: new Map(), + }; + return runWithTaskOtelStore(store, () => executor.execute(ctx, bus)); + }); + }; + + const wrappedCancel = executor.cancelTask + ? async (taskId: string, bus: ExecutionEventBus): Promise => { + const attrs: Record = { + "a2a.task.id": taskId, + "a2a.agent.name": agentName, + "gen_ai.agent.name": agentName, + }; + if (options.wrapperName) attrs["a2a.wrapper.name"] = options.wrapperName; + + if (!policy.enabled) { + return executor.cancelTask!(taskId, bus); + } + + return withSpan("a2a.task.cancel", attrs, async () => executor.cancelTask!(taskId, bus)); + } + : undefined; + + return { + initialize: () => executor.initialize(), + shutdown: () => executor.shutdown(), + execute: wrappedExecute, + cancelTask: wrappedCancel, + getContextContent: executor.getContextContent?.bind(executor), + buildContext: executor.buildContext?.bind(executor), + }; +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts new file mode 100644 index 0000000..7edf91b --- /dev/null +++ b/packages/core/src/telemetry/types.ts @@ -0,0 +1,124 @@ +/** + * OpenTelemetry configuration and emission policy types. + * + * `@a2a-wrapper/core` only peers on `@opentelemetry/api` (optional). + * Exporters / the SDK belong to the host application or wrapper CLI. + * + * @module telemetry/types + */ + +/** + * Optional OpenTelemetry settings on an agent config. + * + * When omitted or `enabled: false`, core instrumentation is a no-op unless a + * host has already registered a tracer via {@link setOtelTracer}. + */ +export interface OtelConfig { + /** + * Master switch for wrapper-owned OTel instrumentation. + * @default false + */ + enabled?: boolean; + + /** + * Service name resource attribute for wrapper spans. + * @default derived from agent card / wrapper name + */ + serviceName?: string; + + /** + * Instrumentation scope name passed to `getTracer`. + * @default "a2a-wrapper" + */ + tracerName?: string; + + /** + * When true, map `AgentEvent` tool calls into wrapper OTel child spans. + * Defaults to false. Forced off when a backend OTel block is present unless + * {@link emitOverlappingBackendSpans} is true. + * @default false + */ + mirrorAgentEvents?: boolean; + + /** + * Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel + * is configured (duplicates Copilot/Claude tool spans in the collector). + * @default false + */ + emitOverlappingBackendSpans?: boolean; + + /** + * Hint that a backend runtime will export its own OTLP spans (Copilot CLI, + * Claude Code subprocess, Codex, …). Used by the emission policy. + */ + backend?: { + copilot?: { otlpEndpoint?: string; exporterType?: string; propagateTraceContext?: boolean }; + claude?: { enableTelemetry?: boolean }; + codex?: Record; + opencode?: { openTelemetry?: boolean }; + }; +} + +/** + * Policy computed once per task for deciding what the wrapper may emit to OTLP. + */ +export interface OtelEmissionPolicy { + /** Wrapper instrumentation active for this task. */ + enabled: boolean; + /** Backend is expected to export overlapping tool/LLM spans. */ + backendOtelOn: boolean; + /** Wrapper may open tool child spans from AgentEvents. */ + emitToolSpans: boolean; + /** Annotate the active task span with lifecycle AgentEvents. */ + annotateLifecycleOnTaskSpan: boolean; +} + +/** + * Structural tracer surface — subset of `@opentelemetry/api` Tracer. + * Hosts inject a real tracer; tests inject fakes. Keeps the API package optional. + */ +export interface OtelTracerLike { + startSpan( + name: string, + options?: { attributes?: Record }, + ): OtelSpanLike; +} + +export interface OtelSpanLike { + setAttribute(key: string, value: string | number | boolean): void; + setStatus?(status: { code: number; message?: string }): void; + recordException?(exception: unknown): void; + addEvent?(name: string, attributes?: Record): void; + end(): void; +} + +/** W3C trace context carrier for backend passthrough (Copilot / Claude env). */ +export interface W3cTraceContext { + traceparent?: string; + tracestate?: string; +} + +/** + * Build an emission policy from config. + */ +export function resolveOtelEmissionPolicy(otel?: OtelConfig | null): OtelEmissionPolicy { + const enabled = Boolean(otel?.enabled); + const backend = otel?.backend; + const backendOtelOn = Boolean( + backend?.copilot?.otlpEndpoint || + backend?.copilot?.exporterType || + backend?.claude?.enableTelemetry || + (backend?.codex && Object.keys(backend.codex).length > 0) || + backend?.opencode?.openTelemetry, + ); + const mirror = otel?.mirrorAgentEvents ?? false; + const overlap = otel?.emitOverlappingBackendSpans === true; + const emitToolSpans = enabled && mirror && (!backendOtelOn || overlap); + + return { + enabled, + backendOtelOn, + emitToolSpans, + annotateLifecycleOnTaskSpan: enabled, + }; +} From de032069eaaf9afee0fbd0acf99f612e7cd1fb43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 18:27:58 +0000 Subject: [PATCH 02/12] feat(otel): Phase 2 usage mapping without double-counting tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook E applies task usage to a2a.task.execute as a2a.task.usage.* always, and gen_ai.usage.* only when backend OTel is off — so Copilot/Claude/Codex LLM spans do not double-count with the parent in Tempo SUMs. Also adds createExecutionObservability (Hook B), session span attrs, injectW3cTraceHeaders, wires Copilot/Antigravity, migrates all wrappers, and documents the anti-dupe rules. Co-authored-by: Shashi Kanth G S --- .changeset/otel-phase-2.md | 15 ++ .../schemas/agent-config.schema.json | 80 ++++++++++ a2a-antigravity/src/antigravity/executor.ts | 26 ++-- a2a-antigravity/src/config/types.ts | 3 + a2a-claude/schemas/agent-config.schema.json | 145 ++++++++++++++++-- a2a-claude/src/claude/executor.ts | 15 +- a2a-claude/src/config/types.ts | 3 + a2a-codex/schemas/agent-config.schema.json | 80 ++++++++++ a2a-codex/src/codex/executor.ts | 21 +-- a2a-codex/src/config/types.ts | 3 + a2a-copilot/schemas/agent-config.schema.json | 80 ++++++++++ a2a-copilot/src/config/types.ts | 3 + a2a-copilot/src/copilot/executor.ts | 68 ++------ a2a-opencode/schemas/agent-config.schema.json | 80 ++++++++++ a2a-opencode/src/config/types.ts | 3 + a2a-opencode/src/opencode/executor.ts | 57 +------ docs/observability.md | 20 +++ packages/core/README.md | 2 + .../core/src/__tests__/telemetry/otel.test.ts | 104 ++++++++++++- packages/core/src/index.ts | 8 +- packages/core/src/telemetry/context.ts | 16 ++ packages/core/src/telemetry/index.ts | 13 ++ packages/core/src/telemetry/observability.ts | 75 +++++++++ packages/core/src/telemetry/types.ts | 11 ++ packages/core/src/telemetry/usage-bridge.ts | 80 ++++++++++ 25 files changed, 845 insertions(+), 166 deletions(-) create mode 100644 .changeset/otel-phase-2.md create mode 100644 packages/core/src/telemetry/observability.ts create mode 100644 packages/core/src/telemetry/usage-bridge.ts diff --git a/.changeset/otel-phase-2.md b/.changeset/otel-phase-2.md new file mode 100644 index 0000000..8beb0d0 --- /dev/null +++ b/.changeset/otel-phase-2.md @@ -0,0 +1,15 @@ +--- +"@a2a-wrapper/core": minor +"a2a-copilot": patch +"a2a-claude": patch +"a2a-codex": patch +"a2a-opencode": patch +"a2a-antigravity": patch +--- + +feat(otel): Phase 2 usage mapping + shared execution observability + +- Hook E: `applyUsageSummaryToActiveSpan` with anti-dupe policy — `a2a.task.usage.*` rollup always; `gen_ai.usage.*` only when backend OTel is off +- Hook B: `createExecutionObservability` adopted by all wrappers +- Hook C: `annotateSessionOnTaskSpan`; W3C `injectW3cTraceHeaders` for outbound calls +- Docs: usage double-count rules in `docs/observability.md` diff --git a/a2a-antigravity/schemas/agent-config.schema.json b/a2a-antigravity/schemas/agent-config.schema.json index 69a80e7..c6b898a 100644 --- a/a2a-antigravity/schemas/agent-config.schema.json +++ b/a2a-antigravity/schemas/agent-config.schema.json @@ -152,6 +152,10 @@ "memory": { "$ref": "#/definitions/MemoryConfig" }, + "otel": { + "$ref": "#/definitions/OtelConfig", + "description": "Optional OpenTelemetry settings (see docs/observability.md)." + }, "server": { "$ref": "#/definitions/ServerConfig" }, @@ -538,6 +542,82 @@ }, "type": "object" }, + "OtelConfig": { + "additionalProperties": false, + "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "properties": { + "backend": { + "additionalProperties": false, + "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "enableTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "codex": { + "additionalProperties": {}, + "type": "object" + }, + "copilot": { + "additionalProperties": false, + "properties": { + "exporterType": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "propagateTraceContext": { + "type": "boolean" + } + }, + "type": "object" + }, + "opencode": { + "additionalProperties": false, + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "emitOverlappingBackendSpans": { + "default": false, + "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Master switch for wrapper-owned OTel instrumentation.", + "type": "boolean" + }, + "mirrorAgentEvents": { + "default": false, + "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "description": "Service name resource attribute for wrapper spans.", + "type": "string" + }, + "tracerName": { + "default": "a2a-wrapper", + "description": "Instrumentation scope name passed to `getTracer`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "properties": { diff --git a/a2a-antigravity/src/antigravity/executor.ts b/a2a-antigravity/src/antigravity/executor.ts index 58438b5..f326558 100644 --- a/a2a-antigravity/src/antigravity/executor.ts +++ b/a2a-antigravity/src/antigravity/executor.ts @@ -4,7 +4,6 @@ import { existsSync, statSync } from "node:fs"; import type { AgentExecutor, ExecutionEventBus, RequestContext } from "@a2a-js/sdk/server"; -import { v4 as uuidv4 } from "uuid"; import type { AgentConfig, McpStdioServerConfig } from "../config/types.js"; import { BridgeClient } from "./bridge-client.js"; @@ -13,7 +12,6 @@ import { EventMapper, sanitizeMessage, usageToCallRecord } from "./event-mapper. import { validateMcpServers, toAntigravityMcpEntry } from "./mcp-adapter.js"; import { ANTIGRAVITY_BACKEND_PATHS } from "./backend-paths.js"; import { - AgentEventEmitter, bootstrapSubAgents, extractUserText, LlmUsageAccumulator, @@ -24,7 +22,8 @@ import { publishStreamingChunk, publishTask, publishTraceArtifact, - resolveTransport, + createExecutionObservability, + applyUsageSummaryToActiveSpan, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -114,19 +113,12 @@ export class AntigravityExecutor implements AgentExecutor { const { taskId, contextId, userMessage, task } = ctx; await this.initialize(); - const agentId = this.config.agentCard.name.toLowerCase().replace(/\s+/g, "-"); - const transport = resolveTransport( - this.config.events, - bus, - taskId, - contextId, - this.customTransport, - ); - const emitter = new AgentEventEmitter({ - agentId, + const { emitter } = createExecutionObservability({ agentName: this.config.agentCard.name, - traceId: contextId || uuidv4(), - transport, + ctx, + bus, + events: this.config.events, + customTransport: this.customTransport, }); const mapper = new EventMapper(emitter, this.config); const accumulator = new LlmUsageAccumulator(); @@ -222,6 +214,8 @@ export class AntigravityExecutor implements AgentExecutor { } else { publishFinalArtifact(bus, taskId, contextId, finalText); } + const usageSummary = accumulator.summary(); + applyUsageSummaryToActiveSpan(usageSummary); publishStatus( bus, taskId, @@ -229,7 +223,7 @@ export class AntigravityExecutor implements AgentExecutor { "completed", undefined, true, - { "x-usage": accumulator.summary() }, + { "x-usage": usageSummary }, ); bus.finished(); return; diff --git a/a2a-antigravity/src/config/types.ts b/a2a-antigravity/src/config/types.ts index d0c2474..adcfcea 100644 --- a/a2a-antigravity/src/config/types.ts +++ b/a2a-antigravity/src/config/types.ts @@ -11,6 +11,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -181,6 +182,8 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + otel?: OtelConfig; /** Directory containing the loaded config file; set by CLI/loader. */ configDir?: string; } diff --git a/a2a-claude/schemas/agent-config.schema.json b/a2a-claude/schemas/agent-config.schema.json index e4f2411..637e427 100644 --- a/a2a-claude/schemas/agent-config.schema.json +++ b/a2a-claude/schemas/agent-config.schema.json @@ -36,6 +36,7 @@ }, "protocolVersion": { "default": "0.3.0", + "deprecated": "Superseded by A2A v1.0's per-interface `protocolVersion`\n(see `AgentCard.supportedInterfaces[]`, built by `buildAgentCard`).\nKept for backward compatibility with existing config files only —\nthe agent card builder no longer reads this value.", "description": "A2A protocol version this agent supports.", "type": "string" }, @@ -62,6 +63,34 @@ "description": "Whether the agent supports push notifications.", "type": "boolean" }, + "signing": { + "additionalProperties": false, + "description": "Optional Signed Agent Card support (A2A v1.0, JWS per RFC 7515). Off by default. When enabled, the private key is read from the environment variable named by `privateKeyJwkEnvVar`, whose value must be a JSON-serialized JWK — never store key material in the config file itself.", + "properties": { + "algorithm": { + "description": "JWS algorithm (e.g. `\"RS256\"`, `\"ES256\"`).", + "type": "string" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "keyId": { + "description": "Key ID (`kid`) to embed in the JWS protected header.", + "type": "string" + }, + "privateKeyJwkEnvVar": { + "description": "Name of the env var holding the signing private key as a JSON JWK.", + "type": "string" + } + }, + "required": [ + "privateKeyJwkEnvVar", + "keyId", + "algorithm" + ], + "type": "object" + }, "skills": { "description": "Skills this agent exposes to callers. Each skill represents a discrete capability advertised in the agent card.", "items": { @@ -71,7 +100,7 @@ }, "stateTransitionHistory": { "default": false, - "deprecated": "This capability is not implemented in the A2A v1.0 spec and\nwas removed. Kept for backward compatibility only \u2014 value is ignored.\nThe agent card builder always sets this to `false`.", + "deprecated": "This capability is not implemented in the A2A v1.0 spec and\nwas removed. Kept for backward compatibility only — value is ignored.\nThe agent card builder always sets this to `false`.", "description": "Enable state transition history capability advertisement.", "type": "boolean" }, @@ -94,7 +123,7 @@ }, "AgentConfig": { "additionalProperties": false, - "description": "Complete agent configuration.\n\nThis is what a JSON config file (e.g. `agents/example/config.json`) maps to. All fields except `agentCard` are optional \u2014 sensible secure defaults are applied.", + "description": "Complete agent configuration.\n\nThis is what a JSON config file (e.g. `agents/example/config.json`) maps to. All fields except `agentCard` are optional — sensible secure defaults are applied.", "properties": { "agentCard": { "$ref": "#/definitions/AgentCardConfig" @@ -126,6 +155,10 @@ "memory": { "$ref": "#/definitions/MemoryConfig" }, + "otel": { + "$ref": "#/definitions/OtelConfig", + "description": "Optional OpenTelemetry settings (see docs/observability.md)." + }, "server": { "$ref": "#/definitions/ServerConfig" }, @@ -194,7 +227,7 @@ "additionalProperties": { "type": "boolean" }, - "description": "Plugins to enable, keyed `\"@\"`, where the marketplace id must appear in `marketplaces`. Maps to the SDK's `settings.enabledPlugins`. Startup fails if an enabled plugin does not load \u2014 see the plugin preflight in the executor.", + "description": "Plugins to enable, keyed `\"@\"`, where the marketplace id must appear in `marketplaces`. Maps to the SDK's `settings.enabledPlugins`. Startup fails if an enabled plugin does not load — see the plugin preflight in the executor.", "type": "object" }, "executablePathOverride": { @@ -209,7 +242,7 @@ "additionalProperties": { "$ref": "#/definitions/ClaudeMarketplaceConfig" }, - "description": "Plugin marketplaces to register for the session, keyed by marketplace id. Maps to the SDK's `settings.extraKnownMarketplaces`, so the SDK fetches and installs the plugins itself \u2014 no pre-baked plugin directories required. Pin every marketplace by `ref` or `sha`: plugin hooks and bundled MCP servers execute at the session's permission mode.", + "description": "Plugin marketplaces to register for the session, keyed by marketplace id. Maps to the SDK's `settings.extraKnownMarketplaces`, so the SDK fetches and installs the plugins itself — no pre-baked plugin directories required. Pin every marketplace by `ref` or `sha`: plugin hooks and bundled MCP servers execute at the session's permission mode.", "type": "object" }, "maxBudgetUsd": { @@ -231,7 +264,7 @@ "permissionMode": { "$ref": "#/definitions/ClaudePermissionMode", "default": "acceptEdits", - "description": "Permission mode. \"default\" and \"auto\" are rejected \u2014 they require an interactive approver / classifier, incompatible with headless A2A." + "description": "Permission mode. \"default\" and \"auto\" are rejected — they require an interactive approver / classifier, incompatible with headless A2A." }, "sandbox": { "additionalProperties": {}, @@ -293,7 +326,7 @@ "additionalProperties": {}, "properties": { "source": { - "description": "Source kind \u2014 e.g. \"github\", \"git\", \"url\", \"npm\", \"directory\".", + "description": "Source kind — e.g. \"github\", \"git\", \"url\", \"npm\", \"directory\".", "type": "string" } }, @@ -389,11 +422,11 @@ "type": "object" } ], - "description": "Extended thinking behavior. Maps to SDK `Options.thinking`.\n\n- \"adaptive\" \u2014 Claude decides when and how much to think (newer models)\n- \"enabled\" \u2014 fixed thinking token budget (older models)\n- \"disabled\" \u2014 no extended thinking" + "description": "Extended thinking behavior. Maps to SDK `Options.thinking`.\n\n- \"adaptive\" — Claude decides when and how much to think (newer models)\n- \"enabled\" — fixed thinking token budget (older models)\n- \"disabled\" — no extended thinking" }, "EventsConfig": { "additionalProperties": false, - "description": "Event transport configuration for sideband observability events.\n\nControls how trace events (MCP tool calls, agent reasoning, lifecycle) are delivered. Only the two built-in transports are configurable via JSON config \u2014 for custom transports (Kafka, Redis, DB, etc.), use the programmatic API and pass a transport function to `createA2AServer()`.", + "description": "Event transport configuration for sideband observability events.\n\nControls how trace events (MCP tool calls, agent reasoning, lifecycle) are delivered. Only the two built-in transports are configurable via JSON config — for custom transports (Kafka, Redis, DB, etc.), use the programmatic API and pass a transport function to `createA2AServer()`.", "properties": { "enabled": { "default": true, @@ -418,7 +451,7 @@ }, "transport": { "default": "a2a", - "description": "Built-in transport type.\n\n- `\"a2a\"` \u2014 publish as sideband trace artifacts on the A2A ExecutionEventBus (default, zero dependencies).\n- `\"http\"` \u2014 POST events as JSON to {@link httpUrl } .\n\nFor custom transports, leave this unset and provide a transport function via the programmatic `createA2AServer()` API.", + "description": "Built-in transport type.\n\n- `\"a2a\"` — publish as sideband trace artifacts on the A2A ExecutionEventBus (default, zero dependencies).\n- `\"http\"` — POST events as JSON to {@link httpUrl } .\n\nFor custom transports, leave this unset and provide a transport function via the programmatic `createA2AServer()` API.", "enum": [ "a2a", "http" @@ -456,7 +489,7 @@ "type": "boolean" }, "holdTaskForBackgroundWork": { - "description": "Hold the A2A Task open in `working` while Claude has background work in flight, completing it only once a turn ends with nothing left running. Default: true. Set false to complete the Task at the first SDK result, as before. Governs the completion decision only — queries use streaming input either way.", + "description": "Hold the A2A Task open in `working` while Claude has background work in flight, completing it only once a turn ends with nothing left running. Default: true. Set false to complete the Task at the first SDK result, as before.\n\nThis governs the completion decision only. Queries are issued in streaming-input mode either way — that is what keeps the CLI subprocess alive past the first result, and it is not switchable.", "type": "boolean" }, "streamArtifactChunks": { @@ -587,7 +620,7 @@ }, "MemoryConfig": { "additionalProperties": false, - "description": "Optional memory configuration section in agent config.json.\n\nDeclares instructions and skills to materialize into the agent's workspace at startup. Both fields are optional \u2014 operators can declare instructions only, skills only, or both.\n\nPaths are resolved relative to the directory containing config.json (Config_Directory) unless they are absolute.", + "description": "Optional memory configuration section in agent config.json.\n\nDeclares instructions and skills to materialize into the agent's workspace at startup. Both fields are optional — operators can declare instructions only, skills only, or both.\n\nPaths are resolved relative to the directory containing config.json (Config_Directory) unless they are absolute.", "properties": { "instructions": { "description": "Path to a markdown instructions file. Resolved relative to Config_Directory when relative, used as-is when absolute.", @@ -603,6 +636,82 @@ }, "type": "object" }, + "OtelConfig": { + "additionalProperties": false, + "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "properties": { + "backend": { + "additionalProperties": false, + "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "enableTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "codex": { + "additionalProperties": {}, + "type": "object" + }, + "copilot": { + "additionalProperties": false, + "properties": { + "exporterType": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "propagateTraceContext": { + "type": "boolean" + } + }, + "type": "object" + }, + "opencode": { + "additionalProperties": false, + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "emitOverlappingBackendSpans": { + "default": false, + "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Master switch for wrapper-owned OTel instrumentation.", + "type": "boolean" + }, + "mirrorAgentEvents": { + "default": false, + "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "description": "Service name resource attribute for wrapper spans.", + "type": "string" + }, + "tracerName": { + "default": "a2a-wrapper", + "description": "Instrumentation scope name passed to `getTracer`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "properties": { @@ -631,7 +740,8 @@ "additionalProperties": false, "properties": { "cleanupInterval": { - "description": "How often the background sweep reclaims expired sessions, in ms. Only ever runs when ttl > 0; 0 or less disables it, leaving expiry to the lazy check in getOrCreate (default: 0).", + "default": 0, + "description": "How often the background sweep reclaims expired sessions, in ms. The sweep only ever runs when `ttl > 0`; 0 or less disables it, leaving expiry to the lazy check in `getOrCreate`. Defaults to 0 to match `ttl` — set both if you want expired sessions reclaimed without waiting for the context to be looked up again.", "type": "number" }, "reuseByContext": { @@ -642,7 +752,8 @@ "type": "string" }, "ttl": { - "description": "Session TTL in ms, measured from session creation \u2014 not from last use. 0 or less disables expiry entirely (default: 0 \u2014 sessions never expire).", + "default": 0, + "description": "Session TTL in ms, measured from when the session was created — not from last use. 0 or less disables session expiry entirely, so a contextId keeps resuming the same Claude session indefinitely.", "type": "number" } }, @@ -754,7 +865,7 @@ "description": "Outbound credentials the bridge presents when calling this sub-agent. Forwarded verbatim into the bridge config.\n\nOmitting this field is equivalent to `{ mode: \"none\" }`." }, "endpointUrlOverride": { - "description": "Optional override for the URL the bridge probes. Useful when the agent card declares an endpoint that differs from where the agent is actually reachable (NAT, port-forward, internal vs external).\n\nWhen set, this URL is passed to the bridge instead of {@link agentCardUrl } . The bridge still resolves a card from this URL \u2014 point it at a card URL or a base URL it can probe under `/.well-known/`. Must use the `http:` or `https:` protocol.", + "description": "Optional override for the URL the bridge probes. Useful when the agent card declares an endpoint that differs from where the agent is actually reachable (NAT, port-forward, internal vs external).\n\nWhen set, this URL is passed to the bridge instead of {@link agentCardUrl } . The bridge still resolves a card from this URL — point it at a card URL or a base URL it can probe under `/.well-known/`. Must use the `http:` or `https:` protocol.", "type": "string" }, "name": { @@ -808,7 +919,7 @@ "type": "string" }, "syncBudgetMs": { - "description": "Sync budget in milliseconds passed to `a2a-mcp-skillmap` via the bridge config. Controls how long the bridge waits for an A2A agent to respond before switching to async task-handle mode.\n\nWhen the budget expires the bridge immediately returns a `taskId` to the MCP client; the A2A dispatch continues in the background. The LLM can then poll via the built-in `task_result` / `task_status` tools which actively re-query the remote agent before responding.\n\n- Set lower (e.g. `10000`) for interactive use where you want fast task-handle responses.\n- Set to `0` to wait indefinitely (no async fallback).\n- Defaults to `30000` (30 s) when unset \u2014 skillmap's own default.", + "description": "Sync budget in milliseconds passed to `a2a-mcp-skillmap` via the bridge config. Controls how long the bridge waits for an A2A agent to respond before switching to async task-handle mode.\n\nWhen the budget expires the bridge immediately returns a `taskId` to the MCP client; the A2A dispatch continues in the background. The LLM can then poll via the built-in `task_result` / `task_status` tools which actively re-query the remote agent before responding.\n\n- Set lower (e.g. `10000`) for interactive use where you want fast task-handle responses.\n- Set to `0` to wait indefinitely (no async fallback).\n- Defaults to `30000` (30 s) when unset — skillmap's own default.", "type": "number" } }, @@ -818,11 +929,11 @@ "additionalProperties": false, "properties": { "prompt": { - "description": "Timeout for a single prompt in ms (default: 600_000 = 10 min). Set to 0 (or any value <= 0) to disable the timeout entirely.", + "description": "Timeout for a single prompt in ms (default: 600_000 = 10 min). Set to `0` (or any value <= 0) to disable the timeout entirely, letting a turn run until it completes. See the README for the caveat about turns being serialized per context.", "type": "number" } }, "type": "object" } } -} +} \ No newline at end of file diff --git a/a2a-claude/src/claude/executor.ts b/a2a-claude/src/claude/executor.ts index 667a100..cb2fe23 100644 --- a/a2a-claude/src/claude/executor.ts +++ b/a2a-claude/src/claude/executor.ts @@ -11,7 +11,6 @@ import { existsSync, statSync } from "node:fs"; import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; -import { v4 as uuidv4 } from "uuid"; import type { AgentExecutor, RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; @@ -32,8 +31,6 @@ import { extractUserText, promptStream } from "./prompt-builder.js"; import { BackgroundTaskTracker } from "./background-tasks.js"; import { - resolveTransport, - AgentEventEmitter, createDeferred, materializeMemory, bootstrapSubAgents, @@ -42,6 +39,7 @@ import { publishFinalArtifact, publishStreamingChunk, publishLastChunkMarker, + createExecutionObservability, } from "@a2a-wrapper/core"; import type { EventTransport, EventTransportFn, SynthesizedMcpDescriptor } from "@a2a-wrapper/core"; @@ -235,13 +233,12 @@ export class ClaudeExecutor implements AgentExecutor { const { taskId, contextId, userMessage, task } = ctx; await this.initialize(); - const agentId = this.config.agentCard.name.toLowerCase().replace(/\s+/g, "-"); - const transport = resolveTransport(this.config.events, bus, taskId, contextId, this.customTransport); - const emitter = new AgentEventEmitter({ - agentId, + const { emitter } = createExecutionObservability({ agentName: this.config.agentCard.name, - traceId: contextId || uuidv4(), - transport, + ctx, + bus, + events: this.config.events, + customTransport: this.customTransport, }); const mapper = new EventMapper(emitter, this.config); diff --git a/a2a-claude/src/config/types.ts b/a2a-claude/src/config/types.ts index b34131b..a6bf5b3 100644 --- a/a2a-claude/src/config/types.ts +++ b/a2a-claude/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -287,6 +288,8 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + otel?: OtelConfig; /** Populated automatically by the CLI loader. Do not set manually. */ configDir?: string; } diff --git a/a2a-codex/schemas/agent-config.schema.json b/a2a-codex/schemas/agent-config.schema.json index 144a428..6151dd7 100644 --- a/a2a-codex/schemas/agent-config.schema.json +++ b/a2a-codex/schemas/agent-config.schema.json @@ -155,6 +155,10 @@ "memory": { "$ref": "#/definitions/MemoryConfig" }, + "otel": { + "$ref": "#/definitions/OtelConfig", + "description": "Optional OpenTelemetry settings (see docs/observability.md)." + }, "server": { "$ref": "#/definitions/ServerConfig" }, @@ -458,6 +462,82 @@ }, "type": "object" }, + "OtelConfig": { + "additionalProperties": false, + "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "properties": { + "backend": { + "additionalProperties": false, + "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "enableTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "codex": { + "additionalProperties": {}, + "type": "object" + }, + "copilot": { + "additionalProperties": false, + "properties": { + "exporterType": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "propagateTraceContext": { + "type": "boolean" + } + }, + "type": "object" + }, + "opencode": { + "additionalProperties": false, + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "emitOverlappingBackendSpans": { + "default": false, + "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Master switch for wrapper-owned OTel instrumentation.", + "type": "boolean" + }, + "mirrorAgentEvents": { + "default": false, + "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "description": "Service name resource attribute for wrapper spans.", + "type": "string" + }, + "tracerName": { + "default": "a2a-wrapper", + "description": "Instrumentation scope name passed to `getTracer`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "properties": { diff --git a/a2a-codex/src/codex/executor.ts b/a2a-codex/src/codex/executor.ts index 41b9b33..cd344d1 100644 --- a/a2a-codex/src/codex/executor.ts +++ b/a2a-codex/src/codex/executor.ts @@ -10,7 +10,6 @@ import { existsSync, statSync } from "node:fs"; import { readFile as fsReadFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; -import { v4 as uuidv4 } from "uuid"; import type { Message as A2AMessage } from "@a2a-js/sdk"; import type { AgentExecutor, RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; @@ -25,8 +24,6 @@ import { CODEX_BACKEND_PATHS } from "./backend-paths.js"; import { extractUserText } from "./prompt-builder.js"; import { - resolveTransport, - AgentEventEmitter, materializeMemory, bootstrapSubAgents, publishTask, @@ -34,6 +31,7 @@ import { publishFinalArtifact, publishStreamingChunk, publishLastChunkMarker, + createExecutionObservability, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -134,19 +132,12 @@ export class CodexExecutor implements AgentExecutor { const { taskId, contextId, userMessage, task } = ctx; await this.initialize(); - const agentId = this.config.agentCard.name.toLowerCase().replace(/\s+/g, "-"); - const transport = resolveTransport( - this.config.events, - bus, - taskId, - contextId, - this.customTransport, - ); - const emitter = new AgentEventEmitter({ - agentId, + const { emitter } = createExecutionObservability({ agentName: this.config.agentCard.name, - traceId: contextId || uuidv4(), - transport, + ctx, + bus, + events: this.config.events, + customTransport: this.customTransport, }); const mapper = new EventMapper(emitter, this.config); diff --git a/a2a-codex/src/config/types.ts b/a2a-codex/src/config/types.ts index 1d8c813..61a83d0 100644 --- a/a2a-codex/src/config/types.ts +++ b/a2a-codex/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -224,6 +225,8 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + otel?: OtelConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI loader. Do not set manually. diff --git a/a2a-copilot/schemas/agent-config.schema.json b/a2a-copilot/schemas/agent-config.schema.json index 168b0e4..3edb804 100644 --- a/a2a-copilot/schemas/agent-config.schema.json +++ b/a2a-copilot/schemas/agent-config.schema.json @@ -167,6 +167,10 @@ "$ref": "#/definitions/MemoryConfig", "description": "Optional memory configuration for persisting instructions and skills. When present, the materializer writes these to backend-specific paths in the workspace directory during executor initialization." }, + "otel": { + "$ref": "#/definitions/OtelConfig", + "description": "Optional OpenTelemetry settings (see docs/observability.md)." + }, "server": { "$ref": "#/definitions/ServerConfig", "description": "Network / server settings" @@ -464,6 +468,82 @@ }, "type": "object" }, + "OtelConfig": { + "additionalProperties": false, + "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "properties": { + "backend": { + "additionalProperties": false, + "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "enableTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "codex": { + "additionalProperties": {}, + "type": "object" + }, + "copilot": { + "additionalProperties": false, + "properties": { + "exporterType": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "propagateTraceContext": { + "type": "boolean" + } + }, + "type": "object" + }, + "opencode": { + "additionalProperties": false, + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "emitOverlappingBackendSpans": { + "default": false, + "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Master switch for wrapper-owned OTel instrumentation.", + "type": "boolean" + }, + "mirrorAgentEvents": { + "default": false, + "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "description": "Service name resource attribute for wrapper spans.", + "type": "string" + }, + "tracerName": { + "default": "a2a-wrapper", + "description": "Instrumentation scope name passed to `getTracer`.", + "type": "string" + } + }, + "type": "object" + }, "ProviderConfig": { "additionalProperties": false, "description": "Custom LLM provider configuration (BYOK — Bring Your Own Key).\n\nWhen set, sessions use this provider instead of GitHub Copilot. Omit entirely to use the default GitHub Copilot API (no BYOK).\n\nSupported providers and their recommended settings:\n\n| Provider | type | baseUrl | wireApi | |-------------------|-------------|----------------------------------------|----------------| | GitHub Copilot | (omit) | (omit) | (omit) | | Ollama (local) | \"openai\" | http://localhost:11434/v1 | \"completions\" | | OpenAI | \"openai\" | https://api.openai.com/v1 | \"responses\" | | Anthropic | \"anthropic\" | https://api.anthropic.com | (N/A) | | Azure OpenAI | \"azure\" | https://.openai.azure.com | \"completions\" | | Azure AI Foundry | \"openai\" | https://.openai.azure.com/openai/v1/ | \"responses\" | | vLLM / LiteLLM | \"openai\" | http://:/v1 | \"completions\" |", diff --git a/a2a-copilot/src/config/types.ts b/a2a-copilot/src/config/types.ts index e39652f..4ab7cad 100644 --- a/a2a-copilot/src/config/types.ts +++ b/a2a-copilot/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -356,6 +357,8 @@ export interface AgentConfig { * @see {@link ../../../.kiro/specs/a2a-subagents/design.md} */ subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + otel?: OtelConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI scaffold when a config file path is provided. diff --git a/a2a-copilot/src/copilot/executor.ts b/a2a-copilot/src/copilot/executor.ts index 1f20ee5..8730abb 100644 --- a/a2a-copilot/src/copilot/executor.ts +++ b/a2a-copilot/src/copilot/executor.ts @@ -24,12 +24,13 @@ import { publishTask, publishTraceArtifact, extractUserText, - resolveTransport, - AgentEventEmitter, materializeMemory, WELL_KNOWN_PATHS, bootstrapSubAgents, LlmUsageAccumulator, + createExecutionObservability, + applyUsageSummaryToActiveSpan, + annotateSessionOnTaskSpan, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -305,24 +306,12 @@ export class CopilotExecutor implements AgentExecutor { const { taskId, contextId, userMessage, task } = ctx; await this.initialize(); - // Extract trace context from A2A request metadata (injected by orchestrator) - const traceCtx = this.extractTraceContext(ctx); - const agentId = this.config.agentCard.name.toLowerCase().replace(/\s+/g, "-"); - const agentName = this.config.agentCard.name; - - // Resolve event transport and create per-execution emitter - const transport = resolveTransport( - this.config.events, + const { emitter } = createExecutionObservability({ + agentName: this.config.agentCard.name, + ctx, bus, - taskId, - contextId, - this.customTransport, - ); - const emitter = new AgentEventEmitter({ - agentId, - agentName, - traceId: traceCtx.traceId, - transport, + events: this.config.events, + customTransport: this.customTransport, }); // Set MCP hooks context → trace artifacts flow via transport @@ -348,6 +337,7 @@ export class CopilotExecutor implements AgentExecutor { // 3. Get or create Copilot session const { sessionId, session, isNew } = await this.sessionManager!.getOrCreate(contextId); this.sessionManager!.trackTask(taskId, sessionId, contextId); + annotateSessionOnTaskSpan({ reused: !isNew, sessionId }); // 4. Build prompt let promptText = extractUserText(userMessage); @@ -674,6 +664,10 @@ export class CopilotExecutor implements AgentExecutor { let usageSummary: UsageTelemetryData | undefined; try { usageSummary = accumulator.summary(); + // OTel: task-span rollup only — never a second LLM span / token metric. + // When backend OTel is on, gen_ai.usage.* is omitted (policy) so Tempo + // SUMs of vendor LLM spans do not double-count with the parent. + applyUsageSummaryToActiveSpan(usageSummary); } catch (e) { log.warn("accumulator.summary() failed, omitting x-usage from final event", { taskId, @@ -745,40 +739,4 @@ export class CopilotExecutor implements AgentExecutor { }; } - /** - * Extract trace context propagated by the orchestrator via A2A metadata. - * - * The orchestrator injects { trace_id, parent_agent_id, propagated_metadata } - * into the A2A request configuration dict. The A2A JS SDK exposes these - * through the RequestContext or the Task object. - */ - private extractTraceContext(ctx: RequestContext): { - traceId: string; - parentAgentId: string | null; - metadata: Record; - } { - // Try multiple access paths — SDK version differences may place metadata differently - const raw = ctx as unknown as Record; - const meta = - (raw.metadata as Record) || - ((raw.task as Record)?.metadata as Record) || - ((raw.task as Record)?.configuration as Record) || - {}; - - return { - traceId: - (meta.trace_id as string) || - (meta.traceId as string) || - ctx.contextId || - uuidv4(), - parentAgentId: - (meta.parent_agent_id as string) || - (meta.parentAgentId as string) || - null, - metadata: - (meta.propagated_metadata as Record) || - (meta.propagatedMetadata as Record) || - {}, - }; - } } diff --git a/a2a-opencode/schemas/agent-config.schema.json b/a2a-opencode/schemas/agent-config.schema.json index 55e5c16..6c32a1e 100644 --- a/a2a-opencode/schemas/agent-config.schema.json +++ b/a2a-opencode/schemas/agent-config.schema.json @@ -160,6 +160,10 @@ "$ref": "#/definitions/OpenCodeConfig", "description": "OpenCode connection settings" }, + "otel": { + "$ref": "#/definitions/OtelConfig", + "description": "Optional OpenTelemetry settings (see docs/observability.md)." + }, "server": { "$ref": "#/definitions/ServerConfig", "description": "Network / server settings" @@ -427,6 +431,82 @@ }, "type": "object" }, + "OtelConfig": { + "additionalProperties": false, + "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "properties": { + "backend": { + "additionalProperties": false, + "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "enableTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "codex": { + "additionalProperties": {}, + "type": "object" + }, + "copilot": { + "additionalProperties": false, + "properties": { + "exporterType": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "propagateTraceContext": { + "type": "boolean" + } + }, + "type": "object" + }, + "opencode": { + "additionalProperties": false, + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "emitOverlappingBackendSpans": { + "default": false, + "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", + "type": "boolean" + }, + "enabled": { + "default": false, + "description": "Master switch for wrapper-owned OTel instrumentation.", + "type": "boolean" + }, + "mirrorAgentEvents": { + "default": false, + "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "description": "Service name resource attribute for wrapper spans.", + "type": "string" + }, + "tracerName": { + "default": "a2a-wrapper", + "description": "Instrumentation scope name passed to `getTracer`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "description": "Network and server settings.", diff --git a/a2a-opencode/src/config/types.ts b/a2a-opencode/src/config/types.ts index 0001370..6689d4d 100644 --- a/a2a-opencode/src/config/types.ts +++ b/a2a-opencode/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -232,6 +233,8 @@ export interface AgentConfig { * @see {@link ../../../.kiro/specs/a2a-subagents/design.md} */ subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + otel?: OtelConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI scaffold when a config file path is provided. diff --git a/a2a-opencode/src/opencode/executor.ts b/a2a-opencode/src/opencode/executor.ts index 47056c9..d8cd42a 100644 --- a/a2a-opencode/src/opencode/executor.ts +++ b/a2a-opencode/src/opencode/executor.ts @@ -27,11 +27,10 @@ import { publishLastChunkMarker, publishTask, extractUserText, - resolveTransport, - AgentEventEmitter, materializeMemory, WELL_KNOWN_PATHS, bootstrapSubAgents, + createExecutionObservability, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -349,24 +348,12 @@ export class OpenCodeExecutor implements AgentExecutor { let stream: EventStreamManager | null = null; - // Extract trace context propagated by the orchestrator via A2A metadata - const traceCtx = this.extractTraceContext(ctx); - const agentId = this.config.agentCard.name.toLowerCase().replace(/\s+/g, "-"); - const agentName = this.config.agentCard.name; - - // Resolve event transport and create per-execution emitter - const transport = resolveTransport( - this.config.events, + const { emitter } = createExecutionObservability({ + agentName: this.config.agentCard.name, + ctx, bus, - taskId, - contextId, - this.customTransport, - ); - const emitter = new AgentEventEmitter({ - agentId, - agentName, - traceId: traceCtx.traceId, - transport, + events: this.config.events, + customTransport: this.customTransport, }); try { @@ -712,38 +699,6 @@ export class OpenCodeExecutor implements AgentExecutor { }; } - /** - * Extract trace context propagated by the orchestrator via A2A metadata. - */ - private extractTraceContext(ctx: RequestContext): { - traceId: string; - parentAgentId: string | null; - metadata: Record; - } { - const raw = ctx as unknown as Record; - const meta = - (raw.metadata as Record) || - ((raw.task as Record)?.metadata as Record) || - ((raw.task as Record)?.configuration as Record) || - {}; - - return { - traceId: - (meta.trace_id as string) || - (meta.traceId as string) || - ctx.contextId || - uuidv4(), - parentAgentId: - (meta.parent_agent_id as string) || - (meta.parentAgentId as string) || - null, - metadata: - (meta.propagated_metadata as Record) || - (meta.propagatedMetadata as Record) || - {}, - }; - } - private buildPromptBody(text: string) { const body: { parts: Array<{ type: "text"; text: string }>; diff --git a/docs/observability.md b/docs/observability.md index 9b295e1..5ab85f4 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -86,6 +86,26 @@ Use the **same collector** for wrapper and backend exporters. Prefer OTLP HTTP ` | `gen_ai.agent.name` / `a2a.agent.name` | `agentCard.name` | | `gen_ai.conversation.id` | A2A `contextId` | | `a2a.wrapper.name` | Server option / package name | +| `a2a.task.usage.*` | Task rollup from `LlmUsageAccumulator` (always when OTel on) | +| `gen_ai.usage.*` on task span | **Only when backend OTel is off** | + +## Usage / tokens — do not double-count + +Backends (Copilot, Claude, Codex, …) already report tokens via SDK events. We keep that on the **A2A sideband** (`trace.usage`, `metadata["x-usage"]`) always — that is not OTLP. + +For **OpenTelemetry**: + +| Situation | What the wrapper puts on `a2a.task.execute` | What the backend puts on LLM spans | +|---|---|---| +| Wrapper OTel only | `a2a.task.usage.*` **and** `gen_ai.usage.*` | nothing | +| Wrapper + backend OTel | `a2a.task.usage.*` only (A2A task total) | `gen_ai.usage.*` on vendor LLM spans | + +Rules: + +1. **Never** open a second LLM span just to carry usage. +2. **Never** emit wrapper token **metrics** that sum with backend GenAI metrics (Phase 2 does not add counters). +3. Dashboards: either sum `gen_ai.usage.*` on backend LLM spans **or** read `a2a.task.usage.*` on `a2a.task.execute` — not both. +4. Copilot / Antigravity call `applyUsageSummaryToActiveSpan` today; Claude/Codex can adopt the same helper when they accumulate with `LlmUsageAccumulator`. ## Programmatic API diff --git a/packages/core/README.md b/packages/core/README.md index 7dd5dd4..6e929ff 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -470,6 +470,8 @@ if (this.config.subAgents?.agents?.length) { Optional distributed tracing for `a2a.task.execute` / `a2a.task.cancel`. Core peers on `@opentelemetry/api` only (optional); register a real SDK exporter in your host. See the monorepo [Observability guide](../../docs/observability.md). +Usage tokens: call `applyUsageSummaryToActiveSpan(summary)` after `LlmUsageAccumulator.summary()`. When a backend OTel exporter is configured, core sets only `a2a.task.usage.*` on the task span (not `gen_ai.usage.*`) so Tempo SUMs do not double-count with vendor LLM spans. + ```ts import { setOtelTracer, withSpan, extractA2ATraceContext } from "@a2a-wrapper/core"; ``` diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index 37419f3..35898b9 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -7,20 +7,26 @@ import { instrumentExecutor, observeAgentEvent, runWithTaskOtelStore, + applyUsageSummaryToActiveSpan, + createExecutionObservability, type OtelTracerLike, type OtelSpanLike, type A2AExecutor, + type UsageTelemetryData, } from "../../index.js"; import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; function fakeTracer() { const ended: string[] = []; const events: string[] = []; + const attrs: Array<{ key: string; value: string | number | boolean }> = []; const spans: OtelSpanLike[] = []; const tracer: OtelTracerLike = { startSpan(name) { const span: OtelSpanLike = { - setAttribute() {}, + setAttribute(key, value) { + attrs.push({ key, value }); + }, setStatus() {}, recordException() {}, addEvent(n) { @@ -34,7 +40,7 @@ function fakeTracer() { return span; }, }; - return { tracer, ended, events, spans }; + return { tracer, ended, events, spans, attrs }; } describe("resolveOtelEmissionPolicy", () => { @@ -65,6 +71,15 @@ describe("resolveOtelEmissionPolicy", () => { backend: { claude: { enableTelemetry: true } }, }); expect(p.emitToolSpans).toBe(true); + expect(p.emitGenAiUsageAttrs).toBe(true); + }); + + it("suppresses gen_ai.usage attrs on the task span when backend OTel is on", () => { + const p = resolveOtelEmissionPolicy({ + enabled: true, + backend: { copilot: { otlpEndpoint: "http://127.0.0.1:4318" } }, + }); + expect(p.emitGenAiUsageAttrs).toBe(false); }); }); @@ -192,3 +207,88 @@ describe("observeAgentEvent de-dupe", () => { expect(ended).toContain("a2a.mcp.tool Bash"); }); }); + +const sampleUsage: UsageTelemetryData = { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + durationMs: 100, + llmCalls: 1, + model: "gpt-4.1", + cost: 0.01, + calls: [], +}; + +describe("applyUsageSummaryToActiveSpan", () => { + afterEach(() => setOtelTracer(undefined)); + + it("sets a2a.task.usage.* but not gen_ai.usage.* when backend OTel is on", async () => { + const { tracer, attrs } = fakeTracer(); + setOtelTracer(tracer); + const policy = resolveOtelEmissionPolicy({ + enabled: true, + backend: { copilot: { otlpEndpoint: "http://127.0.0.1:4318" } }, + }); + await runWithTaskOtelStore( + { + policy, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + applyUsageSummaryToActiveSpan(sampleUsage); + }, + ); + expect(attrs.some((a) => a.key === "a2a.task.usage.input_tokens" && a.value === 10)).toBe(true); + expect(attrs.some((a) => a.key === "gen_ai.usage.input_tokens")).toBe(false); + }); + + it("sets gen_ai.usage.* when wrapper is the sole OTel source", async () => { + const { tracer, attrs } = fakeTracer(); + setOtelTracer(tracer); + const policy = resolveOtelEmissionPolicy({ enabled: true }); + await runWithTaskOtelStore( + { + policy, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + applyUsageSummaryToActiveSpan(sampleUsage); + }, + ); + expect(attrs.some((a) => a.key === "gen_ai.usage.input_tokens" && a.value === 10)).toBe(true); + expect(attrs.some((a) => a.key === "gen_ai.usage.output_tokens" && a.value === 5)).toBe(true); + }); +}); + +describe("createExecutionObservability", () => { + it("builds an emitter with orchestrator trace id and parent agent", () => { + const bus = { publish: () => {} } as unknown as ExecutionEventBus; + const ctx = { + taskId: "t1", + contextId: "c1", + metadata: { trace_id: "orch-99", parent_agent_id: "parent-x" }, + } as unknown as RequestContext; + const { emitter, agentId, traceContext } = createExecutionObservability({ + agentName: "My Agent", + ctx, + bus, + }); + expect(agentId).toBe("my-agent"); + expect(traceContext.traceId).toBe("orch-99"); + expect(traceContext.parentAgentId).toBe("parent-x"); + expect(emitter.traceId).toBe("orch-99"); + expect(emitter.parentAgentId).toBe("parent-x"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 14a462d..8e4f149 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -168,6 +168,8 @@ export type { A2ATraceContext, TaskOtelStore, InstrumentExecutorOptions, + CreateExecutionObservabilityOptions, + ExecutionObservability, } from "./telemetry/index.js"; export { @@ -178,6 +180,7 @@ export { withSpan, extractA2ATraceContext, getW3cTraceContext, + injectW3cTraceHeaders, getTaskOtelStore, runWithTaskOtelStore, buildTaskSpanAttributes, @@ -185,8 +188,11 @@ export { agentIdentityFromCard, instrumentExecutor, observeAgentEvent, + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, + createExecutionObservability, + annotateSessionOnTaskSpan, } from "./telemetry/index.js"; - // ─── A2A SDK Type Re-exports ──────────────────────────────────────────────── // // Core-owned aliases for commonly used A2A SDK types. Wrapper projects diff --git a/packages/core/src/telemetry/context.ts b/packages/core/src/telemetry/context.ts index 7dcca32..749d090 100644 --- a/packages/core/src/telemetry/context.ts +++ b/packages/core/src/telemetry/context.ts @@ -92,3 +92,19 @@ export function getW3cTraceContext(): W3cTraceContext { return {}; } } + +/** + * Merge W3C `traceparent` / `tracestate` into outbound HTTP headers + * (sub-agent calls, skillmap bridge, etc.) without clobbering auth headers. + * + * Returns a **new** object. Empty when no active OTel context is injectable. + */ +export function injectW3cTraceHeaders( + headers: Record = {}, +): Record { + const w3c = getW3cTraceContext(); + const out = { ...headers }; + if (w3c.traceparent) out.traceparent = w3c.traceparent; + if (w3c.tracestate) out.tracestate = w3c.tracestate; + return out; +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index bb40bd9..ba2bdd7 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -18,6 +18,7 @@ export { setOtelTracer, getOtelTracer, tryGetGlobalTracer, withSpan } from "./ap export { extractA2ATraceContext, getW3cTraceContext, + injectW3cTraceHeaders, getTaskOtelStore, runWithTaskOtelStore, type A2ATraceContext, @@ -29,3 +30,15 @@ export { buildTaskSpanAttributes, slugAgentId, agentIdentityFromCard } from "./a export { instrumentExecutor, type InstrumentExecutorOptions } from "./instrument.js"; export { observeAgentEvent } from "./emitter-bridge.js"; + +export { + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, +} from "./usage-bridge.js"; + +export { + createExecutionObservability, + annotateSessionOnTaskSpan, + type CreateExecutionObservabilityOptions, + type ExecutionObservability, +} from "./observability.js"; diff --git a/packages/core/src/telemetry/observability.ts b/packages/core/src/telemetry/observability.ts new file mode 100644 index 0000000..c98ba06 --- /dev/null +++ b/packages/core/src/telemetry/observability.ts @@ -0,0 +1,75 @@ +/** + * Hook B — shared execution observability bootstrap for wrappers. + * + * Replaces copy-pasted `resolveTransport` + `AgentEventEmitter` + local + * `extractTraceContext` blocks in each executor. + * + * @module telemetry/observability + */ + +import type { ExecutionEventBus, RequestContext } from "@a2a-js/sdk/server"; +import type { EventsConfig } from "../config/types.js"; +import { + AgentEventEmitter, + resolveTransport, + type EventTransport, + type EventTransportFn, +} from "../events/transport.js"; +import { slugAgentId } from "./attributes.js"; +import { extractA2ATraceContext, getTaskOtelStore, type A2ATraceContext } from "./context.js"; + +export interface CreateExecutionObservabilityOptions { + /** `agentCard.name` */ + agentName: string; + ctx: RequestContext; + bus: ExecutionEventBus; + events?: EventsConfig; + customTransport?: EventTransport | EventTransportFn; +} + +export interface ExecutionObservability { + emitter: AgentEventEmitter; + traceContext: A2ATraceContext; + agentId: string; + agentName: string; +} + +/** + * Build per-execute sideband emitter + A2A trace context (orchestrator metadata). + */ +export function createExecutionObservability( + opts: CreateExecutionObservabilityOptions, +): ExecutionObservability { + const agentName = opts.agentName; + const agentId = slugAgentId(agentName); + const traceContext = extractA2ATraceContext(opts.ctx); + const { taskId, contextId } = opts.ctx; + const transport = resolveTransport( + opts.events, + opts.bus, + taskId, + contextId, + opts.customTransport, + ); + const emitter = new AgentEventEmitter({ + agentId, + agentName, + traceId: traceContext.traceId, + parentAgentId: traceContext.parentAgentId, + transport, + }); + return { emitter, traceContext, agentId, agentName }; +} + +/** + * Hook C — annotate the active task span after session get-or-create. + */ +export function annotateSessionOnTaskSpan(opts: { + reused: boolean; + sessionId?: string; +}): void { + const store = getTaskOtelStore(); + if (!store?.policy.enabled || !store.span) return; + store.span.setAttribute("a2a.session.reused", opts.reused); + if (opts.sessionId) store.span.setAttribute("a2a.session.id", opts.sessionId); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 7edf91b..784ec97 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -71,6 +71,15 @@ export interface OtelEmissionPolicy { emitToolSpans: boolean; /** Annotate the active task span with lifecycle AgentEvents. */ annotateLifecycleOnTaskSpan: boolean; + /** + * Set GenAI-standard `gen_ai.usage.*` on the wrapper task span. + * + * Off when backend OTel is on — Copilot/Claude/Codex already put tokens on + * their LLM spans; duplicating `gen_ai.usage.*` on the parent would make + * naive SUM queries double-count. Task rollups use `a2a.task.usage.*` + * instead (see {@link applyUsageSummaryToActiveSpan}). + */ + emitGenAiUsageAttrs: boolean; } /** @@ -114,11 +123,13 @@ export function resolveOtelEmissionPolicy(otel?: OtelConfig | null): OtelEmissio const mirror = otel?.mirrorAgentEvents ?? false; const overlap = otel?.emitOverlappingBackendSpans === true; const emitToolSpans = enabled && mirror && (!backendOtelOn || overlap); + const emitGenAiUsageAttrs = enabled && (!backendOtelOn || overlap); return { enabled, backendOtelOn, emitToolSpans, annotateLifecycleOnTaskSpan: enabled, + emitGenAiUsageAttrs, }; } diff --git a/packages/core/src/telemetry/usage-bridge.ts b/packages/core/src/telemetry/usage-bridge.ts new file mode 100644 index 0000000..cb9a87c --- /dev/null +++ b/packages/core/src/telemetry/usage-bridge.ts @@ -0,0 +1,80 @@ +/** + * Hook E — map accumulated LLM usage onto the active wrapper task span. + * + * Anti-duplication (same idea as tool spans): + * - Sideband `trace.usage` / `metadata["x-usage"]` is unchanged (not OTLP). + * - Wrapper never opens a second LLM span for usage. + * - Wrapper never emits token **metrics** here (backends already may). + * - When backend OTel is on: only `a2a.task.usage.*` rollup on `a2a.task.execute` + * (A2A task total). Do **not** set `gen_ai.usage.*` on the parent — those + * live on backend LLM spans; summing both would double-count. + * - When backend OTel is off: also set `gen_ai.usage.*` on the task span so + * collectors still see tokens without a vendor exporter. + * + * @module telemetry/usage-bridge + */ + +import type { UsageCallRecord, UsageTelemetryData } from "../events/usage.js"; +import { getTaskOtelStore } from "./context.js"; +import type { OtelSpanLike } from "./types.js"; + +function setNum(span: OtelSpanLike, key: string, value: number): void { + span.setAttribute(key, value); +} + +/** + * Apply a task-level usage summary to the active `a2a.task.execute` span. + * No-op when OTel is disabled or there is no active task span. + */ +export function applyUsageSummaryToActiveSpan(summary: UsageTelemetryData): void { + const store = getTaskOtelStore(); + if (!store?.policy.enabled || !store.span) return; + + const span = store.span; + // A2A-owned task rollup — safe alongside backend GenAI spans. + setNum(span, "a2a.task.usage.input_tokens", summary.inputTokens); + setNum(span, "a2a.task.usage.output_tokens", summary.outputTokens); + setNum(span, "a2a.task.usage.cache_read_tokens", summary.cacheReadTokens); + setNum(span, "a2a.task.usage.cache_write_tokens", summary.cacheWriteTokens); + setNum(span, "a2a.task.usage.reasoning_tokens", summary.reasoningTokens); + setNum(span, "a2a.task.usage.llm_calls", summary.llmCalls); + setNum(span, "a2a.task.usage.duration_ms", summary.durationMs); + if (summary.cost !== null) setNum(span, "a2a.task.usage.cost", summary.cost); + if (summary.model) span.setAttribute("a2a.task.usage.model", summary.model); + + // GenAI attrs only when the wrapper is the sole OTel token source. + if (store.policy.emitGenAiUsageAttrs) { + setNum(span, "gen_ai.usage.input_tokens", summary.inputTokens); + setNum(span, "gen_ai.usage.output_tokens", summary.outputTokens); + if (summary.cacheReadTokens > 0) { + setNum(span, "gen_ai.usage.cache_read_input_tokens", summary.cacheReadTokens); + } + if (summary.cacheWriteTokens > 0) { + setNum(span, "gen_ai.usage.cache_creation_input_tokens", summary.cacheWriteTokens); + } + if (summary.reasoningTokens > 0) { + setNum(span, "gen_ai.usage.reasoning_tokens", summary.reasoningTokens); + } + if (summary.model) span.setAttribute("gen_ai.request.model", summary.model); + } +} + +/** + * Optional per-call annotation as a span **event** on the task span. + * + * Skipped when backend OTel is on (per-call tokens belong on vendor LLM spans). + * Does not create child spans. + */ +export function applyUsageCallToActiveSpan(call: UsageCallRecord): void { + const store = getTaskOtelStore(); + if (!store?.policy.enabled || !store.span) return; + if (!store.policy.emitGenAiUsageAttrs) return; + if (!store.span.addEvent) return; + + store.span.addEvent("a2a.llm.call", { + "gen_ai.request.model": call.model, + "gen_ai.usage.input_tokens": call.inputTokens, + "gen_ai.usage.output_tokens": call.outputTokens, + "a2a.llm.call.duration_ms": call.durationMs, + }); +} From e3eda810e5ca9db43ae78992574903e0a59ce4c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 18:49:41 +0000 Subject: [PATCH 03/12] fix(otel): one span per request; mark continue/retry re-entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default model is ~99% one a2a.task.execute per inbound request — not a multi-span factory. When the orchestrator calls again (resume/retry), still one new span with the same conversation/task ids plus a2a.task.invocation(_kind) and a2a.message.id for platform joins. Co-authored-by: Shashi Kanth G S --- docs/observability.md | 53 ++++++++++++++---- .../core/src/__tests__/telemetry/otel.test.ts | 53 +++++++++++++++++- packages/core/src/telemetry/attributes.ts | 24 ++++++++ packages/core/src/telemetry/index.ts | 7 ++- packages/core/src/telemetry/instrument.ts | 56 ++++++++++++++++++- 5 files changed, 178 insertions(+), 15 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 5ab85f4..469b477 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -63,18 +63,47 @@ Point your collector (or Grafana Alloy) at OTLP HTTP `:4318` and/or gRPC `:4317` | `otel.backend.copilot` | Copilot CLI will export its own OTLP spans — wrapper **will not** duplicate tool spans | | `otel.emitOverlappingBackendSpans` | Unsafe; allow duplicates (debug only) | -### What appears in Tempo / Jaeger / Langfuse +### Mental model (keep this simple) -One **trace**, parent/child spans (not one merged span): +**~99% of traffic: one inbound A2A request → one wrapper span** (`a2a.task.execute`). +We do **not** invent a span tree “for every purpose.” Either: + +1. open that one wrapper span and parent-link the backend (Copilot/Claude/…) into the same trace, **or** +2. just **pass the same correlation IDs** through (`gen_ai.conversation.id`, `a2a.task.id`, `a2a.message.id`) so Langfuse / Datadog / Phoenix can group turns even when `trace_id` changes. + +A2A OTel conventions are still evolving ([semantic-conventions-genai#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254)). Context propagation is **not** the only join key — platforms group multi-turn work by **session/conversation attributes**. + +| ID | Typical source | Lifespan | +|---|---|---| +| `gen_ai.conversation.id` | A2A `contextId` (or gateway conversation id) | Whole chatbot thread | +| `a2a.task.id` | A2A task | Logical unit of work (may span `input-required` + resume) | +| `a2a.message.id` | Inbound A2A message | One request / one turn | +| `trace_id` / `traceparent` | W3C context | **Usually one request**; new UI call ⇒ often a new trace | + +### What appears in Tempo / Jaeger / Langfuse (happy path) + +One **request** = one wrapper span + backend children (not one merged span): ``` -a2a.task.execute ← wrapper (Node), attributes include gen_ai.agent.name from agentCard.name - └─ Copilot / Claude … ← backend CLI/runtime (separate process), same collector +a2a.task.execute ← wrapper (Node), one per inbound request + └─ Copilot / Claude … ← backend CLI/runtime (same collector, parent-linked when configured) ``` -- **Copilot:** configure `telemetry.otlpEndpoint` (wired from `otel.backend.copilot` in a follow-up) and `onGetTraceContext` for parent linking. -- **Claude Agent SDK:** TypeScript host spawns Claude Code; enable runtime OTel via env (`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*`) and pass `TRACEPARENT` for parent linking. -- **Antigravity:** no backend OTel today — enable `otel.enabled` and set `mirrorAgentEvents: true` if you want tool detail in OTLP from the wrapper. +### Re-entry, resume, retries (the ~1% the wrapper must handle) + +The wrapper **cannot stop** the orchestrator (or another agent) from calling again: + +| Case | What happens | How we mark it | +|---|---|---| +| First `execute` for a task | New request, no prior `ctx.task` | `a2a.task.invocation=1`, `invocation_kind=new` | +| Follow-up / `input-required` resume | Orchestrator sends another message; SDK sets `ctx.task` | New span, **same** `a2a.task.id` + conversation id, `invocation_kind=continue` | +| Client retry after failure | Same task id, often no `ctx.task` yet | New span, same ids, `invocation_kind=retry` | + +So: **still one span per request** — never a multi-purpose span factory. Platforms join those spans via the stable IDs (session/conversation view), not by forcing one eternal parent across human wait time. + +- **Copilot:** configure `telemetry.otlpEndpoint` (wired from `otel.backend.copilot` in a follow-up) and `onGetTraceContext` for parent linking **within** a request. +- **Claude Agent SDK:** enable runtime OTel via env + `TRACEPARENT` for parent linking within a request. +- **Antigravity:** no backend OTel today — `mirrorAgentEvents: true` if you want tool detail from the wrapper. Use the **same collector** for wrapper and backend exporters. Prefer OTLP HTTP `:4318` when mixing Copilot with the Node SDK. @@ -83,11 +112,15 @@ Use the **same collector** for wrapper and backend exporters. Prefer OTLP HTTP ` | Attribute | Source | |---|---| | `a2a.task.id` / `a2a.context.id` | A2A task / context | +| `a2a.message.id` | Inbound A2A `userMessage.messageId` when present | +| `a2a.task.invocation` / `a2a.task.invocation_kind` | In-process execute count: `new` / `continue` / `retry` | | `gen_ai.agent.name` / `a2a.agent.name` | `agentCard.name` | -| `gen_ai.conversation.id` | A2A `contextId` | +| `gen_ai.conversation.id` | A2A `contextId` (platform session join key) | | `a2a.wrapper.name` | Server option / package name | -| `a2a.task.usage.*` | Task rollup from `LlmUsageAccumulator` (always when OTel on) | -| `gen_ai.usage.*` on task span | **Only when backend OTel is off** | +| `a2a.task.usage.*` | Per-**request** rollup from `LlmUsageAccumulator` (when OTel on) | +| `gen_ai.usage.*` on task span | **Only when backend OTel is off** (fallback sole source) | + +**Ownership reminder:** wrapper owns A2A/protocol + correlation IDs. Backend owns per-call `gen_ai.usage.*` on LLM spans when its OTel is on. Do not treat the wrapper as the universal owner of GenAI token attrs ([#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) is still drafting A2A shape). ## Usage / tokens — do not double-count diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index 35898b9..20847fd 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -22,7 +22,12 @@ function fakeTracer() { const attrs: Array<{ key: string; value: string | number | boolean }> = []; const spans: OtelSpanLike[] = []; const tracer: OtelTracerLike = { - startSpan(name) { + startSpan(name, options) { + if (options?.attributes) { + for (const [key, value] of Object.entries(options.attributes)) { + attrs.push({ key, value }); + } + } const span: OtelSpanLike = { setAttribute(key, value) { attrs.push({ key, value }); @@ -143,12 +148,56 @@ describe("instrumentExecutor", () => { wrapperName: "a2a-copilot", }); await ex.execute( - { taskId: "t1", contextId: "c1" } as unknown as RequestContext, + { + taskId: `t-single-${Date.now()}`, + contextId: "c1", + userMessage: { messageId: "m-1" }, + } as unknown as RequestContext, {} as ExecutionEventBus, ); expect(ran).toBe(true); expect(ended).toEqual(["a2a.task.execute"]); }); + + it("marks continue vs retry on re-entry (still one span per request)", async () => { + const { tracer, ended, attrs } = fakeTracer(); + setOtelTracer(tracer); + const inner: A2AExecutor = { + async initialize() {}, + async shutdown() {}, + async execute() {}, + }; + const ex = instrumentExecutor(inner, { + otel: { enabled: true }, + agentName: "Example Agent", + }); + const taskId = `t-reentry-${Date.now()}-${Math.random()}`; + const bus = {} as ExecutionEventBus; + + await ex.execute( + { taskId, contextId: "conv-1", userMessage: { messageId: "m1" } } as unknown as RequestContext, + bus, + ); + await ex.execute( + { + taskId, + contextId: "conv-1", + task: { id: taskId }, + userMessage: { messageId: "m2" }, + } as unknown as RequestContext, + bus, + ); + await ex.execute( + { taskId, contextId: "conv-1", userMessage: { messageId: "m3" } } as unknown as RequestContext, + bus, + ); + + expect(ended).toEqual(["a2a.task.execute", "a2a.task.execute", "a2a.task.execute"]); + const kinds = attrs.filter((a) => a.key === "a2a.task.invocation_kind").map((a) => a.value); + expect(kinds).toEqual(["new", "continue", "retry"]); + expect(attrs.some((a) => a.key === "a2a.message.id" && a.value === "m2")).toBe(true); + expect(attrs.some((a) => a.key === "gen_ai.conversation.id" && a.value === "conv-1")).toBe(true); + }); }); describe("observeAgentEvent de-dupe", () => { diff --git a/packages/core/src/telemetry/attributes.ts b/packages/core/src/telemetry/attributes.ts index 72a77ea..ed70112 100644 --- a/packages/core/src/telemetry/attributes.ts +++ b/packages/core/src/telemetry/attributes.ts @@ -1,11 +1,25 @@ /** * Attribute builders for wrapper-owned spans. * + * Default model (see docs/observability.md): **one wrapper span per inbound + * request**. Correlation across turns / retries is via stable IDs + * (`gen_ai.conversation.id`, `a2a.task.id`, `a2a.message.id`), not by + * inventing extra span trees. + * * @module telemetry/attributes */ import type { A2ATraceContext } from "./context.js"; +/** How this `execute` relates to the A2A task lifecycle. */ +export type TaskInvocationKind = + /** First wrapper span for this taskId in this process. */ + | "new" + /** Orchestrator called again with an existing task (follow-up / input-required resume). */ + | "continue" + /** Same task seen again without prior task object — typically client retry after failure. */ + | "retry"; + export function slugAgentId(agentName: string): string { return agentName.toLowerCase().replace(/\s+/g, "-"); } @@ -20,6 +34,11 @@ export function buildTaskSpanAttributes(opts: { protocolVersion?: string; parentAgentId?: string | null; orchestratorTraceId?: string; + /** A2A inbound message id when present. */ + messageId?: string | null; + /** 1-based count of wrapper execute() calls for this taskId in-process. */ + invocation?: number; + invocationKind?: TaskInvocationKind; }): Record { const agentId = opts.agentId ?? slugAgentId(opts.agentName); const attrs: Record = { @@ -29,6 +48,8 @@ export function buildTaskSpanAttributes(opts: { "a2a.agent.name": opts.agentName, "gen_ai.agent.name": opts.agentName, "gen_ai.agent.id": agentId, + // Platforms (Langfuse/Datadog/Phoenix) group multi-turn work by this id — + // not by assuming one eternal parent span. "gen_ai.conversation.id": opts.contextId, }; if (opts.wrapperName) attrs["a2a.wrapper.name"] = opts.wrapperName; @@ -36,6 +57,9 @@ export function buildTaskSpanAttributes(opts: { if (opts.protocolVersion) attrs["a2a.protocol.version"] = opts.protocolVersion; if (opts.parentAgentId) attrs["a2a.parent_agent.id"] = opts.parentAgentId; if (opts.orchestratorTraceId) attrs["a2a.orchestrator.trace_id"] = opts.orchestratorTraceId; + if (opts.messageId) attrs["a2a.message.id"] = opts.messageId; + if (opts.invocation !== undefined) attrs["a2a.task.invocation"] = opts.invocation; + if (opts.invocationKind) attrs["a2a.task.invocation_kind"] = opts.invocationKind; return attrs; } diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index ba2bdd7..00bd306 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -25,7 +25,12 @@ export { type TaskOtelStore, } from "./context.js"; -export { buildTaskSpanAttributes, slugAgentId, agentIdentityFromCard } from "./attributes.js"; +export { + buildTaskSpanAttributes, + slugAgentId, + agentIdentityFromCard, + type TaskInvocationKind, +} from "./attributes.js"; export { instrumentExecutor, type InstrumentExecutorOptions } from "./instrument.js"; diff --git a/packages/core/src/telemetry/instrument.ts b/packages/core/src/telemetry/instrument.ts index 2105e9d..9b5b273 100644 --- a/packages/core/src/telemetry/instrument.ts +++ b/packages/core/src/telemetry/instrument.ts @@ -2,7 +2,13 @@ * Executor instrumentation — Hook A. * * Wraps `execute` / `cancelTask` once in {@link createA2AServer} so every - * provider gets `a2a.task.*` spans without duplicating OTel in wrappers. + * provider gets **one** `a2a.task.execute` span per inbound request + * (~99% case). We do not invent multi-span trees for “purposes.” + * + * Re-entry / retry (orchestrator calls again — the wrapper cannot block that): + * still **one new span per request**, same `a2a.task.id` / + * `gen_ai.conversation.id`, plus `a2a.task.invocation` / + * `a2a.task.invocation_kind` so Langfuse/Datadog/Phoenix can join turns. * * @module telemetry/instrument */ @@ -10,7 +16,11 @@ import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; import type { A2AExecutor } from "../executor/types.js"; import { withSpan } from "./api.js"; -import { buildTaskSpanAttributes, slugAgentId } from "./attributes.js"; +import { + buildTaskSpanAttributes, + slugAgentId, + type TaskInvocationKind, +} from "./attributes.js"; import { extractA2ATraceContext, runWithTaskOtelStore, type TaskOtelStore } from "./context.js"; import { resolveOtelEmissionPolicy, type OtelConfig } from "./types.js"; @@ -24,6 +34,41 @@ export interface InstrumentExecutorOptions { agentName: string; } +/** In-process execute() counts keyed by taskId (retries / continue). */ +const taskInvocationCounts = new Map(); + +function nextInvocation(taskId: string): number { + const n = (taskInvocationCounts.get(taskId) ?? 0) + 1; + taskInvocationCounts.set(taskId, n); + // Bound memory: drop after a large number of distinct tasks. + if (taskInvocationCounts.size > 10_000) { + const first = taskInvocationCounts.keys().next().value; + if (first !== undefined) taskInvocationCounts.delete(first); + } + return n; +} + +function resolveInvocationKind( + invocation: number, + hasExistingTask: boolean, +): TaskInvocationKind { + if (invocation === 1) return "new"; + if (hasExistingTask) return "continue"; + return "retry"; +} + +function extractMessageId(ctx: RequestContext): string | undefined { + try { + const msg = ctx.userMessage as { messageId?: string } | undefined; + if (msg?.messageId) return msg.messageId; + const req = (ctx as unknown as { request?: { params?: { message?: { messageId?: string } } } }) + .request; + return req?.params?.message?.messageId; + } catch { + return undefined; + } +} + /** * Return an executor whose execute/cancelTask are wrapped with task spans. * Lifecycle methods are passed through unchanged. @@ -40,6 +85,10 @@ export function instrumentExecutor( const taskId = ctx.taskId; const contextId = ctx.contextId; const trace = extractA2ATraceContext(ctx); + const invocation = nextInvocation(taskId); + const hasExistingTask = Boolean(ctx.task); + const invocationKind = resolveInvocationKind(invocation, hasExistingTask); + const messageId = extractMessageId(ctx); const attrs = buildTaskSpanAttributes({ taskId, contextId, @@ -50,6 +99,9 @@ export function instrumentExecutor( protocolVersion: options.protocolVersion, parentAgentId: trace.parentAgentId, orchestratorTraceId: trace.traceId, + messageId, + invocation, + invocationKind, }); if (!policy.enabled) { From eb0c2261f0c445b630c525daaf92ccb68d29241c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 18:55:21 +0000 Subject: [PATCH 04/12] feat(otel): Phase 3 backend passthrough (Hook F) Wire Copilot telemetry + onGetTraceContext, Claude subprocess OTel/TRACEPARENT, and Codex otel configOverrides via core helpers. Activate real OTel context in withSpan so parent-link works. Document OpenCode/Antigravity limits. Co-authored-by: Shashi Kanth G S --- .changeset/otel-phase-3.md | 13 ++ .../schemas/agent-config.schema.json | 46 +++++- a2a-claude/schemas/agent-config.schema.json | 46 +++++- a2a-claude/src/claude/client-factory.ts | 7 +- a2a-codex/schemas/agent-config.schema.json | 46 +++++- a2a-codex/src/codex/client-factory.ts | 4 +- a2a-copilot/schemas/agent-config.schema.json | 46 +++++- a2a-copilot/src/copilot/executor.ts | 18 +++ a2a-opencode/schemas/agent-config.schema.json | 46 +++++- docs/observability.md | 30 +++- .../core/src/__tests__/telemetry/otel.test.ts | 53 ++++++ packages/core/src/config/types.ts | 22 ++- packages/core/src/index.ts | 7 + packages/core/src/telemetry/api.ts | 49 ++++-- .../core/src/telemetry/backend-passthrough.ts | 152 ++++++++++++++++++ packages/core/src/telemetry/index.ts | 10 ++ packages/core/src/telemetry/types.ts | 39 ++++- 17 files changed, 605 insertions(+), 29 deletions(-) create mode 100644 .changeset/otel-phase-3.md create mode 100644 packages/core/src/telemetry/backend-passthrough.ts diff --git a/.changeset/otel-phase-3.md b/.changeset/otel-phase-3.md new file mode 100644 index 0000000..66c3622 --- /dev/null +++ b/.changeset/otel-phase-3.md @@ -0,0 +1,13 @@ +--- +"@a2a-wrapper/core": minor +"a2a-copilot": minor +"a2a-claude": minor +"a2a-codex": minor +--- + +feat(otel): Phase 3 backend passthrough (Hook F) + +- Core helpers: Copilot telemetry/onGetTraceContext, Claude OTel env + TRACEPARENT, Codex otel overrides +- `withSpan` activates real OTel context so parent-link works +- Shared `otel.exporter` endpoint inheritance +- Wire Copilot / Claude / Codex; OpenCode/Antigravity documented diff --git a/a2a-antigravity/schemas/agent-config.schema.json b/a2a-antigravity/schemas/agent-config.schema.json index c6b898a..e61b97b 100644 --- a/a2a-antigravity/schemas/agent-config.schema.json +++ b/a2a-antigravity/schemas/agent-config.schema.json @@ -548,38 +548,67 @@ "properties": { "backend": { "additionalProperties": false, - "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { "additionalProperties": false, "properties": { "enableTelemetry": { "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" } }, "type": "object" }, "codex": { "additionalProperties": {}, + "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", "type": "object" }, "copilot": { "additionalProperties": false, "properties": { + "captureContent": { + "type": "boolean" + }, "exporterType": { "type": "string" }, + "filePath": { + "type": "string" + }, "otlpEndpoint": { "type": "string" }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, "propagateTraceContext": { + "default": "true when Copilot OTel is configured", "type": "boolean" + }, + "sourceName": { + "type": "string" } }, "type": "object" }, "opencode": { "additionalProperties": false, + "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", "properties": { "openTelemetry": { "type": "boolean" @@ -600,6 +629,21 @@ "description": "Master switch for wrapper-owned OTel instrumentation.", "type": "boolean" }, + "exporter": { + "additionalProperties": false, + "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", + "properties": { + "endpoint": { + "description": "e.g. `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "type": "string" + } + }, + "type": "object" + }, "mirrorAgentEvents": { "default": false, "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", diff --git a/a2a-claude/schemas/agent-config.schema.json b/a2a-claude/schemas/agent-config.schema.json index 637e427..b5e586e 100644 --- a/a2a-claude/schemas/agent-config.schema.json +++ b/a2a-claude/schemas/agent-config.schema.json @@ -642,38 +642,67 @@ "properties": { "backend": { "additionalProperties": false, - "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { "additionalProperties": false, "properties": { "enableTelemetry": { "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" } }, "type": "object" }, "codex": { "additionalProperties": {}, + "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", "type": "object" }, "copilot": { "additionalProperties": false, "properties": { + "captureContent": { + "type": "boolean" + }, "exporterType": { "type": "string" }, + "filePath": { + "type": "string" + }, "otlpEndpoint": { "type": "string" }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, "propagateTraceContext": { + "default": "true when Copilot OTel is configured", "type": "boolean" + }, + "sourceName": { + "type": "string" } }, "type": "object" }, "opencode": { "additionalProperties": false, + "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", "properties": { "openTelemetry": { "type": "boolean" @@ -694,6 +723,21 @@ "description": "Master switch for wrapper-owned OTel instrumentation.", "type": "boolean" }, + "exporter": { + "additionalProperties": false, + "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", + "properties": { + "endpoint": { + "description": "e.g. `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "type": "string" + } + }, + "type": "object" + }, "mirrorAgentEvents": { "default": false, "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", diff --git a/a2a-claude/src/claude/client-factory.ts b/a2a-claude/src/claude/client-factory.ts index f6dd3dd..45fba2e 100644 --- a/a2a-claude/src/claude/client-factory.ts +++ b/a2a-claude/src/claude/client-factory.ts @@ -8,6 +8,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; import type { Options } from "@anthropic-ai/claude-agent-sdk"; +import { buildClaudeOtelEnv } from "@a2a-wrapper/core"; import type { AgentConfig, ClaudeThinkingConfig } from "../config/types.js"; import { buildMcpServers } from "./mcp-adapter.js"; @@ -134,6 +135,10 @@ export function buildQueryOptions( const marketplaces = claude.marketplaces ?? {}; const usesMarketplaces = Object.keys(marketplaces).length > 0; + const baseEnv = usesMarketplaces ? syncPluginInstallEnv() : undefined; + // Hook F — Claude Code subprocess OTel + TRACEPARENT from active task span. + const env = buildClaudeOtelEnv(config.otel, baseEnv); + const opts: QueryOptionsLike = { cwd: claude.workingDirectory || undefined, model: claude.model || undefined, @@ -152,7 +157,7 @@ export function buildQueryOptions( enabledPlugins: claude.enabledPlugins ?? {}, } : undefined, - env: usesMarketplaces ? syncPluginInstallEnv() : undefined, + env, maxTurns: claude.maxTurns, maxBudgetUsd: claude.maxBudgetUsd, additionalDirectories: diff --git a/a2a-codex/schemas/agent-config.schema.json b/a2a-codex/schemas/agent-config.schema.json index 6151dd7..de19cdc 100644 --- a/a2a-codex/schemas/agent-config.schema.json +++ b/a2a-codex/schemas/agent-config.schema.json @@ -468,38 +468,67 @@ "properties": { "backend": { "additionalProperties": false, - "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { "additionalProperties": false, "properties": { "enableTelemetry": { "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" } }, "type": "object" }, "codex": { "additionalProperties": {}, + "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", "type": "object" }, "copilot": { "additionalProperties": false, "properties": { + "captureContent": { + "type": "boolean" + }, "exporterType": { "type": "string" }, + "filePath": { + "type": "string" + }, "otlpEndpoint": { "type": "string" }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, "propagateTraceContext": { + "default": "true when Copilot OTel is configured", "type": "boolean" + }, + "sourceName": { + "type": "string" } }, "type": "object" }, "opencode": { "additionalProperties": false, + "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", "properties": { "openTelemetry": { "type": "boolean" @@ -520,6 +549,21 @@ "description": "Master switch for wrapper-owned OTel instrumentation.", "type": "boolean" }, + "exporter": { + "additionalProperties": false, + "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", + "properties": { + "endpoint": { + "description": "e.g. `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "type": "string" + } + }, + "type": "object" + }, "mirrorAgentEvents": { "default": false, "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", diff --git a/a2a-codex/src/codex/client-factory.ts b/a2a-codex/src/codex/client-factory.ts index afc207a..98768ef 100644 --- a/a2a-codex/src/codex/client-factory.ts +++ b/a2a-codex/src/codex/client-factory.ts @@ -10,6 +10,7 @@ */ import { Codex } from "@openai/codex-sdk"; +import { mergeCodexOtelOverrides } from "@a2a-wrapper/core"; import type { AgentConfig } from "../config/types.js"; import { buildMcpConfig } from "./mcp-adapter.js"; @@ -80,8 +81,9 @@ export function createCodexClient(config: Required): CodexClientLik const codexCfg = config.codex; const mcpServers = buildMcpConfig(config.mcp ?? {}); + // Hook F — fold otel.backend.codex into CLI `[otel]` overrides. const sdkConfig: Record = { - ...(codexCfg.configOverrides ?? {}), + ...(mergeCodexOtelOverrides(config.otel, codexCfg.configOverrides) ?? {}), }; if (Object.keys(mcpServers).length > 0) { diff --git a/a2a-copilot/schemas/agent-config.schema.json b/a2a-copilot/schemas/agent-config.schema.json index 3edb804..411e9a1 100644 --- a/a2a-copilot/schemas/agent-config.schema.json +++ b/a2a-copilot/schemas/agent-config.schema.json @@ -474,38 +474,67 @@ "properties": { "backend": { "additionalProperties": false, - "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { "additionalProperties": false, "properties": { "enableTelemetry": { "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" } }, "type": "object" }, "codex": { "additionalProperties": {}, + "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", "type": "object" }, "copilot": { "additionalProperties": false, "properties": { + "captureContent": { + "type": "boolean" + }, "exporterType": { "type": "string" }, + "filePath": { + "type": "string" + }, "otlpEndpoint": { "type": "string" }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, "propagateTraceContext": { + "default": "true when Copilot OTel is configured", "type": "boolean" + }, + "sourceName": { + "type": "string" } }, "type": "object" }, "opencode": { "additionalProperties": false, + "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", "properties": { "openTelemetry": { "type": "boolean" @@ -526,6 +555,21 @@ "description": "Master switch for wrapper-owned OTel instrumentation.", "type": "boolean" }, + "exporter": { + "additionalProperties": false, + "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", + "properties": { + "endpoint": { + "description": "e.g. `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "type": "string" + } + }, + "type": "object" + }, "mirrorAgentEvents": { "default": false, "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", diff --git a/a2a-copilot/src/copilot/executor.ts b/a2a-copilot/src/copilot/executor.ts index 8730abb..a842a2c 100644 --- a/a2a-copilot/src/copilot/executor.ts +++ b/a2a-copilot/src/copilot/executor.ts @@ -31,6 +31,9 @@ import { createExecutionObservability, applyUsageSummaryToActiveSpan, annotateSessionOnTaskSpan, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + createCopilotTraceContextProvider, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -141,6 +144,21 @@ export class CopilotExecutor implements AgentExecutor { } } + // Hook F — Copilot CLI OTel exporter + parent-link into a2a.task.execute. + // `telemetry` only affects SDK-spawned CLI env (not external cliUrl servers). + const telemetry = buildCopilotTelemetryOptions(this.config.otel); + if (telemetry) { + clientOpts.telemetry = telemetry; + log.info("Copilot CLI OTel telemetry configured", { + otlpEndpoint: telemetry.otlpEndpoint, + exporterType: telemetry.exporterType, + externalCli: Boolean(this.config.copilot.cliUrl), + }); + } + if (shouldPropagateCopilotTraceContext(this.config.otel)) { + clientOpts.onGetTraceContext = createCopilotTraceContextProvider(); + } + this.client = new CopilotClient(clientOpts as any); try { await this.client.start(); diff --git a/a2a-opencode/schemas/agent-config.schema.json b/a2a-opencode/schemas/agent-config.schema.json index 6c32a1e..fe961f8 100644 --- a/a2a-opencode/schemas/agent-config.schema.json +++ b/a2a-opencode/schemas/agent-config.schema.json @@ -437,38 +437,67 @@ "properties": { "backend": { "additionalProperties": false, - "description": "Hint that a backend runtime will export its own OTLP spans (Copilot CLI, Claude Code subprocess, Codex, …). Used by the emission policy.", + "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { "additionalProperties": false, "properties": { "enableTelemetry": { "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" } }, "type": "object" }, "codex": { "additionalProperties": {}, + "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", "type": "object" }, "copilot": { "additionalProperties": false, "properties": { + "captureContent": { + "type": "boolean" + }, "exporterType": { "type": "string" }, + "filePath": { + "type": "string" + }, "otlpEndpoint": { "type": "string" }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, "propagateTraceContext": { + "default": "true when Copilot OTel is configured", "type": "boolean" + }, + "sourceName": { + "type": "string" } }, "type": "object" }, "opencode": { "additionalProperties": false, + "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", "properties": { "openTelemetry": { "type": "boolean" @@ -489,6 +518,21 @@ "description": "Master switch for wrapper-owned OTel instrumentation.", "type": "boolean" }, + "exporter": { + "additionalProperties": false, + "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", + "properties": { + "endpoint": { + "description": "e.g. `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "type": "string" + } + }, + "type": "object" + }, "mirrorAgentEvents": { "default": false, "description": "When true, map `AgentEvent` tool calls into wrapper OTel child spans. Defaults to false. Forced off when a backend OTel block is present unless {@link emitOverlappingBackendSpans } is true.", diff --git a/docs/observability.md b/docs/observability.md index 469b477..b40d07b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -45,6 +45,10 @@ Point your collector (or Grafana Alloy) at OTLP HTTP `:4318` and/or gRPC `:4317` "otel": { "enabled": true, "mirrorAgentEvents": false, + "exporter": { + "endpoint": "http://127.0.0.1:4318", + "protocol": "http/protobuf" + }, "backend": { "copilot": { "otlpEndpoint": "http://127.0.0.1:4318", @@ -59,8 +63,12 @@ Point your collector (or Grafana Alloy) at OTLP HTTP `:4318` and/or gRPC `:4317` | Field | Meaning | |---|---| | `otel.enabled` | Emit wrapper spans such as `a2a.task.execute` | +| `otel.exporter.endpoint` | Shared OTLP hint inherited by backends when their own endpoint is omitted | | `otel.mirrorAgentEvents` | Also turn sideband tool events into wrapper OTel tool spans (default `false`) | -| `otel.backend.copilot` | Copilot CLI will export its own OTLP spans — wrapper **will not** duplicate tool spans | +| `otel.backend.copilot` | Hook F: Copilot CLI OTLP + `onGetTraceContext` parent-link | +| `otel.backend.claude` | Hook F: `CLAUDE_CODE_ENABLE_TELEMETRY` + `OTEL_*` + `TRACEPARENT` in subprocess env | +| `otel.backend.codex` | Hook F: merged into Codex `configOverrides.otel` | +| `otel.backend.opencode` | Declares OpenCode server OTel on (de-dupe); enable the flag on the OpenCode server itself | | `otel.emitOverlappingBackendSpans` | Unsafe; allow duplicates (debug only) | ### Mental model (keep this simple) @@ -101,11 +109,23 @@ The wrapper **cannot stop** the orchestrator (or another agent) from calling aga So: **still one span per request** — never a multi-purpose span factory. Platforms join those spans via the stable IDs (session/conversation view), not by forcing one eternal parent across human wait time. -- **Copilot:** configure `telemetry.otlpEndpoint` (wired from `otel.backend.copilot` in a follow-up) and `onGetTraceContext` for parent linking **within** a request. -- **Claude Agent SDK:** enable runtime OTel via env + `TRACEPARENT` for parent linking within a request. -- **Antigravity:** no backend OTel today — `mirrorAgentEvents: true` if you want tool detail from the wrapper. +### Phase 3 — Backend passthrough (Hook F) -Use the **same collector** for wrapper and backend exporters. Prefer OTLP HTTP `:4318` when mixing Copilot with the Node SDK. +Wired in wrappers today: + +| Wrapper | Config | What happens | +|---|---|---| +| **Copilot** | `otel.backend.copilot` | SDK `telemetry` on spawn + `onGetTraceContext` → active `a2a.task.execute` | +| **Claude** | `otel.backend.claude.enableTelemetry` | Subprocess env: `CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*`, `TRACEPARENT` | +| **Codex** | `otel.backend.codex` | Merged into CLI `[otel]` via `configOverrides` | +| **OpenCode** | `otel.backend.opencode.openTelemetry` | Policy/de-dupe only — turn on OpenCode’s own server flag | +| **Antigravity** | — | No backend OTel; use `mirrorAgentEvents: true` for tool detail | + +Notes: + +- Copilot `telemetry` env applies when the SDK **spawns** the CLI. With external `copilot.cliUrl`, set OTEL on that process yourself; `onGetTraceContext` still parent-links RPCs. +- Prefer collector **OTLP HTTP `:4318`** when mixing Copilot + Node SDK (Claude can use HTTP or gRPC). +- Hosts still register the wrapper `TracerProvider` (see above); Hook F only configures **backend** exporters + propagation. ## Wrapper span attributes (selected) diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index 20847fd..bfcabe0 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -9,6 +9,10 @@ import { runWithTaskOtelStore, applyUsageSummaryToActiveSpan, createExecutionObservability, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + buildClaudeOtelEnv, + mergeCodexOtelOverrides, type OtelTracerLike, type OtelSpanLike, type A2AExecutor, @@ -341,3 +345,52 @@ describe("createExecutionObservability", () => { expect(emitter.parentAgentId).toBe("parent-x"); }); }); + +describe("Hook F backend passthrough helpers", () => { + it("builds Copilot telemetry from backend.copilot + shared exporter", () => { + const t = buildCopilotTelemetryOptions({ + enabled: true, + exporter: { endpoint: "http://127.0.0.1:4318" }, + backend: { copilot: { propagateTraceContext: true } }, + }); + expect(t?.otlpEndpoint).toBe("http://127.0.0.1:4318"); + expect(t?.exporterType).toBe("otlp-http"); + expect(shouldPropagateCopilotTraceContext({ + backend: { copilot: { otlpEndpoint: "http://x:4318" } }, + })).toBe(true); + }); + + it("returns undefined Copilot telemetry when backend.copilot is absent", () => { + expect(buildCopilotTelemetryOptions({ enabled: true })).toBeUndefined(); + expect(shouldPropagateCopilotTraceContext({ enabled: true })).toBe(false); + }); + + it("merges Claude OTel env when enableTelemetry is set", () => { + const env = buildClaudeOtelEnv( + { + enabled: true, + serviceName: "a2a-claude", + exporter: { endpoint: "http://127.0.0.1:4318", protocol: "http/protobuf" }, + backend: { claude: { enableTelemetry: true } }, + }, + { KEEP: "1" }, + ); + expect(env?.["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1"); + expect(env?.["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe("http://127.0.0.1:4318"); + expect(env?.["KEEP"]).toBe("1"); + }); + + it("merges Codex otel overrides under configOverrides.otel", () => { + const merged = mergeCodexOtelOverrides( + { + exporter: { endpoint: "http://127.0.0.1:4318" }, + backend: { codex: { environment: "test" } }, + }, + { model: "o3" }, + ); + expect(merged?.model).toBe("o3"); + expect((merged?.otel as Record).endpoint).toBe("http://127.0.0.1:4318"); + expect((merged?.otel as Record).exporter).toBe("otlp"); + expect((merged?.otel as Record).environment).toBe("test"); + }); +}); diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index b23e13d..c10167a 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -519,15 +519,33 @@ export interface BaseAgentConfig> { * Typed loosely here to avoid a hard import cycle with the telemetry * module; see {@link OtelConfig} in `@a2a-wrapper/core` exports. */ + /** + * Optional OpenTelemetry settings. Prefer the exported {@link OtelConfig} + * type from `@a2a-wrapper/core` in wrapper configs. + */ otel?: { enabled?: boolean; serviceName?: string; tracerName?: string; mirrorAgentEvents?: boolean; emitOverlappingBackendSpans?: boolean; + exporter?: { endpoint?: string; protocol?: string }; backend?: { - copilot?: { otlpEndpoint?: string; exporterType?: string; propagateTraceContext?: boolean }; - claude?: { enableTelemetry?: boolean }; + copilot?: { + otlpEndpoint?: string; + exporterType?: string; + otlpProtocol?: string; + sourceName?: string; + captureContent?: boolean; + filePath?: string; + propagateTraceContext?: boolean; + }; + claude?: { + enableTelemetry?: boolean; + otlpEndpoint?: string; + otlpProtocol?: string; + propagateTraceContext?: boolean; + }; codex?: Record; opencode?: { openTelemetry?: boolean }; }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e4f149..8441bbb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -192,6 +192,13 @@ export { applyUsageCallToActiveSpan, createExecutionObservability, annotateSessionOnTaskSpan, + resolveOtlpEndpoint, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + createCopilotTraceContextProvider, + buildClaudeOtelEnv, + mergeCodexOtelOverrides, + isOpenCodeBackendOtelEnabled, } from "./telemetry/index.js"; // ─── A2A SDK Type Re-exports ──────────────────────────────────────────────── // diff --git a/packages/core/src/telemetry/api.ts b/packages/core/src/telemetry/api.ts index 492aea2..cf30952 100644 --- a/packages/core/src/telemetry/api.ts +++ b/packages/core/src/telemetry/api.ts @@ -50,6 +50,11 @@ export function tryGetGlobalTracer(name = "a2a-wrapper", version?: string): Otel /** * Run `block` under a span when a tracer is available; otherwise run plainly. * Always ends the span (including on throw). + * + * When `@opentelemetry/api` is installed and the span looks like a real API + * span (`spanContext()`), the span is set as the active context so + * {@link getW3cTraceContext} / Copilot `onGetTraceContext` / Claude + * `TRACEPARENT` see this span as parent (Hook F). */ export async function withSpan( name: string, @@ -66,18 +71,40 @@ export async function withSpan( } const span = tracer.startSpan(name, { attributes }); - try { - return await block(span); - } catch (err) { + + const run = async (): Promise => { try { - span.recordException?.(err); - // SpanStatusCode.ERROR === 2 in @opentelemetry/api - span.setStatus?.({ code: 2, message: err instanceof Error ? err.message : String(err) }); - } catch { - /* ignore span status failures */ + return await block(span); + } catch (err) { + try { + span.recordException?.(err); + // SpanStatusCode.ERROR === 2 in @opentelemetry/api + span.setStatus?.({ code: 2, message: err instanceof Error ? err.message : String(err) }); + } catch { + /* ignore span status failures */ + } + throw err; + } finally { + span.end(); } - throw err; - } finally { - span.end(); + }; + + try { + const api = require("@opentelemetry/api") as { + context: { + active: () => unknown; + with: (ctx: unknown, fn: () => R) => R; + }; + trace: { setSpan: (ctx: unknown, s: unknown) => unknown }; + }; + const maybeReal = span as OtelSpanLike & { spanContext?: () => unknown }; + if (typeof maybeReal.spanContext === "function") { + const ctx = api.trace.setSpan(api.context.active(), span); + return await api.context.with(ctx, run); + } + } catch { + /* API missing or span not activatable — run without context bind */ } + + return run(); } diff --git a/packages/core/src/telemetry/backend-passthrough.ts b/packages/core/src/telemetry/backend-passthrough.ts new file mode 100644 index 0000000..9c0cfd0 --- /dev/null +++ b/packages/core/src/telemetry/backend-passthrough.ts @@ -0,0 +1,152 @@ +/** + * Hook F helpers — map `otel.backend.*` config into vendor SDK knobs. + * + * Core stays free of vendor SDK imports. Wrappers call these and pass the + * result into CopilotClient / Claude query env / Codex configOverrides. + * + * @module telemetry/backend-passthrough + */ + +import { getW3cTraceContext } from "./context.js"; +import type { OtelConfig, W3cTraceContext } from "./types.js"; + +/** Shared OTLP endpoint from `otel.exporter` or a backend-specific override. */ +export function resolveOtlpEndpoint( + otel: OtelConfig | null | undefined, + backendEndpoint?: string, +): string | undefined { + return backendEndpoint || otel?.exporter?.endpoint || undefined; +} + +/** + * Copilot SDK `telemetry` block. Returns `undefined` when Copilot backend + * OTel is not configured (do not pass an empty object — that can still flip CLI flags). + */ +export function buildCopilotTelemetryOptions( + otel?: OtelConfig | null, +): { + otlpEndpoint?: string; + exporterType?: string; + otlpProtocol?: "http/json" | "http/protobuf"; + sourceName?: string; + captureContent?: boolean; +} | undefined { + const c = otel?.backend?.copilot; + if (!c) return undefined; + const otlpEndpoint = resolveOtlpEndpoint(otel, c.otlpEndpoint); + if (!otlpEndpoint && !c.exporterType && !c.filePath) return undefined; + + const out: { + otlpEndpoint?: string; + exporterType?: string; + otlpProtocol?: "http/json" | "http/protobuf"; + sourceName?: string; + captureContent?: boolean; + filePath?: string; + } = {}; + if (otlpEndpoint) out.otlpEndpoint = otlpEndpoint; + if (c.exporterType) out.exporterType = c.exporterType; + else if (otlpEndpoint) out.exporterType = "otlp-http"; + if (c.otlpProtocol === "http/json" || c.otlpProtocol === "http/protobuf") { + out.otlpProtocol = c.otlpProtocol; + } else if (otlpEndpoint) { + out.otlpProtocol = "http/protobuf"; + } + if (c.sourceName) out.sourceName = c.sourceName; + else if (otel?.serviceName) out.sourceName = otel.serviceName; + if (c.captureContent === true) out.captureContent = true; + if (c.filePath) out.filePath = c.filePath; + return out; +} + +/** + * Whether Copilot should receive `onGetTraceContext` (parent-link into the + * active wrapper span). Default true when Copilot backend OTel is configured. + */ +export function shouldPropagateCopilotTraceContext(otel?: OtelConfig | null): boolean { + const c = otel?.backend?.copilot; + if (!c) return false; + if (c.propagateTraceContext === false) return false; + return Boolean(c.otlpEndpoint || c.exporterType || c.filePath || otel?.exporter?.endpoint); +} + +/** Provider for CopilotClientOptions.onGetTraceContext. */ +export function createCopilotTraceContextProvider(): () => W3cTraceContext { + return () => getW3cTraceContext(); +} + +/** + * Env vars for the Claude Code subprocess when `otel.backend.claude` is on. + * Merges into (or creates) the query `env` map. Includes ambient `process.env` + * keys already present in `base` when provided. + * + * `TRACEPARENT` / `TRACESTATE` are taken from the **current** active span + * (call this inside `a2a.task.execute`). + */ +export function buildClaudeOtelEnv( + otel: OtelConfig | null | undefined, + base?: Record, +): Record | undefined { + const c = otel?.backend?.claude; + if (!c?.enableTelemetry) return base; + + const env: Record = { ...(base ?? {}) }; + env["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1"; + + const endpoint = resolveOtlpEndpoint(otel, c.otlpEndpoint); + if (endpoint) { + env["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint; + env["OTEL_EXPORTER_OTLP_PROTOCOL"] = + c.otlpProtocol ?? otel?.exporter?.protocol ?? "http/protobuf"; + } + if (otel?.serviceName) env["OTEL_SERVICE_NAME"] = otel.serviceName; + + if (c.propagateTraceContext !== false) { + const w3c = getW3cTraceContext(); + if (w3c.traceparent) env["TRACEPARENT"] = w3c.traceparent; + if (w3c.tracestate) env["TRACESTATE"] = w3c.tracestate; + } + + return env; +} + +/** + * Merge Codex `[otel]`-shaped overrides from `otel.backend.codex` into an + * existing `configOverrides` object. + */ +export function mergeCodexOtelOverrides( + otel: OtelConfig | null | undefined, + configOverrides?: Record, +): Record | undefined { + const c = otel?.backend?.codex; + if (!c || Object.keys(c).length === 0) { + return configOverrides && Object.keys(configOverrides).length > 0 + ? { ...configOverrides } + : undefined; + } + + const endpoint = resolveOtlpEndpoint(otel, typeof c.endpoint === "string" ? c.endpoint : undefined); + const otelBlock: Record = { + ...(typeof c === "object" ? c : {}), + }; + if (endpoint && otelBlock.endpoint === undefined) { + otelBlock.endpoint = endpoint; + } + // Codex defaults exporter to "none"; if operator enabled backend.codex, prefer otlp. + if (otelBlock.exporter === undefined && endpoint) { + otelBlock.exporter = "otlp"; + } + + return { + ...(configOverrides ?? {}), + otel: { + ...((configOverrides?.otel as Record) ?? {}), + ...otelBlock, + }, + }; +} + +/** Whether OpenCode server-side experimental OTel should be considered on (de-dupe). */ +export function isOpenCodeBackendOtelEnabled(otel?: OtelConfig | null): boolean { + return Boolean(otel?.backend?.opencode?.openTelemetry); +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 00bd306..230cac2 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -47,3 +47,13 @@ export { type CreateExecutionObservabilityOptions, type ExecutionObservability, } from "./observability.js"; + +export { + resolveOtlpEndpoint, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + createCopilotTraceContextProvider, + buildClaudeOtelEnv, + mergeCodexOtelOverrides, + isOpenCodeBackendOtelEnabled, +} from "./backend-passthrough.js"; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 784ec97..a342944 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -48,13 +48,44 @@ export interface OtelConfig { emitOverlappingBackendSpans?: boolean; /** - * Hint that a backend runtime will export its own OTLP spans (Copilot CLI, - * Claude Code subprocess, Codex, …). Used by the emission policy. + * Shared OTLP exporter hint for wrapper hosts and backend passthrough. + * Backends inherit `endpoint` / `protocol` when their own fields are omitted. + */ + exporter?: { + /** e.g. `http://127.0.0.1:4318` */ + endpoint?: string; + /** e.g. `http/protobuf` (preferred with Copilot) or `grpc` */ + protocol?: string; + }; + + /** + * Backend runtime OTel passthrough (Hook F). Presence of a configured + * backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate). */ backend?: { - copilot?: { otlpEndpoint?: string; exporterType?: string; propagateTraceContext?: boolean }; - claude?: { enableTelemetry?: boolean }; + copilot?: { + otlpEndpoint?: string; + exporterType?: string; + otlpProtocol?: "http/json" | "http/protobuf"; + sourceName?: string; + captureContent?: boolean; + filePath?: string; + /** @default true when Copilot OTel is configured */ + propagateTraceContext?: boolean; + }; + claude?: { + enableTelemetry?: boolean; + otlpEndpoint?: string; + otlpProtocol?: string; + /** @default true when Claude telemetry is enabled */ + propagateTraceContext?: boolean; + }; + /** Merged into Codex `configOverrides.otel` (CLI `[otel]` shape). */ codex?: Record; + /** + * OpenCode server-side experimental flag. The wrapper does not start + * OpenCode's exporter; set this so de-dupe policy matches your server. + */ opencode?: { openTelemetry?: boolean }; }; } From 8a221b3bc65b95810a9e62341299d5deaaf76076 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 18:59:47 +0000 Subject: [PATCH 05/12] feat(otel): Phase 4 CLI bootstrap, span links, Jaeger demo stack createCli dynamically bootstraps NodeSDK when otel.exporter.endpoint is set (optional peers). Continue/retry invocations get span links. Ship examples/otel-stack (Jaeger OTLP) and fix withSpan not to re-run on errors. Co-authored-by: Shashi Kanth G S --- .changeset/otel-phase-4.md | 10 +++ README.md | 2 +- docs/observability.md | 22 ++++- examples/otel-stack/README.md | 54 +++++++++++ examples/otel-stack/agent-otel.snippet.json | 22 +++++ examples/otel-stack/docker-compose.yml | 22 +++++ packages/core/README.md | 2 + packages/core/package.json | 8 ++ .../core/src/__tests__/telemetry/otel.test.ts | 18 +++- packages/core/src/cli/scaffold.ts | 13 +++ packages/core/src/index.ts | 5 ++ packages/core/src/telemetry/api.ts | 50 +++++++---- packages/core/src/telemetry/cli-bootstrap.ts | 90 +++++++++++++++++++ packages/core/src/telemetry/index.ts | 15 +++- packages/core/src/telemetry/instrument.ts | 65 +++++++++++--- packages/core/src/telemetry/types.ts | 18 +++- 16 files changed, 381 insertions(+), 35 deletions(-) create mode 100644 .changeset/otel-phase-4.md create mode 100644 examples/otel-stack/README.md create mode 100644 examples/otel-stack/agent-otel.snippet.json create mode 100644 examples/otel-stack/docker-compose.yml create mode 100644 packages/core/src/telemetry/cli-bootstrap.ts diff --git a/.changeset/otel-phase-4.md b/.changeset/otel-phase-4.md new file mode 100644 index 0000000..3d2560e --- /dev/null +++ b/.changeset/otel-phase-4.md @@ -0,0 +1,10 @@ +--- +"@a2a-wrapper/core": minor +--- + +feat(otel): Phase 4 CLI bootstrap, span links, Jaeger demo stack + +- `bootstrapOtelSdkFromConfig` / `shutdownOtelSdk` via dynamic import in `createCli` +- Span links on continue/retry invocations +- Optional peers for sdk-node + OTLP HTTP exporter +- `examples/otel-stack` docker-compose (Jaeger OTLP) diff --git a/README.md b/README.md index 374b9ab..47fc61f 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Wrappers run coding agents behind an HTTP endpoint. Read the [Security Guide](do ## Roadmap -- **OpenTelemetry observability** — optional distributed tracing for the A2A task lifecycle via `@opentelemetry/api` (optional peer; no-op until a host registers a TracerProvider / OTLP exporter). Core hooks ship in `@a2a-wrapper/core`; backend CLI passthrough (Copilot / Claude) is follow-up work. See [Observability](docs/observability.md). +- **OpenTelemetry observability** — optional distributed tracing (`a2a.task.execute` + backend passthrough). Optional peers for `@opentelemetry/api` / SDK; CLI auto-bootstraps when `otel.exporter.endpoint` is set. Demo stack: [`examples/otel-stack`](examples/otel-stack). See [Observability](docs/observability.md). ## Contributing diff --git a/docs/observability.md b/docs/observability.md index b40d07b..4785ad4 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -36,7 +36,25 @@ await sdk.start(); setOtelTracer(trace.getTracer("a2a-wrapper")); ``` -Point your collector (or Grafana Alloy) at OTLP HTTP `:4318` and/or gRPC `:4317`. +### CLI bootstrap (Phase 4) + +Wrapper CLIs call `bootstrapOtelSdkFromConfig` when `otel.enabled` and +`otel.exporter.endpoint` are set. That **dynamically imports** optional peers +(`@opentelemetry/sdk-node`, `@opentelemetry/exporter-trace-otlp-http`). If they +are missing, the process continues with no-op spans. + +```bash +npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http +``` + +### Local demo stack + +```bash +docker compose -f examples/otel-stack/docker-compose.yml up -d +# UI: http://localhost:16686 +``` + +Point your collector (or Grafana Alloy / Langfuse OTLP) at OTLP HTTP `:4318` and/or gRPC `:4317`. ## Agent config @@ -109,6 +127,8 @@ The wrapper **cannot stop** the orchestrator (or another agent) from calling aga So: **still one span per request** — never a multi-purpose span factory. Platforms join those spans via the stable IDs (session/conversation view), not by forcing one eternal parent across human wait time. +When a prior in-process attempt exists, the new span also gets an OTel **span link** (`a2a.task.link_reason=continue|retry`) for Tempo/Jaeger. Langfuse/Datadog still primarily group on `gen_ai.conversation.id`. + ### Phase 3 — Backend passthrough (Hook F) Wired in wrappers today: diff --git a/examples/otel-stack/README.md b/examples/otel-stack/README.md new file mode 100644 index 0000000..aa9a71b --- /dev/null +++ b/examples/otel-stack/README.md @@ -0,0 +1,54 @@ +# OpenTelemetry demo stack (Jaeger) + +Minimal local collector UI for developing `a2a-*` wrappers with OpenTelemetry. + +## Start + +```bash +docker compose -f examples/otel-stack/docker-compose.yml up -d +``` + +| Endpoint | Use | +|---|---| +| `http://127.0.0.1:4318` | OTLP HTTP — **preferred** (Copilot CLI + Node SDK) | +| `http://127.0.0.1:4317` | OTLP gRPC (Claude / generic) | +| `http://localhost:16686` | Jaeger UI | + +## Agent config + +```json +{ + "otel": { + "enabled": true, + "serviceName": "a2a-copilot-demo", + "exporter": { + "endpoint": "http://127.0.0.1:4318", + "protocol": "http/protobuf" + }, + "backend": { + "copilot": { + "propagateTraceContext": true + } + } + } +} +``` + +## CLI bootstrap (optional deps) + +When you run a wrapper CLI (`a2a-copilot`, …) with `otel.enabled` + `otel.exporter.endpoint`, `@a2a-wrapper/core` tries to start NodeSDK via **dynamic import**. + +Install once in the wrapper (or host) package: + +```bash +npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http +``` + +Without those packages the CLI still runs; wrapper spans are no-ops until a host registers a tracer. + +## What you should see + +1. One **trace** per inbound A2A request (`a2a.task.execute` + backend children when Hook F is on). +2. Multi-turn / resume: separate traces joined by `gen_ai.conversation.id` / `a2a.task.id` (and optional span links with `a2a.task.link_reason=continue|retry`). + +See [docs/observability.md](../../docs/observability.md). diff --git a/examples/otel-stack/agent-otel.snippet.json b/examples/otel-stack/agent-otel.snippet.json new file mode 100644 index 0000000..6069e78 --- /dev/null +++ b/examples/otel-stack/agent-otel.snippet.json @@ -0,0 +1,22 @@ +{ + "otel": { + "enabled": true, + "serviceName": "a2a-wrapper-demo", + "exporter": { + "endpoint": "http://127.0.0.1:4318", + "protocol": "http/protobuf" + }, + "backend": { + "copilot": { + "propagateTraceContext": true + }, + "claude": { + "enableTelemetry": true, + "propagateTraceContext": true + }, + "codex": { + "environment": "local" + } + } + } +} diff --git a/examples/otel-stack/docker-compose.yml b/examples/otel-stack/docker-compose.yml new file mode 100644 index 0000000..eda74fc --- /dev/null +++ b/examples/otel-stack/docker-compose.yml @@ -0,0 +1,22 @@ +# Local OpenTelemetry collector + Jaeger for a2a-* wrapper demos. +# +# docker compose -f examples/otel-stack/docker-compose.yml up -d +# open http://localhost:16686 +# +# Point wrappers at: +# otel.exporter.endpoint = "http://127.0.0.1:4318" +# otel.backend.copilot.otlpEndpoint = "http://127.0.0.1:4318" # or inherit exporter +# +# Jaeger all-in-one accepts OTLP HTTP (:4318) and gRPC (:4317). + +services: + jaeger: + image: jaegertracing/all-in-one:1.57 + container_name: a2a-otel-jaeger + environment: + COLLECTOR_OTLP_ENABLED: "true" + ports: + - "4317:4317" # OTLP gRPC (Claude / generic) + - "4318:4318" # OTLP HTTP (Copilot + NodeSDK — preferred) + - "16686:16686" # Jaeger UI + restart: unless-stopped diff --git a/packages/core/README.md b/packages/core/README.md index 6e929ff..3f97fe9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -472,6 +472,8 @@ Optional distributed tracing for `a2a.task.execute` / `a2a.task.cancel`. Core pe Usage tokens: call `applyUsageSummaryToActiveSpan(summary)` after `LlmUsageAccumulator.summary()`. When a backend OTel exporter is configured, core sets only `a2a.task.usage.*` on the task span (not `gen_ai.usage.*`) so Tempo SUMs do not double-count with vendor LLM spans. +CLI bootstrap: with `otel.enabled` + `otel.exporter.endpoint`, `createCli` dynamically loads `@opentelemetry/sdk-node` + OTLP HTTP exporter when installed (optional peers). Local Jaeger demo: `examples/otel-stack`. + ```ts import { setOtelTracer, withSpan, extractA2ATraceContext } from "@a2a-wrapper/core"; ``` diff --git a/packages/core/package.json b/packages/core/package.json index 712836d..7996971 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,12 +23,20 @@ "peerDependencies": { "@a2a-js/sdk": "^1.0.0", "@opentelemetry/api": ">=1.0.0 <1.10.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.50.0", + "@opentelemetry/sdk-node": ">=0.50.0", "express": "^4.18.2", "uuid": "^9.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true + }, + "@opentelemetry/sdk-node": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true } }, "devDependencies": { diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index bfcabe0..60a3a6c 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -25,6 +25,8 @@ function fakeTracer() { const events: string[] = []; const attrs: Array<{ key: string; value: string | number | boolean }> = []; const spans: OtelSpanLike[] = []; + const linksSeen: number[] = []; + let spanSeq = 0; const tracer: OtelTracerLike = { startSpan(name, options) { if (options?.attributes) { @@ -32,6 +34,9 @@ function fakeTracer() { attrs.push({ key, value }); } } + linksSeen.push(options?.links?.length ?? 0); + spanSeq += 1; + const id = spanSeq; const span: OtelSpanLike = { setAttribute(key, value) { attrs.push({ key, value }); @@ -41,6 +46,13 @@ function fakeTracer() { addEvent(n) { events.push(n); }, + spanContext() { + return { + traceId: `trace-${id}`, + spanId: `span-${id}`, + traceFlags: 1, + }; + }, end() { ended.push(name); }, @@ -49,7 +61,7 @@ function fakeTracer() { return span; }, }; - return { tracer, ended, events, spans, attrs }; + return { tracer, ended, events, spans, attrs, linksSeen }; } describe("resolveOtelEmissionPolicy", () => { @@ -164,7 +176,7 @@ describe("instrumentExecutor", () => { }); it("marks continue vs retry on re-entry (still one span per request)", async () => { - const { tracer, ended, attrs } = fakeTracer(); + const { tracer, ended, attrs, linksSeen } = fakeTracer(); setOtelTracer(tracer); const inner: A2AExecutor = { async initialize() {}, @@ -201,6 +213,8 @@ describe("instrumentExecutor", () => { expect(kinds).toEqual(["new", "continue", "retry"]); expect(attrs.some((a) => a.key === "a2a.message.id" && a.value === "m2")).toBe(true); expect(attrs.some((a) => a.key === "gen_ai.conversation.id" && a.value === "conv-1")).toBe(true); + // First attempt: no link; continue/retry link to prior span context. + expect(linksSeen).toEqual([0, 1, 1]); }); }); diff --git a/packages/core/src/cli/scaffold.ts b/packages/core/src/cli/scaffold.ts index b9657a9..0abddf5 100644 --- a/packages/core/src/cli/scaffold.ts +++ b/packages/core/src/cli/scaffold.ts @@ -416,6 +416,18 @@ export function createCli>( port: config.server?.port, }); + // 4b. Optional OTel SDK bootstrap when otel.enabled + otel.exporter.endpoint + // (dynamic import — no hard dep on sdk-node / OTLP exporter). + const { bootstrapOtelSdkFromConfig, shutdownOtelSdk } = await import( + "../telemetry/cli-bootstrap.js" + ); + const otelBootstrapped = await bootstrapOtelSdkFromConfig( + (config as { otel?: import("../telemetry/types.js").OtelConfig }).otel, + ); + if (otelBootstrapped) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + // 5. Create and start the A2A server const handle = await createA2AServer( config, @@ -427,6 +439,7 @@ export function createCli>( const shutdown = async (signal: string): Promise => { log.info(`${signal} received, shutting down...`); await handle.shutdown(); + await shutdownOtelSdk(); process.exit(0); }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8441bbb..23b8a57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,12 +164,15 @@ export type { OtelEmissionPolicy, OtelTracerLike, OtelSpanLike, + OtelSpanContextLike, + OtelSpanLinkLike, W3cTraceContext, A2ATraceContext, TaskOtelStore, InstrumentExecutorOptions, CreateExecutionObservabilityOptions, ExecutionObservability, + WithSpanOptions, } from "./telemetry/index.js"; export { @@ -178,6 +181,8 @@ export { getOtelTracer, tryGetGlobalTracer, withSpan, + bootstrapOtelSdkFromConfig, + shutdownOtelSdk, extractA2ATraceContext, getW3cTraceContext, injectW3cTraceHeaders, diff --git a/packages/core/src/telemetry/api.ts b/packages/core/src/telemetry/api.ts index cf30952..ef7013d 100644 --- a/packages/core/src/telemetry/api.ts +++ b/packages/core/src/telemetry/api.ts @@ -9,7 +9,12 @@ */ import { createRequire } from "node:module"; -import type { OtelSpanLike, OtelTracerLike } from "./types.js"; +import type { OtelSpanLike, OtelSpanLinkLike, OtelTracerLike } from "./types.js"; + +export interface WithSpanOptions { + /** Span links (e.g. prior attempt of the same A2A task). */ + links?: OtelSpanLinkLike[]; +} const require = createRequire(import.meta.url); @@ -60,6 +65,7 @@ export async function withSpan( name: string, attributes: Record, block: (span: OtelSpanLike | undefined) => Promise, + options?: WithSpanOptions, ): Promise { const tracer = getOtelTracer(); if (!tracer) { @@ -70,7 +76,10 @@ export async function withSpan( return block(undefined); } - const span = tracer.startSpan(name, { attributes }); + const span = tracer.startSpan(name, { + attributes, + ...(options?.links?.length ? { links: options.links } : {}), + }); const run = async (): Promise => { try { @@ -89,21 +98,32 @@ export async function withSpan( } }; - try { - const api = require("@opentelemetry/api") as { - context: { - active: () => unknown; - with: (ctx: unknown, fn: () => R) => R; - }; - trace: { setSpan: (ctx: unknown, s: unknown) => unknown }; + // Bind active context when possible so Hook F parent-link works. + // Only catch failures while *building* the context — never catch `run()` + // errors (that would fall through and invoke the block a second time). + type Api = { + context: { + active: () => unknown; + with: (ctx: unknown, fn: () => R) => R; }; - const maybeReal = span as OtelSpanLike & { spanContext?: () => unknown }; - if (typeof maybeReal.spanContext === "function") { - const ctx = api.trace.setSpan(api.context.active(), span); - return await api.context.with(ctx, run); - } + trace: { setSpan: (ctx: unknown, s: unknown) => unknown }; + }; + + let api: Api | undefined; + try { + api = require("@opentelemetry/api") as Api; } catch { - /* API missing or span not activatable — run without context bind */ + api = undefined; + } + + if (api && typeof span.spanContext === "function") { + let ctx: unknown; + try { + ctx = api.trace.setSpan(api.context.active(), span); + } catch { + return run(); + } + return await api.context.with(ctx, run); } return run(); diff --git a/packages/core/src/telemetry/cli-bootstrap.ts b/packages/core/src/telemetry/cli-bootstrap.ts new file mode 100644 index 0000000..bab0a0e --- /dev/null +++ b/packages/core/src/telemetry/cli-bootstrap.ts @@ -0,0 +1,90 @@ +/** + * Optional CLI OTel SDK bootstrap (Phase 4). + * + * Dynamically imports `@opentelemetry/sdk-node` + OTLP HTTP exporter when + * `otel.enabled` and `otel.exporter.endpoint` are set. If those packages are + * not installed, logs a one-line hint and continues (spans stay no-op unless + * the host registered a tracer another way). + * + * @module telemetry/cli-bootstrap + */ + +import { setOtelTracer, getOtelTracer } from "./api.js"; +import type { OtelConfig } from "./types.js"; + +type Shutdownable = { shutdown: () => Promise }; + +let _sdk: Shutdownable | undefined; +let _warnedSkip = false; + +function tracesUrl(endpoint: string): string { + const base = endpoint.replace(/\/$/, ""); + return base.endsWith("/v1/traces") ? base : `${base}/v1/traces`; +} + +/** + * Start a NodeSDK + OTLP HTTP exporter and register the tracer for core hooks. + * No-op when already bootstrapped, when OTel is disabled, or when endpoint is missing. + */ +export async function bootstrapOtelSdkFromConfig( + otel?: OtelConfig | null, +): Promise { + if (!otel?.enabled) return false; + const endpoint = otel.exporter?.endpoint; + if (!endpoint) return false; + if (getOtelTracer() && _sdk) return true; + + try { + // Specifiers are variables so tsc does not require the optional peer packages + // to be present at compile time (they are optionalPeers for CLI hosts). + const sdkSpec = "@opentelemetry/sdk-node"; + const expSpec = "@opentelemetry/exporter-trace-otlp-http"; + const apiSpec = "@opentelemetry/api"; + const [sdkMod, expMod, apiMod] = await Promise.all([ + import(sdkSpec) as Promise>, + import(expSpec) as Promise>, + import(apiSpec) as Promise>, + ]); + + const NodeSDK = sdkMod.NodeSDK as new (opts: Record) => Shutdownable & { + start: () => void | Promise; + }; + const OTLPTraceExporter = expMod.OTLPTraceExporter as new (opts: { url: string }) => unknown; + const api = apiMod as { + trace: { getTracer: (name: string, version?: string) => import("./types.js").OtelTracerLike }; + }; + + const serviceName = otel.serviceName ?? "a2a-wrapper"; + const sdk = new NodeSDK({ + serviceName, + traceExporter: new OTLPTraceExporter({ url: tracesUrl(endpoint) }), + }); + await Promise.resolve(sdk.start()); + _sdk = sdk; + setOtelTracer(api.trace.getTracer(otel.tracerName ?? "a2a-wrapper")); + return true; + } catch (err) { + if (!_warnedSkip) { + _warnedSkip = true; + const msg = err instanceof Error ? err.message : String(err); + console.warn( + "[a2a-wrapper/otel] CLI OTel bootstrap skipped — install optional deps " + + "`@opentelemetry/sdk-node` and `@opentelemetry/exporter-trace-otlp-http` " + + `(and ensure @opentelemetry/api is present). (${msg})`, + ); + } + return false; + } +} + +/** Flush/shutdown the SDK started by {@link bootstrapOtelSdkFromConfig}. */ +export async function shutdownOtelSdk(): Promise { + if (!_sdk) return; + const sdk = _sdk; + _sdk = undefined; + try { + await sdk.shutdown(); + } catch { + /* ignore shutdown errors */ + } +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 230cac2..2fa9e9d 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -9,11 +9,24 @@ export type { OtelEmissionPolicy, OtelTracerLike, OtelSpanLike, + OtelSpanContextLike, + OtelSpanLinkLike, W3cTraceContext, } from "./types.js"; export { resolveOtelEmissionPolicy } from "./types.js"; -export { setOtelTracer, getOtelTracer, tryGetGlobalTracer, withSpan } from "./api.js"; +export { + setOtelTracer, + getOtelTracer, + tryGetGlobalTracer, + withSpan, + type WithSpanOptions, +} from "./api.js"; + +export { + bootstrapOtelSdkFromConfig, + shutdownOtelSdk, +} from "./cli-bootstrap.js"; export { extractA2ATraceContext, diff --git a/packages/core/src/telemetry/instrument.ts b/packages/core/src/telemetry/instrument.ts index 9b5b273..75a0328 100644 --- a/packages/core/src/telemetry/instrument.ts +++ b/packages/core/src/telemetry/instrument.ts @@ -22,7 +22,12 @@ import { type TaskInvocationKind, } from "./attributes.js"; import { extractA2ATraceContext, runWithTaskOtelStore, type TaskOtelStore } from "./context.js"; -import { resolveOtelEmissionPolicy, type OtelConfig } from "./types.js"; +import { + resolveOtelEmissionPolicy, + type OtelConfig, + type OtelSpanContextLike, + type OtelSpanLinkLike, +} from "./types.js"; export interface InstrumentExecutorOptions { /** Optional otel block from agent config. */ @@ -36,6 +41,8 @@ export interface InstrumentExecutorOptions { /** In-process execute() counts keyed by taskId (retries / continue). */ const taskInvocationCounts = new Map(); +/** Prior span contexts for optional span links on continue/retry. */ +const lastSpanContexts = new Map(); function nextInvocation(taskId: string): number { const n = (taskInvocationCounts.get(taskId) ?? 0) + 1; @@ -43,11 +50,33 @@ function nextInvocation(taskId: string): number { // Bound memory: drop after a large number of distinct tasks. if (taskInvocationCounts.size > 10_000) { const first = taskInvocationCounts.keys().next().value; - if (first !== undefined) taskInvocationCounts.delete(first); + if (first !== undefined) { + taskInvocationCounts.delete(first); + lastSpanContexts.delete(first); + } } return n; } +function linkToPriorAttempt( + taskId: string, + invocation: number, + kind: TaskInvocationKind, +): OtelSpanLinkLike[] | undefined { + if (invocation <= 1) return undefined; + const prev = lastSpanContexts.get(taskId); + if (!prev) return undefined; + return [ + { + context: prev, + attributes: { + "a2a.task.link_reason": kind, + "a2a.task.prior_invocation": invocation - 1, + }, + }, + ]; +} + function resolveInvocationKind( invocation: number, hasExistingTask: boolean, @@ -117,18 +146,26 @@ export function instrumentExecutor( return runWithTaskOtelStore(store, () => executor.execute(ctx, bus)); } - return withSpan("a2a.task.execute", attrs, async (span) => { - const store: TaskOtelStore = { - span, - policy, - taskId, - contextId, - agentId, - agentName, - toolSpans: new Map(), - }; - return runWithTaskOtelStore(store, () => executor.execute(ctx, bus)); - }); + const links = linkToPriorAttempt(taskId, invocation, invocationKind); + return withSpan( + "a2a.task.execute", + attrs, + async (span) => { + const sc = span?.spanContext?.(); + if (sc?.traceId && sc?.spanId) lastSpanContexts.set(taskId, sc); + const store: TaskOtelStore = { + span, + policy, + taskId, + contextId, + agentId, + agentName, + toolSpans: new Map(), + }; + return runWithTaskOtelStore(store, () => executor.execute(ctx, bus)); + }, + links ? { links } : undefined, + ); }; const wrappedCancel = executor.cancelTask diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index a342944..d23aba5 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -117,10 +117,25 @@ export interface OtelEmissionPolicy { * Structural tracer surface — subset of `@opentelemetry/api` Tracer. * Hosts inject a real tracer; tests inject fakes. Keeps the API package optional. */ +/** Minimal span context for span links (continue/retry across traces). */ +export interface OtelSpanContextLike { + traceId: string; + spanId: string; + traceFlags?: number; +} + +export interface OtelSpanLinkLike { + context: OtelSpanContextLike; + attributes?: Record; +} + export interface OtelTracerLike { startSpan( name: string, - options?: { attributes?: Record }, + options?: { + attributes?: Record; + links?: OtelSpanLinkLike[]; + }, ): OtelSpanLike; } @@ -129,6 +144,7 @@ export interface OtelSpanLike { setStatus?(status: { code: number; message?: string }): void; recordException?(exception: unknown): void; addEvent?(name: string, attributes?: Record): void; + spanContext?(): OtelSpanContextLike; end(): void; } From 534afbfd6691660805ab0070d908094f9222220a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:02:17 +0000 Subject: [PATCH 06/12] feat(otel): Phase 5 attribute ownership matrix + gateway ID mapping Document who owns which attrs vs draft GenAI A2A #254. Stamp gen_ai.operation.name, session.id, a2a.method.name, and a2a.gateway.* from orchestrator metadata. Claude/Codex now apply per-request usage rollups. Co-authored-by: Shashi Kanth G S --- .changeset/otel-phase-5.md | 12 +++ README.md | 2 +- a2a-claude/src/claude/executor.ts | 47 ++++++++- a2a-codex/src/codex/executor.ts | 46 ++++++++- docs/observability.md | 4 +- docs/otel-attribute-ownership.md | 99 +++++++++++++++++++ .../core/src/__tests__/telemetry/otel.test.ts | 22 +++++ packages/core/src/telemetry/attributes.ts | 22 ++++- packages/core/src/telemetry/context.ts | 72 +++++++++++--- packages/core/src/telemetry/instrument.ts | 3 + 10 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 .changeset/otel-phase-5.md create mode 100644 docs/otel-attribute-ownership.md diff --git a/.changeset/otel-phase-5.md b/.changeset/otel-phase-5.md new file mode 100644 index 0000000..c4e93ee --- /dev/null +++ b/.changeset/otel-phase-5.md @@ -0,0 +1,12 @@ +--- +"@a2a-wrapper/core": minor +"a2a-claude": minor +"a2a-codex": minor +--- + +feat(otel): Phase 5 attribute ownership + gateway ID mapping + +- docs/otel-attribute-ownership.md aligned with draft GenAI A2A #254 +- Span attrs: gen_ai.operation.name, a2a.method.name, session.id, a2a.gateway.* +- extractA2ATraceContext reads conversation/session/passkey/spectrum/ticket metadata +- Claude + Codex applyUsageSummaryToActiveSpan + x-usage on completed turns diff --git a/README.md b/README.md index 47fc61f..6745c26 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Wrappers run coding agents behind an HTTP endpoint. Read the [Security Guide](do ## Roadmap -- **OpenTelemetry observability** — optional distributed tracing (`a2a.task.execute` + backend passthrough). Optional peers for `@opentelemetry/api` / SDK; CLI auto-bootstraps when `otel.exporter.endpoint` is set. Demo stack: [`examples/otel-stack`](examples/otel-stack). See [Observability](docs/observability.md). +- **OpenTelemetry observability** — optional distributed tracing (`a2a.task.execute` + backend passthrough). Optional peers for `@opentelemetry/api` / SDK; CLI auto-bootstraps when `otel.exporter.endpoint` is set. Demo stack: [`examples/otel-stack`](examples/otel-stack). Guides: [Observability](docs/observability.md), [attribute ownership](docs/otel-attribute-ownership.md). ## Contributing diff --git a/a2a-claude/src/claude/executor.ts b/a2a-claude/src/claude/executor.ts index cb2fe23..8c52140 100644 --- a/a2a-claude/src/claude/executor.ts +++ b/a2a-claude/src/claude/executor.ts @@ -40,14 +40,43 @@ import { publishStreamingChunk, publishLastChunkMarker, createExecutionObservability, + LlmUsageAccumulator, + applyUsageSummaryToActiveSpan, +} from "@a2a-wrapper/core"; +import type { + EventTransport, + EventTransportFn, + SynthesizedMcpDescriptor, + UsageCallRecord, } from "@a2a-wrapper/core"; -import type { EventTransport, EventTransportFn, SynthesizedMcpDescriptor } from "@a2a-wrapper/core"; import { logger } from "../utils/logger.js"; const log = logger.child("executor"); const VALID_PERMISSION_MODES = new Set(["acceptEdits", "dontAsk", "plan", "bypassPermissions"]); + +/** Map Claude Agent SDK result.usage → core UsageCallRecord. */ +function claudeUsageToCallRecord( + msg: SDKMessageLike, + modelFallback: string | undefined, +): UsageCallRecord { + const usage = (msg.usage ?? {}) as Record; + const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + return { + model: (typeof msg.model === "string" ? msg.model : undefined) || modelFallback || "", + inputTokens: num(usage.input_tokens), + outputTokens: num(usage.output_tokens), + cacheReadTokens: num(usage.cache_read_input_tokens), + cacheWriteTokens: num(usage.cache_creation_input_tokens), + reasoningTokens: 0, + durationMs: num(msg.duration_ms), + timeToFirstTokenMs: null, + cost: typeof msg.total_cost_usd === "number" ? msg.total_cost_usd : null, + apiEndpoint: null, + initiator: "claude", + }; +} const VALID_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); const VALID_THINKING_TYPES = new Set(["adaptive", "enabled", "disabled"]); const VALID_OUTPUT_FORMAT_TYPES = new Set(["json_schema"]); @@ -256,6 +285,7 @@ export class ClaudeExecutor implements AgentExecutor { this.sessionManager!.trackExecution(taskId, contextId, abortController); const turnFn = async (): Promise => { + const accumulator = new LlmUsageAccumulator(); let timedOut = false; // A prompt timeout of 0 (or any value <= 0) disables the bound entirely: // the turn runs until the SDK iterator completes. Without this guard @@ -358,7 +388,17 @@ export class ClaudeExecutor implements AgentExecutor { /** Single definition of the successful ending, mirroring endTurnRateLimited. */ const endTurnCompleted = (): void => { - publishStatus(bus, taskId, contextId, "completed", undefined, true); + const usageSummary = accumulator.summary(); + applyUsageSummaryToActiveSpan(usageSummary); + publishStatus( + bus, + taskId, + contextId, + "completed", + undefined, + true, + { "x-usage": usageSummary }, + ); terminalPublished = true; bus.finished(); }; @@ -440,6 +480,9 @@ export class ClaudeExecutor implements AgentExecutor { } lastResult = msg; + if (msg.subtype === "success") { + accumulator.record(claudeUsageToCallRecord(msg, this.config.claude.model)); + } const holding = holdEnabled && resultError === null && backgroundTasks.size > 0; mapper.handleResult(msg, { held: holding }); diff --git a/a2a-codex/src/codex/executor.ts b/a2a-codex/src/codex/executor.ts index cd344d1..c7c0d66 100644 --- a/a2a-codex/src/codex/executor.ts +++ b/a2a-codex/src/codex/executor.ts @@ -32,17 +32,41 @@ import { publishStreamingChunk, publishLastChunkMarker, createExecutionObservability, + LlmUsageAccumulator, + applyUsageSummaryToActiveSpan, } from "@a2a-wrapper/core"; import type { EventTransport, EventTransportFn, SynthesizedMcpDescriptor, + UsageCallRecord, } from "@a2a-wrapper/core"; import { logger } from "../utils/logger.js"; const log = logger.child("executor"); +/** Map Codex turn.completed usage → core UsageCallRecord. */ +function codexUsageToCallRecord( + usage: Record, + modelFallback: string | undefined, +): UsageCallRecord { + const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + return { + model: (typeof usage.model === "string" ? usage.model : undefined) || modelFallback || "", + inputTokens: num(usage.input_tokens), + outputTokens: num(usage.output_tokens), + cacheReadTokens: num(usage.cached_input_tokens), + cacheWriteTokens: 0, + reasoningTokens: num(usage.reasoning_output_tokens), + durationMs: 0, + timeToFirstTokenMs: null, + cost: null, + apiEndpoint: null, + initiator: "codex", + }; +} + export class CodexExecutor implements AgentExecutor { private readonly config: Required; private client: CodexClientLike | null = null; @@ -163,6 +187,7 @@ export class CodexExecutor implements AgentExecutor { this.sessionManager!.trackExecution(taskId, contextId, abortController); const turnFn = async (): Promise => { + const accumulator = new LlmUsageAccumulator(); try { publishStatus(bus, taskId, contextId, "working", "Processing request..."); @@ -209,6 +234,15 @@ export class CodexExecutor implements AgentExecutor { } } + if (event.type === "turn.completed" && event.usage) { + accumulator.record( + codexUsageToCallRecord( + event.usage as Record, + this.config.codex.model, + ), + ); + } + mapper.handleEvent(event); } @@ -219,7 +253,17 @@ export class CodexExecutor implements AgentExecutor { publishFinalArtifact(bus, taskId, contextId, finalText); } - publishStatus(bus, taskId, contextId, "completed", undefined, true); + const usageSummary = accumulator.summary(); + applyUsageSummaryToActiveSpan(usageSummary); + publishStatus( + bus, + taskId, + contextId, + "completed", + undefined, + true, + { "x-usage": usageSummary }, + ); bus.finished(); } catch (err) { diff --git a/docs/observability.md b/docs/observability.md index 4785ad4..0dea7b6 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -162,6 +162,8 @@ Notes: **Ownership reminder:** wrapper owns A2A/protocol + correlation IDs. Backend owns per-call `gen_ai.usage.*` on LLM spans when its OTel is on. Do not treat the wrapper as the universal owner of GenAI token attrs ([#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) is still drafting A2A shape). +Full matrix + gateway metadata mapping: [otel-attribute-ownership.md](./otel-attribute-ownership.md). + ## Usage / tokens — do not double-count Backends (Copilot, Claude, Codex, …) already report tokens via SDK events. We keep that on the **A2A sideband** (`trace.usage`, `metadata["x-usage"]`) always — that is not OTLP. @@ -178,7 +180,7 @@ Rules: 1. **Never** open a second LLM span just to carry usage. 2. **Never** emit wrapper token **metrics** that sum with backend GenAI metrics (Phase 2 does not add counters). 3. Dashboards: either sum `gen_ai.usage.*` on backend LLM spans **or** read `a2a.task.usage.*` on `a2a.task.execute` — not both. -4. Copilot / Antigravity call `applyUsageSummaryToActiveSpan` today; Claude/Codex can adopt the same helper when they accumulate with `LlmUsageAccumulator`. +4. Copilot, Antigravity, Claude, and Codex call `applyUsageSummaryToActiveSpan` on completed turns (per-request rollup). ## Programmatic API diff --git a/docs/otel-attribute-ownership.md b/docs/otel-attribute-ownership.md new file mode 100644 index 0000000..a4c8856 --- /dev/null +++ b/docs/otel-attribute-ownership.md @@ -0,0 +1,99 @@ +# OpenTelemetry attribute ownership (A2A wrappers) + +Status: **implemented baseline** (Phases 1–5) +Spec tracking: [semantic-conventions-genai#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) (A2A semconv — **open / draft**), [#243](https://github.com/open-telemetry/semantic-conventions-genai/issues/243) (multi-agent identity). + +This is the **who owns what** guide for `@a2a-wrapper/core` and the `a2a-*` wrappers. It is intentionally thinner than research dumps: only attributes we emit or deliberately refuse to emit. + +## Mental model + +| Unit | Meaning | OTel join key | +|---|---|---| +| **Request / turn** | One inbound A2A `execute` | Usually one `trace_id`; one `a2a.task.execute` span | +| **Conversation** | Chatbot / orchestrator thread | `gen_ai.conversation.id` (+ optional `session.id`) | +| **Task** | A2A task (may resume after `input-required`) | `a2a.task.id` | +| **Message** | One A2A user message | `a2a.message.id` | + +~99%: **one request → one wrapper span**. Resume/retry = **new span**, same conversation/task ids (optional span link). Platforms (Langfuse, Datadog, Phoenix) group by conversation/session attributes — not by assuming one eternal parent span. See [observability.md](./observability.md). + +## Ownership legend + +| Owner | Responsibility | +|---|---| +| **W** | Wrapper / `@a2a-wrapper/core` | +| **BE** | Backend runtime (Copilot CLI, Claude Code, Codex, …) when Hook F enables its OTel | +| **SB** | A2A sideband only (`trace.*`, `metadata["x-usage"]`) — not OTLP | +| **—** | Do not emit | + +## Correlation IDs (always wrapper) + +| Attribute | Owner | Source | Notes | +|---|---|---|---| +| `gen_ai.conversation.id` | **W** | Metadata `conversation_id` / `conversationId`, else A2A `contextId` | Langfuse/Datadog/Phoenix session grouping ([#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) + GenAI registry) | +| `session.id` | **W** | Metadata `session_id` / `sessionId`, else same as conversation | Phoenix / generic session attribute; broader than a single LLM thread when provided | +| `a2a.context.id` | **W** | A2A `contextId` | Always the protocol context id | +| `a2a.task.id` | **W** | A2A `taskId` | Draft [#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) | +| `a2a.message.id` | **W** | Inbound `userMessage.messageId` | Draft #254 | +| `a2a.task.invocation` / `a2a.task.invocation_kind` | **W** | In-process execute count (`new` / `continue` / `retry`) | Wrapper extension (not in #254 yet) | +| `a2a.gateway.*` | **W** | Metadata `passkey`, `spectrum_id` / `spectrumId`, etc. | Gateway business keys — **attrs only**, never become `trace_id` | +| `a2a.parent_agent.id` | **W** | Metadata `parent_agent_id` | Acting/target identity still evolving in [#243](https://github.com/open-telemetry/semantic-conventions-genai/issues/243) | +| `a2a.orchestrator.trace_id` | **W** | Metadata `trace_id` | Orchestrator-supplied id (may differ from W3C `trace_id`) | + +## Protocol / operation (wrapper) + +| Attribute | Owner | Value today | +|---|---|---| +| Span name | **W** | `a2a.task.execute` / `a2a.task.cancel` | +| `gen_ai.operation.name` | **W** | `invoke_agent` (aligned with #254 “existing shared”) | +| `a2a.method.name` | **W** | `message/send` for executor path (draft #254) | +| `a2a.protocol.version` | **W** | Server protocol version option | +| `a2a.wrapper.name` / `.version` | **W** | Package identity | +| `gen_ai.agent.name` / `gen_ai.agent.id` | **W** | From `agentCard.name` — interim until #243 settles acting vs target | +| `a2a.agent.name` / `a2a.agent.id` | **W** | Same (A2A-local mirror) | + +## Usage / tokens + +| Signal | Owner when backend OTel **on** | Owner when backend OTel **off** | +|---|---|---| +| Per-call `gen_ai.usage.*` on **LLM spans** | **BE** | — | +| `gen_ai.usage.*` on **wrapper task span** | **—** (de-dupe) | **W** (fallback sole source) | +| `a2a.task.usage.*` on task span | **W** (per-**request** rollup) | **W** | +| Sideband `trace.usage` / `x-usage` | **SB** always | **SB** always | +| Token **metrics** from wrapper | **—** | **—** | + +Never sum backend `gen_ai.usage.*` **and** wrapper task `gen_ai.usage.*` in the same dashboard query. + +## Tools / LLM child spans + +| Signal | Backend OTel on | Backend OTel off + `mirrorAgentEvents` | +|---|---|---| +| Tool / LLM child spans | **BE** only | **W** may emit `a2a.mcp.tool` from AgentEvents | +| Sideband `trace.mcp` / tool events | **SB** always | **SB** always | + +## Gateway → span mapping (no extra spans) + +Orchestrators / API gateways should put stable ids on A2A request **metadata** (or message metadata). The wrapper copies them onto `a2a.task.execute`: + +| Gateway concept | Metadata keys accepted | Span attribute | +|---|---|---| +| Conversation / thread | `conversation_id`, `conversationId` | `gen_ai.conversation.id` | +| UX session | `session_id`, `sessionId` | `session.id` | +| Passkey / spectrum / ticket | `passkey`, `spectrum_id`, `spectrumId`, `ticket_id`, `ticketId` | `a2a.gateway.passkey` / `a2a.gateway.spectrum_id` / `a2a.gateway.ticket_id` | +| Orchestrator trace | `trace_id`, `traceId` | `a2a.orchestrator.trace_id` | +| Parent agent | `parent_agent_id`, `parentAgentId` | `a2a.parent_agent.id` | + +Do **not** invent a span per gateway hop. Pass ids; parent-link only within the live request (`traceparent`). + +## Spec drift policy + +Until #254 / #243 merge: + +1. Prefer draft `a2a.*` names from #254 when we already have the data. +2. Keep interim `gen_ai.agent.*` on the **acting** (local) agent; do not invent a parallel target-agent schema yet. +3. Avoid high-cardinality ids on **metrics** (`a2a.task.id`, `a2a.message.id` must stay on spans only — #254). +4. When the official YAML lands, map/rename in a dedicated semconv bump — do not block product wiring. + +## Related + +- Operator guide: [observability.md](./observability.md) +- Demo stack: [examples/otel-stack](../examples/otel-stack) diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index 60a3a6c..b5cd1bb 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -136,11 +136,33 @@ describe("extractA2ATraceContext", () => { const t = extractA2ATraceContext(ctx); expect(t.traceId).toBe("orch-1"); expect(t.parentAgentId).toBe("parent-a"); + expect(t.conversationId).toBe("ctx-1"); }); it("falls back to contextId", () => { const ctx = { taskId: "t1", contextId: "ctx-99", metadata: {} } as unknown as RequestContext; expect(extractA2ATraceContext(ctx).traceId).toBe("ctx-99"); + expect(extractA2ATraceContext(ctx).conversationId).toBe("ctx-99"); + }); + + it("maps gateway conversation / session / passkey / spectrum ids", () => { + const ctx = { + taskId: "t1", + contextId: "ctx-1", + metadata: { + conversation_id: "conv-gateway", + session_id: "sess-ux", + passkey: "pk-9", + spectrum_id: "sp-1", + ticket_id: "tkt-2", + }, + } as unknown as RequestContext; + const t = extractA2ATraceContext(ctx); + expect(t.conversationId).toBe("conv-gateway"); + expect(t.sessionId).toBe("sess-ux"); + expect(t.gateway.passkey).toBe("pk-9"); + expect(t.gateway.spectrumId).toBe("sp-1"); + expect(t.gateway.ticketId).toBe("tkt-2"); }); }); diff --git a/packages/core/src/telemetry/attributes.ts b/packages/core/src/telemetry/attributes.ts index ed70112..95928be 100644 --- a/packages/core/src/telemetry/attributes.ts +++ b/packages/core/src/telemetry/attributes.ts @@ -34,13 +34,25 @@ export function buildTaskSpanAttributes(opts: { protocolVersion?: string; parentAgentId?: string | null; orchestratorTraceId?: string; + /** Override for gen_ai.conversation.id (gateway conversation); defaults to contextId. */ + conversationId?: string | null; + /** Phoenix / generic session.id when distinct from conversation. */ + sessionId?: string | null; /** A2A inbound message id when present. */ messageId?: string | null; + /** Draft #254 method name; default message/send for executor path. */ + methodName?: string | null; /** 1-based count of wrapper execute() calls for this taskId in-process. */ invocation?: number; invocationKind?: TaskInvocationKind; + gateway?: { + passkey?: string; + spectrumId?: string; + ticketId?: string; + }; }): Record { const agentId = opts.agentId ?? slugAgentId(opts.agentName); + const conversationId = opts.conversationId || opts.contextId; const attrs: Record = { "a2a.task.id": opts.taskId, "a2a.context.id": opts.contextId, @@ -50,8 +62,13 @@ export function buildTaskSpanAttributes(opts: { "gen_ai.agent.id": agentId, // Platforms (Langfuse/Datadog/Phoenix) group multi-turn work by this id — // not by assuming one eternal parent span. - "gen_ai.conversation.id": opts.contextId, + "gen_ai.conversation.id": conversationId, + // Draft semantic-conventions-genai#254 shared GenAI op for A2A invoke. + "gen_ai.operation.name": "invoke_agent", + "a2a.method.name": opts.methodName || "message/send", }; + const sessionId = opts.sessionId || conversationId; + attrs["session.id"] = sessionId; if (opts.wrapperName) attrs["a2a.wrapper.name"] = opts.wrapperName; if (opts.wrapperVersion) attrs["a2a.wrapper.version"] = opts.wrapperVersion; if (opts.protocolVersion) attrs["a2a.protocol.version"] = opts.protocolVersion; @@ -60,6 +77,9 @@ export function buildTaskSpanAttributes(opts: { if (opts.messageId) attrs["a2a.message.id"] = opts.messageId; if (opts.invocation !== undefined) attrs["a2a.task.invocation"] = opts.invocation; if (opts.invocationKind) attrs["a2a.task.invocation_kind"] = opts.invocationKind; + if (opts.gateway?.passkey) attrs["a2a.gateway.passkey"] = opts.gateway.passkey; + if (opts.gateway?.spectrumId) attrs["a2a.gateway.spectrum_id"] = opts.gateway.spectrumId; + if (opts.gateway?.ticketId) attrs["a2a.gateway.ticket_id"] = opts.gateway.ticketId; return attrs; } diff --git a/packages/core/src/telemetry/context.ts b/packages/core/src/telemetry/context.ts index 749d090..998a77c 100644 --- a/packages/core/src/telemetry/context.ts +++ b/packages/core/src/telemetry/context.ts @@ -16,6 +16,19 @@ export interface A2ATraceContext { traceId: string; parentAgentId: string | null; metadata: Record; + /** + * Stable conversation / thread id for platform session grouping. + * From metadata when provided; else A2A `contextId`. + */ + conversationId: string; + /** Broader UX session id when the gateway distinguishes it from conversation. */ + sessionId?: string; + /** Gateway business keys (passkey, spectrum, ticket, …) — correlation only. */ + gateway: { + passkey?: string; + spectrumId?: string; + ticketId?: string; + }; } export interface TaskOtelStore { @@ -39,32 +52,65 @@ export function runWithTaskOtelStore(store: TaskOtelStore, fn: () => Promise< return als.run(store, fn); } +function pickString(...candidates: unknown[]): string | undefined { + for (const c of candidates) { + if (typeof c === "string" && c.length > 0) return c; + } + return undefined; +} + +function collectMetadata(ctx: RequestContext): Record { + const raw = ctx as unknown as Record; + const fromRequest = (raw.request as { params?: { metadata?: Record } } | undefined) + ?.params?.metadata; + const fromMessage = (ctx.userMessage as { metadata?: Record } | undefined)?.metadata; + const fromCtx = raw.metadata as Record | undefined; + const fromTask = (raw.task as Record | undefined)?.metadata as + | Record + | undefined; + const fromTaskConfig = (raw.task as Record | undefined)?.configuration as + | Record + | undefined; + return { + ...(fromTaskConfig ?? {}), + ...(fromTask ?? {}), + ...(fromCtx ?? {}), + ...(fromRequest ?? {}), + ...(fromMessage ?? {}), + }; +} + /** - * Extract orchestrator-propagated trace metadata from an A2A RequestContext. + * Extract orchestrator / gateway correlation ids from an A2A RequestContext. * Shared replacement for the Copilot/OpenCode private helpers. + * + * @see docs/otel-attribute-ownership.md */ export function extractA2ATraceContext(ctx: RequestContext): A2ATraceContext { - const raw = ctx as unknown as Record; - const meta = - (raw.metadata as Record) || - ((raw.task as Record)?.metadata as Record) || - ((raw.task as Record)?.configuration as Record) || - {}; + const meta = collectMetadata(ctx); + const conversationId = + pickString(meta.conversation_id, meta.conversationId, meta["gen_ai.conversation.id"]) || + ctx.contextId || + uuidv4(); + const sessionId = pickString(meta.session_id, meta.sessionId, meta["session.id"]); return { traceId: - (meta.trace_id as string) || - (meta.traceId as string) || + pickString(meta.trace_id, meta.traceId) || ctx.contextId || uuidv4(), - parentAgentId: - (meta.parent_agent_id as string) || - (meta.parentAgentId as string) || - null, + parentAgentId: pickString(meta.parent_agent_id, meta.parentAgentId) ?? null, metadata: (meta.propagated_metadata as Record) || (meta.propagatedMetadata as Record) || {}, + conversationId, + sessionId, + gateway: { + passkey: pickString(meta.passkey, meta.pass_key, meta.passKey), + spectrumId: pickString(meta.spectrum_id, meta.spectrumId), + ticketId: pickString(meta.ticket_id, meta.ticketId), + }, }; } diff --git a/packages/core/src/telemetry/instrument.ts b/packages/core/src/telemetry/instrument.ts index 75a0328..8ee7009 100644 --- a/packages/core/src/telemetry/instrument.ts +++ b/packages/core/src/telemetry/instrument.ts @@ -128,6 +128,9 @@ export function instrumentExecutor( protocolVersion: options.protocolVersion, parentAgentId: trace.parentAgentId, orchestratorTraceId: trace.traceId, + conversationId: trace.conversationId, + sessionId: trace.sessionId, + gateway: trace.gateway, messageId, invocation, invocationKind, From f13227a050d65f895e91727e3ab7f987937c216d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:10:34 +0000 Subject: [PATCH 07/12] feat(otel): Phoenix collector demo, package identity, usage flags Drop the Jaeger-required compose stack for a lightweight Arize Phoenix demo (pip/uv; optional Docker). CLI bootstrap defaults to OTLP protobuf (`exporter-trace-otlp-proto`) so Phoenix ingest works. Stamp a2a.wrapper.core.version plus a2a.wrapper.sdk / .sdk.version on task spans and wire every wrapper server with its package identity. Add otel.taskUsageRollup (default on) and otel.annotateUsageCalls (default off; Claude/Codex call applyUsageCallToActiveSpan). Verified live against phoenix serve in this environment. Co-authored-by: Shashi Kanth G S --- .changeset/otel-phoenix-identity.md | 17 +++ README.md | 2 +- .../schemas/agent-config.schema.json | 14 +- a2a-antigravity/src/server/index.ts | 6 + a2a-claude/schemas/agent-config.schema.json | 14 +- a2a-claude/src/claude/executor.ts | 6 +- a2a-claude/src/server/index.ts | 6 + a2a-codex/schemas/agent-config.schema.json | 14 +- a2a-codex/src/codex/executor.ts | 12 +- a2a-codex/src/server/index.ts | 6 + a2a-copilot/schemas/agent-config.schema.json | 14 +- a2a-copilot/src/server/index.ts | 6 + a2a-opencode/schemas/agent-config.schema.json | 14 +- a2a-opencode/src/server/index.ts | 6 + docs/observability.md | 46 +++++-- docs/otel-attribute-ownership.md | 6 +- examples/otel-stack/README.md | 60 +++++--- examples/otel-stack/agent-otel.snippet.json | 4 +- examples/otel-stack/docker-compose.yml | 30 ++-- examples/otel-stack/smoke-export.mjs | 84 +++++++++++ packages/core/README.md | 2 +- packages/core/package.json | 4 + .../core/src/__tests__/telemetry/otel.test.ts | 130 +++++++++++++++++- packages/core/src/index.ts | 2 + packages/core/src/telemetry/attributes.ts | 28 +++- packages/core/src/telemetry/cli-bootstrap.ts | 58 ++++++-- packages/core/src/telemetry/index.ts | 2 + packages/core/src/telemetry/instrument.ts | 12 +- packages/core/src/telemetry/package-meta.ts | 46 +++++++ packages/core/src/telemetry/types.ts | 40 +++++- packages/core/src/telemetry/usage-bridge.ts | 3 +- 31 files changed, 612 insertions(+), 82 deletions(-) create mode 100644 .changeset/otel-phoenix-identity.md create mode 100644 examples/otel-stack/smoke-export.mjs create mode 100644 packages/core/src/telemetry/package-meta.ts diff --git a/.changeset/otel-phoenix-identity.md b/.changeset/otel-phoenix-identity.md new file mode 100644 index 0000000..8261954 --- /dev/null +++ b/.changeset/otel-phoenix-identity.md @@ -0,0 +1,17 @@ +--- +"@a2a-wrapper/core": minor +"a2a-claude": minor +"a2a-codex": minor +"a2a-copilot": minor +"a2a-opencode": minor +"a2a-antigravity": minor +--- + +feat(otel): Phoenix demo, package identity attrs, usage flags + +- Replace Jaeger compose requirement with Arize Phoenix (`phoenix serve`, optional Docker) +- CLI bootstrap defaults to `http/protobuf` via `@opentelemetry/exporter-trace-otlp-proto` (Phoenix rejects JSON) +- Stamp `a2a.wrapper.core.version`, `a2a.wrapper.sdk`, `a2a.wrapper.sdk.version` on task spans +- Wire all wrapper servers with package name/version +- `otel.taskUsageRollup` (default true) + `otel.annotateUsageCalls` (default false; Claude/Codex per-call events) +- Smoke script: `examples/otel-stack/smoke-export.mjs` diff --git a/README.md b/README.md index 6745c26..9a739ba 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Wrappers run coding agents behind an HTTP endpoint. Read the [Security Guide](do ## Roadmap -- **OpenTelemetry observability** — optional distributed tracing (`a2a.task.execute` + backend passthrough). Optional peers for `@opentelemetry/api` / SDK; CLI auto-bootstraps when `otel.exporter.endpoint` is set. Demo stack: [`examples/otel-stack`](examples/otel-stack). Guides: [Observability](docs/observability.md), [attribute ownership](docs/otel-attribute-ownership.md). +- **OpenTelemetry observability** — optional distributed tracing (`a2a.task.execute` + backend passthrough). Optional peers for `@opentelemetry/api` / SDK; CLI auto-bootstraps when `otel.exporter.endpoint` is set. Lightweight Phoenix demo (no Docker): [`examples/otel-stack`](examples/otel-stack). Guides: [Observability](docs/observability.md), [attribute ownership](docs/otel-attribute-ownership.md). ## Contributing diff --git a/a2a-antigravity/schemas/agent-config.schema.json b/a2a-antigravity/schemas/agent-config.schema.json index e61b97b..0e1cd55 100644 --- a/a2a-antigravity/schemas/agent-config.schema.json +++ b/a2a-antigravity/schemas/agent-config.schema.json @@ -546,6 +546,11 @@ "additionalProperties": false, "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", "properties": { + "annotateUsageCalls": { + "default": false, + "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", + "type": "boolean" + }, "backend": { "additionalProperties": false, "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", @@ -634,11 +639,11 @@ "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", "properties": { "endpoint": { - "description": "e.g. `http://127.0.0.1:4318`", + "description": "e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318`", "type": "string" }, "protocol": { - "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "description": "OTLP encoding for CLI bootstrap.\n- `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` (Phoenix accepts only protobuf over HTTP)\n- `http/json` — `@opentelemetry/exporter-trace-otlp-http`", "type": "string" } }, @@ -654,6 +659,11 @@ "description": "Service name resource attribute for wrapper spans.", "type": "string" }, + "taskUsageRollup": { + "default": true, + "description": "Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` when the wrapper is the sole OTel source) via {@link applyUsageSummaryToActiveSpan } .\n\nDefault `true` when `enabled`. Sideband `x-usage` is unaffected. Set `false` only to debug de-dupe / cardinality — rarely needed because the whole bridge is already a no-op when `enabled` is false.", + "type": "boolean" + }, "tracerName": { "default": "a2a-wrapper", "description": "Instrumentation scope name passed to `getTracer`.", diff --git a/a2a-antigravity/src/server/index.ts b/a2a-antigravity/src/server/index.ts index bdbf9fa..3fc015a 100644 --- a/a2a-antigravity/src/server/index.ts +++ b/a2a-antigravity/src/server/index.ts @@ -7,6 +7,7 @@ * antigravity-specific executor and startup banner. */ +import { createRequire } from "node:module"; import { createA2AServer as coreCreateA2AServer } from "@a2a-wrapper/core"; import type { ServerHandle as CoreServerHandle } from "@a2a-wrapper/core"; @@ -15,6 +16,9 @@ import { AntigravityExecutor } from "../antigravity/executor.js"; import { logger } from "../utils/logger.js"; const log = logger.child("server"); +const { name: WRAPPER_SDK, version: WRAPPER_SDK_VERSION } = createRequire(import.meta.url)( + "../../package.json", +) as { name: string; version: string }; export interface ServerHandle extends Omit { executor: AntigravityExecutor; @@ -23,6 +27,8 @@ export interface ServerHandle extends Omit { export async function createA2AServer(config: Required): Promise { const handle = await coreCreateA2AServer>(config, (cfg) => new AntigravityExecutor(cfg), { protocolVersion: "1.0", + wrapperName: WRAPPER_SDK, + wrapperVersion: WRAPPER_SDK_VERSION, onListening: ({ port, hostname, advertiseHost, advertiseProtocol }) => { log.info("A2A server started", { bind: hostname, diff --git a/a2a-claude/schemas/agent-config.schema.json b/a2a-claude/schemas/agent-config.schema.json index b5e586e..5a7352a 100644 --- a/a2a-claude/schemas/agent-config.schema.json +++ b/a2a-claude/schemas/agent-config.schema.json @@ -640,6 +640,11 @@ "additionalProperties": false, "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", "properties": { + "annotateUsageCalls": { + "default": false, + "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", + "type": "boolean" + }, "backend": { "additionalProperties": false, "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", @@ -728,11 +733,11 @@ "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", "properties": { "endpoint": { - "description": "e.g. `http://127.0.0.1:4318`", + "description": "e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318`", "type": "string" }, "protocol": { - "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "description": "OTLP encoding for CLI bootstrap.\n- `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` (Phoenix accepts only protobuf over HTTP)\n- `http/json` — `@opentelemetry/exporter-trace-otlp-http`", "type": "string" } }, @@ -748,6 +753,11 @@ "description": "Service name resource attribute for wrapper spans.", "type": "string" }, + "taskUsageRollup": { + "default": true, + "description": "Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` when the wrapper is the sole OTel source) via {@link applyUsageSummaryToActiveSpan } .\n\nDefault `true` when `enabled`. Sideband `x-usage` is unaffected. Set `false` only to debug de-dupe / cardinality — rarely needed because the whole bridge is already a no-op when `enabled` is false.", + "type": "boolean" + }, "tracerName": { "default": "a2a-wrapper", "description": "Instrumentation scope name passed to `getTracer`.", diff --git a/a2a-claude/src/claude/executor.ts b/a2a-claude/src/claude/executor.ts index 8c52140..1cdeaf9 100644 --- a/a2a-claude/src/claude/executor.ts +++ b/a2a-claude/src/claude/executor.ts @@ -42,6 +42,7 @@ import { createExecutionObservability, LlmUsageAccumulator, applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -481,7 +482,10 @@ export class ClaudeExecutor implements AgentExecutor { lastResult = msg; if (msg.subtype === "success") { - accumulator.record(claudeUsageToCallRecord(msg, this.config.claude.model)); + const call = claudeUsageToCallRecord(msg, this.config.claude.model); + accumulator.record(call); + // Optional: otel.annotateUsageCalls — per-call span events (noisy; off by default). + applyUsageCallToActiveSpan(call); } const holding = holdEnabled && resultError === null && backgroundTasks.size > 0; mapper.handleResult(msg, { held: holding }); diff --git a/a2a-claude/src/server/index.ts b/a2a-claude/src/server/index.ts index d25f447..ac22a4a 100644 --- a/a2a-claude/src/server/index.ts +++ b/a2a-claude/src/server/index.ts @@ -7,6 +7,7 @@ * claude-specific executor, the `/context` routes, and the startup banner. */ +import { createRequire } from "node:module"; import express from "express"; import { createA2AServer as coreCreateA2AServer } from "@a2a-wrapper/core"; import type { ServerHandle as CoreServerHandle } from "@a2a-wrapper/core"; @@ -16,6 +17,9 @@ import { ClaudeExecutor } from "../claude/executor.js"; import { logger } from "../utils/logger.js"; const log = logger.child("server"); +const { name: WRAPPER_SDK, version: WRAPPER_SDK_VERSION } = createRequire(import.meta.url)( + "../../package.json", +) as { name: string; version: string }; export interface ServerHandle extends Omit { executor: ClaudeExecutor; @@ -24,6 +28,8 @@ export interface ServerHandle extends Omit { export async function createA2AServer(config: Required): Promise { const handle = await coreCreateA2AServer>(config, (cfg) => new ClaudeExecutor(cfg), { protocolVersion: "1.0", + wrapperName: WRAPPER_SDK, + wrapperVersion: WRAPPER_SDK_VERSION, registerRoutes: (app, executor) => { const claudeExecutor = executor as ClaudeExecutor; diff --git a/a2a-codex/schemas/agent-config.schema.json b/a2a-codex/schemas/agent-config.schema.json index de19cdc..11d4fce 100644 --- a/a2a-codex/schemas/agent-config.schema.json +++ b/a2a-codex/schemas/agent-config.schema.json @@ -466,6 +466,11 @@ "additionalProperties": false, "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", "properties": { + "annotateUsageCalls": { + "default": false, + "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", + "type": "boolean" + }, "backend": { "additionalProperties": false, "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", @@ -554,11 +559,11 @@ "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", "properties": { "endpoint": { - "description": "e.g. `http://127.0.0.1:4318`", + "description": "e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318`", "type": "string" }, "protocol": { - "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "description": "OTLP encoding for CLI bootstrap.\n- `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` (Phoenix accepts only protobuf over HTTP)\n- `http/json` — `@opentelemetry/exporter-trace-otlp-http`", "type": "string" } }, @@ -574,6 +579,11 @@ "description": "Service name resource attribute for wrapper spans.", "type": "string" }, + "taskUsageRollup": { + "default": true, + "description": "Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` when the wrapper is the sole OTel source) via {@link applyUsageSummaryToActiveSpan } .\n\nDefault `true` when `enabled`. Sideband `x-usage` is unaffected. Set `false` only to debug de-dupe / cardinality — rarely needed because the whole bridge is already a no-op when `enabled` is false.", + "type": "boolean" + }, "tracerName": { "default": "a2a-wrapper", "description": "Instrumentation scope name passed to `getTracer`.", diff --git a/a2a-codex/src/codex/executor.ts b/a2a-codex/src/codex/executor.ts index c7c0d66..708b12b 100644 --- a/a2a-codex/src/codex/executor.ts +++ b/a2a-codex/src/codex/executor.ts @@ -34,6 +34,7 @@ import { createExecutionObservability, LlmUsageAccumulator, applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -235,12 +236,13 @@ export class CodexExecutor implements AgentExecutor { } if (event.type === "turn.completed" && event.usage) { - accumulator.record( - codexUsageToCallRecord( - event.usage as Record, - this.config.codex.model, - ), + const call = codexUsageToCallRecord( + event.usage as Record, + this.config.codex.model, ); + accumulator.record(call); + // Optional: otel.annotateUsageCalls — per-call span events (noisy; off by default). + applyUsageCallToActiveSpan(call); } mapper.handleEvent(event); diff --git a/a2a-codex/src/server/index.ts b/a2a-codex/src/server/index.ts index dbb8992..9fc7e4d 100644 --- a/a2a-codex/src/server/index.ts +++ b/a2a-codex/src/server/index.ts @@ -7,6 +7,7 @@ * codex-specific executor, the `/context` routes, and the startup banner. */ +import { createRequire } from "node:module"; import express from "express"; import { createA2AServer as coreCreateA2AServer } from "@a2a-wrapper/core"; import type { ServerHandle as CoreServerHandle } from "@a2a-wrapper/core"; @@ -16,6 +17,9 @@ import { CodexExecutor } from "../codex/executor.js"; import { logger } from "../utils/logger.js"; const log = logger.child("server"); +const { name: WRAPPER_SDK, version: WRAPPER_SDK_VERSION } = createRequire(import.meta.url)( + "../../package.json", +) as { name: string; version: string }; export interface ServerHandle extends Omit { executor: CodexExecutor; @@ -24,6 +28,8 @@ export interface ServerHandle extends Omit { export async function createA2AServer(config: Required): Promise { const handle = await coreCreateA2AServer>(config, (cfg) => new CodexExecutor(cfg), { protocolVersion: "1.0", + wrapperName: WRAPPER_SDK, + wrapperVersion: WRAPPER_SDK_VERSION, registerRoutes: (app, executor) => { const codexExecutor = executor as CodexExecutor; diff --git a/a2a-copilot/schemas/agent-config.schema.json b/a2a-copilot/schemas/agent-config.schema.json index 411e9a1..c0b5385 100644 --- a/a2a-copilot/schemas/agent-config.schema.json +++ b/a2a-copilot/schemas/agent-config.schema.json @@ -472,6 +472,11 @@ "additionalProperties": false, "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", "properties": { + "annotateUsageCalls": { + "default": false, + "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", + "type": "boolean" + }, "backend": { "additionalProperties": false, "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", @@ -560,11 +565,11 @@ "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", "properties": { "endpoint": { - "description": "e.g. `http://127.0.0.1:4318`", + "description": "e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318`", "type": "string" }, "protocol": { - "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "description": "OTLP encoding for CLI bootstrap.\n- `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` (Phoenix accepts only protobuf over HTTP)\n- `http/json` — `@opentelemetry/exporter-trace-otlp-http`", "type": "string" } }, @@ -580,6 +585,11 @@ "description": "Service name resource attribute for wrapper spans.", "type": "string" }, + "taskUsageRollup": { + "default": true, + "description": "Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` when the wrapper is the sole OTel source) via {@link applyUsageSummaryToActiveSpan } .\n\nDefault `true` when `enabled`. Sideband `x-usage` is unaffected. Set `false` only to debug de-dupe / cardinality — rarely needed because the whole bridge is already a no-op when `enabled` is false.", + "type": "boolean" + }, "tracerName": { "default": "a2a-wrapper", "description": "Instrumentation scope name passed to `getTracer`.", diff --git a/a2a-copilot/src/server/index.ts b/a2a-copilot/src/server/index.ts index 9f5af54..a47904f 100644 --- a/a2a-copilot/src/server/index.ts +++ b/a2a-copilot/src/server/index.ts @@ -7,6 +7,7 @@ * copilot-specific executor, the `/context` routes, and the startup banner. */ +import { createRequire } from "node:module"; import express from "express"; import { createA2AServer as coreCreateA2AServer, buildAgentCard } from "@a2a-wrapper/core"; import type { ServerHandle as CoreServerHandle } from "@a2a-wrapper/core"; @@ -16,6 +17,9 @@ import { CopilotExecutor } from "../copilot/executor.js"; import { logger } from "../utils/logger.js"; const log = logger.child("server"); +const { name: WRAPPER_SDK, version: WRAPPER_SDK_VERSION } = createRequire(import.meta.url)( + "../../package.json", +) as { name: string; version: string }; export interface ServerHandle extends Omit { executor: CopilotExecutor; @@ -28,6 +32,8 @@ export interface ServerHandle extends Omit { export async function createA2AServer(config: Required): Promise { const handle = await coreCreateA2AServer>(config, (cfg) => new CopilotExecutor(cfg), { protocolVersion: "1.0", + wrapperName: WRAPPER_SDK, + wrapperVersion: WRAPPER_SDK_VERSION, registerRoutes: (app, executor) => { const copilotExecutor = executor as CopilotExecutor; diff --git a/a2a-opencode/schemas/agent-config.schema.json b/a2a-opencode/schemas/agent-config.schema.json index fe961f8..4a1bd56 100644 --- a/a2a-opencode/schemas/agent-config.schema.json +++ b/a2a-opencode/schemas/agent-config.schema.json @@ -435,6 +435,11 @@ "additionalProperties": false, "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", "properties": { + "annotateUsageCalls": { + "default": false, + "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", + "type": "boolean" + }, "backend": { "additionalProperties": false, "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", @@ -523,11 +528,11 @@ "description": "Shared OTLP exporter hint for wrapper hosts and backend passthrough. Backends inherit `endpoint` / `protocol` when their own fields are omitted.", "properties": { "endpoint": { - "description": "e.g. `http://127.0.0.1:4318`", + "description": "e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318`", "type": "string" }, "protocol": { - "description": "e.g. `http/protobuf` (preferred with Copilot) or `grpc`", + "description": "OTLP encoding for CLI bootstrap.\n- `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` (Phoenix accepts only protobuf over HTTP)\n- `http/json` — `@opentelemetry/exporter-trace-otlp-http`", "type": "string" } }, @@ -543,6 +548,11 @@ "description": "Service name resource attribute for wrapper spans.", "type": "string" }, + "taskUsageRollup": { + "default": true, + "description": "Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` when the wrapper is the sole OTel source) via {@link applyUsageSummaryToActiveSpan } .\n\nDefault `true` when `enabled`. Sideband `x-usage` is unaffected. Set `false` only to debug de-dupe / cardinality — rarely needed because the whole bridge is already a no-op when `enabled` is false.", + "type": "boolean" + }, "tracerName": { "default": "a2a-wrapper", "description": "Instrumentation scope name passed to `getTracer`.", diff --git a/a2a-opencode/src/server/index.ts b/a2a-opencode/src/server/index.ts index 83e1f2c..d002471 100644 --- a/a2a-opencode/src/server/index.ts +++ b/a2a-opencode/src/server/index.ts @@ -8,6 +8,7 @@ * and the startup banner. */ +import { createRequire } from "node:module"; import express from "express"; import { createA2AServer as coreCreateA2AServer, buildAgentCard } from "@a2a-wrapper/core"; import type { ServerHandle as CoreServerHandle } from "@a2a-wrapper/core"; @@ -17,6 +18,9 @@ import { OpenCodeExecutor } from "../opencode/executor.js"; import { logger } from "../utils/logger.js"; const log = logger.child("server"); +const { name: WRAPPER_SDK, version: WRAPPER_SDK_VERSION } = createRequire(import.meta.url)( + "../../package.json", +) as { name: string; version: string }; export interface ServerHandle extends Omit { executor: OpenCodeExecutor; @@ -29,6 +33,8 @@ export interface ServerHandle extends Omit { export async function createA2AServer(config: Required): Promise { const handle = await coreCreateA2AServer>(config, (cfg) => new OpenCodeExecutor(cfg), { protocolVersion: "1.0", + wrapperName: WRAPPER_SDK, + wrapperVersion: WRAPPER_SDK_VERSION, registerRoutes: (app, executor) => { const opencodeExecutor = executor as OpenCodeExecutor; diff --git a/docs/observability.md b/docs/observability.md index 0dea7b6..e07eb22 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -7,7 +7,7 @@ How to collect traces from `a2a-*` wrappers with [OpenTelemetry](https://opentel | Channel | Purpose | Transport | |---|---|---| | **A2A sideband** (`trace.mcp`, `trace.usage`, …) | Orchestrators and A2A clients | Event bus / optional HTTP event transport | -| **OpenTelemetry** | Platform collectors (Grafana Tempo, Jaeger, Langfuse, …) | OTLP | +| **OpenTelemetry** | Platform collectors (Phoenix, Grafana Tempo, Langfuse, …) | OTLP | Sideband is unchanged when you enable OTel. OTel does **not** replace A2A artifacts. @@ -44,17 +44,23 @@ Wrapper CLIs call `bootstrapOtelSdkFromConfig` when `otel.enabled` and are missing, the process continues with no-op spans. ```bash -npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http +npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto ``` -### Local demo stack +Use `@opentelemetry/exporter-trace-otlp-http` only when `otel.exporter.protocol` is +`"http/json"`. Phoenix’s `/v1/traces` accepts **protobuf only** (`application/x-protobuf`). + +### Local demo — Phoenix (no Docker required) ```bash -docker compose -f examples/otel-stack/docker-compose.yml up -d -# UI: http://localhost:16686 +pip install 'arize-phoenix' +phoenix serve +# UI + OTLP HTTP: http://localhost:6006 (traces → /v1/traces) +# OTLP gRPC: http://localhost:4317 ``` -Point your collector (or Grafana Alloy / Langfuse OTLP) at OTLP HTTP `:4318` and/or gRPC `:4317`. +Optional Docker image is in [`examples/otel-stack`](../examples/otel-stack) if you prefer compose. +Smoke without a full agent: `node examples/otel-stack/smoke-export.mjs`. ## Agent config @@ -63,13 +69,15 @@ Point your collector (or Grafana Alloy / Langfuse OTLP) at OTLP HTTP `:4318` and "otel": { "enabled": true, "mirrorAgentEvents": false, + "taskUsageRollup": true, + "annotateUsageCalls": false, "exporter": { - "endpoint": "http://127.0.0.1:4318", + "endpoint": "http://127.0.0.1:6006", "protocol": "http/protobuf" }, "backend": { "copilot": { - "otlpEndpoint": "http://127.0.0.1:4318", + "otlpEndpoint": "http://127.0.0.1:6006", "exporterType": "otlp-http", "propagateTraceContext": true } @@ -83,12 +91,22 @@ Point your collector (or Grafana Alloy / Langfuse OTLP) at OTLP HTTP `:4318` and | `otel.enabled` | Emit wrapper spans such as `a2a.task.execute` | | `otel.exporter.endpoint` | Shared OTLP hint inherited by backends when their own endpoint is omitted | | `otel.mirrorAgentEvents` | Also turn sideband tool events into wrapper OTel tool spans (default `false`) | +| `otel.taskUsageRollup` | Stamp `a2a.task.usage.*` (± `gen_ai.usage.*`) on the task span (default `true`; sideband `x-usage` always) | +| `otel.annotateUsageCalls` | Per-call `a2a.llm.call` span **events** when wrapper is sole token source (default `false`; Claude/Codex wired) | | `otel.backend.copilot` | Hook F: Copilot CLI OTLP + `onGetTraceContext` parent-link | | `otel.backend.claude` | Hook F: `CLAUDE_CODE_ENABLE_TELEMETRY` + `OTEL_*` + `TRACEPARENT` in subprocess env | | `otel.backend.codex` | Hook F: merged into Codex `configOverrides.otel` | | `otel.backend.opencode` | Declares OpenCode server OTel on (de-dupe); enable the flag on the OpenCode server itself | | `otel.emitOverlappingBackendSpans` | Unsafe; allow duplicates (debug only) | +Every wrapper stamps package identity on `a2a.task.execute`: + +| Attribute | Value | +|---|---| +| `a2a.wrapper.core.version` | `@a2a-wrapper/core` version | +| `a2a.wrapper.sdk` | e.g. `a2a-claude`, `a2a-copilot` | +| `a2a.wrapper.sdk.version` | that package’s version | + ### Mental model (keep this simple) **~99% of traffic: one inbound A2A request → one wrapper span** (`a2a.task.execute`). @@ -106,7 +124,7 @@ A2A OTel conventions are still evolving ([semantic-conventions-genai#254](https: | `a2a.message.id` | Inbound A2A message | One request / one turn | | `trace_id` / `traceparent` | W3C context | **Usually one request**; new UI call ⇒ often a new trace | -### What appears in Tempo / Jaeger / Langfuse (happy path) +### What appears in Phoenix / Tempo / Langfuse (happy path) One **request** = one wrapper span + backend children (not one merged span): @@ -127,7 +145,7 @@ The wrapper **cannot stop** the orchestrator (or another agent) from calling aga So: **still one span per request** — never a multi-purpose span factory. Platforms join those spans via the stable IDs (session/conversation view), not by forcing one eternal parent across human wait time. -When a prior in-process attempt exists, the new span also gets an OTel **span link** (`a2a.task.link_reason=continue|retry`) for Tempo/Jaeger. Langfuse/Datadog still primarily group on `gen_ai.conversation.id`. +When a prior in-process attempt exists, the new span also gets an OTel **span link** (`a2a.task.link_reason=continue|retry`) for Tempo/Phoenix. Langfuse/Datadog still primarily group on `gen_ai.conversation.id`. ### Phase 3 — Backend passthrough (Hook F) @@ -156,8 +174,9 @@ Notes: | `a2a.task.invocation` / `a2a.task.invocation_kind` | In-process execute count: `new` / `continue` / `retry` | | `gen_ai.agent.name` / `a2a.agent.name` | `agentCard.name` | | `gen_ai.conversation.id` | A2A `contextId` (platform session join key) | -| `a2a.wrapper.name` | Server option / package name | -| `a2a.task.usage.*` | Per-**request** rollup from `LlmUsageAccumulator` (when OTel on) | +| `a2a.wrapper.core.version` | `@a2a-wrapper/core` package version | +| `a2a.wrapper.sdk` / `.sdk.version` | Which `a2a-*` package + version (also mirrored as `.name` / `.version`) | +| `a2a.task.usage.*` | Per-**request** rollup from `LlmUsageAccumulator` when `otel.taskUsageRollup` (default on) | | `gen_ai.usage.*` on task span | **Only when backend OTel is off** (fallback sole source) | **Ownership reminder:** wrapper owns A2A/protocol + correlation IDs. Backend owns per-call `gen_ai.usage.*` on LLM spans when its OTel is on. Do not treat the wrapper as the universal owner of GenAI token attrs ([#254](https://github.com/open-telemetry/semantic-conventions-genai/issues/254) is still drafting A2A shape). @@ -180,7 +199,8 @@ Rules: 1. **Never** open a second LLM span just to carry usage. 2. **Never** emit wrapper token **metrics** that sum with backend GenAI metrics (Phase 2 does not add counters). 3. Dashboards: either sum `gen_ai.usage.*` on backend LLM spans **or** read `a2a.task.usage.*` on `a2a.task.execute` — not both. -4. Copilot, Antigravity, Claude, and Codex call `applyUsageSummaryToActiveSpan` on completed turns (per-request rollup). +4. Copilot, Antigravity, Claude, and Codex call `applyUsageSummaryToActiveSpan` on completed turns (per-request rollup; opt out with `otel.taskUsageRollup: false`). +5. Claude/Codex also call `applyUsageCallToActiveSpan` per LLM call — **no-op unless** `otel.annotateUsageCalls: true` (and backend OTel is off). Sideband `x-usage` is never gated by these flags. ## Programmatic API diff --git a/docs/otel-attribute-ownership.md b/docs/otel-attribute-ownership.md index a4c8856..a3f4ac9 100644 --- a/docs/otel-attribute-ownership.md +++ b/docs/otel-attribute-ownership.md @@ -47,7 +47,9 @@ This is the **who owns what** guide for `@a2a-wrapper/core` and the `a2a-*` wrap | `gen_ai.operation.name` | **W** | `invoke_agent` (aligned with #254 “existing shared”) | | `a2a.method.name` | **W** | `message/send` for executor path (draft #254) | | `a2a.protocol.version` | **W** | Server protocol version option | -| `a2a.wrapper.name` / `.version` | **W** | Package identity | +| `a2a.wrapper.core.version` | **W** | Installed `@a2a-wrapper/core` package.json version (auto) | +| `a2a.wrapper.sdk` / `a2a.wrapper.sdk.version` | **W** | Which `a2a-*` package is serving the request + its version | +| `a2a.wrapper.name` / `.version` | **W** | Legacy aliases of `a2a.wrapper.sdk` / `.sdk.version` | | `gen_ai.agent.name` / `gen_ai.agent.id` | **W** | From `agentCard.name` — interim until #243 settles acting vs target | | `a2a.agent.name` / `a2a.agent.id` | **W** | Same (A2A-local mirror) | @@ -96,4 +98,4 @@ Until #254 / #243 merge: ## Related - Operator guide: [observability.md](./observability.md) -- Demo stack: [examples/otel-stack](../examples/otel-stack) +- Demo stack (Phoenix, no Docker required): [examples/otel-stack](../examples/otel-stack) diff --git a/examples/otel-stack/README.md b/examples/otel-stack/README.md index aa9a71b..1566397 100644 --- a/examples/otel-stack/README.md +++ b/examples/otel-stack/README.md @@ -1,34 +1,41 @@ -# OpenTelemetry demo stack (Jaeger) +# OpenTelemetry demo — Arize Phoenix (lightweight) -Minimal local collector UI for developing `a2a-*` wrappers with OpenTelemetry. +Local collector UI for developing `a2a-*` wrappers. **No Docker required.** -## Start +Phoenix is lighter than a Jaeger all-in-one compose stack: one Python process, +OTLP HTTP on the same port as the UI. + +## Start Phoenix ```bash -docker compose -f examples/otel-stack/docker-compose.yml up -d +pip install 'arize-phoenix' +# or: uvx --from arize-phoenix phoenix serve +phoenix serve ``` | Endpoint | Use | |---|---| -| `http://127.0.0.1:4318` | OTLP HTTP — **preferred** (Copilot CLI + Node SDK) | +| `http://127.0.0.1:6006/v1/traces` | OTLP HTTP — **preferred** (wrapper NodeSDK + Copilot) | | `http://127.0.0.1:4317` | OTLP gRPC (Claude / generic) | -| `http://localhost:16686` | Jaeger UI | +| `http://localhost:6006` | Phoenix UI | + +Set `otel.exporter.endpoint` to the **origin** (`http://127.0.0.1:6006`); +`@a2a-wrapper/core` appends `/v1/traces` for the HTTP exporter. ## Agent config +See [`agent-otel.snippet.json`](./agent-otel.snippet.json): + ```json { "otel": { "enabled": true, - "serviceName": "a2a-copilot-demo", + "serviceName": "a2a-wrapper-demo", + "taskUsageRollup": true, + "annotateUsageCalls": false, "exporter": { - "endpoint": "http://127.0.0.1:4318", + "endpoint": "http://127.0.0.1:6006", "protocol": "http/protobuf" - }, - "backend": { - "copilot": { - "propagateTraceContext": true - } } } } @@ -36,19 +43,34 @@ docker compose -f examples/otel-stack/docker-compose.yml up -d ## CLI bootstrap (optional deps) -When you run a wrapper CLI (`a2a-copilot`, …) with `otel.enabled` + `otel.exporter.endpoint`, `@a2a-wrapper/core` tries to start NodeSDK via **dynamic import**. +When a wrapper CLI runs with `otel.enabled` + `otel.exporter.endpoint`, core +dynamically starts NodeSDK. Install once: + +```bash +# protobuf (default) — required for Phoenix; preferred for Copilot +npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto +# optional: JSON encoding if otel.exporter.protocol = "http/json" +# npm install @opentelemetry/exporter-trace-otlp-http +``` + +## Smoke script (this repo) -Install once in the wrapper (or host) package: +With Phoenix already running: ```bash -npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http +node examples/otel-stack/smoke-export.mjs ``` -Without those packages the CLI still runs; wrapper spans are no-ops until a host registers a tracer. +That boots the same CLI bootstrap path, emits one `a2a.task.execute`-shaped +span (with `a2a.wrapper.core.version` + `a2a.wrapper.sdk*`), and prints the +Phoenix projects API response so you can confirm ingest without a full agent. ## What you should see 1. One **trace** per inbound A2A request (`a2a.task.execute` + backend children when Hook F is on). -2. Multi-turn / resume: separate traces joined by `gen_ai.conversation.id` / `a2a.task.id` (and optional span links with `a2a.task.link_reason=continue|retry`). +2. Attributes: `a2a.wrapper.core.version`, `a2a.wrapper.sdk`, `a2a.wrapper.sdk.version`, + `gen_ai.conversation.id` / `session.id`, optional `a2a.task.usage.*`. +3. Multi-turn / resume: separate traces joined by conversation/task ids (optional span links). -See [docs/observability.md](../../docs/observability.md). +See [docs/observability.md](../../docs/observability.md) and +[docs/otel-attribute-ownership.md](../../docs/otel-attribute-ownership.md). diff --git a/examples/otel-stack/agent-otel.snippet.json b/examples/otel-stack/agent-otel.snippet.json index 6069e78..7224cd9 100644 --- a/examples/otel-stack/agent-otel.snippet.json +++ b/examples/otel-stack/agent-otel.snippet.json @@ -2,8 +2,10 @@ "otel": { "enabled": true, "serviceName": "a2a-wrapper-demo", + "taskUsageRollup": true, + "annotateUsageCalls": false, "exporter": { - "endpoint": "http://127.0.0.1:4318", + "endpoint": "http://127.0.0.1:6006", "protocol": "http/protobuf" }, "backend": { diff --git a/examples/otel-stack/docker-compose.yml b/examples/otel-stack/docker-compose.yml index eda74fc..e322200 100644 --- a/examples/otel-stack/docker-compose.yml +++ b/examples/otel-stack/docker-compose.yml @@ -1,22 +1,24 @@ -# Local OpenTelemetry collector + Jaeger for a2a-* wrapper demos. +# Optional: run Phoenix via Docker instead of `pip install arize-phoenix`. +# Prefer the pip/uv path in README.md when Docker is unavailable. # # docker compose -f examples/otel-stack/docker-compose.yml up -d -# open http://localhost:16686 +# open http://localhost:6006 # # Point wrappers at: -# otel.exporter.endpoint = "http://127.0.0.1:4318" -# otel.backend.copilot.otlpEndpoint = "http://127.0.0.1:4318" # or inherit exporter -# -# Jaeger all-in-one accepts OTLP HTTP (:4318) and gRPC (:4317). +# otel.exporter.endpoint = "http://127.0.0.1:6006" services: - jaeger: - image: jaegertracing/all-in-one:1.57 - container_name: a2a-otel-jaeger - environment: - COLLECTOR_OTLP_ENABLED: "true" + phoenix: + image: arizephoenix/phoenix:latest + container_name: a2a-otel-phoenix ports: - - "4317:4317" # OTLP gRPC (Claude / generic) - - "4318:4318" # OTLP HTTP (Copilot + NodeSDK — preferred) - - "16686:16686" # Jaeger UI + - "6006:6006" # UI + OTLP HTTP (/v1/traces) + - "4317:4317" # OTLP gRPC + environment: + PHOENIX_WORKING_DIR: /data + volumes: + - phoenix-data:/data restart: unless-stopped + +volumes: + phoenix-data: diff --git a/examples/otel-stack/smoke-export.mjs b/examples/otel-stack/smoke-export.mjs new file mode 100644 index 0000000..af6618e --- /dev/null +++ b/examples/otel-stack/smoke-export.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Emit one wrapper-shaped span to a local Phoenix (or any OTLP HTTP collector). + * + * phoenix serve # UI http://127.0.0.1:6006 OTLP HTTP …/v1/traces + * node examples/otel-stack/smoke-export.mjs + * + * Requires optional peers: + * @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto + */ + +import { createRequire } from "node:module"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); +const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const corePkg = join(root, "packages/core/package.json"); +const { version: coreVersion } = require(corePkg); + +const endpoint = process.env.OTEL_EXPORTER_ENDPOINT ?? "http://127.0.0.1:6006"; +const phoenixApi = process.env.PHOENIX_API ?? "http://127.0.0.1:6006"; + +async function loadCore() { + // Prefer built dist; fall back to telling the user to build. + const dist = pathToFileURL(join(root, "packages/core/dist/index.js")).href; + try { + return await import(dist); + } catch { + console.error("Build core first: (cd packages/core && npm run build)"); + process.exit(1); + } +} + +const core = await loadCore(); +const ok = await core.bootstrapOtelSdkFromConfig({ + enabled: true, + serviceName: "a2a-wrapper-smoke", + exporter: { endpoint, protocol: "http/protobuf" }, +}); +if (!ok) { + console.error( + "bootstrapOtelSdkFromConfig failed — install @opentelemetry/sdk-node and " + + "@opentelemetry/exporter-trace-otlp-proto (and @opentelemetry/api).", + ); + process.exit(1); +} + +const taskId = `smoke-${Date.now()}`; +await core.withSpan( + "a2a.task.execute", + core.buildTaskSpanAttributes({ + taskId, + contextId: "smoke-conversation", + agentName: "Smoke Agent", + wrapperName: "a2a-wrapper-smoke", + wrapperVersion: "0.0.0-smoke", + coreVersion, + messageId: "msg-smoke-1", + invocation: 1, + invocationKind: "new", + }), + async (span) => { + span?.setAttribute?.("a2a.task.usage.input_tokens", 3); + span?.setAttribute?.("a2a.task.usage.output_tokens", 1); + span?.setAttribute?.("a2a.task.usage.llm_calls", 1); + await new Promise((r) => setTimeout(r, 50)); + }, +); + +await core.shutdownOtelSdk(); +console.log(`Exported smoke span taskId=${taskId} → ${endpoint}/v1/traces`); + +// Give Phoenix a moment to index, then list projects. +await new Promise((r) => setTimeout(r, 1500)); +try { + const res = await fetch(`${phoenixApi}/v1/projects`); + const body = await res.text(); + console.log(`Phoenix GET /v1/projects → ${res.status}`); + console.log(body.slice(0, 2000)); +} catch (err) { + console.warn("Could not query Phoenix API (is phoenix serve running?)", err); + process.exitCode = 2; +} diff --git a/packages/core/README.md b/packages/core/README.md index 3f97fe9..691820a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -472,7 +472,7 @@ Optional distributed tracing for `a2a.task.execute` / `a2a.task.cancel`. Core pe Usage tokens: call `applyUsageSummaryToActiveSpan(summary)` after `LlmUsageAccumulator.summary()`. When a backend OTel exporter is configured, core sets only `a2a.task.usage.*` on the task span (not `gen_ai.usage.*`) so Tempo SUMs do not double-count with vendor LLM spans. -CLI bootstrap: with `otel.enabled` + `otel.exporter.endpoint`, `createCli` dynamically loads `@opentelemetry/sdk-node` + OTLP HTTP exporter when installed (optional peers). Local Jaeger demo: `examples/otel-stack`. +CLI bootstrap: with `otel.enabled` + `otel.exporter.endpoint`, `createCli` dynamically loads `@opentelemetry/sdk-node` + `@opentelemetry/exporter-trace-otlp-proto` (default `http/protobuf`; optional peers). Local Phoenix demo (no Docker): `examples/otel-stack`. ```ts import { setOtelTracer, withSpan, extractA2ATraceContext } from "@a2a-wrapper/core"; diff --git a/packages/core/package.json b/packages/core/package.json index 7996971..39f3f8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "@a2a-js/sdk": "^1.0.0", "@opentelemetry/api": ">=1.0.0 <1.10.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.50.0", + "@opentelemetry/exporter-trace-otlp-proto": ">=0.50.0", "@opentelemetry/sdk-node": ">=0.50.0", "express": "^4.18.2", "uuid": "^9.0.0" @@ -37,6 +38,9 @@ }, "@opentelemetry/exporter-trace-otlp-http": { "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true } }, "devDependencies": { diff --git a/packages/core/src/__tests__/telemetry/otel.test.ts b/packages/core/src/__tests__/telemetry/otel.test.ts index b5cd1bb..450d09c 100644 --- a/packages/core/src/__tests__/telemetry/otel.test.ts +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -8,11 +8,14 @@ import { observeAgentEvent, runWithTaskOtelStore, applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, createExecutionObservability, buildCopilotTelemetryOptions, shouldPropagateCopilotTraceContext, buildClaudeOtelEnv, mergeCodexOtelOverrides, + getCorePackageVersion, + buildTaskSpanAttributes, type OtelTracerLike, type OtelSpanLike, type A2AExecutor, @@ -95,6 +98,26 @@ describe("resolveOtelEmissionPolicy", () => { expect(p.emitGenAiUsageAttrs).toBe(true); }); + it("defaults taskUsageRollup on and annotateUsageCalls off", () => { + const p = resolveOtelEmissionPolicy({ enabled: true }); + expect(p.emitTaskUsageRollup).toBe(true); + expect(p.annotateUsageCalls).toBe(false); + }); + + it("honors taskUsageRollup: false and annotateUsageCalls: true", () => { + const off = resolveOtelEmissionPolicy({ enabled: true, taskUsageRollup: false }); + expect(off.emitTaskUsageRollup).toBe(false); + const on = resolveOtelEmissionPolicy({ enabled: true, annotateUsageCalls: true }); + expect(on.annotateUsageCalls).toBe(true); + const blocked = resolveOtelEmissionPolicy({ + enabled: true, + annotateUsageCalls: true, + backend: { claude: { enableTelemetry: true } }, + }); + // Per-call events only when wrapper is sole GenAI usage source. + expect(blocked.annotateUsageCalls).toBe(false); + }); + it("suppresses gen_ai.usage attrs on the task span when backend OTel is on", () => { const p = resolveOtelEmissionPolicy({ enabled: true, @@ -166,11 +189,29 @@ describe("extractA2ATraceContext", () => { }); }); +describe("wrapper package identity attrs", () => { + it("stamps core + sdk versions on task spans", () => { + const attrs = buildTaskSpanAttributes({ + taskId: "t", + contextId: "c", + agentName: "Example", + wrapperName: "a2a-claude", + wrapperVersion: "0.4.1", + }); + expect(attrs["a2a.wrapper.core.version"]).toBe(getCorePackageVersion()); + expect(attrs["a2a.wrapper.sdk"]).toBe("a2a-claude"); + expect(attrs["a2a.wrapper.sdk.version"]).toBe("0.4.1"); + expect(attrs["a2a.wrapper.name"]).toBe("a2a-claude"); + expect(attrs["a2a.wrapper.version"]).toBe("0.4.1"); + expect(getCorePackageVersion()).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + describe("instrumentExecutor", () => { afterEach(() => setOtelTracer(undefined)); it("wraps execute in a2a.task.execute when otel.enabled", async () => { - const { tracer, ended } = fakeTracer(); + const { tracer, ended, attrs } = fakeTracer(); setOtelTracer(tracer); let ran = false; const inner: A2AExecutor = { @@ -184,6 +225,7 @@ describe("instrumentExecutor", () => { otel: { enabled: true }, agentName: "Example Agent", wrapperName: "a2a-copilot", + wrapperVersion: "1.8.2", }); await ex.execute( { @@ -195,6 +237,9 @@ describe("instrumentExecutor", () => { ); expect(ran).toBe(true); expect(ended).toEqual(["a2a.task.execute"]); + expect(attrs.some((a) => a.key === "a2a.wrapper.sdk" && a.value === "a2a-copilot")).toBe(true); + expect(attrs.some((a) => a.key === "a2a.wrapper.sdk.version" && a.value === "1.8.2")).toBe(true); + expect(attrs.some((a) => a.key === "a2a.wrapper.core.version")).toBe(true); }); it("marks continue vs retry on re-entry (still one span per request)", async () => { @@ -359,6 +404,89 @@ describe("applyUsageSummaryToActiveSpan", () => { expect(attrs.some((a) => a.key === "gen_ai.usage.input_tokens" && a.value === 10)).toBe(true); expect(attrs.some((a) => a.key === "gen_ai.usage.output_tokens" && a.value === 5)).toBe(true); }); + + it("skips rollup attrs when taskUsageRollup is false", async () => { + const { tracer, attrs } = fakeTracer(); + setOtelTracer(tracer); + const policy = resolveOtelEmissionPolicy({ enabled: true, taskUsageRollup: false }); + await runWithTaskOtelStore( + { + policy, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + applyUsageSummaryToActiveSpan(sampleUsage); + }, + ); + expect(attrs.some((a) => a.key.startsWith("a2a.task.usage."))).toBe(false); + }); + + it("emits a2a.llm.call events only when annotateUsageCalls is on", async () => { + const { tracer, events } = fakeTracer(); + setOtelTracer(tracer); + const off = resolveOtelEmissionPolicy({ enabled: true }); + await runWithTaskOtelStore( + { + policy: off, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task"), + }, + async () => { + applyUsageCallToActiveSpan({ + model: "m", + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + durationMs: 1, + timeToFirstTokenMs: null, + cost: null, + apiEndpoint: null, + initiator: "claude", + }); + }, + ); + expect(events).not.toContain("a2a.llm.call"); + + const on = resolveOtelEmissionPolicy({ enabled: true, annotateUsageCalls: true }); + await runWithTaskOtelStore( + { + policy: on, + taskId: "t", + contextId: "c", + agentId: "a", + agentName: "A", + toolSpans: new Map(), + span: tracer.startSpan("task2"), + }, + async () => { + applyUsageCallToActiveSpan({ + model: "m", + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + durationMs: 1, + timeToFirstTokenMs: null, + cost: null, + apiEndpoint: null, + initiator: "claude", + }); + }, + ); + expect(events).toContain("a2a.llm.call"); + }); }); describe("createExecutionObservability", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 23b8a57..8d8ac11 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,6 +191,8 @@ export { buildTaskSpanAttributes, slugAgentId, agentIdentityFromCard, + getCorePackageVersion, + readPackageIdentity, instrumentExecutor, observeAgentEvent, applyUsageSummaryToActiveSpan, diff --git a/packages/core/src/telemetry/attributes.ts b/packages/core/src/telemetry/attributes.ts index 95928be..7b8425d 100644 --- a/packages/core/src/telemetry/attributes.ts +++ b/packages/core/src/telemetry/attributes.ts @@ -10,6 +10,7 @@ */ import type { A2ATraceContext } from "./context.js"; +import { getCorePackageVersion } from "./package-meta.js"; /** How this `execute` relates to the A2A task lifecycle. */ export type TaskInvocationKind = @@ -29,8 +30,21 @@ export function buildTaskSpanAttributes(opts: { contextId: string; agentName: string; agentId?: string; + /** + * Wrapper SDK package name (e.g. `"a2a-copilot"`). Emitted as + * `a2a.wrapper.sdk` (and legacy `a2a.wrapper.name`). + */ wrapperName?: string; + /** + * Wrapper SDK package version. Emitted as `a2a.wrapper.sdk.version` + * (and legacy `a2a.wrapper.version`). + */ wrapperVersion?: string; + /** + * `@a2a-wrapper/core` version. Defaults to the installed core package.json + * version when omitted. + */ + coreVersion?: string; protocolVersion?: string; parentAgentId?: string | null; orchestratorTraceId?: string; @@ -53,6 +67,7 @@ export function buildTaskSpanAttributes(opts: { }): Record { const agentId = opts.agentId ?? slugAgentId(opts.agentName); const conversationId = opts.conversationId || opts.contextId; + const coreVersion = opts.coreVersion ?? getCorePackageVersion(); const attrs: Record = { "a2a.task.id": opts.taskId, "a2a.context.id": opts.contextId, @@ -66,11 +81,20 @@ export function buildTaskSpanAttributes(opts: { // Draft semantic-conventions-genai#254 shared GenAI op for A2A invoke. "gen_ai.operation.name": "invoke_agent", "a2a.method.name": opts.methodName || "message/send", + "a2a.wrapper.core.version": coreVersion, }; const sessionId = opts.sessionId || conversationId; attrs["session.id"] = sessionId; - if (opts.wrapperName) attrs["a2a.wrapper.name"] = opts.wrapperName; - if (opts.wrapperVersion) attrs["a2a.wrapper.version"] = opts.wrapperVersion; + if (opts.wrapperName) { + // Preferred names — which a2a-* SDK package is serving this request. + attrs["a2a.wrapper.sdk"] = opts.wrapperName; + // Legacy aliases (same values) for early Phase 1–5 consumers. + attrs["a2a.wrapper.name"] = opts.wrapperName; + } + if (opts.wrapperVersion) { + attrs["a2a.wrapper.sdk.version"] = opts.wrapperVersion; + attrs["a2a.wrapper.version"] = opts.wrapperVersion; + } if (opts.protocolVersion) attrs["a2a.protocol.version"] = opts.protocolVersion; if (opts.parentAgentId) attrs["a2a.parent_agent.id"] = opts.parentAgentId; if (opts.orchestratorTraceId) attrs["a2a.orchestrator.trace_id"] = opts.orchestratorTraceId; diff --git a/packages/core/src/telemetry/cli-bootstrap.ts b/packages/core/src/telemetry/cli-bootstrap.ts index bab0a0e..79e1e80 100644 --- a/packages/core/src/telemetry/cli-bootstrap.ts +++ b/packages/core/src/telemetry/cli-bootstrap.ts @@ -1,11 +1,16 @@ /** * Optional CLI OTel SDK bootstrap (Phase 4). * - * Dynamically imports `@opentelemetry/sdk-node` + OTLP HTTP exporter when + * Dynamically imports `@opentelemetry/sdk-node` + an OTLP HTTP exporter when * `otel.enabled` and `otel.exporter.endpoint` are set. If those packages are * not installed, logs a one-line hint and continues (spans stay no-op unless * the host registered a tracer another way). * + * Protocol selection: + * - `http/protobuf` (default) → `@opentelemetry/exporter-trace-otlp-proto` + * (required by Arize Phoenix; also what Copilot prefers) + * - `http/json` → `@opentelemetry/exporter-trace-otlp-http` + * * @module telemetry/cli-bootstrap */ @@ -22,6 +27,43 @@ function tracesUrl(endpoint: string): string { return base.endsWith("/v1/traces") ? base : `${base}/v1/traces`; } +function normalizeProtocol(protocol?: string): "http/protobuf" | "http/json" { + const p = (protocol ?? "http/protobuf").toLowerCase(); + if (p === "http/json" || p === "json") return "http/json"; + return "http/protobuf"; +} + +async function loadExporter( + protocol: "http/protobuf" | "http/json", +): Promise<{ OTLPTraceExporter: new (opts: { url: string }) => unknown; used: string }> { + if (protocol === "http/json") { + const expSpec = "@opentelemetry/exporter-trace-otlp-http"; + const expMod = (await import(expSpec)) as Record; + return { + OTLPTraceExporter: expMod.OTLPTraceExporter as new (opts: { url: string }) => unknown, + used: expSpec, + }; + } + + // Prefer protobuf package; fall back to http-json only with a clear failure + // if protobuf is missing (Phoenix rejects application/json with 415). + const protoSpec = "@opentelemetry/exporter-trace-otlp-proto"; + try { + const expMod = (await import(protoSpec)) as Record; + return { + OTLPTraceExporter: expMod.OTLPTraceExporter as new (opts: { url: string }) => unknown, + used: protoSpec, + }; + } catch (protoErr) { + const msg = protoErr instanceof Error ? protoErr.message : String(protoErr); + throw new Error( + `http/protobuf requires optional peer \`${protoSpec}\` ` + + `(Phoenix and many collectors reject JSON). Install it, or set ` + + `otel.exporter.protocol to "http/json". (${msg})`, + ); + } +} + /** * Start a NodeSDK + OTLP HTTP exporter and register the tracer for core hooks. * No-op when already bootstrapped, when OTel is disabled, or when endpoint is missing. @@ -38,18 +80,17 @@ export async function bootstrapOtelSdkFromConfig( // Specifiers are variables so tsc does not require the optional peer packages // to be present at compile time (they are optionalPeers for CLI hosts). const sdkSpec = "@opentelemetry/sdk-node"; - const expSpec = "@opentelemetry/exporter-trace-otlp-http"; const apiSpec = "@opentelemetry/api"; - const [sdkMod, expMod, apiMod] = await Promise.all([ + const protocol = normalizeProtocol(otel.exporter?.protocol); + const [sdkMod, apiMod, exporter] = await Promise.all([ import(sdkSpec) as Promise>, - import(expSpec) as Promise>, import(apiSpec) as Promise>, + loadExporter(protocol), ]); const NodeSDK = sdkMod.NodeSDK as new (opts: Record) => Shutdownable & { start: () => void | Promise; }; - const OTLPTraceExporter = expMod.OTLPTraceExporter as new (opts: { url: string }) => unknown; const api = apiMod as { trace: { getTracer: (name: string, version?: string) => import("./types.js").OtelTracerLike }; }; @@ -57,7 +98,7 @@ export async function bootstrapOtelSdkFromConfig( const serviceName = otel.serviceName ?? "a2a-wrapper"; const sdk = new NodeSDK({ serviceName, - traceExporter: new OTLPTraceExporter({ url: tracesUrl(endpoint) }), + traceExporter: new exporter.OTLPTraceExporter({ url: tracesUrl(endpoint) }), }); await Promise.resolve(sdk.start()); _sdk = sdk; @@ -69,8 +110,9 @@ export async function bootstrapOtelSdkFromConfig( const msg = err instanceof Error ? err.message : String(err); console.warn( "[a2a-wrapper/otel] CLI OTel bootstrap skipped — install optional deps " + - "`@opentelemetry/sdk-node` and `@opentelemetry/exporter-trace-otlp-http` " + - `(and ensure @opentelemetry/api is present). (${msg})`, + "`@opentelemetry/sdk-node` and `@opentelemetry/exporter-trace-otlp-proto` " + + "(protobuf; preferred) or `@opentelemetry/exporter-trace-otlp-http` (JSON), " + + `plus @opentelemetry/api. (${msg})`, ); } return false; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 2fa9e9d..07b0c39 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -45,6 +45,8 @@ export { type TaskInvocationKind, } from "./attributes.js"; +export { getCorePackageVersion, readPackageIdentity } from "./package-meta.js"; + export { instrumentExecutor, type InstrumentExecutorOptions } from "./instrument.js"; export { observeAgentEvent } from "./emitter-bridge.js"; diff --git a/packages/core/src/telemetry/instrument.ts b/packages/core/src/telemetry/instrument.ts index 8ee7009..28ea751 100644 --- a/packages/core/src/telemetry/instrument.ts +++ b/packages/core/src/telemetry/instrument.ts @@ -28,6 +28,7 @@ import { type OtelSpanContextLike, type OtelSpanLinkLike, } from "./types.js"; +import { getCorePackageVersion } from "./package-meta.js"; export interface InstrumentExecutorOptions { /** Optional otel block from agent config. */ @@ -177,8 +178,17 @@ export function instrumentExecutor( "a2a.task.id": taskId, "a2a.agent.name": agentName, "gen_ai.agent.name": agentName, + "a2a.method.name": "tasks/cancel", + "a2a.wrapper.core.version": getCorePackageVersion(), }; - if (options.wrapperName) attrs["a2a.wrapper.name"] = options.wrapperName; + if (options.wrapperName) { + attrs["a2a.wrapper.sdk"] = options.wrapperName; + attrs["a2a.wrapper.name"] = options.wrapperName; + } + if (options.wrapperVersion) { + attrs["a2a.wrapper.sdk.version"] = options.wrapperVersion; + attrs["a2a.wrapper.version"] = options.wrapperVersion; + } if (!policy.enabled) { return executor.cancelTask!(taskId, bus); diff --git a/packages/core/src/telemetry/package-meta.ts b/packages/core/src/telemetry/package-meta.ts new file mode 100644 index 0000000..f0a4f88 --- /dev/null +++ b/packages/core/src/telemetry/package-meta.ts @@ -0,0 +1,46 @@ +/** + * Resolve package identity for OTel attributes without hard-coding versions + * in every call site. Core version always comes from `@a2a-wrapper/core`'s + * package.json; wrapper SDK name/version are supplied by each `a2a-*` package. + * + * @module telemetry/package-meta + */ + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); + +let _coreVersion: string | undefined; + +/** + * Version of the installed `@a2a-wrapper/core` package (from its package.json). + * Falls back to `"unknown"` only if package.json cannot be read. + */ +export function getCorePackageVersion(): string { + if (_coreVersion) return _coreVersion; + try { + // Dist layout: …/packages/core/dist/telemetry/package-meta.js → ../../package.json + // Src/vitest: …/packages/core/src/telemetry/package-meta.ts → ../../package.json + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = require(join(here, "..", "..", "package.json")) as { version?: string }; + _coreVersion = pkg.version ?? "unknown"; + } catch { + _coreVersion = "unknown"; + } + return _coreVersion; +} + +/** Read `{ name, version }` from a package.json via createRequire from a caller URL. */ +export function readPackageIdentity( + requireFromUrl: string, + relativePackageJson = "../package.json", +): { name: string; version: string } { + const req = createRequire(requireFromUrl); + const pkg = req(relativePackageJson) as { name?: string; version?: string }; + return { + name: pkg.name ?? "unknown", + version: pkg.version ?? "unknown", + }; +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index d23aba5..e3ef518 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -47,14 +47,38 @@ export interface OtelConfig { */ emitOverlappingBackendSpans?: boolean; + /** + * Stamp per-request usage rollups (`a2a.task.usage.*`, and `gen_ai.usage.*` + * when the wrapper is the sole OTel source) via + * {@link applyUsageSummaryToActiveSpan}. + * + * Default `true` when `enabled`. Sideband `x-usage` is unaffected. + * Set `false` only to debug de-dupe / cardinality — rarely needed because + * the whole bridge is already a no-op when `enabled` is false. + * @default true + */ + taskUsageRollup?: boolean; + + /** + * Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when + * the wrapper is the sole OTel token source. Off by default (noisy). + * @default false + */ + annotateUsageCalls?: boolean; + /** * Shared OTLP exporter hint for wrapper hosts and backend passthrough. * Backends inherit `endpoint` / `protocol` when their own fields are omitted. */ exporter?: { - /** e.g. `http://127.0.0.1:4318` */ + /** e.g. `http://127.0.0.1:6006` (Phoenix) or `http://127.0.0.1:4318` */ endpoint?: string; - /** e.g. `http/protobuf` (preferred with Copilot) or `grpc` */ + /** + * OTLP encoding for CLI bootstrap. + * - `http/protobuf` (default) — `@opentelemetry/exporter-trace-otlp-proto` + * (Phoenix accepts only protobuf over HTTP) + * - `http/json` — `@opentelemetry/exporter-trace-otlp-http` + */ protocol?: string; }; @@ -111,6 +135,13 @@ export interface OtelEmissionPolicy { * instead (see {@link applyUsageSummaryToActiveSpan}). */ emitGenAiUsageAttrs: boolean; + /** + * Apply task-level usage rollups on the active span + * (`a2a.task.usage.*` ± `gen_ai.usage.*`). + */ + emitTaskUsageRollup: boolean; + /** Emit per-call `a2a.llm.call` span events (wrapper-only token source). */ + annotateUsageCalls: boolean; } /** @@ -171,6 +202,9 @@ export function resolveOtelEmissionPolicy(otel?: OtelConfig | null): OtelEmissio const overlap = otel?.emitOverlappingBackendSpans === true; const emitToolSpans = enabled && mirror && (!backendOtelOn || overlap); const emitGenAiUsageAttrs = enabled && (!backendOtelOn || overlap); + const emitTaskUsageRollup = enabled && otel?.taskUsageRollup !== false; + const annotateUsageCalls = + enabled && otel?.annotateUsageCalls === true && emitGenAiUsageAttrs; return { enabled, @@ -178,5 +212,7 @@ export function resolveOtelEmissionPolicy(otel?: OtelConfig | null): OtelEmissio emitToolSpans, annotateLifecycleOnTaskSpan: enabled, emitGenAiUsageAttrs, + emitTaskUsageRollup, + annotateUsageCalls, }; } diff --git a/packages/core/src/telemetry/usage-bridge.ts b/packages/core/src/telemetry/usage-bridge.ts index cb9a87c..b57becf 100644 --- a/packages/core/src/telemetry/usage-bridge.ts +++ b/packages/core/src/telemetry/usage-bridge.ts @@ -29,6 +29,7 @@ function setNum(span: OtelSpanLike, key: string, value: number): void { export function applyUsageSummaryToActiveSpan(summary: UsageTelemetryData): void { const store = getTaskOtelStore(); if (!store?.policy.enabled || !store.span) return; + if (!store.policy.emitTaskUsageRollup) return; const span = store.span; // A2A-owned task rollup — safe alongside backend GenAI spans. @@ -68,7 +69,7 @@ export function applyUsageSummaryToActiveSpan(summary: UsageTelemetryData): void export function applyUsageCallToActiveSpan(call: UsageCallRecord): void { const store = getTaskOtelStore(); if (!store?.policy.enabled || !store.span) return; - if (!store.policy.emitGenAiUsageAttrs) return; + if (!store.policy.annotateUsageCalls) return; if (!store.span.addEvent) return; store.span.addEvent("a2a.llm.call", { From 9d111504cd40d6f02942ee34e980738065d3e6cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:25:40 +0000 Subject: [PATCH 08/12] test(otel): smoke real Copilot/Claude executor paths into Phoenix Add examples/otel-stack/smoke-providers.mjs that drives a2a-copilot and a2a-claude executors under instrumentExecutor against a live Phoenix collector. Backends are faked (this environment has no Copilot policy access / Claude login); wrapper spans still show sdk identity + usage. Co-authored-by: Shashi Kanth G S --- examples/otel-stack/smoke-providers.mjs | 300 ++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 examples/otel-stack/smoke-providers.mjs diff --git a/examples/otel-stack/smoke-providers.mjs b/examples/otel-stack/smoke-providers.mjs new file mode 100644 index 0000000..2012b1d --- /dev/null +++ b/examples/otel-stack/smoke-providers.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node +/** + * Drive real a2a-copilot + a2a-claude executor code paths into a local Phoenix. + * + * This is NOT a live LLM call (this env has no Copilot policy access / + * Claude login). It runs each wrapper's executor under instrumentExecutor with + * a fake/mocked backend session so Phoenix receives authentic wrapper spans: + * - span name a2a.task.execute + * - a2a.wrapper.sdk = a2a-copilot | a2a-claude + * - a2a.wrapper.core.version + * - a2a.task.usage.* from applyUsageSummaryToActiveSpan + * + * phoenix serve + * node examples/otel-stack/smoke-providers.mjs + */ + +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, "../.."); +const require = createRequire(import.meta.url); +const phoenixApi = process.env.PHOENIX_API ?? "http://127.0.0.1:6006"; +const endpoint = process.env.OTEL_EXPORTER_ENDPOINT ?? "http://127.0.0.1:6006"; + +async function load(rel) { + return import(pathToFileURL(join(root, rel)).href); +} + +function makeBus() { + const events = []; + return { + events, + publish: (e) => events.push(e), + finished: () => {}, + on() { return this; }, + off() { return this; }, + once() { return this; }, + removeAllListeners() { return this; }, + }; +} + +function makeCtx(taskId, contextId, text) { + return { + taskId, + contextId, + task: undefined, + userMessage: { + messageId: `msg-${taskId}`, + contextId, + taskId, + role: 1, + parts: [{ content: { $case: "text", value: text }, metadata: undefined }], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }, + }; +} + +/** Minimal Claude Agent SDK fake (mirrors test FakeClaudeClient happy path). */ +function makeFakeClaudeClient(text = "claude-otel-pong") { + const sessionId = "claude-otel-sess"; + return { + calls: [], + runQuery(prompt, options) { + this.calls.push({ prompt, options }); + const messages = [ + { type: "system", subtype: "init", session_id: sessionId, model: "claude-test", plugins: [] }, + { + type: "assistant", + parent_tool_use_id: null, + message: { content: [{ type: "text", text }] }, + }, + { + type: "result", + subtype: "success", + result: text, + usage: { input_tokens: 11, output_tokens: 7 }, + total_cost_usd: 0.02, + num_turns: 1, + session_id: sessionId, + model: "claude-test", + duration_ms: 42, + }, + ]; + return { + async *[Symbol.asyncIterator]() { + for (const m of messages) yield m; + }, + interrupt: async () => {}, + close: async () => {}, + }; + }, + }; +} + +function makeMockCopilotSession() { + const handlers = new Map(); + return { + sessionId: "copilot-otel-sess", + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(handler); + return () => {}; + }, + async send() {}, + async sendAndWait() { + return { data: { content: "copilot-otel-pong" } }; + }, + async disconnect() {}, + async destroy() {}, + emit(event, data) { + for (const h of handlers.get(event) ?? []) h({ data }); + }, + }; +} + +async function queryPhoenixSpans(limit = 20) { + const res = await fetch(`${phoenixApi}/v1/projects/default/spans?limit=${limit}`); + if (!res.ok) throw new Error(`Phoenix spans HTTP ${res.status}`); + return res.json(); +} + +function findProviderSpans(spans, sdk) { + return (spans.data ?? []).filter( + (s) => + s.name === "a2a.task.execute" && + s.attributes?.["a2a.wrapper.sdk"] === sdk, + ); +} + +async function runClaude(core) { + const { ClaudeExecutor } = await load("a2a-claude/dist/claude/executor.js"); + const { DEFAULTS } = await load("a2a-claude/dist/config/defaults.js"); + const pkg = require(join(root, "a2a-claude/package.json")); + + const ws = mkdtempSync(join(tmpdir(), "a2a-claude-otel-")); + try { + const config = structuredClone(DEFAULTS); + config.claude.workingDirectory = ws; + config.events = { enabled: false }; + config.otel = { + enabled: true, + serviceName: "a2a-claude-smoke", + taskUsageRollup: true, + annotateUsageCalls: true, + exporter: { endpoint, protocol: "http/protobuf" }, + }; + + const fake = makeFakeClaudeClient(); + const raw = new ClaudeExecutor(config, () => fake); + const ex = core.instrumentExecutor(raw, { + otel: config.otel, + agentName: config.agentCard.name, + wrapperName: pkg.name, + wrapperVersion: pkg.version, + }); + + await ex.initialize(); + const bus = makeBus(); + const taskId = `claude-${Date.now()}`; + await ex.execute(makeCtx(taskId, "claude-conv", "say pong"), bus); + await ex.shutdown(); + return { taskId, sdk: pkg.name, version: pkg.version }; + } finally { + rmSync(ws, { recursive: true, force: true }); + } +} + +async function runCopilot(core) { + const { CopilotExecutor } = await load("a2a-copilot/dist/copilot/executor.js"); + const { DEFAULTS } = await load("a2a-copilot/dist/config/defaults.js"); + const pkg = require(join(root, "a2a-copilot/package.json")); + + const config = structuredClone(DEFAULTS); + config.copilot.streaming = false; // sendAndWait path is simpler for the mock + config.events = { enabled: false }; + config.features = { ...(config.features ?? {}), trackUsage: true }; + config.otel = { + enabled: true, + serviceName: "a2a-copilot-smoke", + taskUsageRollup: true, + annotateUsageCalls: true, + exporter: { endpoint, protocol: "http/protobuf" }, + backend: { + // Marks backend OTel "on" for de-dupe policy; we still want task usage rollup. + copilot: { otlpEndpoint: endpoint, propagateTraceContext: true }, + }, + }; + + const raw = new CopilotExecutor(config); + const session = makeMockCopilotSession(); + + // Skip real Copilot CLI spawn (policy-denied in this env) but keep executor logic. + raw.initialize = async () => { + raw.sessionManager = { + getOrCreate: async () => ({ sessionId: session.sessionId, session, isNew: true }), + trackTask: () => {}, + untrackTask: () => {}, + getSessionForTask: () => undefined, + getContextForTask: () => "copilot-conv", + startCleanup: () => {}, + shutdown: async () => {}, + }; + raw.mcpHooks = { setEmitter: () => {}, clearEmitter: () => {} }; + raw.initialized = true; + }; + + const origSendAndWait = session.sendAndWait.bind(session); + session.sendAndWait = async (params) => { + // Fire usage the same way Copilot SDK does (see usage-telemetry tests). + session.emit("assistant.usage", { + model: "gpt-4.1-smoke", + inputTokens: 21, + outputTokens: 9, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + duration: 55, + timeToFirstTokenMs: 12, + cost: 0, + apiEndpoint: null, + initiator: null, + }); + return origSendAndWait(params); + }; + + const ex = core.instrumentExecutor(raw, { + otel: config.otel, + agentName: config.agentCard.name, + wrapperName: pkg.name, + wrapperVersion: pkg.version, + }); + + await ex.initialize(); + const bus = makeBus(); + const taskId = `copilot-${Date.now()}`; + await ex.execute(makeCtx(taskId, "copilot-conv", "say pong"), bus); + await ex.shutdown(); + return { taskId, sdk: pkg.name, version: pkg.version }; +} + +async function main() { + const core = await load("packages/core/dist/index.js"); + const ok = await core.bootstrapOtelSdkFromConfig({ + enabled: true, + serviceName: "a2a-provider-smoke", + exporter: { endpoint, protocol: "http/protobuf" }, + }); + if (!ok) { + console.error("OTel bootstrap failed — need sdk-node + exporter-trace-otlp-proto"); + process.exit(1); + } + + console.log("Running Claude executor path…"); + const claude = await runClaude(core); + console.log(" ok", claude); + + console.log("Running Copilot executor path…"); + const copilot = await runCopilot(core); + console.log(" ok", copilot); + + await core.shutdownOtelSdk(); + await new Promise((r) => setTimeout(r, 2000)); + + const spans = await queryPhoenixSpans(50); + const claudeSpans = findProviderSpans(spans, "a2a-claude"); + const copilotSpans = findProviderSpans(spans, "a2a-copilot"); + + const summarize = (s) => ({ + name: s.name, + sdk: s.attributes?.["a2a.wrapper.sdk"], + sdkVersion: s.attributes?.["a2a.wrapper.sdk.version"], + coreVersion: s.attributes?.["a2a.wrapper.core.version"], + taskId: s.attributes?.["a2a.task.id"], + input: s.attributes?.["a2a.task.usage.input_tokens"], + output: s.attributes?.["a2a.task.usage.output_tokens"], + conversation: s.attributes?.["gen_ai.conversation.id"], + }); + + console.log("\nPhoenix spans for a2a-claude:", claudeSpans.length); + console.log(JSON.stringify(claudeSpans.map(summarize), null, 2)); + console.log("\nPhoenix spans for a2a-copilot:", copilotSpans.length); + console.log(JSON.stringify(copilotSpans.map(summarize), null, 2)); + + if (!claudeSpans.length || !copilotSpans.length) { + console.error("\nFAILED: expected at least one span per provider SDK in Phoenix"); + process.exit(2); + } + console.log("\nOK — both provider wrapper spans ingested by Phoenix"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From f95fb7f44863800a170732b8610f26e1dc9b8d5d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:25:44 +0000 Subject: [PATCH 09/12] docs(otel): document provider smoke against Phoenix Co-authored-by: Shashi Kanth G S --- .changeset/otel-phoenix-identity.md | 2 +- examples/otel-stack/README.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.changeset/otel-phoenix-identity.md b/.changeset/otel-phoenix-identity.md index 8261954..8d2c6e1 100644 --- a/.changeset/otel-phoenix-identity.md +++ b/.changeset/otel-phoenix-identity.md @@ -14,4 +14,4 @@ feat(otel): Phoenix demo, package identity attrs, usage flags - Stamp `a2a.wrapper.core.version`, `a2a.wrapper.sdk`, `a2a.wrapper.sdk.version` on task spans - Wire all wrapper servers with package name/version - `otel.taskUsageRollup` (default true) + `otel.annotateUsageCalls` (default false; Claude/Codex per-call events) -- Smoke script: `examples/otel-stack/smoke-export.mjs` +- Smoke scripts: `examples/otel-stack/smoke-export.mjs` + `smoke-providers.mjs` (copilot/claude executor → Phoenix) diff --git a/examples/otel-stack/README.md b/examples/otel-stack/README.md index 1566397..04d23ef 100644 --- a/examples/otel-stack/README.md +++ b/examples/otel-stack/README.md @@ -53,17 +53,23 @@ npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-t # npm install @opentelemetry/exporter-trace-otlp-http ``` -## Smoke script (this repo) +## Smoke scripts (this repo) With Phoenix already running: ```bash +# Synthetic core span (bootstrap + attrs only) node examples/otel-stack/smoke-export.mjs + +# Real a2a-copilot + a2a-claude *executor* paths → Phoenix +# (mocked/fake backends — this env has no Copilot policy access / Claude login) +node examples/otel-stack/smoke-providers.mjs ``` -That boots the same CLI bootstrap path, emits one `a2a.task.execute`-shaped -span (with `a2a.wrapper.core.version` + `a2a.wrapper.sdk*`), and prints the -Phoenix projects API response so you can confirm ingest without a full agent. +`smoke-providers.mjs` runs each wrapper’s `instrumentExecutor` + executor +`execute()` so Phoenix gets authentic `a2a.wrapper.sdk=a2a-copilot|a2a-claude` +spans with `a2a.task.usage.*`. It does **not** exercise live Copilot CLI / +Claude Code child spans (needs working provider auth). ## What you should see From 29fb6af8d1809fc16c6c21f8bf24c68a9f4aa116 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:34:02 +0000 Subject: [PATCH 10/12] feat(otel): mock Ollama BYOK cookout for Copilot + Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fake Ollama (OpenAI Responses + Anthropic Messages) and cookout-byok that boots real a2a-copilot / a2a-claude CLIs against it into Phoenix — no cloud keys or GPU. Wire bootstrapOtelSdkFromConfig into all hand-rolled wrapper CLIs (they were skipping SDK start) and use SimpleSpanProcessor for reliable short-lived export. Co-authored-by: Shashi Kanth G S --- .changeset/otel-byok-cookout.md | 15 + a2a-antigravity/src/cli.ts | 8 +- a2a-claude/src/cli.ts | 11 + a2a-codex/src/cli.ts | 10 + a2a-copilot/src/cli.ts | 15 +- a2a-opencode/src/cli.ts | 14 +- docs/observability.md | 12 +- examples/otel-stack/README.md | 25 +- examples/otel-stack/agent-claude-byok.json | 62 ++++ examples/otel-stack/agent-copilot-byok.json | 58 +++ examples/otel-stack/cookout-byok.mjs | 328 +++++++++++++++++ examples/otel-stack/mock-ollama.mjs | 352 +++++++++++++++++++ packages/core/src/telemetry/cli-bootstrap.ts | 13 +- 13 files changed, 906 insertions(+), 17 deletions(-) create mode 100644 .changeset/otel-byok-cookout.md create mode 100644 examples/otel-stack/agent-claude-byok.json create mode 100644 examples/otel-stack/agent-copilot-byok.json create mode 100644 examples/otel-stack/cookout-byok.mjs create mode 100644 examples/otel-stack/mock-ollama.mjs diff --git a/.changeset/otel-byok-cookout.md b/.changeset/otel-byok-cookout.md new file mode 100644 index 0000000..f9a2810 --- /dev/null +++ b/.changeset/otel-byok-cookout.md @@ -0,0 +1,15 @@ +--- +"@a2a-wrapper/core": patch +"a2a-copilot": patch +"a2a-claude": patch +"a2a-codex": patch +"a2a-opencode": patch +"a2a-antigravity": patch +--- + +feat(otel): BYOK mock-Ollama cookout; wire CLI OTel bootstrap on all wrappers + +- examples/otel-stack/mock-ollama.mjs (OpenAI Responses + Anthropic Messages) +- examples/otel-stack/cookout-byok.mjs end-to-end against Phoenix +- Hand-rolled wrapper CLIs now call bootstrapOtelSdkFromConfig / shutdownOtelSdk +- CLI bootstrap uses SimpleSpanProcessor for reliable short-lived export diff --git a/a2a-antigravity/src/cli.ts b/a2a-antigravity/src/cli.ts index 32f25ac..1a986ae 100644 --- a/a2a-antigravity/src/cli.ts +++ b/a2a-antigravity/src/cli.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; -import { Logger } from "@a2a-wrapper/core"; +import { Logger, bootstrapOtelSdkFromConfig, shutdownOtelSdk } from "@a2a-wrapper/core"; import { resolveConfig } from "./config/loader.js"; import type { AgentConfig, AntigravityConfig } from "./config/types.js"; import { createA2AServer } from "./server/index.js"; @@ -211,6 +211,10 @@ async function main(): Promise { authMode: config.antigravity.provider?.authMode ?? "sdkDefault", }); + if (await bootstrapOtelSdkFromConfig(config.otel)) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + const handle = await createA2AServer(config); let shuttingDown = false; @@ -220,6 +224,8 @@ async function main(): Promise { log.info(`${signal} received, shutting down...`); try { await handle.shutdown(); + await shutdownOtelSdk(); + await new Promise((r) => setTimeout(r, 300)); } catch (err) { log.error("Shutdown failed", { error: (err as Error).message, stack: (err as Error).stack }); process.exit(1); diff --git a/a2a-claude/src/cli.ts b/a2a-claude/src/cli.ts index 531482b..9660d5c 100644 --- a/a2a-claude/src/cli.ts +++ b/a2a-claude/src/cli.ts @@ -19,6 +19,7 @@ import { parseArgs } from "node:util"; import { dirname, resolve } from "node:path"; import { resolveConfig } from "./config/loader.js"; import type { AgentConfig, ClaudeConfig } from "./config/types.js"; +import { bootstrapOtelSdkFromConfig, shutdownOtelSdk } from "@a2a-wrapper/core"; import { createA2AServer } from "./server/index.js"; import { logger, LogLevel } from "./utils/logger.js"; @@ -172,11 +173,21 @@ async function main(): Promise { permissionMode: config.claude?.permissionMode, }); + if (await bootstrapOtelSdkFromConfig(config.otel)) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + const handle = await createA2AServer(config); + let shuttingDown = false; const shutdown = async (signal: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; log.info(`${signal} received, shutting down...`); await handle.shutdown(); + await shutdownOtelSdk(); + // Brief drain so OTLP HTTP sockets finish (do not .unref() — that races exit). + await new Promise((r) => setTimeout(r, 300)); process.exit(0); }; diff --git a/a2a-codex/src/cli.ts b/a2a-codex/src/cli.ts index d477632..f097ade 100644 --- a/a2a-codex/src/cli.ts +++ b/a2a-codex/src/cli.ts @@ -19,6 +19,7 @@ import { parseArgs } from "node:util"; import { dirname, resolve } from "node:path"; import { resolveConfig } from "./config/loader.js"; import type { AgentConfig, CodexConfig } from "./config/types.js"; +import { bootstrapOtelSdkFromConfig, shutdownOtelSdk } from "@a2a-wrapper/core"; import { createA2AServer } from "./server/index.js"; import { logger, LogLevel } from "./utils/logger.js"; @@ -173,11 +174,20 @@ async function main(): Promise { sandboxMode: config.codex?.sandboxMode, }); + if (await bootstrapOtelSdkFromConfig(config.otel)) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + const handle = await createA2AServer(config); + let shuttingDown = false; const shutdown = async (signal: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; log.info(`${signal} received, shutting down...`); await handle.shutdown(); + await shutdownOtelSdk(); + await new Promise((r) => setTimeout(r, 300)); process.exit(0); }; diff --git a/a2a-copilot/src/cli.ts b/a2a-copilot/src/cli.ts index 7352136..e0d77df 100644 --- a/a2a-copilot/src/cli.ts +++ b/a2a-copilot/src/cli.ts @@ -19,6 +19,7 @@ import { parseArgs } from "node:util"; import { dirname, resolve } from "node:path"; import { resolveConfig } from "./config/loader.js"; import type { AgentConfig } from "./config/types.js"; +import { bootstrapOtelSdkFromConfig, shutdownOtelSdk } from "@a2a-wrapper/core"; import { createA2AServer } from "./server/index.js"; import { logger, LogLevel } from "./utils/logger.js"; @@ -170,18 +171,28 @@ async function main() { cliUrl: config.copilot?.cliUrl || "(auto-discovery)", }); + if (await bootstrapOtelSdkFromConfig(config.otel)) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + // Start server const handle = await createA2AServer(config as Required); + let shuttingDown = false; // Graceful shutdown const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; log.info(`${signal} received, shutting down...`); await handle.shutdown(); + await shutdownOtelSdk(); + // Brief drain so OTLP HTTP sockets finish (do not .unref() — that races exit). + await new Promise((r) => setTimeout(r, 300)); process.exit(0); }; - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); } main().catch((err) => { diff --git a/a2a-opencode/src/cli.ts b/a2a-opencode/src/cli.ts index 10ac285..dafd464 100644 --- a/a2a-opencode/src/cli.ts +++ b/a2a-opencode/src/cli.ts @@ -19,6 +19,7 @@ import { parseArgs } from "node:util"; import { dirname, resolve } from "node:path"; import { resolveConfig } from "./config/loader.js"; import type { AgentConfig } from "./config/types.js"; +import { bootstrapOtelSdkFromConfig, shutdownOtelSdk } from "@a2a-wrapper/core"; import { createA2AServer } from "./server/index.js"; import { logger, LogLevel } from "./utils/logger.js"; @@ -184,18 +185,27 @@ async function main() { model: config.opencode?.model || "(provider default)", }); + if (await bootstrapOtelSdkFromConfig(config.otel)) { + log.info("OpenTelemetry SDK bootstrapped from otel.exporter.endpoint"); + } + // Start server const handle = await createA2AServer(config as Required); + let shuttingDown = false; // Graceful shutdown const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; log.info(`${signal} received, shutting down...`); await handle.shutdown(); + await shutdownOtelSdk(); + await new Promise((r) => setTimeout(r, 300)); process.exit(0); }; - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); } main().catch((err) => { diff --git a/docs/observability.md b/docs/observability.md index e07eb22..2617e46 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -60,7 +60,17 @@ phoenix serve ``` Optional Docker image is in [`examples/otel-stack`](../examples/otel-stack) if you prefer compose. -Smoke without a full agent: `node examples/otel-stack/smoke-export.mjs`. + +**BYOK cookout (recommended):** fake Ollama + real wrapper CLIs — no cloud keys: + +```bash +phoenix serve # terminal 1 +node examples/otel-stack/cookout-byok.mjs # terminal 2 +``` + +That starts `mock-ollama.mjs`, points `a2a-copilot` at `/v1` (Responses API) and +`a2a-claude` at `ANTHROPIC_BASE_URL` (Messages API), sends A2A `message/send`, and +checks Phoenix for `a2a.wrapper.sdk` spans. Same shape as real Ollama BYOK. ## Agent config diff --git a/examples/otel-stack/README.md b/examples/otel-stack/README.md index 04d23ef..3a6a251 100644 --- a/examples/otel-stack/README.md +++ b/examples/otel-stack/README.md @@ -53,23 +53,30 @@ npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-t # npm install @opentelemetry/exporter-trace-otlp-http ``` -## Smoke scripts (this repo) +## Smoke / cookout scripts (this repo) With Phoenix already running: ```bash -# Synthetic core span (bootstrap + attrs only) +# 1) Synthetic core span (bootstrap + attrs only) node examples/otel-stack/smoke-export.mjs -# Real a2a-copilot + a2a-claude *executor* paths → Phoenix -# (mocked/fake backends — this env has no Copilot policy access / Claude login) -node examples/otel-stack/smoke-providers.mjs +# 2) Recommended: BYOK cookout via **fake Ollama** (no GPU, no cloud keys) +# - mock-ollama speaks OpenAI /v1/responses (Copilot) + Anthropic /v1/messages (Claude) +# - boots real a2a-copilot + a2a-claude CLIs against it +# - asserts Phoenix has a2a.wrapper.sdk spans + usage +node examples/otel-stack/cookout-byok.mjs ``` -`smoke-providers.mjs` runs each wrapper’s `instrumentExecutor` + executor -`execute()` so Phoenix gets authentic `a2a.wrapper.sdk=a2a-copilot|a2a-claude` -spans with `a2a.task.usage.*`. It does **not** exercise live Copilot CLI / -Claude Code child spans (needs working provider auth). +Why fake Ollama? Copilot already supports BYOK → Ollama (`copilot.provider.baseUrl`), +and Claude Code supports Ollama via `ANTHROPIC_BASE_URL` (Anthropic-compatible API). +That is a more realistic cookout than stubbing the session manager. + +Optional lower-level script (executor + fake client, no CLI spawn): + +```bash +node examples/otel-stack/smoke-providers.mjs +``` ## What you should see diff --git a/examples/otel-stack/agent-claude-byok.json b/examples/otel-stack/agent-claude-byok.json new file mode 100644 index 0000000..34a526f --- /dev/null +++ b/examples/otel-stack/agent-claude-byok.json @@ -0,0 +1,62 @@ +{ + "agentCard": { + "name": "Claude BYOK Cookout", + "description": "a2a-claude against mock Ollama Anthropic API + Phoenix OTel", + "version": "1.0.0", + "protocolVersion": "0.3.0", + "streaming": true, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [] + }, + "server": { + "port": 3102, + "hostname": "127.0.0.1", + "advertiseHost": "127.0.0.1", + "advertiseProtocol": "http" + }, + "claude": { + "workingDirectory": "/tmp/a2a-cookout-claude-ws", + "model": "mock-ollama", + "permissionMode": "bypassPermissions", + "dangerouslyAllowBypassPermissions": true, + "settingSources": [], + "systemPromptAppend": "Reply briefly. You are a cookout test agent." + }, + "session": { + "reuseByContext": true, + "ttl": 0 + }, + "features": { + "streamArtifactChunks": false, + "emitThinkingEvents": false, + "emitToolEvents": false, + "emitFileChangeEvents": false, + "emitTodoEvents": false, + "holdTaskForBackgroundWork": false + }, + "timeouts": { + "prompt": 60000 + }, + "logging": { + "level": "info" + }, + "events": { + "enabled": false + }, + "otel": { + "enabled": true, + "serviceName": "a2a-claude-byok-cookout", + "taskUsageRollup": true, + "exporter": { + "endpoint": "http://127.0.0.1:6006", + "protocol": "http/protobuf" + }, + "backend": { + "claude": { + "enableTelemetry": false, + "propagateTraceContext": true + } + } + } +} diff --git a/examples/otel-stack/agent-copilot-byok.json b/examples/otel-stack/agent-copilot-byok.json new file mode 100644 index 0000000..c05423d --- /dev/null +++ b/examples/otel-stack/agent-copilot-byok.json @@ -0,0 +1,58 @@ +{ + "agentCard": { + "name": "Copilot BYOK Cookout", + "description": "a2a-copilot against mock Ollama + Phoenix OTel", + "version": "1.0.0", + "protocolVersion": "0.3.0", + "streaming": true, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [] + }, + "server": { + "port": 3101, + "hostname": "127.0.0.1", + "advertiseHost": "127.0.0.1", + "advertiseProtocol": "http" + }, + "copilot": { + "model": "mock-ollama", + "streaming": true, + "systemPromptMode": "replace", + "systemPrompt": "Reply briefly. You are a cookout test agent.", + "workspaceDirectory": "/tmp/a2a-cookout-copilot-ws", + "provider": { + "type": "openai", + "baseUrl": "http://127.0.0.1:11434/v1", + "apiKey": "ollama", + "wireApi": "responses" + } + }, + "features": { + "streamArtifactChunks": false, + "trackUsage": true + }, + "timeouts": { + "prompt": 60000 + }, + "logging": { + "level": "info" + }, + "events": { + "enabled": false + }, + "otel": { + "enabled": true, + "serviceName": "a2a-copilot-byok-cookout", + "taskUsageRollup": true, + "exporter": { + "endpoint": "http://127.0.0.1:6006", + "protocol": "http/protobuf" + }, + "backend": { + "copilot": { + "propagateTraceContext": true + } + } + } +} diff --git a/examples/otel-stack/cookout-byok.mjs b/examples/otel-stack/cookout-byok.mjs new file mode 100644 index 0000000..8c2e3e4 --- /dev/null +++ b/examples/otel-stack/cookout-byok.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +/** + * End-to-end OTel cookout via fake Ollama (BYOK) — Copilot + Claude. + * + * Prerequisites: + * - packages built: core, a2a-copilot, a2a-claude + * - optional peers: @opentelemetry/sdk-node + exporter-trace-otlp-proto + * - Phoenix: `phoenix serve` (or set SKIP_PHOENIX_CHECK=1) + * + * node examples/otel-stack/cookout-byok.mjs + * + * Flow: + * 1. Start mock-ollama (OpenAI + Anthropic compatible) + * 2. Start a2a-copilot with BYOK provider → mock + * 3. Start a2a-claude with ANTHROPIC_BASE_URL → mock + * 4. POST message/send to each + * 5. Assert Phoenix has a2a.wrapper.sdk spans for both + */ + +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, "../.."); +const require = createRequire(import.meta.url); + +const MOCK_PORT = Number(process.env.MOCK_OLLAMA_PORT || 19134); +const PHOENIX = process.env.PHOENIX_API || "http://127.0.0.1:6006"; +const COPILOT_PORT = Number(process.env.COOKOUT_COPILOT_PORT || 3101); +const CLAUDE_PORT = Number(process.env.COOKOUT_CLAUDE_PORT || 3102); +const MODEL = process.env.MOCK_OLLAMA_MODEL || "mock-ollama"; + +const children = []; +/** @type {import('node:child_process').ChildProcess[]} */ +const agentChildren = []; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function spawnLogged(cmd, args, opts = {}) { + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + ...opts, + }); + const label = opts.label || cmd; + child.stdout.on("data", (d) => process.stdout.write(`[${label}] ${d}`)); + child.stderr.on("data", (d) => process.stderr.write(`[${label}] ${d}`)); + children.push(child); + if (opts.agent) agentChildren.push(child); + return child; +} + +async function killAgentsAndWait(timeoutMs = 15_000) { + await Promise.all( + agentChildren.map( + (c) => + new Promise((resolve) => { + if (c.exitCode !== null || c.signalCode) { + resolve(undefined); + return; + } + const timer = setTimeout(() => { + try { + c.kill("SIGKILL"); + } catch { + /* ignore */ + } + resolve(undefined); + }, timeoutMs); + c.once("exit", () => { + clearTimeout(timer); + resolve(undefined); + }); + try { + c.kill("SIGTERM"); + } catch { + clearTimeout(timer); + resolve(undefined); + } + }), + ), + ); +} + +async function waitHttp(url, { timeoutMs = 30_000, ok = (s) => s >= 200 && s < 500 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url, { method: "GET" }); + if (ok(res.status)) return; + } catch { + /* retry */ + } + await sleep(250); + } + throw new Error(`timeout waiting for ${url}`); +} + +async function messageSend(port, text) { + const contextId = randomUUID(); + const messageId = randomUUID(); + // Do NOT pre-assign taskId — A2A creates the task. Passing an unknown taskId + // yields "Task not found" from the SDK ResultManager. + const res = await fetch(`http://127.0.0.1:${port}/a2a/jsonrpc`, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(90_000), + body: JSON.stringify({ + jsonrpc: "2.0", + id: randomUUID(), + method: "message/send", + params: { + message: { + kind: "message", + messageId, + role: "user", + contextId, + parts: [{ kind: "text", text }], + }, + configuration: { blocking: true }, + }, + }), + }); + const body = await res.json(); + const taskId = body.result?.id ?? body.result?.taskId ?? body.result?.status?.taskId; + return { status: res.status, body, taskId, contextId, messageId }; +} + +function writePatchedConfig(srcName, port, mockBase) { + const src = JSON.parse(readFileSync(join(__dirname, srcName), "utf8")); + src.server.port = port; + if (src.copilot?.provider) { + src.copilot.provider.baseUrl = `${mockBase}/v1`; + src.copilot.model = MODEL; + src.copilot.workspaceDirectory = `/tmp/a2a-cookout-copilot-ws`; + mkdirSync(src.copilot.workspaceDirectory, { recursive: true }); + } + if (src.claude) { + src.claude.model = MODEL; + src.claude.workingDirectory = `/tmp/a2a-cookout-claude-ws`; + mkdirSync(src.claude.workingDirectory, { recursive: true }); + } + const out = join("/tmp", `a2a-cookout-${srcName}`); + writeFileSync(out, JSON.stringify(src, null, 2)); + return out; +} + +async function phoenixSpans(limit = 50) { + const res = await fetch(`${PHOENIX}/v1/projects/default/spans?limit=${limit}`); + if (!res.ok) throw new Error(`Phoenix spans HTTP ${res.status}`); + return res.json(); +} + +function cleanup() { + for (const c of children) { + try { + c.kill("SIGTERM"); + } catch { + /* ignore */ + } + } +} + +process.on("exit", cleanup); +process.on("SIGINT", () => { + cleanup(); + process.exit(130); +}); + +async function main() { + if (!process.env.SKIP_PHOENIX_CHECK) { + await waitHttp(`${PHOENIX}/healthz`, { timeoutMs: 5_000 }); + console.log("Phoenix OK", PHOENIX); + } + + const mockBase = `http://127.0.0.1:${MOCK_PORT}`; + console.log("Starting mock-ollama on", MOCK_PORT); + spawnLogged(process.execPath, [join(__dirname, "mock-ollama.mjs")], { + label: "mock-ollama", + env: { + ...process.env, + MOCK_OLLAMA_PORT: String(MOCK_PORT), + MOCK_OLLAMA_MODEL: MODEL, + MOCK_OLLAMA_REPLY: "pong-from-mock-ollama", + }, + }); + await waitHttp(`${mockBase}/health`); + + // Verify CLIs can talk to mock (cheap sanity) + const chat = await fetch(`${mockBase}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, messages: [{ role: "user", content: "hi" }] }), + }).then((r) => r.json()); + console.log("mock chat ok:", chat.choices?.[0]?.message?.content); + + const copilotCfg = writePatchedConfig("agent-copilot-byok.json", COPILOT_PORT, mockBase); + const claudeCfg = writePatchedConfig("agent-claude-byok.json", CLAUDE_PORT, mockBase); + + // Sequential: one provider at a time so each CLI can flush OTel cleanly. + console.log("Starting a2a-copilot…"); + spawnLogged(process.execPath, [join(root, "a2a-copilot/dist/cli.js"), "--config", copilotCfg], { + label: "a2a-copilot", + agent: true, + env: { + ...process.env, + COPILOT_PROVIDER_BASE_URL: `${mockBase}/v1`, + COPILOT_PROVIDER_API_KEY: "ollama", + COPILOT_PROVIDER_WIRE_API: "responses", + COPILOT_MODEL: MODEL, + COPILOT_ALLOW_ALL: "1", + }, + cwd: root, + }); + await waitHttp(`http://127.0.0.1:${COPILOT_PORT}/health`, { timeoutMs: 60_000 }); + + console.log("Sending A2A message/send → copilot…"); + const copilotRes = await messageSend(COPILOT_PORT, "Reply with exactly: pong"); + console.log("copilot status", copilotRes.status); + if (copilotRes.body.error) console.error("copilot error", copilotRes.body.error); + else console.log("copilot taskId", copilotRes.taskId, "contextId", copilotRes.contextId); + // Let SimpleSpanProcessor finish the OTLP POST before SIGTERM. + await sleep(1000); + + console.log("Stopping a2a-copilot (flush OTel)…"); + await killAgentsAndWait(); + agentChildren.length = 0; + + console.log("Starting a2a-claude…"); + spawnLogged(process.execPath, [join(root, "a2a-claude/dist/cli.js"), "--config", claudeCfg], { + label: "a2a-claude", + agent: true, + env: { + ...process.env, + ANTHROPIC_BASE_URL: mockBase, + ANTHROPIC_API_KEY: "ollama", + ANTHROPIC_AUTH_TOKEN: "ollama", + WORKSPACE_DIR: "/tmp/a2a-cookout-claude-ws", + CLAUDE_MODEL: MODEL, + }, + cwd: root, + }); + await waitHttp(`http://127.0.0.1:${CLAUDE_PORT}/health`, { timeoutMs: 60_000 }); + + console.log("Sending A2A message/send → claude…"); + const claudeRes = await messageSend(CLAUDE_PORT, "Reply with exactly: pong"); + console.log("claude status", claudeRes.status); + if (claudeRes.body.error) console.error("claude error", claudeRes.body.error); + else console.log("claude taskId", claudeRes.taskId, "contextId", claudeRes.contextId); + await sleep(1000); + + console.log("Stopping a2a-claude (flush OTel)…"); + await killAgentsAndWait(); + await sleep(500); + + const spans = await phoenixSpans(100); + const matchCookout = (sdk, contextId, taskId) => + (spans.data ?? []).filter((s) => { + if (s.name !== "a2a.task.execute") return false; + if (s.attributes?.["a2a.wrapper.sdk"] !== sdk) return false; + const conv = s.attributes?.["gen_ai.conversation.id"]; + const tid = s.attributes?.["a2a.task.id"]; + return conv === contextId || tid === taskId; + }); + + const copilotSpans = matchCookout("a2a-copilot", copilotRes.contextId, copilotRes.taskId); + const claudeSpans = matchCookout("a2a-claude", claudeRes.contextId, claudeRes.taskId); + const summarize = (s) => ({ + sdk: s.attributes?.["a2a.wrapper.sdk"], + sdkVersion: s.attributes?.["a2a.wrapper.sdk.version"], + core: s.attributes?.["a2a.wrapper.core.version"], + taskId: s.attributes?.["a2a.task.id"], + conversation: s.attributes?.["gen_ai.conversation.id"], + input: s.attributes?.["a2a.task.usage.input_tokens"], + output: s.attributes?.["a2a.task.usage.output_tokens"], + }); + + console.log("\nPhoenix a2a-copilot cookout spans:", copilotSpans.length); + console.log(JSON.stringify(copilotSpans.map(summarize), null, 2)); + console.log("\nPhoenix a2a-claude cookout spans:", claudeSpans.length); + console.log(JSON.stringify(claudeSpans.map(summarize), null, 2)); + + const mockReqs = await fetch(`${mockBase}/__mock__/requests`).then((r) => r.json()); + const paths = mockReqs.requests?.map((r) => `${r.method} ${r.path}`) ?? []; + console.log("\nMock Ollama request paths:", paths); + const sawCopilotLlm = paths.some((p) => p.includes("/v1/responses") || p.includes("/chat/completions")); + const sawClaudeLlm = paths.some((p) => p.includes("/v1/messages")); + + let failed = false; + if (copilotRes.body.error || claudeRes.body.error) { + console.error("\nFAILED: message/send returned RPC error"); + failed = true; + } + if (!sawCopilotLlm) { + console.error("\nFAILED: mock-ollama never saw Copilot LLM traffic (/v1/responses)"); + failed = true; + } + if (!sawClaudeLlm) { + console.error("\nFAILED: mock-ollama never saw Claude LLM traffic (/v1/messages)"); + failed = true; + } + if (!copilotSpans.length || !claudeSpans.length) { + console.error("\nFAILED: expected Phoenix spans for this cookout's context/task ids"); + failed = true; + } + + if (failed) { + process.exitCode = 2; + } else { + console.log("\nOK — BYOK cookout: mock Ollama served Copilot+Claude; Phoenix has both wrapper spans"); + } + + cleanup(); + // Give children a moment to exit before process ends. + await sleep(500); + process.exit(process.exitCode ?? 0); +} + +main().catch((err) => { + console.error(err); + cleanup(); + process.exit(1); +}); diff --git a/examples/otel-stack/mock-ollama.mjs b/examples/otel-stack/mock-ollama.mjs new file mode 100644 index 0000000..482248e --- /dev/null +++ b/examples/otel-stack/mock-ollama.mjs @@ -0,0 +1,352 @@ +#!/usr/bin/env node +/** + * Lightweight fake Ollama for BYOK cookouts (no GPU / no model pull). + * + * Speaks enough of: + * - OpenAI Chat Completions POST /v1/chat/completions + * - OpenAI Responses POST /v1/responses (Copilot ↔ Ollama default) + * - Anthropic Messages POST /v1/messages (Claude Code ↔ Ollama) + * - Model list GET /v1/models, GET /api/tags + * + * node examples/otel-stack/mock-ollama.mjs + * MOCK_OLLAMA_PORT=11434 node examples/otel-stack/mock-ollama.mjs + * + * Point Copilot at: http://127.0.0.1:/v1 + * Point Claude at: ANTHROPIC_BASE_URL=http://127.0.0.1: + */ + +import http from "node:http"; +import { randomUUID } from "node:crypto"; + +const PORT = Number(process.env.MOCK_OLLAMA_PORT || 11434); +const MODEL = process.env.MOCK_OLLAMA_MODEL || "mock-ollama"; +const REPLY = process.env.MOCK_OLLAMA_REPLY || "pong-from-mock-ollama"; + +const requests = []; + +function json(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + }); + res.end(data); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function usage() { + return { + prompt_tokens: 12, + completion_tokens: 5, + total_tokens: 17, + input_tokens: 12, + output_tokens: 5, + }; +} + +function chatCompletion(body, stream) { + const id = `chatcmpl-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + if (stream) { + return { id, model, stream: true }; + } + return { + id, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: REPLY }, + finish_reason: "stop", + }, + ], + usage: usage(), + }; +} + +function writeChatStream(res, body) { + const id = `chatcmpl-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const chunk1 = { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { role: "assistant", content: REPLY }, finish_reason: null }], + }; + const chunk2 = { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: usage(), + }; + res.write(`data: ${JSON.stringify(chunk1)}\n\n`); + res.write(`data: ${JSON.stringify(chunk2)}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); +} + +function responsesApi(body) { + const id = `resp-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + return { + id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model, + output: [ + { + type: "message", + id: `msg-${randomUUID().slice(0, 8)}`, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + ], + output_text: REPLY, + usage: { + input_tokens: 12, + output_tokens: 5, + total_tokens: 17, + }, + }; +} + +function writeResponsesStream(res, body) { + const model = body.model || MODEL; + const responseId = `resp-${randomUUID().slice(0, 8)}`; + const itemId = `msg-${randomUUID().slice(0, 8)}`; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const events = [ + { + type: "response.created", + response: { id: responseId, object: "response", status: "in_progress", model }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] }, + }, + { + type: "response.output_text.delta", + item_id: itemId, + output_index: 0, + content_index: 0, + delta: REPLY, + }, + { + type: "response.output_text.done", + item_id: itemId, + output_index: 0, + content_index: 0, + text: REPLY, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + }, + { + type: "response.completed", + response: { + id: responseId, + object: "response", + status: "completed", + model, + output: [ + { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + ], + usage: { input_tokens: 12, output_tokens: 5, total_tokens: 17 }, + }, + }, + ]; + for (const ev of events) { + res.write(`event: ${ev.type}\n`); + res.write(`data: ${JSON.stringify(ev)}\n\n`); + } + res.end(); +} + +function anthropicMessage(body, stream) { + const id = `msg_${randomUUID().slice(0, 12)}`; + const model = body.model || MODEL; + if (stream) { + return { id, model, stream: true }; + } + return { + id, + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: REPLY }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 5 }, + }; +} + +function writeAnthropicStream(res, body) { + const id = `msg_${randomUUID().slice(0, 12)}`; + const model = body.model || MODEL; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const events = [ + { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model, + content: [], + stop_reason: null, + usage: { input_tokens: 12, output_tokens: 0 }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: REPLY } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 5 }, + }, + { type: "message_stop" }, + ]; + for (const ev of events) { + res.write(`event: ${ev.type}\n`); + res.write(`data: ${JSON.stringify(ev)}\n\n`); + } + res.end(); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + const path = url.pathname; + let body = {}; + try { + if (req.method === "POST") body = await readBody(req); + } catch { + return json(res, 400, { error: { message: "invalid json" } }); + } + + requests.push({ + t: new Date().toISOString(), + method: req.method, + path, + model: body.model, + stream: Boolean(body.stream), + }); + console.log(`[mock-ollama] ${req.method} ${path}${body.model ? ` model=${body.model}` : ""}${body.stream ? " stream" : ""}`); + + // Health / discovery (Claude Code probes HEAD /api/hello) + if ( + (req.method === "GET" || req.method === "HEAD") && + (path === "/" || path === "/health" || path === "/api/version" || path === "/api/hello") + ) { + if (req.method === "HEAD") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(); + return; + } + return json(res, 200, { status: "ok", version: "0.0.0-mock", mock: true }); + } + if (req.method === "GET" && (path === "/v1/models" || path === "/models")) { + return json(res, 200, { + object: "list", + data: [{ id: MODEL, object: "model", owned_by: "mock-ollama" }], + }); + } + if (req.method === "GET" && path === "/api/tags") { + return json(res, 200, { + models: [{ name: MODEL, model: MODEL, modified_at: new Date().toISOString(), size: 1 }], + }); + } + if (req.method === "GET" && path === "/__mock__/requests") { + return json(res, 200, { requests }); + } + + // OpenAI chat completions + if (req.method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) { + if (body.stream) return writeChatStream(res, body); + return json(res, 200, chatCompletion(body, false)); + } + + // OpenAI responses (Copilot ↔ Ollama recommended wireApi) + if (req.method === "POST" && (path === "/v1/responses" || path === "/responses")) { + if (body.stream) return writeResponsesStream(res, body); + return json(res, 200, responsesApi(body)); + } + + // Anthropic Messages (Claude Code ↔ Ollama) + if (req.method === "POST" && (path === "/v1/messages" || path === "/messages")) { + if (body.stream) return writeAnthropicStream(res, body); + return json(res, 200, anthropicMessage(body, false)); + } + + // count_tokens sometimes probed + if (req.method === "POST" && path.endsWith("/count_tokens")) { + return json(res, 200, { input_tokens: 12 }); + } + + json(res, 404, { error: { message: `mock-ollama: no handler for ${req.method} ${path}` } }); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`[mock-ollama] listening on http://127.0.0.1:${PORT}`); + console.log(`[mock-ollama] Copilot BYOK baseUrl: http://127.0.0.1:${PORT}/v1`); + console.log(`[mock-ollama] Claude ANTHROPIC_BASE_URL: http://127.0.0.1:${PORT}`); + console.log(`[mock-ollama] model=${MODEL} reply=${JSON.stringify(REPLY)}`); +}); + +process.on("SIGINT", () => server.close(() => process.exit(0))); +process.on("SIGTERM", () => server.close(() => process.exit(0))); diff --git a/packages/core/src/telemetry/cli-bootstrap.ts b/packages/core/src/telemetry/cli-bootstrap.ts index 79e1e80..37b8edd 100644 --- a/packages/core/src/telemetry/cli-bootstrap.ts +++ b/packages/core/src/telemetry/cli-bootstrap.ts @@ -81,24 +81,33 @@ export async function bootstrapOtelSdkFromConfig( // to be present at compile time (they are optionalPeers for CLI hosts). const sdkSpec = "@opentelemetry/sdk-node"; const apiSpec = "@opentelemetry/api"; + // SimpleSpanProcessor is in sdk-trace-base (pulled in by sdk-node). + const traceBaseSpec = "@opentelemetry/sdk-trace-base"; const protocol = normalizeProtocol(otel.exporter?.protocol); - const [sdkMod, apiMod, exporter] = await Promise.all([ + const [sdkMod, apiMod, exporter, traceBaseMod] = await Promise.all([ import(sdkSpec) as Promise>, import(apiSpec) as Promise>, loadExporter(protocol), + import(traceBaseSpec) as Promise>, ]); const NodeSDK = sdkMod.NodeSDK as new (opts: Record) => Shutdownable & { start: () => void | Promise; }; + const SimpleSpanProcessor = traceBaseMod.SimpleSpanProcessor as new ( + exporter: unknown, + ) => unknown; const api = apiMod as { trace: { getTracer: (name: string, version?: string) => import("./types.js").OtelTracerLike }; }; const serviceName = otel.serviceName ?? "a2a-wrapper"; + // CLI processes are short-lived — use SimpleSpanProcessor so spans export + // promptly (BatchSpanProcessor often loses the last flush on SIGTERM). + const traceExporter = new exporter.OTLPTraceExporter({ url: tracesUrl(endpoint) }); const sdk = new NodeSDK({ serviceName, - traceExporter: new exporter.OTLPTraceExporter({ url: tracesUrl(endpoint) }), + spanProcessors: [new SimpleSpanProcessor(traceExporter)], }); await Promise.resolve(sdk.start()); _sdk = sdk; From 43f343bf289239efc658cb7432715d4649ed9562 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 09:03:42 +0000 Subject: [PATCH 11/12] chore(examples): extract shared mock-ollama; ship OTel on branch otel Move the fake Ollama stub out of otel-stack into examples/mock-ollama so any feature can cook out against real a2a-copilot / a2a-claude without cloud keys. OTel cookout now layers Phoenix config on those shared agents. Co-authored-by: Shashi Kanth G S --- .changeset/otel-byok-cookout.md | 6 +- docs/observability.md | 11 +- examples/mock-ollama/README.md | 81 ++++ .../agents/claude.json} | 24 +- .../agents/copilot.json} | 23 +- examples/mock-ollama/server.mjs | 356 +++++++++++++++++ examples/mock-ollama/smoke.mjs | 248 ++++++++++++ examples/otel-stack/README.md | 20 +- examples/otel-stack/cookout-byok.mjs | 29 +- examples/otel-stack/mock-ollama.mjs | 366 +----------------- 10 files changed, 758 insertions(+), 406 deletions(-) create mode 100644 examples/mock-ollama/README.md rename examples/{otel-stack/agent-claude-byok.json => mock-ollama/agents/claude.json} (60%) rename examples/{otel-stack/agent-copilot-byok.json => mock-ollama/agents/copilot.json} (59%) create mode 100644 examples/mock-ollama/server.mjs create mode 100644 examples/mock-ollama/smoke.mjs diff --git a/.changeset/otel-byok-cookout.md b/.changeset/otel-byok-cookout.md index f9a2810..d4c91db 100644 --- a/.changeset/otel-byok-cookout.md +++ b/.changeset/otel-byok-cookout.md @@ -7,9 +7,9 @@ "a2a-antigravity": patch --- -feat(otel): BYOK mock-Ollama cookout; wire CLI OTel bootstrap on all wrappers +feat(otel): BYOK cookout + shared mock-ollama; wire CLI OTel bootstrap -- examples/otel-stack/mock-ollama.mjs (OpenAI Responses + Anthropic Messages) -- examples/otel-stack/cookout-byok.mjs end-to-end against Phoenix +- examples/mock-ollama/ — reusable fake Ollama (OpenAI + Anthropic) for any cookout +- examples/otel-stack/cookout-byok.mjs layers Phoenix/OTel on that stub - Hand-rolled wrapper CLIs now call bootstrapOtelSdkFromConfig / shutdownOtelSdk - CLI bootstrap uses SimpleSpanProcessor for reliable short-lived export diff --git a/docs/observability.md b/docs/observability.md index 2617e46..a2f3547 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -61,16 +61,19 @@ phoenix serve Optional Docker image is in [`examples/otel-stack`](../examples/otel-stack) if you prefer compose. -**BYOK cookout (recommended):** fake Ollama + real wrapper CLIs — no cloud keys: +**BYOK cookout (recommended):** shared fake Ollama + real wrapper CLIs — no cloud keys: ```bash phoenix serve # terminal 1 node examples/otel-stack/cookout-byok.mjs # terminal 2 ``` -That starts `mock-ollama.mjs`, points `a2a-copilot` at `/v1` (Responses API) and -`a2a-claude` at `ANTHROPIC_BASE_URL` (Messages API), sends A2A `message/send`, and -checks Phoenix for `a2a.wrapper.sdk` spans. Same shape as real Ollama BYOK. +The LLM stub is [`examples/mock-ollama`](../examples/mock-ollama/) (reusable for +any feature, not just OTel). The cookout points `a2a-copilot` at `/v1` +(Responses API) and `a2a-claude` at `ANTHROPIC_BASE_URL` (Messages API), sends +A2A `message/send`, and checks Phoenix for `a2a.wrapper.sdk` spans. + +Provider-only smoke (no Phoenix): `node examples/mock-ollama/smoke.mjs`. ## Agent config diff --git a/examples/mock-ollama/README.md b/examples/mock-ollama/README.md new file mode 100644 index 0000000..e883643 --- /dev/null +++ b/examples/mock-ollama/README.md @@ -0,0 +1,81 @@ +# Mock Ollama — shared local LLM stub + +Lightweight **fake Ollama** for cookouts and feature tests. No GPU, no model +pull, no cloud keys. + +Use it whenever you need Copilot or Claude (or any OpenAI-/Anthropic-compatible +client) to complete a real turn without talking to a vendor. + +``` +examples/mock-ollama/ + server.mjs # HTTP stub (OpenAI + Anthropic APIs) + smoke.mjs # Start mock → run a2a-copilot + a2a-claude → message/send + agents/ + copilot.json # BYOK config for a2a-copilot + claude.json # config for a2a-claude (+ ANTHROPIC_BASE_URL) + README.md +``` + +## What it speaks + +| API | Path | Used by | +|---|---|---| +| OpenAI Chat Completions | `POST /v1/chat/completions` | generic OpenAI clients | +| OpenAI Responses | `POST /v1/responses` | Copilot CLI ↔ Ollama | +| Anthropic Messages | `POST /v1/messages` | Claude Code ↔ Ollama | +| Model list | `GET /v1/models`, `GET /api/tags` | discovery probes | + +Default listen: `http://127.0.0.1:11434` +Configurable reply via `MOCK_OLLAMA_REPLY` (default `pong-from-mock-ollama`). + +## Quick start + +```bash +# terminal 1 — stub +node examples/mock-ollama/server.mjs + +# terminal 2 — Copilot BYOK (no GitHub Copilot account) +# packages must be built: npm run build -w a2a-copilot +export COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:11434/v1 +export COPILOT_PROVIDER_API_KEY=ollama +export COPILOT_PROVIDER_WIRE_API=responses +export COPILOT_MODEL=mock-ollama +node a2a-copilot/dist/cli.js --config examples/mock-ollama/agents/copilot.json + +# terminal 3 — Claude via Anthropic-compatible base URL +export ANTHROPIC_BASE_URL=http://127.0.0.1:11434 +export ANTHROPIC_API_KEY=ollama +export ANTHROPIC_AUTH_TOKEN=ollama +node a2a-claude/dist/cli.js --config examples/mock-ollama/agents/claude.json +``` + +Or one-shot smoke (starts mock + both agents + `message/send`): + +```bash +node examples/mock-ollama/smoke.mjs +``` + +## Env knobs + +| Var | Default | Meaning | +|---|---|---| +| `MOCK_OLLAMA_PORT` | `11434` | listen port | +| `MOCK_OLLAMA_MODEL` | `mock-ollama` | model id returned / accepted | +| `MOCK_OLLAMA_REPLY` | `pong-from-mock-ollama` | assistant text | + +Debug: `GET /__mock__/requests` returns the request log. + +## When to use this + +- Local A2A cookouts without Copilot subscription / Claude login +- Regression tests for sessions, streaming, artifacts, usage, **OTel**, memory, … +- CI / sandboxes where real LLM calls are blocked by policy + +This is **not** a substitute for `agents/ollama/` (real Ollama + real model). +Use real Ollama when you need tool-calling quality; use this stub when you need +a deterministic “LLM answered” signal. + +## Related + +- OTel + Phoenix cookout that consumes this stub: [`examples/otel-stack`](../otel-stack) +- Real Ollama agent: [`a2a-copilot/agents/ollama`](../../a2a-copilot/agents/ollama) diff --git a/examples/otel-stack/agent-claude-byok.json b/examples/mock-ollama/agents/claude.json similarity index 60% rename from examples/otel-stack/agent-claude-byok.json rename to examples/mock-ollama/agents/claude.json index 34a526f..e97a6d6 100644 --- a/examples/otel-stack/agent-claude-byok.json +++ b/examples/mock-ollama/agents/claude.json @@ -1,7 +1,8 @@ { + "$comment": "Generic a2a-claude agent for mock-ollama Anthropic API. Set ANTHROPIC_BASE_URL=http://127.0.0.1:11434 when starting. No OTel required.", "agentCard": { - "name": "Claude BYOK Cookout", - "description": "a2a-claude against mock Ollama Anthropic API + Phoenix OTel", + "name": "Mock Ollama Claude Agent", + "description": "a2a-claude against examples/mock-ollama (Anthropic-compatible). Useful for local cookouts without Anthropic login.", "version": "1.0.0", "protocolVersion": "0.3.0", "streaming": true, @@ -16,12 +17,12 @@ "advertiseProtocol": "http" }, "claude": { - "workingDirectory": "/tmp/a2a-cookout-claude-ws", + "workingDirectory": "/tmp/a2a-mock-ollama-claude-ws", "model": "mock-ollama", "permissionMode": "bypassPermissions", "dangerouslyAllowBypassPermissions": true, "settingSources": [], - "systemPromptAppend": "Reply briefly. You are a cookout test agent." + "systemPromptAppend": "Reply briefly. You are a local mock-ollama test agent." }, "session": { "reuseByContext": true, @@ -43,20 +44,5 @@ }, "events": { "enabled": false - }, - "otel": { - "enabled": true, - "serviceName": "a2a-claude-byok-cookout", - "taskUsageRollup": true, - "exporter": { - "endpoint": "http://127.0.0.1:6006", - "protocol": "http/protobuf" - }, - "backend": { - "claude": { - "enableTelemetry": false, - "propagateTraceContext": true - } - } } } diff --git a/examples/otel-stack/agent-copilot-byok.json b/examples/mock-ollama/agents/copilot.json similarity index 59% rename from examples/otel-stack/agent-copilot-byok.json rename to examples/mock-ollama/agents/copilot.json index c05423d..f339a7b 100644 --- a/examples/otel-stack/agent-copilot-byok.json +++ b/examples/mock-ollama/agents/copilot.json @@ -1,7 +1,8 @@ { + "$comment": "Generic a2a-copilot agent pointing at mock-ollama (BYOK). No OTel required — add an otel block when needed.", "agentCard": { - "name": "Copilot BYOK Cookout", - "description": "a2a-copilot against mock Ollama + Phoenix OTel", + "name": "Mock Ollama Copilot Agent", + "description": "a2a-copilot against examples/mock-ollama (OpenAI-compatible). Useful for local cookouts without GitHub Copilot auth.", "version": "1.0.0", "protocolVersion": "0.3.0", "streaming": true, @@ -19,8 +20,8 @@ "model": "mock-ollama", "streaming": true, "systemPromptMode": "replace", - "systemPrompt": "Reply briefly. You are a cookout test agent.", - "workspaceDirectory": "/tmp/a2a-cookout-copilot-ws", + "systemPrompt": "Reply briefly. You are a local mock-ollama test agent.", + "workspaceDirectory": "/tmp/a2a-mock-ollama-copilot-ws", "provider": { "type": "openai", "baseUrl": "http://127.0.0.1:11434/v1", @@ -40,19 +41,5 @@ }, "events": { "enabled": false - }, - "otel": { - "enabled": true, - "serviceName": "a2a-copilot-byok-cookout", - "taskUsageRollup": true, - "exporter": { - "endpoint": "http://127.0.0.1:6006", - "protocol": "http/protobuf" - }, - "backend": { - "copilot": { - "propagateTraceContext": true - } - } } } diff --git a/examples/mock-ollama/server.mjs b/examples/mock-ollama/server.mjs new file mode 100644 index 0000000..9da7abb --- /dev/null +++ b/examples/mock-ollama/server.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node +/** + * Lightweight fake Ollama for local cookouts (no GPU / no model pull). + * + * Shared under examples/mock-ollama/ — reuse for OTel, sessions, streaming, + * usage, CI, or any feature that needs a real Copilot/Claude turn without + * cloud credentials. See README.md. + * + * Speaks enough of: + * - OpenAI Chat Completions POST /v1/chat/completions + * - OpenAI Responses POST /v1/responses (Copilot ↔ Ollama default) + * - Anthropic Messages POST /v1/messages (Claude Code ↔ Ollama) + * - Model list GET /v1/models, GET /api/tags + * + * node examples/mock-ollama/server.mjs + * MOCK_OLLAMA_PORT=11434 node examples/mock-ollama/server.mjs + * + * Point Copilot at: http://127.0.0.1:/v1 + * Point Claude at: ANTHROPIC_BASE_URL=http://127.0.0.1: + */ + +import http from "node:http"; +import { randomUUID } from "node:crypto"; + +const PORT = Number(process.env.MOCK_OLLAMA_PORT || 11434); +const MODEL = process.env.MOCK_OLLAMA_MODEL || "mock-ollama"; +const REPLY = process.env.MOCK_OLLAMA_REPLY || "pong-from-mock-ollama"; + +const requests = []; + +function json(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + }); + res.end(data); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function usage() { + return { + prompt_tokens: 12, + completion_tokens: 5, + total_tokens: 17, + input_tokens: 12, + output_tokens: 5, + }; +} + +function chatCompletion(body, stream) { + const id = `chatcmpl-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + if (stream) { + return { id, model, stream: true }; + } + return { + id, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: REPLY }, + finish_reason: "stop", + }, + ], + usage: usage(), + }; +} + +function writeChatStream(res, body) { + const id = `chatcmpl-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const chunk1 = { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { role: "assistant", content: REPLY }, finish_reason: null }], + }; + const chunk2 = { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: usage(), + }; + res.write(`data: ${JSON.stringify(chunk1)}\n\n`); + res.write(`data: ${JSON.stringify(chunk2)}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); +} + +function responsesApi(body) { + const id = `resp-${randomUUID().slice(0, 8)}`; + const model = body.model || MODEL; + return { + id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model, + output: [ + { + type: "message", + id: `msg-${randomUUID().slice(0, 8)}`, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + ], + output_text: REPLY, + usage: { + input_tokens: 12, + output_tokens: 5, + total_tokens: 17, + }, + }; +} + +function writeResponsesStream(res, body) { + const model = body.model || MODEL; + const responseId = `resp-${randomUUID().slice(0, 8)}`; + const itemId = `msg-${randomUUID().slice(0, 8)}`; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const events = [ + { + type: "response.created", + response: { id: responseId, object: "response", status: "in_progress", model }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] }, + }, + { + type: "response.output_text.delta", + item_id: itemId, + output_index: 0, + content_index: 0, + delta: REPLY, + }, + { + type: "response.output_text.done", + item_id: itemId, + output_index: 0, + content_index: 0, + text: REPLY, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + }, + { + type: "response.completed", + response: { + id: responseId, + object: "response", + status: "completed", + model, + output: [ + { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: REPLY }], + }, + ], + usage: { input_tokens: 12, output_tokens: 5, total_tokens: 17 }, + }, + }, + ]; + for (const ev of events) { + res.write(`event: ${ev.type}\n`); + res.write(`data: ${JSON.stringify(ev)}\n\n`); + } + res.end(); +} + +function anthropicMessage(body, stream) { + const id = `msg_${randomUUID().slice(0, 12)}`; + const model = body.model || MODEL; + if (stream) { + return { id, model, stream: true }; + } + return { + id, + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: REPLY }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 5 }, + }; +} + +function writeAnthropicStream(res, body) { + const id = `msg_${randomUUID().slice(0, 12)}`; + const model = body.model || MODEL; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const events = [ + { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model, + content: [], + stop_reason: null, + usage: { input_tokens: 12, output_tokens: 0 }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: REPLY } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 5 }, + }, + { type: "message_stop" }, + ]; + for (const ev of events) { + res.write(`event: ${ev.type}\n`); + res.write(`data: ${JSON.stringify(ev)}\n\n`); + } + res.end(); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + const path = url.pathname; + let body = {}; + try { + if (req.method === "POST") body = await readBody(req); + } catch { + return json(res, 400, { error: { message: "invalid json" } }); + } + + requests.push({ + t: new Date().toISOString(), + method: req.method, + path, + model: body.model, + stream: Boolean(body.stream), + }); + console.log(`[mock-ollama] ${req.method} ${path}${body.model ? ` model=${body.model}` : ""}${body.stream ? " stream" : ""}`); + + // Health / discovery (Claude Code probes HEAD /api/hello) + if ( + (req.method === "GET" || req.method === "HEAD") && + (path === "/" || path === "/health" || path === "/api/version" || path === "/api/hello") + ) { + if (req.method === "HEAD") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(); + return; + } + return json(res, 200, { status: "ok", version: "0.0.0-mock", mock: true }); + } + if (req.method === "GET" && (path === "/v1/models" || path === "/models")) { + return json(res, 200, { + object: "list", + data: [{ id: MODEL, object: "model", owned_by: "mock-ollama" }], + }); + } + if (req.method === "GET" && path === "/api/tags") { + return json(res, 200, { + models: [{ name: MODEL, model: MODEL, modified_at: new Date().toISOString(), size: 1 }], + }); + } + if (req.method === "GET" && path === "/__mock__/requests") { + return json(res, 200, { requests }); + } + + // OpenAI chat completions + if (req.method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) { + if (body.stream) return writeChatStream(res, body); + return json(res, 200, chatCompletion(body, false)); + } + + // OpenAI responses (Copilot ↔ Ollama recommended wireApi) + if (req.method === "POST" && (path === "/v1/responses" || path === "/responses")) { + if (body.stream) return writeResponsesStream(res, body); + return json(res, 200, responsesApi(body)); + } + + // Anthropic Messages (Claude Code ↔ Ollama) + if (req.method === "POST" && (path === "/v1/messages" || path === "/messages")) { + if (body.stream) return writeAnthropicStream(res, body); + return json(res, 200, anthropicMessage(body, false)); + } + + // count_tokens sometimes probed + if (req.method === "POST" && path.endsWith("/count_tokens")) { + return json(res, 200, { input_tokens: 12 }); + } + + json(res, 404, { error: { message: `mock-ollama: no handler for ${req.method} ${path}` } }); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`[mock-ollama] listening on http://127.0.0.1:${PORT}`); + console.log(`[mock-ollama] Copilot BYOK baseUrl: http://127.0.0.1:${PORT}/v1`); + console.log(`[mock-ollama] Claude ANTHROPIC_BASE_URL: http://127.0.0.1:${PORT}`); + console.log(`[mock-ollama] model=${MODEL} reply=${JSON.stringify(REPLY)}`); +}); + +process.on("SIGINT", () => server.close(() => process.exit(0))); +process.on("SIGTERM", () => server.close(() => process.exit(0))); diff --git a/examples/mock-ollama/smoke.mjs b/examples/mock-ollama/smoke.mjs new file mode 100644 index 0000000..d8e1230 --- /dev/null +++ b/examples/mock-ollama/smoke.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +/** + * Generic mock-ollama cookout — no OTel / Phoenix required. + * + * Starts the stub, boots a2a-copilot + a2a-claude against it, sends + * message/send to each, asserts the mock saw LLM traffic and A2A succeeded. + * + * node examples/mock-ollama/smoke.mjs + * + * Reuse this pattern for any feature you want to exercise with real CLIs + * without cloud credentials. + */ + +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, "../.."); + +const MOCK_PORT = Number(process.env.MOCK_OLLAMA_PORT || 19134); +const COPILOT_PORT = Number(process.env.MOCK_SMOKE_COPILOT_PORT || 3101); +const CLAUDE_PORT = Number(process.env.MOCK_SMOKE_CLAUDE_PORT || 3102); +const MODEL = process.env.MOCK_OLLAMA_MODEL || "mock-ollama"; + +const children = []; +const agentChildren = []; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function spawnLogged(cmd, args, opts = {}) { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts }); + const label = opts.label || cmd; + child.stdout.on("data", (d) => process.stdout.write(`[${label}] ${d}`)); + child.stderr.on("data", (d) => process.stderr.write(`[${label}] ${d}`)); + children.push(child); + if (opts.agent) agentChildren.push(child); + return child; +} + +async function waitHttp(url, { timeoutMs = 30_000 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.status >= 200 && res.status < 500) return; + } catch { + /* retry */ + } + await sleep(250); + } + throw new Error(`timeout waiting for ${url}`); +} + +async function killAgentsAndWait(timeoutMs = 15_000) { + await Promise.all( + agentChildren.map( + (c) => + new Promise((resolve) => { + if (c.exitCode !== null || c.signalCode) { + resolve(undefined); + return; + } + const timer = setTimeout(() => { + try { + c.kill("SIGKILL"); + } catch { + /* ignore */ + } + resolve(undefined); + }, timeoutMs); + c.once("exit", () => { + clearTimeout(timer); + resolve(undefined); + }); + try { + c.kill("SIGTERM"); + } catch { + clearTimeout(timer); + resolve(undefined); + } + }), + ), + ); + agentChildren.length = 0; +} + +async function messageSend(port, text) { + const contextId = randomUUID(); + const res = await fetch(`http://127.0.0.1:${port}/a2a/jsonrpc`, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(90_000), + body: JSON.stringify({ + jsonrpc: "2.0", + id: randomUUID(), + method: "message/send", + params: { + message: { + kind: "message", + messageId: randomUUID(), + role: "user", + contextId, + parts: [{ kind: "text", text }], + }, + configuration: { blocking: true }, + }, + }), + }); + const body = await res.json(); + return { + status: res.status, + body, + taskId: body.result?.id, + contextId, + }; +} + +function writePatchedConfig(agentFile, port, mockBase) { + const src = JSON.parse(readFileSync(join(__dirname, "agents", agentFile), "utf8")); + src.server.port = port; + if (src.copilot?.provider) { + src.copilot.provider.baseUrl = `${mockBase}/v1`; + src.copilot.model = MODEL; + src.copilot.workspaceDirectory = "/tmp/a2a-mock-ollama-copilot-ws"; + mkdirSync(src.copilot.workspaceDirectory, { recursive: true }); + } + if (src.claude) { + src.claude.model = MODEL; + src.claude.workingDirectory = "/tmp/a2a-mock-ollama-claude-ws"; + mkdirSync(src.claude.workingDirectory, { recursive: true }); + } + const out = join("/tmp", `a2a-mock-ollama-${agentFile}`); + writeFileSync(out, JSON.stringify(src, null, 2)); + return out; +} + +function cleanup() { + for (const c of children) { + try { + c.kill("SIGTERM"); + } catch { + /* ignore */ + } + } +} + +process.on("exit", cleanup); +process.on("SIGINT", () => { + cleanup(); + process.exit(130); +}); + +async function main() { + const mockBase = `http://127.0.0.1:${MOCK_PORT}`; + console.log("Starting mock-ollama on", MOCK_PORT); + spawnLogged(process.execPath, [join(__dirname, "server.mjs")], { + label: "mock-ollama", + env: { + ...process.env, + MOCK_OLLAMA_PORT: String(MOCK_PORT), + MOCK_OLLAMA_MODEL: MODEL, + MOCK_OLLAMA_REPLY: "pong-from-mock-ollama", + }, + }); + await waitHttp(`${mockBase}/health`); + + const copilotCfg = writePatchedConfig("copilot.json", COPILOT_PORT, mockBase); + const claudeCfg = writePatchedConfig("claude.json", CLAUDE_PORT, mockBase); + + console.log("Starting a2a-copilot…"); + spawnLogged(process.execPath, [join(root, "a2a-copilot/dist/cli.js"), "--config", copilotCfg], { + label: "a2a-copilot", + agent: true, + env: { + ...process.env, + COPILOT_PROVIDER_BASE_URL: `${mockBase}/v1`, + COPILOT_PROVIDER_API_KEY: "ollama", + COPILOT_PROVIDER_WIRE_API: "responses", + COPILOT_MODEL: MODEL, + COPILOT_ALLOW_ALL: "1", + }, + cwd: root, + }); + await waitHttp(`http://127.0.0.1:${COPILOT_PORT}/health`, { timeoutMs: 60_000 }); + + console.log("Sending A2A message/send → copilot…"); + const copilotRes = await messageSend(COPILOT_PORT, "Reply with exactly: pong"); + if (copilotRes.body.error) console.error("copilot error", copilotRes.body.error); + else console.log("copilot taskId", copilotRes.taskId); + await sleep(500); + await killAgentsAndWait(); + + console.log("Starting a2a-claude…"); + spawnLogged(process.execPath, [join(root, "a2a-claude/dist/cli.js"), "--config", claudeCfg], { + label: "a2a-claude", + agent: true, + env: { + ...process.env, + ANTHROPIC_BASE_URL: mockBase, + ANTHROPIC_API_KEY: "ollama", + ANTHROPIC_AUTH_TOKEN: "ollama", + WORKSPACE_DIR: "/tmp/a2a-mock-ollama-claude-ws", + CLAUDE_MODEL: MODEL, + }, + cwd: root, + }); + await waitHttp(`http://127.0.0.1:${CLAUDE_PORT}/health`, { timeoutMs: 60_000 }); + + console.log("Sending A2A message/send → claude…"); + const claudeRes = await messageSend(CLAUDE_PORT, "Reply with exactly: pong"); + if (claudeRes.body.error) console.error("claude error", claudeRes.body.error); + else console.log("claude taskId", claudeRes.taskId); + await sleep(500); + await killAgentsAndWait(); + + const mockReqs = await fetch(`${mockBase}/__mock__/requests`).then((r) => r.json()); + const paths = mockReqs.requests?.map((r) => `${r.method} ${r.path}`) ?? []; + console.log("\nMock Ollama request paths:", paths); + + const sawCopilot = paths.some((p) => p.includes("/v1/responses") || p.includes("/chat/completions")); + const sawClaude = paths.some((p) => p.includes("/v1/messages")); + let failed = false; + if (copilotRes.body.error || claudeRes.body.error) { + console.error("\nFAILED: message/send RPC error"); + failed = true; + } + if (!sawCopilot || !sawClaude) { + console.error("\nFAILED: expected mock LLM traffic for both Copilot and Claude"); + failed = true; + } + if (failed) process.exitCode = 2; + else console.log("\nOK — mock-ollama served real a2a-copilot + a2a-claude turns"); + + cleanup(); + await sleep(400); + process.exit(process.exitCode ?? 0); +} + +main().catch((err) => { + console.error(err); + cleanup(); + process.exit(1); +}); diff --git a/examples/otel-stack/README.md b/examples/otel-stack/README.md index 3a6a251..afde4fa 100644 --- a/examples/otel-stack/README.md +++ b/examples/otel-stack/README.md @@ -5,6 +5,10 @@ Local collector UI for developing `a2a-*` wrappers. **No Docker required.** Phoenix is lighter than a Jaeger all-in-one compose stack: one Python process, OTLP HTTP on the same port as the UI. +> **LLM stub:** the fake Ollama used by cookouts lives in +> [`examples/mock-ollama`](../mock-ollama/) — shared for any feature test, not +> only OTel. This folder only adds Phoenix + OTel assertions on top. + ## Start Phoenix ```bash @@ -53,7 +57,7 @@ npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-t # npm install @opentelemetry/exporter-trace-otlp-http ``` -## Smoke / cookout scripts (this repo) +## Smoke / cookout scripts With Phoenix already running: @@ -61,16 +65,18 @@ With Phoenix already running: # 1) Synthetic core span (bootstrap + attrs only) node examples/otel-stack/smoke-export.mjs -# 2) Recommended: BYOK cookout via **fake Ollama** (no GPU, no cloud keys) -# - mock-ollama speaks OpenAI /v1/responses (Copilot) + Anthropic /v1/messages (Claude) -# - boots real a2a-copilot + a2a-claude CLIs against it +# 2) Recommended: BYOK + Phoenix (uses shared examples/mock-ollama) +# - starts mock-ollama server +# - boots real a2a-copilot + a2a-claude with otel.enabled # - asserts Phoenix has a2a.wrapper.sdk spans + usage node examples/otel-stack/cookout-byok.mjs ``` -Why fake Ollama? Copilot already supports BYOK → Ollama (`copilot.provider.baseUrl`), -and Claude Code supports Ollama via `ANTHROPIC_BASE_URL` (Anthropic-compatible API). -That is a more realistic cookout than stubbing the session manager. +Provider-only smoke (no Phoenix) lives with the stub: + +```bash +node examples/mock-ollama/smoke.mjs +``` Optional lower-level script (executor + fake client, no CLI spawn): diff --git a/examples/otel-stack/cookout-byok.mjs b/examples/otel-stack/cookout-byok.mjs index 8c2e3e4..77b362d 100644 --- a/examples/otel-stack/cookout-byok.mjs +++ b/examples/otel-stack/cookout-byok.mjs @@ -26,6 +26,7 @@ import { randomUUID } from "node:crypto"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, "../.."); +const mockOllamaDir = join(__dirname, "../mock-ollama"); const require = createRequire(import.meta.url); const MOCK_PORT = Number(process.env.MOCK_OLLAMA_PORT || 19134); @@ -131,9 +132,11 @@ async function messageSend(port, text) { return { status: res.status, body, taskId, contextId, messageId }; } -function writePatchedConfig(srcName, port, mockBase) { - const src = JSON.parse(readFileSync(join(__dirname, srcName), "utf8")); +function writePatchedConfig(agentFile, port, mockBase, otelPatch) { + // Shared BYOK agents live under examples/mock-ollama — OTel is layered on here. + const src = JSON.parse(readFileSync(join(mockOllamaDir, "agents", agentFile), "utf8")); src.server.port = port; + src.otel = otelPatch; if (src.copilot?.provider) { src.copilot.provider.baseUrl = `${mockBase}/v1`; src.copilot.model = MODEL; @@ -145,7 +148,7 @@ function writePatchedConfig(srcName, port, mockBase) { src.claude.workingDirectory = `/tmp/a2a-cookout-claude-ws`; mkdirSync(src.claude.workingDirectory, { recursive: true }); } - const out = join("/tmp", `a2a-cookout-${srcName}`); + const out = join("/tmp", `a2a-cookout-${agentFile}`); writeFileSync(out, JSON.stringify(src, null, 2)); return out; } @@ -179,8 +182,8 @@ async function main() { } const mockBase = `http://127.0.0.1:${MOCK_PORT}`; - console.log("Starting mock-ollama on", MOCK_PORT); - spawnLogged(process.execPath, [join(__dirname, "mock-ollama.mjs")], { + console.log("Starting shared mock-ollama on", MOCK_PORT); + spawnLogged(process.execPath, [join(mockOllamaDir, "server.mjs")], { label: "mock-ollama", env: { ...process.env, @@ -199,8 +202,20 @@ async function main() { }).then((r) => r.json()); console.log("mock chat ok:", chat.choices?.[0]?.message?.content); - const copilotCfg = writePatchedConfig("agent-copilot-byok.json", COPILOT_PORT, mockBase); - const claudeCfg = writePatchedConfig("agent-claude-byok.json", CLAUDE_PORT, mockBase); + const copilotCfg = writePatchedConfig("copilot.json", COPILOT_PORT, mockBase, { + enabled: true, + serviceName: "a2a-copilot-byok-cookout", + taskUsageRollup: true, + exporter: { endpoint: PHOENIX, protocol: "http/protobuf" }, + backend: { copilot: { propagateTraceContext: true } }, + }); + const claudeCfg = writePatchedConfig("claude.json", CLAUDE_PORT, mockBase, { + enabled: true, + serviceName: "a2a-claude-byok-cookout", + taskUsageRollup: true, + exporter: { endpoint: PHOENIX, protocol: "http/protobuf" }, + backend: { claude: { enableTelemetry: false, propagateTraceContext: true } }, + }); // Sequential: one provider at a time so each CLI can flush OTel cleanly. console.log("Starting a2a-copilot…"); diff --git a/examples/otel-stack/mock-ollama.mjs b/examples/otel-stack/mock-ollama.mjs index 482248e..c863e29 100644 --- a/examples/otel-stack/mock-ollama.mjs +++ b/examples/otel-stack/mock-ollama.mjs @@ -1,352 +1,22 @@ #!/usr/bin/env node /** - * Lightweight fake Ollama for BYOK cookouts (no GPU / no model pull). - * - * Speaks enough of: - * - OpenAI Chat Completions POST /v1/chat/completions - * - OpenAI Responses POST /v1/responses (Copilot ↔ Ollama default) - * - Anthropic Messages POST /v1/messages (Claude Code ↔ Ollama) - * - Model list GET /v1/models, GET /api/tags - * - * node examples/otel-stack/mock-ollama.mjs - * MOCK_OLLAMA_PORT=11434 node examples/otel-stack/mock-ollama.mjs - * - * Point Copilot at: http://127.0.0.1:/v1 - * Point Claude at: ANTHROPIC_BASE_URL=http://127.0.0.1: + * Compatibility shim — mock Ollama moved to examples/mock-ollama/server.mjs + * so it can be reused beyond OTel cookouts. */ - -import http from "node:http"; -import { randomUUID } from "node:crypto"; - -const PORT = Number(process.env.MOCK_OLLAMA_PORT || 11434); -const MODEL = process.env.MOCK_OLLAMA_MODEL || "mock-ollama"; -const REPLY = process.env.MOCK_OLLAMA_REPLY || "pong-from-mock-ollama"; - -const requests = []; - -function json(res, status, body) { - const data = JSON.stringify(body); - res.writeHead(status, { - "content-type": "application/json", - "content-length": Buffer.byteLength(data), - }); - res.end(data); -} - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw) return resolve({}); - try { - resolve(JSON.parse(raw)); - } catch (err) { - reject(err); - } - }); - req.on("error", reject); - }); -} - -function usage() { - return { - prompt_tokens: 12, - completion_tokens: 5, - total_tokens: 17, - input_tokens: 12, - output_tokens: 5, - }; -} - -function chatCompletion(body, stream) { - const id = `chatcmpl-${randomUUID().slice(0, 8)}`; - const model = body.model || MODEL; - if (stream) { - return { id, model, stream: true }; - } - return { - id, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { role: "assistant", content: REPLY }, - finish_reason: "stop", - }, - ], - usage: usage(), - }; -} - -function writeChatStream(res, body) { - const id = `chatcmpl-${randomUUID().slice(0, 8)}`; - const model = body.model || MODEL; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - const chunk1 = { - id, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: "assistant", content: REPLY }, finish_reason: null }], - }; - const chunk2 = { - id, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: usage(), - }; - res.write(`data: ${JSON.stringify(chunk1)}\n\n`); - res.write(`data: ${JSON.stringify(chunk2)}\n\n`); - res.write("data: [DONE]\n\n"); - res.end(); -} - -function responsesApi(body) { - const id = `resp-${randomUUID().slice(0, 8)}`; - const model = body.model || MODEL; - return { - id, - object: "response", - created_at: Math.floor(Date.now() / 1000), - status: "completed", - model, - output: [ - { - type: "message", - id: `msg-${randomUUID().slice(0, 8)}`, - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: REPLY }], - }, - ], - output_text: REPLY, - usage: { - input_tokens: 12, - output_tokens: 5, - total_tokens: 17, - }, - }; -} - -function writeResponsesStream(res, body) { - const model = body.model || MODEL; - const responseId = `resp-${randomUUID().slice(0, 8)}`; - const itemId = `msg-${randomUUID().slice(0, 8)}`; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - const events = [ - { - type: "response.created", - response: { id: responseId, object: "response", status: "in_progress", model }, - }, - { - type: "response.output_item.added", - output_index: 0, - item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] }, - }, - { - type: "response.output_text.delta", - item_id: itemId, - output_index: 0, - content_index: 0, - delta: REPLY, - }, - { - type: "response.output_text.done", - item_id: itemId, - output_index: 0, - content_index: 0, - text: REPLY, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "message", - id: itemId, - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: REPLY }], - }, - }, - { - type: "response.completed", - response: { - id: responseId, - object: "response", - status: "completed", - model, - output: [ - { - type: "message", - id: itemId, - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: REPLY }], - }, - ], - usage: { input_tokens: 12, output_tokens: 5, total_tokens: 17 }, - }, - }, - ]; - for (const ev of events) { - res.write(`event: ${ev.type}\n`); - res.write(`data: ${JSON.stringify(ev)}\n\n`); - } - res.end(); -} - -function anthropicMessage(body, stream) { - const id = `msg_${randomUUID().slice(0, 12)}`; - const model = body.model || MODEL; - if (stream) { - return { id, model, stream: true }; - } - return { - id, - type: "message", - role: "assistant", - model, - content: [{ type: "text", text: REPLY }], - stop_reason: "end_turn", - stop_sequence: null, - usage: { input_tokens: 12, output_tokens: 5 }, - }; -} - -function writeAnthropicStream(res, body) { - const id = `msg_${randomUUID().slice(0, 12)}`; - const model = body.model || MODEL; - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - const events = [ - { - type: "message_start", - message: { - id, - type: "message", - role: "assistant", - model, - content: [], - stop_reason: null, - usage: { input_tokens: 12, output_tokens: 0 }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - }, - { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: REPLY } }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { output_tokens: 5 }, - }, - { type: "message_stop" }, - ]; - for (const ev of events) { - res.write(`event: ${ev.type}\n`); - res.write(`data: ${JSON.stringify(ev)}\n\n`); - } - res.end(); -} - -const server = http.createServer(async (req, res) => { - const url = new URL(req.url || "/", `http://${req.headers.host}`); - const path = url.pathname; - let body = {}; - try { - if (req.method === "POST") body = await readBody(req); - } catch { - return json(res, 400, { error: { message: "invalid json" } }); - } - - requests.push({ - t: new Date().toISOString(), - method: req.method, - path, - model: body.model, - stream: Boolean(body.stream), - }); - console.log(`[mock-ollama] ${req.method} ${path}${body.model ? ` model=${body.model}` : ""}${body.stream ? " stream" : ""}`); - - // Health / discovery (Claude Code probes HEAD /api/hello) - if ( - (req.method === "GET" || req.method === "HEAD") && - (path === "/" || path === "/health" || path === "/api/version" || path === "/api/hello") - ) { - if (req.method === "HEAD") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(); - return; - } - return json(res, 200, { status: "ok", version: "0.0.0-mock", mock: true }); - } - if (req.method === "GET" && (path === "/v1/models" || path === "/models")) { - return json(res, 200, { - object: "list", - data: [{ id: MODEL, object: "model", owned_by: "mock-ollama" }], - }); - } - if (req.method === "GET" && path === "/api/tags") { - return json(res, 200, { - models: [{ name: MODEL, model: MODEL, modified_at: new Date().toISOString(), size: 1 }], - }); - } - if (req.method === "GET" && path === "/__mock__/requests") { - return json(res, 200, { requests }); - } - - // OpenAI chat completions - if (req.method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) { - if (body.stream) return writeChatStream(res, body); - return json(res, 200, chatCompletion(body, false)); - } - - // OpenAI responses (Copilot ↔ Ollama recommended wireApi) - if (req.method === "POST" && (path === "/v1/responses" || path === "/responses")) { - if (body.stream) return writeResponsesStream(res, body); - return json(res, 200, responsesApi(body)); - } - - // Anthropic Messages (Claude Code ↔ Ollama) - if (req.method === "POST" && (path === "/v1/messages" || path === "/messages")) { - if (body.stream) return writeAnthropicStream(res, body); - return json(res, 200, anthropicMessage(body, false)); - } - - // count_tokens sometimes probed - if (req.method === "POST" && path.endsWith("/count_tokens")) { - return json(res, 200, { input_tokens: 12 }); - } - - json(res, 404, { error: { message: `mock-ollama: no handler for ${req.method} ${path}` } }); +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +console.warn( + "[deprecated] examples/otel-stack/mock-ollama.mjs → use examples/mock-ollama/server.mjs", +); + +const child = spawn( + process.execPath, + [join(dirname(fileURLToPath(import.meta.url)), "../mock-ollama/server.mjs")], + { stdio: "inherit", env: process.env }, +); +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + process.exit(code ?? 0); }); - -server.listen(PORT, "127.0.0.1", () => { - console.log(`[mock-ollama] listening on http://127.0.0.1:${PORT}`); - console.log(`[mock-ollama] Copilot BYOK baseUrl: http://127.0.0.1:${PORT}/v1`); - console.log(`[mock-ollama] Claude ANTHROPIC_BASE_URL: http://127.0.0.1:${PORT}`); - console.log(`[mock-ollama] model=${MODEL} reply=${JSON.stringify(REPLY)}`); -}); - -process.on("SIGINT", () => server.close(() => process.exit(0))); -process.on("SIGTERM", () => server.close(() => process.exit(0))); From 3e7a0a67195c9b689caf4897ef7a8dff28fa400a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 09:23:50 +0000 Subject: [PATCH 12/12] fix(otel): narrow per-wrapper OTel schemas to one backend key Common settings stay on otel.*; Hook F knobs are only otel.backend.. OpenCode schema no longer lists Codex/Claude/Copilot backends (and peers). Regenerated agent-config.schema.json for all wrappers. Co-authored-by: Shashi Kanth G S --- .changeset/otel-per-wrapper-schema.md | 13 ++ .../schemas/agent-config.schema.json | 85 ++------------ a2a-antigravity/src/config/types.ts | 5 +- a2a-claude/package.json | 2 + a2a-claude/schemas/agent-config.schema.json | 94 ++++----------- a2a-claude/src/config/types.ts | 5 +- a2a-codex/package.json | 2 + a2a-codex/schemas/agent-config.schema.json | 79 ++----------- a2a-codex/src/config/types.ts | 5 +- a2a-copilot/schemas/agent-config.schema.json | 107 ++++++----------- a2a-copilot/src/config/types.ts | 5 +- a2a-opencode/schemas/agent-config.schema.json | 84 +++---------- a2a-opencode/src/config/types.ts | 5 +- docs/observability.md | 17 ++- packages/core/src/config/types.ts | 37 ++---- packages/core/src/index.ts | 10 ++ packages/core/src/telemetry/index.ts | 10 ++ packages/core/src/telemetry/types.ts | 111 +++++++++++++----- 18 files changed, 252 insertions(+), 424 deletions(-) create mode 100644 .changeset/otel-per-wrapper-schema.md diff --git a/.changeset/otel-per-wrapper-schema.md b/.changeset/otel-per-wrapper-schema.md new file mode 100644 index 0000000..6bf86a0 --- /dev/null +++ b/.changeset/otel-per-wrapper-schema.md @@ -0,0 +1,13 @@ +--- +"@a2a-wrapper/core": patch +"a2a-copilot": patch +"a2a-claude": patch +"a2a-codex": patch +"a2a-opencode": patch +"a2a-antigravity": patch +--- + +fix(otel): per-wrapper OTel schema — common at top, backend knobs only for that package + +OpenCode no longer advertises `otel.backend.codex` (and peers). Shared flags stay +on `otel.*`; only `otel.backend.` appears in each agent schema. diff --git a/a2a-antigravity/schemas/agent-config.schema.json b/a2a-antigravity/schemas/agent-config.schema.json index 0e1cd55..787d45d 100644 --- a/a2a-antigravity/schemas/agent-config.schema.json +++ b/a2a-antigravity/schemas/agent-config.schema.json @@ -153,8 +153,8 @@ "$ref": "#/definitions/MemoryConfig" }, "otel": { - "$ref": "#/definitions/OtelConfig", - "description": "Optional OpenTelemetry settings (see docs/observability.md)." + "$ref": "#/definitions/OtelAntigravityConfig", + "description": "Wrapper-level OTel only (no Hook F backend passthrough yet)." }, "server": { "$ref": "#/definitions/ServerConfig" @@ -542,88 +542,19 @@ }, "type": "object" }, - "OtelConfig": { + "OtelAntigravityConfig": { + "$ref": "#/definitions/OtelCommonConfig", + "description": "`a2a-antigravity` agent `otel` — common settings only (no Hook F backend passthrough yet)." + }, + "OtelCommonConfig": { "additionalProperties": false, - "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "description": "Common wrapper-level OTel settings — shared by every `a2a-*` package.\n\nThese are **not** provider-specific. Put only Hook F / vendor knobs under {@link OtelConfig.backend } (and each wrapper's schema only exposes *its* backend key — see {@link OtelCopilotConfig } , {@link OtelClaudeConfig } , …).", "properties": { "annotateUsageCalls": { "default": false, "description": "Emit per-LLM-call span **events** (`a2a.llm.call`) on the task span when the wrapper is the sole OTel token source. Off by default (noisy).", "type": "boolean" }, - "backend": { - "additionalProperties": false, - "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", - "properties": { - "claude": { - "additionalProperties": false, - "properties": { - "enableTelemetry": { - "type": "boolean" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Claude telemetry is enabled", - "type": "boolean" - } - }, - "type": "object" - }, - "codex": { - "additionalProperties": {}, - "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", - "type": "object" - }, - "copilot": { - "additionalProperties": false, - "properties": { - "captureContent": { - "type": "boolean" - }, - "exporterType": { - "type": "string" - }, - "filePath": { - "type": "string" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "enum": [ - "http/json", - "http/protobuf" - ], - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Copilot OTel is configured", - "type": "boolean" - }, - "sourceName": { - "type": "string" - } - }, - "type": "object" - }, - "opencode": { - "additionalProperties": false, - "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", - "properties": { - "openTelemetry": { - "type": "boolean" - } - }, - "type": "object" - } - }, - "type": "object" - }, "emitOverlappingBackendSpans": { "default": false, "description": "Unsafe escape hatch: allow wrapper tool/LLM spans even when backend OTel is configured (duplicates Copilot/Claude tool spans in the collector).", diff --git a/a2a-antigravity/src/config/types.ts b/a2a-antigravity/src/config/types.ts index adcfcea..6ad3c71 100644 --- a/a2a-antigravity/src/config/types.ts +++ b/a2a-antigravity/src/config/types.ts @@ -11,7 +11,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, - OtelConfig, + OtelAntigravityConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -183,7 +183,8 @@ export interface AgentConfig { memory?: MemoryConfig; subAgents?: SubAgentsConfig; /** Optional OpenTelemetry settings (see docs/observability.md). */ - otel?: OtelConfig; + /** Wrapper-level OTel only (no Hook F backend passthrough yet). */ + otel?: OtelAntigravityConfig; /** Directory containing the loaded config file; set by CLI/loader. */ configDir?: string; } diff --git a/a2a-claude/package.json b/a2a-claude/package.json index 0e891e1..b328eb8 100644 --- a/a2a-claude/package.json +++ b/a2a-claude/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf dist", "rebuild": "npm run clean && npm run build", "typecheck": "tsc --noEmit", + "schema": "ts-json-schema-generator --path src/config/types.ts --type AgentConfig --tsconfig tsconfig.json --no-type-check --out schemas/agent-config.schema.json", "test": "vitest --run" }, "files": [ @@ -62,6 +63,7 @@ "@types/express": "^4.17.21", "@types/node": "^20.10.0", "@types/uuid": "^9.0.7", + "ts-json-schema-generator": "^2.9.0", "tsx": "^4.6.0", "typescript": "^5.3.0", "vitest": "^1.0.0" diff --git a/a2a-claude/schemas/agent-config.schema.json b/a2a-claude/schemas/agent-config.schema.json index 5a7352a..5e2ee51 100644 --- a/a2a-claude/schemas/agent-config.schema.json +++ b/a2a-claude/schemas/agent-config.schema.json @@ -156,8 +156,8 @@ "$ref": "#/definitions/MemoryConfig" }, "otel": { - "$ref": "#/definitions/OtelConfig", - "description": "Optional OpenTelemetry settings (see docs/observability.md)." + "$ref": "#/definitions/OtelClaudeConfig", + "description": "Wrapper-level OTel + Claude Hook F only (`otel.backend.claude`)." }, "server": { "$ref": "#/definitions/ServerConfig" @@ -636,9 +636,29 @@ }, "type": "object" }, - "OtelConfig": { + "OtelClaudeBackendConfig": { "additionalProperties": false, - "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "description": "Claude Code subprocess OTel knobs (only meaningful on `a2a-claude`).", + "properties": { + "enableTelemetry": { + "type": "boolean" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Claude telemetry is enabled", + "type": "boolean" + } + }, + "type": "object" + }, + "OtelClaudeConfig": { + "additionalProperties": false, + "description": "`a2a-claude` agent `otel` — common + Claude Hook F only.", "properties": { "annotateUsageCalls": { "default": false, @@ -647,73 +667,9 @@ }, "backend": { "additionalProperties": false, - "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { "claude": { - "additionalProperties": false, - "properties": { - "enableTelemetry": { - "type": "boolean" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Claude telemetry is enabled", - "type": "boolean" - } - }, - "type": "object" - }, - "codex": { - "additionalProperties": {}, - "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", - "type": "object" - }, - "copilot": { - "additionalProperties": false, - "properties": { - "captureContent": { - "type": "boolean" - }, - "exporterType": { - "type": "string" - }, - "filePath": { - "type": "string" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "enum": [ - "http/json", - "http/protobuf" - ], - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Copilot OTel is configured", - "type": "boolean" - }, - "sourceName": { - "type": "string" - } - }, - "type": "object" - }, - "opencode": { - "additionalProperties": false, - "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", - "properties": { - "openTelemetry": { - "type": "boolean" - } - }, - "type": "object" + "$ref": "#/definitions/OtelClaudeBackendConfig" } }, "type": "object" diff --git a/a2a-claude/src/config/types.ts b/a2a-claude/src/config/types.ts index a6bf5b3..0c13934 100644 --- a/a2a-claude/src/config/types.ts +++ b/a2a-claude/src/config/types.ts @@ -9,7 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, - OtelConfig, + OtelClaudeConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -289,7 +289,8 @@ export interface AgentConfig { memory?: MemoryConfig; subAgents?: SubAgentsConfig; /** Optional OpenTelemetry settings (see docs/observability.md). */ - otel?: OtelConfig; + /** Wrapper-level OTel + Claude Hook F only (`otel.backend.claude`). */ + otel?: OtelClaudeConfig; /** Populated automatically by the CLI loader. Do not set manually. */ configDir?: string; } diff --git a/a2a-codex/package.json b/a2a-codex/package.json index caed13c..383c5ec 100644 --- a/a2a-codex/package.json +++ b/a2a-codex/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf dist", "rebuild": "npm run clean && npm run build", "typecheck": "tsc --noEmit", + "schema": "ts-json-schema-generator --path src/config/types.ts --type AgentConfig --tsconfig tsconfig.json --no-type-check --out schemas/agent-config.schema.json", "test": "vitest --run --passWithNoTests" }, "files": [ @@ -63,6 +64,7 @@ "@types/express": "^4.17.21", "@types/node": "^20.10.0", "@types/uuid": "^9.0.7", + "ts-json-schema-generator": "^2.9.0", "tsx": "^4.6.0", "typescript": "^5.3.0", "vitest": "^1.0.0" diff --git a/a2a-codex/schemas/agent-config.schema.json b/a2a-codex/schemas/agent-config.schema.json index 11d4fce..e2a8fb0 100644 --- a/a2a-codex/schemas/agent-config.schema.json +++ b/a2a-codex/schemas/agent-config.schema.json @@ -156,8 +156,8 @@ "$ref": "#/definitions/MemoryConfig" }, "otel": { - "$ref": "#/definitions/OtelConfig", - "description": "Optional OpenTelemetry settings (see docs/observability.md)." + "$ref": "#/definitions/OtelCodexConfig", + "description": "Wrapper-level OTel + Codex Hook F only (`otel.backend.codex`)." }, "server": { "$ref": "#/definitions/ServerConfig" @@ -462,9 +462,14 @@ }, "type": "object" }, - "OtelConfig": { + "OtelCodexBackendConfig": { + "additionalProperties": {}, + "description": "Codex CLI `[otel]` overrides (only meaningful on `a2a-codex`).", + "type": "object" + }, + "OtelCodexConfig": { "additionalProperties": false, - "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "description": "`a2a-codex` agent `otel` — common + Codex Hook F only.", "properties": { "annotateUsageCalls": { "default": false, @@ -473,73 +478,9 @@ }, "backend": { "additionalProperties": false, - "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { - "claude": { - "additionalProperties": false, - "properties": { - "enableTelemetry": { - "type": "boolean" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Claude telemetry is enabled", - "type": "boolean" - } - }, - "type": "object" - }, "codex": { - "additionalProperties": {}, - "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", - "type": "object" - }, - "copilot": { - "additionalProperties": false, - "properties": { - "captureContent": { - "type": "boolean" - }, - "exporterType": { - "type": "string" - }, - "filePath": { - "type": "string" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "enum": [ - "http/json", - "http/protobuf" - ], - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Copilot OTel is configured", - "type": "boolean" - }, - "sourceName": { - "type": "string" - } - }, - "type": "object" - }, - "opencode": { - "additionalProperties": false, - "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", - "properties": { - "openTelemetry": { - "type": "boolean" - } - }, - "type": "object" + "$ref": "#/definitions/OtelCodexBackendConfig" } }, "type": "object" diff --git a/a2a-codex/src/config/types.ts b/a2a-codex/src/config/types.ts index 61a83d0..35e8f77 100644 --- a/a2a-codex/src/config/types.ts +++ b/a2a-codex/src/config/types.ts @@ -9,7 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, - OtelConfig, + OtelCodexConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -226,7 +226,8 @@ export interface AgentConfig { memory?: MemoryConfig; subAgents?: SubAgentsConfig; /** Optional OpenTelemetry settings (see docs/observability.md). */ - otel?: OtelConfig; + /** Wrapper-level OTel + Codex Hook F only (`otel.backend.codex`). */ + otel?: OtelCodexConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI loader. Do not set manually. diff --git a/a2a-copilot/schemas/agent-config.schema.json b/a2a-copilot/schemas/agent-config.schema.json index c0b5385..b714cf6 100644 --- a/a2a-copilot/schemas/agent-config.schema.json +++ b/a2a-copilot/schemas/agent-config.schema.json @@ -168,8 +168,8 @@ "description": "Optional memory configuration for persisting instructions and skills. When present, the materializer writes these to backend-specific paths in the workspace directory during executor initialization." }, "otel": { - "$ref": "#/definitions/OtelConfig", - "description": "Optional OpenTelemetry settings (see docs/observability.md)." + "$ref": "#/definitions/OtelCopilotConfig", + "description": "Wrapper-level OTel + Copilot Hook F only (`otel.backend.copilot`)." }, "server": { "$ref": "#/definitions/ServerConfig", @@ -468,9 +468,42 @@ }, "type": "object" }, - "OtelConfig": { + "OtelCopilotBackendConfig": { "additionalProperties": false, - "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "description": "Copilot CLI OTel knobs (only meaningful on `a2a-copilot`).", + "properties": { + "captureContent": { + "type": "boolean" + }, + "exporterType": { + "type": "string" + }, + "filePath": { + "type": "string" + }, + "otlpEndpoint": { + "type": "string" + }, + "otlpProtocol": { + "enum": [ + "http/json", + "http/protobuf" + ], + "type": "string" + }, + "propagateTraceContext": { + "default": "true when Copilot OTel is configured", + "type": "boolean" + }, + "sourceName": { + "type": "string" + } + }, + "type": "object" + }, + "OtelCopilotConfig": { + "additionalProperties": false, + "description": "`a2a-copilot` agent `otel` — common + Copilot Hook F only.", "properties": { "annotateUsageCalls": { "default": false, @@ -479,73 +512,9 @@ }, "backend": { "additionalProperties": false, - "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { - "claude": { - "additionalProperties": false, - "properties": { - "enableTelemetry": { - "type": "boolean" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Claude telemetry is enabled", - "type": "boolean" - } - }, - "type": "object" - }, - "codex": { - "additionalProperties": {}, - "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", - "type": "object" - }, "copilot": { - "additionalProperties": false, - "properties": { - "captureContent": { - "type": "boolean" - }, - "exporterType": { - "type": "string" - }, - "filePath": { - "type": "string" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "enum": [ - "http/json", - "http/protobuf" - ], - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Copilot OTel is configured", - "type": "boolean" - }, - "sourceName": { - "type": "string" - } - }, - "type": "object" - }, - "opencode": { - "additionalProperties": false, - "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", - "properties": { - "openTelemetry": { - "type": "boolean" - } - }, - "type": "object" + "$ref": "#/definitions/OtelCopilotBackendConfig" } }, "type": "object" diff --git a/a2a-copilot/src/config/types.ts b/a2a-copilot/src/config/types.ts index 4ab7cad..65210d1 100644 --- a/a2a-copilot/src/config/types.ts +++ b/a2a-copilot/src/config/types.ts @@ -9,7 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, - OtelConfig, + OtelCopilotConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -358,7 +358,8 @@ export interface AgentConfig { */ subAgents?: SubAgentsConfig; /** Optional OpenTelemetry settings (see docs/observability.md). */ - otel?: OtelConfig; + /** Wrapper-level OTel + Copilot Hook F only (`otel.backend.copilot`). */ + otel?: OtelCopilotConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI scaffold when a config file path is provided. diff --git a/a2a-opencode/schemas/agent-config.schema.json b/a2a-opencode/schemas/agent-config.schema.json index 4a1bd56..31a9eba 100644 --- a/a2a-opencode/schemas/agent-config.schema.json +++ b/a2a-opencode/schemas/agent-config.schema.json @@ -161,8 +161,8 @@ "description": "OpenCode connection settings" }, "otel": { - "$ref": "#/definitions/OtelConfig", - "description": "Optional OpenTelemetry settings (see docs/observability.md)." + "$ref": "#/definitions/OtelOpenCodeConfig", + "description": "Wrapper-level OTel + OpenCode Hook F only (`otel.backend.opencode`)." }, "server": { "$ref": "#/definitions/ServerConfig", @@ -431,9 +431,19 @@ }, "type": "object" }, - "OtelConfig": { + "OtelOpenCodeBackendConfig": { "additionalProperties": false, - "description": "Optional OpenTelemetry settings on an agent config.\n\nWhen omitted or `enabled: false`, core instrumentation is a no-op unless a host has already registered a tracer via {@link setOtelTracer } .", + "description": "OpenCode server-side experimental flag (only meaningful on `a2a-opencode`). The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", + "properties": { + "openTelemetry": { + "type": "boolean" + } + }, + "type": "object" + }, + "OtelOpenCodeConfig": { + "additionalProperties": false, + "description": "`a2a-opencode` agent `otel` — common + OpenCode Hook F only.", "properties": { "annotateUsageCalls": { "default": false, @@ -442,73 +452,9 @@ }, "backend": { "additionalProperties": false, - "description": "Backend runtime OTel passthrough (Hook F). Presence of a configured backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate).", "properties": { - "claude": { - "additionalProperties": false, - "properties": { - "enableTelemetry": { - "type": "boolean" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Claude telemetry is enabled", - "type": "boolean" - } - }, - "type": "object" - }, - "codex": { - "additionalProperties": {}, - "description": "Merged into Codex `configOverrides.otel` (CLI `[otel]` shape).", - "type": "object" - }, - "copilot": { - "additionalProperties": false, - "properties": { - "captureContent": { - "type": "boolean" - }, - "exporterType": { - "type": "string" - }, - "filePath": { - "type": "string" - }, - "otlpEndpoint": { - "type": "string" - }, - "otlpProtocol": { - "enum": [ - "http/json", - "http/protobuf" - ], - "type": "string" - }, - "propagateTraceContext": { - "default": "true when Copilot OTel is configured", - "type": "boolean" - }, - "sourceName": { - "type": "string" - } - }, - "type": "object" - }, "opencode": { - "additionalProperties": false, - "description": "OpenCode server-side experimental flag. The wrapper does not start OpenCode's exporter; set this so de-dupe policy matches your server.", - "properties": { - "openTelemetry": { - "type": "boolean" - } - }, - "type": "object" + "$ref": "#/definitions/OtelOpenCodeBackendConfig" } }, "type": "object" diff --git a/a2a-opencode/src/config/types.ts b/a2a-opencode/src/config/types.ts index 6689d4d..9df1436 100644 --- a/a2a-opencode/src/config/types.ts +++ b/a2a-opencode/src/config/types.ts @@ -9,7 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, - OtelConfig, + OtelOpenCodeConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -234,7 +234,8 @@ export interface AgentConfig { */ subAgents?: SubAgentsConfig; /** Optional OpenTelemetry settings (see docs/observability.md). */ - otel?: OtelConfig; + /** Wrapper-level OTel + OpenCode Hook F only (`otel.backend.opencode`). */ + otel?: OtelOpenCodeConfig; /** * Directory containing the agent's config.json file. * Populated automatically by the CLI scaffold when a config file path is provided. diff --git a/docs/observability.md b/docs/observability.md index a2f3547..6eef139 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -99,6 +99,15 @@ Provider-only smoke (no Phoenix): `node examples/mock-ollama/smoke.mjs`. } ``` +**Structure (important):** + +- **Wrapper-level (common to all packages):** `enabled`, `exporter`, `serviceName`, + feature flags (`taskUsageRollup`, `annotateUsageCalls`, `mirrorAgentEvents`, …). +- **Provider-specific only under `otel.backend.`:** Copilot’s OTLP + knobs, Claude’s subprocess env flags, Codex’s `[otel]` overrides, OpenCode’s + server flag. Each wrapper’s JSON Schema exposes **only its own** backend key + (OpenCode does **not** list Codex, etc.). + | Field | Meaning | |---|---| | `otel.enabled` | Emit wrapper spans such as `a2a.task.execute` | @@ -106,10 +115,10 @@ Provider-only smoke (no Phoenix): `node examples/mock-ollama/smoke.mjs`. | `otel.mirrorAgentEvents` | Also turn sideband tool events into wrapper OTel tool spans (default `false`) | | `otel.taskUsageRollup` | Stamp `a2a.task.usage.*` (± `gen_ai.usage.*`) on the task span (default `true`; sideband `x-usage` always) | | `otel.annotateUsageCalls` | Per-call `a2a.llm.call` span **events** when wrapper is sole token source (default `false`; Claude/Codex wired) | -| `otel.backend.copilot` | Hook F: Copilot CLI OTLP + `onGetTraceContext` parent-link | -| `otel.backend.claude` | Hook F: `CLAUDE_CODE_ENABLE_TELEMETRY` + `OTEL_*` + `TRACEPARENT` in subprocess env | -| `otel.backend.codex` | Hook F: merged into Codex `configOverrides.otel` | -| `otel.backend.opencode` | Declares OpenCode server OTel on (de-dupe); enable the flag on the OpenCode server itself | +| `otel.backend.copilot` | **a2a-copilot only** — Hook F: Copilot CLI OTLP + `onGetTraceContext` | +| `otel.backend.claude` | **a2a-claude only** — Hook F: `CLAUDE_CODE_ENABLE_TELEMETRY` + `OTEL_*` + `TRACEPARENT` | +| `otel.backend.codex` | **a2a-codex only** — Hook F: merged into Codex `configOverrides.otel` | +| `otel.backend.opencode` | **a2a-opencode only** — declares OpenCode server OTel on (de-dupe) | | `otel.emitOverlappingBackendSpans` | Unsafe; allow duplicates (debug only) | Every wrapper stamps package identity on `a2a.task.execute`: diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index c10167a..ce18d2c 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -512,16 +512,14 @@ export interface BaseAgentConfig> { /** * Optional OpenTelemetry settings for wrapper-owned spans. * - * Core only uses `@opentelemetry/api` (optional peer). Register a real - * TracerProvider / OTLP exporter in the host app or wrapper CLI — see - * `docs/observability.md`. When omitted, instrumentation is a no-op. + * Common fields (`enabled`, `exporter`, feature flags, …) live here at the + * wrapper level. Provider-specific Hook F knobs belong under + * `otel.backend.` — each package's AgentConfig uses a + * narrowed type (`OtelCopilotConfig`, `OtelOpenCodeConfig`, …) so schemas + * do not leak other backends (e.g. Codex under OpenCode). * * Typed loosely here to avoid a hard import cycle with the telemetry - * module; see {@link OtelConfig} in `@a2a-wrapper/core` exports. - */ - /** - * Optional OpenTelemetry settings. Prefer the exported {@link OtelConfig} - * type from `@a2a-wrapper/core` in wrapper configs. + * module; see {@link OtelConfig} / per-wrapper aliases in exports. */ otel?: { enabled?: boolean; @@ -529,25 +527,10 @@ export interface BaseAgentConfig> { tracerName?: string; mirrorAgentEvents?: boolean; emitOverlappingBackendSpans?: boolean; + taskUsageRollup?: boolean; + annotateUsageCalls?: boolean; exporter?: { endpoint?: string; protocol?: string }; - backend?: { - copilot?: { - otlpEndpoint?: string; - exporterType?: string; - otlpProtocol?: string; - sourceName?: string; - captureContent?: boolean; - filePath?: string; - propagateTraceContext?: boolean; - }; - claude?: { - enableTelemetry?: boolean; - otlpEndpoint?: string; - otlpProtocol?: string; - propagateTraceContext?: boolean; - }; - codex?: Record; - opencode?: { openTelemetry?: boolean }; - }; + /** Only the key for the running wrapper is meaningful. */ + backend?: Record; }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d8ac11..eaa0c31 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -160,7 +160,17 @@ export { LlmUsageAccumulator } from "./events/usage.js"; // ─── OpenTelemetry (optional @opentelemetry/api peer) ─────────────────────── export type { + OtelCommonConfig, OtelConfig, + OtelCopilotBackendConfig, + OtelClaudeBackendConfig, + OtelCodexBackendConfig, + OtelOpenCodeBackendConfig, + OtelCopilotConfig, + OtelClaudeConfig, + OtelCodexConfig, + OtelOpenCodeConfig, + OtelAntigravityConfig, OtelEmissionPolicy, OtelTracerLike, OtelSpanLike, diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 07b0c39..39a12ea 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -5,7 +5,17 @@ */ export type { + OtelCommonConfig, OtelConfig, + OtelCopilotBackendConfig, + OtelClaudeBackendConfig, + OtelCodexBackendConfig, + OtelOpenCodeBackendConfig, + OtelCopilotConfig, + OtelClaudeConfig, + OtelCodexConfig, + OtelOpenCodeConfig, + OtelAntigravityConfig, OtelEmissionPolicy, OtelTracerLike, OtelSpanLike, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index e3ef518..0cff93b 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -8,12 +8,13 @@ */ /** - * Optional OpenTelemetry settings on an agent config. + * Common wrapper-level OTel settings — shared by every `a2a-*` package. * - * When omitted or `enabled: false`, core instrumentation is a no-op unless a - * host has already registered a tracer via {@link setOtelTracer}. + * These are **not** provider-specific. Put only Hook F / vendor knobs under + * {@link OtelConfig.backend} (and each wrapper's schema only exposes *its* + * backend key — see {@link OtelCopilotConfig}, {@link OtelClaudeConfig}, …). */ -export interface OtelConfig { +export interface OtelCommonConfig { /** * Master switch for wrapper-owned OTel instrumentation. * @default false @@ -81,39 +82,89 @@ export interface OtelConfig { */ protocol?: string; }; +} + +/** Copilot CLI OTel knobs (only meaningful on `a2a-copilot`). */ +export interface OtelCopilotBackendConfig { + otlpEndpoint?: string; + exporterType?: string; + otlpProtocol?: "http/json" | "http/protobuf"; + sourceName?: string; + captureContent?: boolean; + filePath?: string; + /** @default true when Copilot OTel is configured */ + propagateTraceContext?: boolean; +} + +/** Claude Code subprocess OTel knobs (only meaningful on `a2a-claude`). */ +export interface OtelClaudeBackendConfig { + enableTelemetry?: boolean; + otlpEndpoint?: string; + otlpProtocol?: string; + /** @default true when Claude telemetry is enabled */ + propagateTraceContext?: boolean; +} + +/** Codex CLI `[otel]` overrides (only meaningful on `a2a-codex`). */ +export type OtelCodexBackendConfig = Record; +/** + * OpenCode server-side experimental flag (only meaningful on `a2a-opencode`). + * The wrapper does not start OpenCode's exporter; set this so de-dupe policy + * matches your server. + */ +export interface OtelOpenCodeBackendConfig { + openTelemetry?: boolean; +} + +/** + * Full OTel config accepted by core helpers (all backend keys optional). + * + * Prefer the per-wrapper aliases in agent configs / JSON Schema so editors + * only surface the backend that package actually implements: + * {@link OtelCopilotConfig}, {@link OtelClaudeConfig}, {@link OtelCodexConfig}, + * {@link OtelOpenCodeConfig}, {@link OtelAntigravityConfig}. + */ +export interface OtelConfig extends OtelCommonConfig { /** - * Backend runtime OTel passthrough (Hook F). Presence of a configured - * backend block turns on de-dupe (no wrapper tool / gen_ai.usage duplicate). + * Backend runtime OTel passthrough (Hook F). Only the key matching the + * running wrapper is read. Presence of a configured backend block turns on + * de-dupe (no wrapper tool / gen_ai.usage duplicate). */ backend?: { - copilot?: { - otlpEndpoint?: string; - exporterType?: string; - otlpProtocol?: "http/json" | "http/protobuf"; - sourceName?: string; - captureContent?: boolean; - filePath?: string; - /** @default true when Copilot OTel is configured */ - propagateTraceContext?: boolean; - }; - claude?: { - enableTelemetry?: boolean; - otlpEndpoint?: string; - otlpProtocol?: string; - /** @default true when Claude telemetry is enabled */ - propagateTraceContext?: boolean; - }; - /** Merged into Codex `configOverrides.otel` (CLI `[otel]` shape). */ - codex?: Record; - /** - * OpenCode server-side experimental flag. The wrapper does not start - * OpenCode's exporter; set this so de-dupe policy matches your server. - */ - opencode?: { openTelemetry?: boolean }; + copilot?: OtelCopilotBackendConfig; + claude?: OtelClaudeBackendConfig; + codex?: OtelCodexBackendConfig; + opencode?: OtelOpenCodeBackendConfig; }; } +/** `a2a-copilot` agent `otel` — common + Copilot Hook F only. */ +export type OtelCopilotConfig = OtelCommonConfig & { + backend?: { copilot?: OtelCopilotBackendConfig }; +}; + +/** `a2a-claude` agent `otel` — common + Claude Hook F only. */ +export type OtelClaudeConfig = OtelCommonConfig & { + backend?: { claude?: OtelClaudeBackendConfig }; +}; + +/** `a2a-codex` agent `otel` — common + Codex Hook F only. */ +export type OtelCodexConfig = OtelCommonConfig & { + backend?: { codex?: OtelCodexBackendConfig }; +}; + +/** `a2a-opencode` agent `otel` — common + OpenCode Hook F only. */ +export type OtelOpenCodeConfig = OtelCommonConfig & { + backend?: { opencode?: OtelOpenCodeBackendConfig }; +}; + +/** + * `a2a-antigravity` agent `otel` — common settings only (no Hook F backend + * passthrough yet). + */ +export type OtelAntigravityConfig = OtelCommonConfig; + /** * Policy computed once per task for deciding what the wrapper may emit to OTLP. */