diff --git a/.changeset/otel-byok-cookout.md b/.changeset/otel-byok-cookout.md new file mode 100644 index 0000000..d4c91db --- /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 cookout + shared mock-ollama; wire CLI OTel bootstrap + +- 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/.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/.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/.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/.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/.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/.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/.changeset/otel-phoenix-identity.md b/.changeset/otel-phoenix-identity.md new file mode 100644 index 0000000..8d2c6e1 --- /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 scripts: `examples/otel-stack/smoke-export.mjs` + `smoke-providers.mjs` (copilot/claude executor → Phoenix) diff --git a/README.md b/README.md index 12aef71..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/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 (`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 69a80e7..787d45d 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/OtelAntigravityConfig", + "description": "Wrapper-level OTel only (no Hook F backend passthrough yet)." + }, "server": { "$ref": "#/definitions/ServerConfig" }, @@ -538,6 +542,67 @@ }, "type": "object" }, + "OtelAntigravityConfig": { + "$ref": "#/definitions/OtelCommonConfig", + "description": "`a2a-antigravity` agent `otel` — common settings only (no Hook F backend passthrough yet)." + }, + "OtelCommonConfig": { + "additionalProperties": false, + "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" + }, + "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" + }, + "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:6006` (Phoenix) or `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "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" + } + }, + "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.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "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`.", + "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/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-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-antigravity/src/config/types.ts b/a2a-antigravity/src/config/types.ts index d0c2474..6ad3c71 100644 --- a/a2a-antigravity/src/config/types.ts +++ b/a2a-antigravity/src/config/types.ts @@ -11,6 +11,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelAntigravityConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -181,6 +182,9 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + /** 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-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/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 e4f2411..5e2ee51 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/OtelClaudeConfig", + "description": "Wrapper-level OTel + Claude Hook F only (`otel.backend.claude`)." + }, "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,92 @@ }, "type": "object" }, + "OtelClaudeBackendConfig": { + "additionalProperties": false, + "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, + "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, + "properties": { + "claude": { + "$ref": "#/definitions/OtelClaudeBackendConfig" + } + }, + "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" + }, + "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:6006` (Phoenix) or `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "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" + } + }, + "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.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "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`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "properties": { @@ -631,7 +750,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 +762,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 +875,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 +929,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 +939,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/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-claude/src/claude/executor.ts b/a2a-claude/src/claude/executor.ts index 667a100..1cdeaf9 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,14 +39,45 @@ import { publishFinalArtifact, publishStreamingChunk, publishLastChunkMarker, + createExecutionObservability, + LlmUsageAccumulator, + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, +} 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"]); @@ -235,13 +263,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); @@ -259,6 +286,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 @@ -361,7 +389,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(); }; @@ -443,6 +481,12 @@ export class ClaudeExecutor implements AgentExecutor { } lastResult = msg; + if (msg.subtype === "success") { + 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/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-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-claude/src/config/types.ts b/a2a-claude/src/config/types.ts index b34131b..0c13934 100644 --- a/a2a-claude/src/config/types.ts +++ b/a2a-claude/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelClaudeConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -287,6 +288,9 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + /** 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-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/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 2f65f5a..c640782 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/OtelCodexConfig", + "description": "Wrapper-level OTel + Codex Hook F only (`otel.backend.codex`)." + }, "server": { "$ref": "#/definitions/ServerConfig" }, @@ -504,6 +508,77 @@ }, "type": "object" }, + "OtelCodexBackendConfig": { + "additionalProperties": {}, + "description": "Codex CLI `[otel]` overrides (only meaningful on `a2a-codex`).", + "type": "object" + }, + "OtelCodexConfig": { + "additionalProperties": false, + "description": "`a2a-codex` agent `otel` — common + Codex Hook F only.", + "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, + "properties": { + "codex": { + "$ref": "#/definitions/OtelCodexBackendConfig" + } + }, + "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" + }, + "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:6006` (Phoenix) or `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "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" + } + }, + "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.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "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`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "properties": { 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-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-codex/src/codex/executor.ts b/a2a-codex/src/codex/executor.ts index 5d2c56e..b22c3f8 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,17 +31,43 @@ import { publishFinalArtifact, publishStreamingChunk, publishLastChunkMarker, + createExecutionObservability, + LlmUsageAccumulator, + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, } 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; @@ -136,19 +159,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); @@ -174,6 +190,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..."); @@ -220,6 +237,16 @@ export class CodexExecutor implements AgentExecutor { } } + if (event.type === "turn.completed" && event.usage) { + 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); } @@ -230,7 +257,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/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-codex/src/config/types.ts b/a2a-codex/src/config/types.ts index 7372959..054b68f 100644 --- a/a2a-codex/src/config/types.ts +++ b/a2a-codex/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelCodexConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -253,6 +254,9 @@ export interface AgentConfig { events?: EventsConfig; memory?: MemoryConfig; subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + /** 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-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 168b0e4..b714cf6 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/OtelCopilotConfig", + "description": "Wrapper-level OTel + Copilot Hook F only (`otel.backend.copilot`)." + }, "server": { "$ref": "#/definitions/ServerConfig", "description": "Network / server settings" @@ -464,6 +468,105 @@ }, "type": "object" }, + "OtelCopilotBackendConfig": { + "additionalProperties": false, + "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, + "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, + "properties": { + "copilot": { + "$ref": "#/definitions/OtelCopilotBackendConfig" + } + }, + "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" + }, + "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:6006` (Phoenix) or `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "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" + } + }, + "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.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "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`.", + "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/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-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-copilot/src/config/types.ts b/a2a-copilot/src/config/types.ts index e39652f..65210d1 100644 --- a/a2a-copilot/src/config/types.ts +++ b/a2a-copilot/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelCopilotConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -356,6 +357,9 @@ export interface AgentConfig { * @see {@link ../../../.kiro/specs/a2a-subagents/design.md} */ subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + /** 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-copilot/src/copilot/executor.ts b/a2a-copilot/src/copilot/executor.ts index 1f20ee5..a842a2c 100644 --- a/a2a-copilot/src/copilot/executor.ts +++ b/a2a-copilot/src/copilot/executor.ts @@ -24,12 +24,16 @@ import { publishTask, publishTraceArtifact, extractUserText, - resolveTransport, - AgentEventEmitter, materializeMemory, WELL_KNOWN_PATHS, bootstrapSubAgents, LlmUsageAccumulator, + createExecutionObservability, + applyUsageSummaryToActiveSpan, + annotateSessionOnTaskSpan, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + createCopilotTraceContextProvider, } from "@a2a-wrapper/core"; import type { EventTransport, @@ -140,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(); @@ -305,24 +324,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 +355,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 +682,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 +757,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-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 55e5c16..31a9eba 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/OtelOpenCodeConfig", + "description": "Wrapper-level OTel + OpenCode Hook F only (`otel.backend.opencode`)." + }, "server": { "$ref": "#/definitions/ServerConfig", "description": "Network / server settings" @@ -427,6 +431,82 @@ }, "type": "object" }, + "OtelOpenCodeBackendConfig": { + "additionalProperties": false, + "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, + "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, + "properties": { + "opencode": { + "$ref": "#/definitions/OtelOpenCodeBackendConfig" + } + }, + "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" + }, + "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:6006` (Phoenix) or `http://127.0.0.1:4318`", + "type": "string" + }, + "protocol": { + "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" + } + }, + "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.", + "type": "boolean" + }, + "serviceName": { + "default": "derived from agent card / wrapper name", + "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`.", + "type": "string" + } + }, + "type": "object" + }, "ServerConfig": { "additionalProperties": false, "description": "Network and server settings.", 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/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/a2a-opencode/src/config/types.ts b/a2a-opencode/src/config/types.ts index 0001370..9df1436 100644 --- a/a2a-opencode/src/config/types.ts +++ b/a2a-opencode/src/config/types.ts @@ -9,6 +9,7 @@ import type { AgentCardConfig, EventsConfig, MemoryConfig, + OtelOpenCodeConfig, SubAgentsConfig, } from "@a2a-wrapper/core"; @@ -232,6 +233,9 @@ export interface AgentConfig { * @see {@link ../../../.kiro/specs/a2a-subagents/design.md} */ subAgents?: SubAgentsConfig; + /** Optional OpenTelemetry settings (see docs/observability.md). */ + /** 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/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/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 new file mode 100644 index 0000000..6eef139 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,243 @@ +# 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 (Phoenix, Grafana Tempo, 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")); +``` + +### 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-proto +``` + +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 +pip install 'arize-phoenix' +phoenix serve +# UI + OTLP HTTP: http://localhost:6006 (traces → /v1/traces) +# OTLP gRPC: http://localhost:4317 +``` + +Optional Docker image is in [`examples/otel-stack`](../examples/otel-stack) if you prefer compose. + +**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 +``` + +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 + +```json +{ + "otel": { + "enabled": true, + "mirrorAgentEvents": false, + "taskUsageRollup": true, + "annotateUsageCalls": false, + "exporter": { + "endpoint": "http://127.0.0.1:6006", + "protocol": "http/protobuf" + }, + "backend": { + "copilot": { + "otlpEndpoint": "http://127.0.0.1:6006", + "exporterType": "otlp-http", + "propagateTraceContext": true + } + } + } +} +``` + +**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` | +| `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` | **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`: + +| 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`). +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 Phoenix / Tempo / Langfuse (happy path) + +One **request** = one wrapper span + backend children (not one merged span): + +``` +a2a.task.execute ← wrapper (Node), one per inbound request + └─ Copilot / Claude … ← backend CLI/runtime (same collector, parent-linked when configured) +``` + +### 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. + +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) + +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) + +| 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` (platform session join key) | +| `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). + +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. + +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, 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 + +```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/docs/otel-attribute-ownership.md b/docs/otel-attribute-ownership.md new file mode 100644 index 0000000..a3f4ac9 --- /dev/null +++ b/docs/otel-attribute-ownership.md @@ -0,0 +1,101 @@ +# 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.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) | + +## 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 (Phoenix, no Docker required): [examples/otel-stack](../examples/otel-stack) 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/mock-ollama/agents/claude.json b/examples/mock-ollama/agents/claude.json new file mode 100644 index 0000000..e97a6d6 --- /dev/null +++ b/examples/mock-ollama/agents/claude.json @@ -0,0 +1,48 @@ +{ + "$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": "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, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [] + }, + "server": { + "port": 3102, + "hostname": "127.0.0.1", + "advertiseHost": "127.0.0.1", + "advertiseProtocol": "http" + }, + "claude": { + "workingDirectory": "/tmp/a2a-mock-ollama-claude-ws", + "model": "mock-ollama", + "permissionMode": "bypassPermissions", + "dangerouslyAllowBypassPermissions": true, + "settingSources": [], + "systemPromptAppend": "Reply briefly. You are a local mock-ollama 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 + } +} diff --git a/examples/mock-ollama/agents/copilot.json b/examples/mock-ollama/agents/copilot.json new file mode 100644 index 0000000..f339a7b --- /dev/null +++ b/examples/mock-ollama/agents/copilot.json @@ -0,0 +1,45 @@ +{ + "$comment": "Generic a2a-copilot agent pointing at mock-ollama (BYOK). No OTel required — add an otel block when needed.", + "agentCard": { + "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, + "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 local mock-ollama test agent.", + "workspaceDirectory": "/tmp/a2a-mock-ollama-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 + } +} 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 new file mode 100644 index 0000000..afde4fa --- /dev/null +++ b/examples/otel-stack/README.md @@ -0,0 +1,95 @@ +# OpenTelemetry demo — Arize Phoenix (lightweight) + +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 +pip install 'arize-phoenix' +# or: uvx --from arize-phoenix phoenix serve +phoenix serve +``` + +| Endpoint | Use | +|---|---| +| `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: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-wrapper-demo", + "taskUsageRollup": true, + "annotateUsageCalls": false, + "exporter": { + "endpoint": "http://127.0.0.1:6006", + "protocol": "http/protobuf" + } + } +} +``` + +## CLI bootstrap (optional deps) + +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 / cookout scripts + +With Phoenix already running: + +```bash +# 1) Synthetic core span (bootstrap + attrs only) +node examples/otel-stack/smoke-export.mjs + +# 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 +``` + +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): + +```bash +node examples/otel-stack/smoke-providers.mjs +``` + +## What you should see + +1. One **trace** per inbound A2A request (`a2a.task.execute` + backend children when Hook F is on). +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) 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 new file mode 100644 index 0000000..7224cd9 --- /dev/null +++ b/examples/otel-stack/agent-otel.snippet.json @@ -0,0 +1,24 @@ +{ + "otel": { + "enabled": true, + "serviceName": "a2a-wrapper-demo", + "taskUsageRollup": true, + "annotateUsageCalls": false, + "exporter": { + "endpoint": "http://127.0.0.1:6006", + "protocol": "http/protobuf" + }, + "backend": { + "copilot": { + "propagateTraceContext": true + }, + "claude": { + "enableTelemetry": true, + "propagateTraceContext": true + }, + "codex": { + "environment": "local" + } + } + } +} diff --git a/examples/otel-stack/cookout-byok.mjs b/examples/otel-stack/cookout-byok.mjs new file mode 100644 index 0000000..77b362d --- /dev/null +++ b/examples/otel-stack/cookout-byok.mjs @@ -0,0 +1,343 @@ +#!/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 mockOllamaDir = join(__dirname, "../mock-ollama"); +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(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; + 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-${agentFile}`); + 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 shared mock-ollama on", MOCK_PORT); + spawnLogged(process.execPath, [join(mockOllamaDir, "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`); + + // 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("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…"); + 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/docker-compose.yml b/examples/otel-stack/docker-compose.yml new file mode 100644 index 0000000..e322200 --- /dev/null +++ b/examples/otel-stack/docker-compose.yml @@ -0,0 +1,24 @@ +# 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:6006 +# +# Point wrappers at: +# otel.exporter.endpoint = "http://127.0.0.1:6006" + +services: + phoenix: + image: arizephoenix/phoenix:latest + container_name: a2a-otel-phoenix + ports: + - "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/mock-ollama.mjs b/examples/otel-stack/mock-ollama.mjs new file mode 100644 index 0000000..c863e29 --- /dev/null +++ b/examples/otel-stack/mock-ollama.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +/** + * Compatibility shim — mock Ollama moved to examples/mock-ollama/server.mjs + * so it can be reused beyond OTel cookouts. + */ +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); +}); 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/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); +}); 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..691820a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -466,6 +466,20 @@ 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). + +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` + `@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"; +``` + +`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..39f3f8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,11 +22,30 @@ }, "peerDependencies": { "@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" }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/sdk-node": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "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..450d09c --- /dev/null +++ b/packages/core/src/__tests__/telemetry/otel.test.ts @@ -0,0 +1,560 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + setOtelTracer, + withSpan, + resolveOtelEmissionPolicy, + extractA2ATraceContext, + instrumentExecutor, + observeAgentEvent, + runWithTaskOtelStore, + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, + createExecutionObservability, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + buildClaudeOtelEnv, + mergeCodexOtelOverrides, + getCorePackageVersion, + buildTaskSpanAttributes, + 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 linksSeen: number[] = []; + let spanSeq = 0; + const tracer: OtelTracerLike = { + startSpan(name, options) { + if (options?.attributes) { + for (const [key, value] of Object.entries(options.attributes)) { + 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 }); + }, + setStatus() {}, + recordException() {}, + addEvent(n) { + events.push(n); + }, + spanContext() { + return { + traceId: `trace-${id}`, + spanId: `span-${id}`, + traceFlags: 1, + }; + }, + end() { + ended.push(name); + }, + }; + spans.push(span); + return span; + }, + }; + return { tracer, ended, events, spans, attrs, linksSeen }; +} + +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); + 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, + backend: { copilot: { otlpEndpoint: "http://127.0.0.1:4318" } }, + }); + expect(p.emitGenAiUsageAttrs).toBe(false); + }); +}); + +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"); + 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"); + }); +}); + +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, attrs } = 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", + wrapperVersion: "1.8.2", + }); + await ex.execute( + { + 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"]); + 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 () => { + const { tracer, ended, attrs, linksSeen } = 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); + // First attempt: no link; continue/retry link to prior span context. + expect(linksSeen).toEqual([0, 1, 1]); + }); +}); + +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"); + }); +}); + +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); + }); + + 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", () => { + 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"); + }); +}); + +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/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/config/types.ts b/packages/core/src/config/types.ts index fb0d61d..ce18d2c 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -508,4 +508,29 @@ export interface BaseAgentConfig> { * Defaults to process.cwd() when no config file is specified. */ configDir?: string; + + /** + * Optional OpenTelemetry settings for wrapper-owned spans. + * + * 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} / per-wrapper aliases in exports. + */ + otel?: { + enabled?: boolean; + serviceName?: string; + tracerName?: string; + mirrorAgentEvents?: boolean; + emitOverlappingBackendSpans?: boolean; + taskUsageRollup?: boolean; + annotateUsageCalls?: boolean; + exporter?: { endpoint?: string; protocol?: string }; + /** Only the key for the running wrapper is meaningful. */ + backend?: Record; + }; } 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..eaa0c31 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,66 @@ export { export type { UsageCallRecord, UsageTelemetryData, ContextWindowSnapshot } from "./events/usage.js"; 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, + OtelSpanContextLike, + OtelSpanLinkLike, + W3cTraceContext, + A2ATraceContext, + TaskOtelStore, + InstrumentExecutorOptions, + CreateExecutionObservabilityOptions, + ExecutionObservability, + WithSpanOptions, +} from "./telemetry/index.js"; + +export { + resolveOtelEmissionPolicy, + setOtelTracer, + getOtelTracer, + tryGetGlobalTracer, + withSpan, + bootstrapOtelSdkFromConfig, + shutdownOtelSdk, + extractA2ATraceContext, + getW3cTraceContext, + injectW3cTraceHeaders, + getTaskOtelStore, + runWithTaskOtelStore, + buildTaskSpanAttributes, + slugAgentId, + agentIdentityFromCard, + getCorePackageVersion, + readPackageIdentity, + instrumentExecutor, + observeAgentEvent, + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, + createExecutionObservability, + annotateSessionOnTaskSpan, + resolveOtlpEndpoint, + buildCopilotTelemetryOptions, + shouldPropagateCopilotTraceContext, + createCopilotTraceContextProvider, + buildClaudeOtelEnv, + mergeCodexOtelOverrides, + isOpenCodeBackendOtelEnabled, +} 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..ef7013d --- /dev/null +++ b/packages/core/src/telemetry/api.ts @@ -0,0 +1,130 @@ +/** + * 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, 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); + +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). + * + * 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, + attributes: Record, + block: (span: OtelSpanLike | undefined) => Promise, + options?: WithSpanOptions, +): 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, + ...(options?.links?.length ? { links: options.links } : {}), + }); + + const run = async (): Promise => { + 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(); + } + }; + + // 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; + }; + trace: { setSpan: (ctx: unknown, s: unknown) => unknown }; + }; + + let api: Api | undefined; + try { + api = require("@opentelemetry/api") as Api; + } catch { + 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/attributes.ts b/packages/core/src/telemetry/attributes.ts new file mode 100644 index 0000000..7b8425d --- /dev/null +++ b/packages/core/src/telemetry/attributes.ts @@ -0,0 +1,119 @@ +/** + * 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"; +import { getCorePackageVersion } from "./package-meta.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, "-"); +} + +export function buildTaskSpanAttributes(opts: { + taskId: string; + 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; + /** 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 coreVersion = opts.coreVersion ?? getCorePackageVersion(); + 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, + // Platforms (Langfuse/Datadog/Phoenix) group multi-turn work by this id — + // not by assuming one eternal parent span. + "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", + "a2a.wrapper.core.version": coreVersion, + }; + const sessionId = opts.sessionId || conversationId; + attrs["session.id"] = sessionId; + 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; + 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; +} + +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/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/cli-bootstrap.ts b/packages/core/src/telemetry/cli-bootstrap.ts new file mode 100644 index 0000000..37b8edd --- /dev/null +++ b/packages/core/src/telemetry/cli-bootstrap.ts @@ -0,0 +1,141 @@ +/** + * Optional CLI OTel SDK bootstrap (Phase 4). + * + * 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 + */ + +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`; +} + +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. + */ +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 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, 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, + spanProcessors: [new SimpleSpanProcessor(traceExporter)], + }); + 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-proto` " + + "(protobuf; preferred) or `@opentelemetry/exporter-trace-otlp-http` (JSON), " + + `plus @opentelemetry/api. (${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/context.ts b/packages/core/src/telemetry/context.ts new file mode 100644 index 0000000..998a77c --- /dev/null +++ b/packages/core/src/telemetry/context.ts @@ -0,0 +1,156 @@ +/** + * 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; + /** + * 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 { + 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); +} + +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 / 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 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: + pickString(meta.trace_id, meta.traceId) || + ctx.contextId || + uuidv4(), + 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), + }, + }; +} + +/** + * 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 {}; + } +} + +/** + * 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/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..39a12ea --- /dev/null +++ b/packages/core/src/telemetry/index.ts @@ -0,0 +1,84 @@ +/** + * OpenTelemetry integration for `@a2a-wrapper/core`. + * + * @module telemetry + */ + +export type { + OtelCommonConfig, + OtelConfig, + OtelCopilotBackendConfig, + OtelClaudeBackendConfig, + OtelCodexBackendConfig, + OtelOpenCodeBackendConfig, + OtelCopilotConfig, + OtelClaudeConfig, + OtelCodexConfig, + OtelOpenCodeConfig, + OtelAntigravityConfig, + OtelEmissionPolicy, + OtelTracerLike, + OtelSpanLike, + OtelSpanContextLike, + OtelSpanLinkLike, + W3cTraceContext, +} from "./types.js"; +export { resolveOtelEmissionPolicy } from "./types.js"; + +export { + setOtelTracer, + getOtelTracer, + tryGetGlobalTracer, + withSpan, + type WithSpanOptions, +} from "./api.js"; + +export { + bootstrapOtelSdkFromConfig, + shutdownOtelSdk, +} from "./cli-bootstrap.js"; + +export { + extractA2ATraceContext, + getW3cTraceContext, + injectW3cTraceHeaders, + getTaskOtelStore, + runWithTaskOtelStore, + type A2ATraceContext, + type TaskOtelStore, +} from "./context.js"; + +export { + buildTaskSpanAttributes, + slugAgentId, + agentIdentityFromCard, + 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"; + +export { + applyUsageSummaryToActiveSpan, + applyUsageCallToActiveSpan, +} from "./usage-bridge.js"; + +export { + createExecutionObservability, + annotateSessionOnTaskSpan, + 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/instrument.ts b/packages/core/src/telemetry/instrument.ts new file mode 100644 index 0000000..28ea751 --- /dev/null +++ b/packages/core/src/telemetry/instrument.ts @@ -0,0 +1,209 @@ +/** + * Executor instrumentation — Hook A. + * + * Wraps `execute` / `cancelTask` once in {@link createA2AServer} so every + * 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 + */ + +import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server"; +import type { A2AExecutor } from "../executor/types.js"; +import { withSpan } from "./api.js"; +import { + buildTaskSpanAttributes, + slugAgentId, + type TaskInvocationKind, +} from "./attributes.js"; +import { extractA2ATraceContext, runWithTaskOtelStore, type TaskOtelStore } from "./context.js"; +import { + resolveOtelEmissionPolicy, + type OtelConfig, + type OtelSpanContextLike, + type OtelSpanLinkLike, +} from "./types.js"; +import { getCorePackageVersion } from "./package-meta.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; +} + +/** 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; + 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); + 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, +): 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. + */ +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 invocation = nextInvocation(taskId); + const hasExistingTask = Boolean(ctx.task); + const invocationKind = resolveInvocationKind(invocation, hasExistingTask); + const messageId = extractMessageId(ctx); + const attrs = buildTaskSpanAttributes({ + taskId, + contextId, + agentName, + agentId, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + protocolVersion: options.protocolVersion, + parentAgentId: trace.parentAgentId, + orchestratorTraceId: trace.traceId, + conversationId: trace.conversationId, + sessionId: trace.sessionId, + gateway: trace.gateway, + messageId, + invocation, + invocationKind, + }); + + 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)); + } + + 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 + ? async (taskId: string, bus: ExecutionEventBus): Promise => { + const attrs: Record = { + "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.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); + } + + 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/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/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 new file mode 100644 index 0000000..0cff93b --- /dev/null +++ b/packages/core/src/telemetry/types.ts @@ -0,0 +1,269 @@ +/** + * 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 + */ + +/** + * Common wrapper-level OTel settings — shared by every `a2a-*` package. + * + * 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 OtelCommonConfig { + /** + * 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; + + /** + * 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:6006` (Phoenix) or `http://127.0.0.1:4318` */ + endpoint?: string; + /** + * 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; + }; +} + +/** 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). 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?: 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. + */ +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; + /** + * 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; + /** + * 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; +} + +/** + * 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; + links?: OtelSpanLinkLike[]; + }, + ): 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; + spanContext?(): OtelSpanContextLike; + 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); + const emitGenAiUsageAttrs = enabled && (!backendOtelOn || overlap); + const emitTaskUsageRollup = enabled && otel?.taskUsageRollup !== false; + const annotateUsageCalls = + enabled && otel?.annotateUsageCalls === true && emitGenAiUsageAttrs; + + return { + enabled, + backendOtelOn, + emitToolSpans, + annotateLifecycleOnTaskSpan: enabled, + emitGenAiUsageAttrs, + emitTaskUsageRollup, + annotateUsageCalls, + }; +} diff --git a/packages/core/src/telemetry/usage-bridge.ts b/packages/core/src/telemetry/usage-bridge.ts new file mode 100644 index 0000000..b57becf --- /dev/null +++ b/packages/core/src/telemetry/usage-bridge.ts @@ -0,0 +1,81 @@ +/** + * 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; + if (!store.policy.emitTaskUsageRollup) 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.annotateUsageCalls) 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, + }); +}