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.
This commit is contained in:
Chris Greeno
2026-06-03 17:23:46 +01:00
parent f967f9d789
commit d6a0d05fc0
9 changed files with 324 additions and 1 deletions
@@ -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);