From d6a0d05fc09e95529d363a8f7e7358526beeb6eb Mon Sep 17 00:00:00 2001 From: Chris Greeno Date: Wed, 3 Jun 2026 17:23:46 +0100 Subject: [PATCH] feat(coding-agent): env-driven OTLP trace export for headless hosts When OTEL_EXPORTER_OTLP_ENDPOINT (or _TRACES_ENDPOINT) is set, register a global OTLP/proto TracerProvider and switch on the agent loop's telemetry so its invoke_agent / chat / execute_tool spans reach a collector instead of a no-op tracer. Honors the standard OTEL_* env contract (OTEL_SERVICE_NAME, OTEL_SDK_DISABLED, OTEL_TRACES_EXPORTER=none) and the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT capture toggle; no-op when no endpoint is configured. Uses the exporter-trace-otlp-proto 2.x line, which exports cleanly under Bun (the 1.x line deadlocks). Adds a flushTelemetryExport() helper and a periodic + signal-driven flush so long-lived processes surface spans. This makes the existing telemetry usable from hosts that spawn omp as a child process (e.g. an ACP harness), where an in-process provider registered by the parent can't reach the child process. --- bun.lock | 23 ++++ package.json | 3 + packages/coding-agent/CHANGELOG.md | 5 +- packages/coding-agent/package.json | 6 + packages/coding-agent/src/main.ts | 12 ++ packages/coding-agent/src/modes/print-mode.ts | 5 + packages/coding-agent/src/telemetry-export.ts | 126 ++++++++++++++++++ .../coding-agent/test/otel-export-probe.ts | 54 ++++++++ .../test/telemetry-export.test.ts | 91 +++++++++++++ 9 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/telemetry-export.ts create mode 100644 packages/coding-agent/test/otel-export-probe.ts create mode 100644 packages/coding-agent/test/telemetry-export.test.ts diff --git a/bun.lock b/bun.lock index 847d14690..551627c73 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,12 @@ "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-tui": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@opentelemetry/api": "catalog:", + "@opentelemetry/context-async-hooks": "catalog:", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:", + "@opentelemetry/resources": "catalog:", + "@opentelemetry/sdk-trace-base": "catalog:", + "@opentelemetry/sdk-trace-node": "catalog:", "@puppeteer/browsers": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", @@ -253,7 +259,10 @@ "@oh-my-pi/pi-utils": "15.8.3", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@puppeteer/browsers": "^3.0.4", "@tailwindcss/node": "^4.3.0", "@tailwindcss/vite": "^4.3.0", @@ -647,14 +656,28 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.7.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ=="], "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], diff --git a/package.json b/package.json index a19620f1d..e463ae187 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "@oh-my-pi/pi-utils": "15.8.3", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", + "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@puppeteer/browsers": "^3.0.4", "@tailwindcss/node": "^4.3.0", "@tailwindcss/vite": "^4.3.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89a88a09f..c5b02c424 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added env-driven OpenTelemetry trace export. When `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) is set, `omp` registers a global OTLP/proto trace exporter and switches on the agent loop's telemetry, so the `invoke_agent` / `chat` / `execute_tool` spans actually reach a collector instead of a no-op tracer. Honors the standard `OTEL_*` env contract (endpoint, headers, `OTEL_SERVICE_NAME`, `OTEL_SDK_DISABLED` and `OTEL_TRACES_EXPORTER=none` parsed case-insensitively) and the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` capture toggle; it is a no-op when no endpoint is configured. Only the `http/protobuf` transport is supported — a `grpc` or `http/json` `OTEL_EXPORTER_OTLP*_PROTOCOL` declines rather than misrouting spans. This makes the existing telemetry usable from headless hosts that run `omp` as a spawned child process, where an in-process `TracerProvider` registered by the parent can't reach the child. Uses the `@opentelemetry/exporter-trace-otlp-proto` 2.x line, which exports cleanly under Bun. + ## [15.8.3] - 2026-06-03 ### Fixed @@ -37,7 +41,6 @@ - Fixed an unhandled `EPIPE` rejection when an MCP stdio server exits between returning the `initialize` response and the client's `notifications/initialized` send. `StdioTransport.notify()` and `#sendResponse()` now route stdin writes through a shared helper that catches synchronous sink failures: `notify()` tears the transport down (firing `onClose`) and surfaces a `Transport closed while sending notification` rejection so `connectToServer()` treats the handshake as a failed connection instead of returning a "connected" handle wrapping a dead transport; `#sendResponse()` stays silent because a dead subprocess has no use for the response. `StdioTransport.close()` is now the authoritative resource teardown — it no longer early-returns when `#handleClose()` has already flipped `#connected`, so the subprocess and read loop are always cleaned up (including in the `connectToServer()` failure path) ([#1710](https://github.com/can1357/oh-my-pi/issues/1710)). - Fixed startup model resolution ignoring cached discovery rows for special built-in providers (`google-antigravity`, `google-gemini-cli`, `openai-codex`) until the background refresh completed ([#1721](https://github.com/can1357/oh-my-pi/issues/1721)). - Fixed Windows clipboard-image paste keeping `Ctrl+V` unregistered by default. The TUI now registers `Ctrl+V` plus the Windows Terminal-safe `Alt+V` fallback, and the keybinding docs call out when to use the fallback ([#1708](https://github.com/can1357/oh-my-pi/issues/1708)). - ## [15.8.0] - 2026-06-02 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 80f998aa7..30c3c679e 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -55,6 +55,12 @@ "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-tui": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@opentelemetry/api": "catalog:", + "@opentelemetry/context-async-hooks": "catalog:", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:", + "@opentelemetry/resources": "catalog:", + "@opentelemetry/sdk-trace-base": "catalog:", + "@opentelemetry/sdk-trace-node": "catalog:", "@puppeteer/browsers": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e7ca42a38..70ca3e87c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -66,6 +66,7 @@ import type { AgentSession } from "./session/agent-session"; import type { AuthStorage } from "./session/auth-storage"; import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager"; import { resolvePromptInput } from "./system-prompt"; +import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export"; import { AUTO_THINKING } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; @@ -973,6 +974,17 @@ export async function runRootCommand( sessionOptions.hasUI = isInteractive || mode === "rpc-ui"; sessionOptions.settings = settingsInstance; + // OTEL: register the global OTLP trace exporter when an OTLP endpoint is + // configured via env, then switch on the agent loop's telemetry so its + // GenAI spans (invoke_agent / chat / execute_tool) are actually emitted. + // Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config + // is enough to enable telemetry — content capture is governed by the + // standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var. + initTelemetryExport(); + if (isTelemetryExportEnabled()) { + sessionOptions.telemetry = {}; + } + // Handle CLI --api-key as runtime override (not persisted) if (parsedArgs.apiKey) { if (!sessionOptions.model && !sessionOptions.modelPattern) { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 902da05c1..af7f92d1b 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,6 +9,7 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import { logger, sanitizeText } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; import { isSilentAbort } from "../session/messages"; +import { flushTelemetryExport } from "../telemetry-export"; import { initializeExtensions } from "./runtime-init"; /** @@ -83,6 +84,10 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti !isSilentAbort(assistantMsg.errorMessage) ) { const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); + // Flush before this hard exit — it bypasses the awaited postmortem.quit() + // in main(), and the postmortem `exit` handler can't await, so the error + // spans would otherwise stay buffered in the batch processor and drop. + await flushTelemetryExport(); const flushed = process.stderr.write(`${errorLine}\n`); if (flushed) { process.exit(1); diff --git a/packages/coding-agent/src/telemetry-export.ts b/packages/coding-agent/src/telemetry-export.ts new file mode 100644 index 000000000..fbb5b3a62 --- /dev/null +++ b/packages/coding-agent/src/telemetry-export.ts @@ -0,0 +1,126 @@ +/** + * OTLP trace export bootstrap. + * + * oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI + * spans through the global `@opentelemetry/api` tracer, but only when a + * TracerProvider is registered in the process — otherwise the API returns a + * no-op tracer and the spans are silently dropped. The shipped CLI never + * registered one, so headless / embedded hosts (e.g. an ACP harness that + * spawns `omp` as a child process) had no way to collect omp's internal traces. + * + * This module registers a NodeTracerProvider with an OTLP/proto exporter when + * the standard `OTEL_EXPORTER_OTLP_ENDPOINT` (or `..._TRACES_ENDPOINT`) env var + * is set, following the zero-code OTEL env contract: the exporter reads its + * endpoint, headers, and timeout from `OTEL_EXPORTER_OTLP_*` itself. The + * consuming process configures the destination entirely through env; omp stays + * provider-agnostic and ships no vendor coupling. Only the `http/protobuf` + * transport is supported — an `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or + * `http/json` declines rather than misrouting spans. + * + * The OTLP/proto exporter on the 2.x line is used deliberately: the 1.x line + * deadlocks under Bun — its `req.on('close')` handler fires a spurious failure + * after the success path. `exporter-trace-otlp-proto@0.218` paired with + * `sdk-trace-base@2.7` exports cleanly on Bun. + */ +import { logger, postmortem } from "@oh-my-pi/pi-utils"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; + +/** + * Periodic flush interval. A long-lived `omp` process (the ACP server is + * spawned once and reused across many turns) would otherwise hold finished + * spans until the batch window elapses or the process exits. + */ +const FLUSH_INTERVAL_MS = 30_000; + +let provider: NodeTracerProvider | undefined; + +/** + * Whether {@link initTelemetryExport} registered a real provider. The CLI uses + * this to decide whether to switch on the agent loop's telemetry config — there + * is no point emitting spans into a no-op tracer. + */ +export function isTelemetryExportEnabled(): boolean { + return provider !== undefined; +} + +/** + * Register the global TracerProvider + OTLP exporter when an OTLP endpoint is + * configured via env. Idempotent, and a no-op when no endpoint is set (or when + * the OTEL kill-switches are engaged), so it is safe to call unconditionally at + * startup. + */ +export function initTelemetryExport(): void { + if (provider) return; + // The OTEL env contract parses booleans and enum lists case-insensitively, so + // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export. + if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return; + if (tracesExporterDisabled(process.env.OTEL_TRACES_EXPORTER)) return; + + const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return; + + // We only ship the http/protobuf transport (the line validated on Bun). The + // OTEL contract lets OTEL_EXPORTER_OTLP*_PROTOCOL select grpc / http/json; + // rather than silently send protobuf-over-HTTP to a grpc :4317 port and lose + // every span, decline when an unsupported protocol is requested. + const protocol = (process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL) + ?.trim() + .toLowerCase(); + if (protocol && protocol !== "http/protobuf") { + logger.warn( + `OTEL trace export disabled: OTEL_EXPORTER_OTLP_PROTOCOL=${protocol} is unsupported (only http/protobuf)`, + ); + return; + } + + // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself, + // so there is nothing to thread through here. + const exporter = new OTLPTraceExporter(); + const tracerProvider = new NodeTracerProvider({ + resource: resourceFromAttributes({ + "service.name": process.env.OTEL_SERVICE_NAME ?? "oh-my-pi", + }), + spanProcessors: [new BatchSpanProcessor(exporter)], + }); + // register() installs the global tracer provider and the W3C trace-context + + // baggage propagators; the explicit AsyncLocalStorage context manager keeps + // parent/child span linkage working under Bun. + tracerProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() }); + provider = tracerProvider; + + const flushTimer = setInterval(() => { + provider?.forceFlush().catch(() => {}); + }, FLUSH_INTERVAL_MS); + flushTimer.unref(); + + // Shut down through postmortem rather than a bare signal listener. postmortem + // owns SIGINT/SIGTERM/SIGHUP/exit and quit(), and awaits registered cleanups + // before calling process.exit — so the batch processor's final OTLP export + // completes instead of being cut off mid-flight on the shutdown path. + postmortem.register("otel-trace-export", async () => { + clearInterval(flushTimer); + await provider?.shutdown(); + }); +} + +/** + * Parse the `OTEL_TRACES_EXPORTER` selection. The value is a case-insensitive, + * comma-separated list; the literal `none` disables span export entirely. + */ +function tracesExporterDisabled(raw: string | undefined): boolean { + if (!raw) return false; + return raw.split(",").some(entry => entry.trim().toLowerCase() === "none"); +} + +/** + * Flush any buffered spans to the exporter. No-op when export is disabled. + * Hosts embedding the agent can call this at natural boundaries (e.g. the end + * of a turn) so traces surface promptly rather than on the batch interval. + */ +export async function flushTelemetryExport(): Promise { + await provider?.forceFlush(); +} diff --git a/packages/coding-agent/test/otel-export-probe.ts b/packages/coding-agent/test/otel-export-probe.ts new file mode 100644 index 000000000..2ada60550 --- /dev/null +++ b/packages/coding-agent/test/otel-export-probe.ts @@ -0,0 +1,54 @@ +/** + * Positive-path probe for the OTLP trace exporter, run as a subprocess by + * telemetry-export.test.ts. Keeping it out-of-process means the global + * TracerProvider singleton that initTelemetryExport() registers never leaks + * into the test runner. + * + * Stands up a loopback OTLP/proto receiver, points the standard env var at it, + * registers the provider, emits a span through the same tracer name the agent + * core uses, flushes, and exits 0 only if the receiver got a non-empty + * protobuf POST at /v1/traces. + */ +import { trace } from "@opentelemetry/api"; +import { flushTelemetryExport, initTelemetryExport, isTelemetryExportEnabled } from "../src/telemetry-export"; + +let received = false; + +const server = Bun.serve({ + port: 0, + async fetch(req) { + const path = new URL(req.url).pathname; + if (req.method === "POST" && path.endsWith("/v1/traces")) { + const body = await req.arrayBuffer(); + if (body.byteLength > 0 && req.headers.get("content-type") === "application/x-protobuf") { + received = true; + } + return new Response('{"partialSuccess":{}}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }, +}); + +process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = `http://localhost:${server.port}/v1/traces`; +process.env.OTEL_SERVICE_NAME = "oh-my-pi-export-probe"; + +initTelemetryExport(); +if (!isTelemetryExportEnabled()) { + console.error("PROBE: provider did not register"); + await server.stop(true); + process.exit(2); +} + +const span = trace.getTracer("@oh-my-pi/pi-agent-core").startSpan("agent.llm_call"); +span.setAttribute("gen_ai.system", "probe"); +span.setAttribute("gen_ai.request.model", "claude-haiku-4-5"); +span.end(); + +await flushTelemetryExport(); +await server.stop(true); + +console.log(received ? "PROBE: RECEIVED" : "PROBE: NO_EXPORT"); +process.exit(received ? 0 : 1); diff --git a/packages/coding-agent/test/telemetry-export.test.ts b/packages/coding-agent/test/telemetry-export.test.ts new file mode 100644 index 000000000..ecdaf86e8 --- /dev/null +++ b/packages/coding-agent/test/telemetry-export.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { initTelemetryExport, isTelemetryExportEnabled } from "../src/telemetry-export"; + +/** + * Gating contract for the OTLP export bootstrap. These cases all short-circuit + * before a provider is registered, so they never mutate the module singleton + * and are order-independent. The positive export path runs in a subprocess (see + * the "exports spans" test) so the registered global provider can't leak here. + */ +const OTEL_KEYS = [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_SDK_DISABLED", + "OTEL_TRACES_EXPORTER", +] as const; + +let saved: Record; + +beforeEach(() => { + saved = Object.fromEntries(OTEL_KEYS.map(k => [k, process.env[k]])); + for (const k of OTEL_KEYS) delete process.env[k]; +}); + +afterEach(() => { + for (const k of OTEL_KEYS) { + const v = saved[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +describe("initTelemetryExport gating", () => { + it("stays disabled when no OTLP endpoint is configured", () => { + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + }); + + it("stays disabled when OTEL_SDK_DISABLED=true even with an endpoint", () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + process.env.OTEL_SDK_DISABLED = "true"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + }); + + it("stays disabled when OTEL_TRACES_EXPORTER=none even with an endpoint", () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + process.env.OTEL_TRACES_EXPORTER = "none"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + }); + + it("declines unsupported OTLP protocols instead of misrouting spans", () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317"; + process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + + process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = "http/json"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + }); + + it("honors the kill-switches case-insensitively per the OTEL env contract", () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + process.env.OTEL_SDK_DISABLED = "TRUE"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + + delete process.env.OTEL_SDK_DISABLED; + process.env.OTEL_TRACES_EXPORTER = "otlp,None"; + initTelemetryExport(); + expect(isTelemetryExportEnabled()).toBe(false); + }); +}); + +describe("initTelemetryExport export path", () => { + it("registers a provider and exports spans to an OTLP/proto receiver", async () => { + // Run in a subprocess: initTelemetryExport() registers a process-global + // provider, so exercising the positive path in-process would leak that + // singleton into every later test. The probe stands up its own loopback + // receiver and exits 0 only when a protobuf trace export actually lands. + const probe = fileURLToPath(new URL("./otel-export-probe.ts", import.meta.url)); + const proc = Bun.spawn(["bun", probe], { stdout: "pipe", stderr: "pipe" }); + const [code, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]); + expect(stdout).toContain("PROBE: RECEIVED"); + expect(code).toBe(0); + }, 20_000); +});