Files
oh-my-pi/packages/coding-agent/test/otel-signals-probe.ts
T
roboomp 506d0942cf feat(telemetry): added otlp log and metric export
Extended the OTLP export bootstrap beyond traces so omp emits the full
OpenTelemetry signal set from a single session.

- Registered a LoggerProvider + BatchLogRecordProcessor and a MeterProvider
  + PeriodicExportingMetricReader when their OTLP endpoints (or the shared
  endpoint) are set, each gated independently by OTEL_*_EXPORTER=none,
  OTEL_SDK_DISABLED, and http/protobuf protocol checks.
- Bridged the centralized logger through a new registerLogSink API so every
  log event also becomes an OTLP log record with severity, attributes, and
  active span context for log-trace correlation (min level via OTEL_LOG_LEVEL).
- Recorded gen_ai.client.token.usage and pi.omp.agent.* metrics from the
  agent run summary and per-chat usage hooks, and emitted a structured run
  summary log event.
- Added an out-of-process logs+metrics probe and gating tests covering the
  per-signal kill switches.

Fixes #4604
2026-07-14 19:39:11 +00:00

128 lines
3.9 KiB
TypeScript

/**
* Positive-path probe for the OTLP log + metric exporters, run as a subprocess
* by telemetry-export.test.ts. Keeping it out-of-process means the global
* LoggerProvider / MeterProvider singletons that initTelemetryExport() registers
* never leak into the test runner.
*
* Stands up a loopback OTLP/proto receiver, points the standard env vars at it,
* registers the providers, drives a log record through the bridged
* `@oh-my-pi/pi-utils` logger and metric instruments through the agent
* telemetry hooks, flushes, and exits 0 only if the receiver got a non-empty
* protobuf POST at both /v1/logs and /v1/metrics.
*/
import type { AgentRunCoverage, AgentRunSummary, ChatUsageEvent } from "@oh-my-pi/pi-agent-core";
import { emptyAgentRunCoverage, emptyAgentRunSummary } from "@oh-my-pi/pi-agent-core";
import {
createTelemetryExportConfig,
flushTelemetryExport,
initTelemetryExport,
isTelemetryExportEnabled,
} from "@oh-my-pi/pi-coding-agent/telemetry-export";
import { logger } from "@oh-my-pi/pi-utils";
const seen = new Set<string>();
const server = Bun.serve({
port: 0,
async fetch(req) {
const path = new URL(req.url).pathname;
if (req.method === "POST" && req.headers.get("content-type") === "application/x-protobuf") {
const body = await req.arrayBuffer();
if (body.byteLength > 0) {
if (path.endsWith("/v1/logs")) seen.add("logs");
if (path.endsWith("/v1/metrics")) seen.add("metrics");
}
}
return new Response('{"partialSuccess":{}}', {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
const base = `http://localhost:${server.port}`;
process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = `${base}/v1/logs`;
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = `${base}/v1/metrics`;
process.env.OTEL_SERVICE_NAME = "oh-my-pi-signals-probe";
// Force a short metric export interval so the periodic reader flushes fast.
process.env.OTEL_METRIC_EXPORT_INTERVAL = "500";
await initTelemetryExport();
if (!isTelemetryExportEnabled()) {
console.error("PROBE: providers did not register");
await server.stop(true);
process.exit(2);
}
const config = createTelemetryExportConfig(undefined);
if (!config) {
console.error("PROBE: export config not produced");
await server.stop(true);
process.exit(2);
}
// Bridged utility logger -> OTel log record.
logger.error("probe error", { code: "probe" });
// Metric instruments via the agent telemetry hooks.
const usage: ChatUsageEvent = {
span: undefined as never,
agent: { id: "main", name: "Main" },
conversationId: "probe-session",
stepNumber: 0,
model: "claude-haiku-4-5",
provider: "anthropic",
serviceTier: undefined,
usage: {
inputTokens: 1000,
outputTokens: 200,
totalTokens: 1200,
cachedInputTokens: 0,
cacheWriteTokens: 0,
reasoningOutputTokens: 0,
},
cost: { usd: 0.01 },
attributes: undefined,
headers: undefined,
};
config.onChatUsage?.(usage);
const summary: AgentRunSummary = {
...emptyAgentRunSummary(),
chats: { total: 1, byStopReason: { end_turn: 1 }, totalLatencyMs: 1500 },
tools: {
total: 1,
ok: 1,
error: 0,
skipped: 0,
blocked: 0,
timeout: 0,
aborted: 0,
totalLatencyMs: 42,
byName: {
read: { total: 1, ok: 1, error: 0, skipped: 0, blocked: 0, timeout: 0, aborted: 0, totalLatencyMs: 42 },
},
},
stepCount: 1,
};
const coverage: AgentRunCoverage = {
...emptyAgentRunCoverage(),
toolsAvailable: ["read", "write"],
toolsInvoked: ["read"],
toolsUnused: ["write"],
modelsUsed: ["claude-haiku-4-5"],
providersUsed: ["anthropic"],
};
config.onRunEnd?.(summary, coverage);
await flushTelemetryExport();
// The metric reader exports on its own interval; wait one cycle then flush.
await Bun.sleep(700);
await flushTelemetryExport();
await server.stop(true);
const ok = seen.has("logs") && seen.has("metrics");
console.log(ok ? "PROBE: RECEIVED" : `PROBE: MISSING ${["logs", "metrics"].filter(s => !seen.has(s)).join(",")}`);
process.exit(ok ? 0 : 1);