From ca84c09dfa89b147697f759d26e62268930a27ff Mon Sep 17 00:00:00 2001 From: Marc Bernabeu Date: Wed, 3 Jun 2026 14:14:07 +0200 Subject: [PATCH] fix: force-flush OTLP exporters on session.idle and session.error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporters are periodic (default 5s). A session shorter than one interval held all its telemetry in memory, and when the host killed the process right after the chat returned (the GitHub action does proc.kill() on opencode serve) nothing was ever exported — CI runs of ~1s produced zero datapoints. The SIGTERM/beforeExit handlers don't cover this: they start an async export the runtime may not wait for before exiting. Flush all three providers while the process is still healthy, as soon as the session goes idle or errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2cc5e77..f9c7bd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,6 +137,30 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree await Promise.allSettled([meterProvider.shutdown(), loggerProvider.shutdown(), tracerProvider.shutdown()]) } + /** + * Push every pending metric/log/span to the OTLP endpoint right away. + * + * Exporters are periodic (default 5s), so a session shorter than one + * interval would otherwise hold all its telemetry in memory when the + * host kills the process (e.g. the GitHub action kills `opencode serve` + * as soon as the chat returns). The SIGTERM/beforeExit handlers can't + * cover that case reliably — they start an async export the runtime may + * not wait for — so we flush while the process is still healthy. + */ + async function flushAll(reason: string) { + const results = await Promise.allSettled([ + meterProvider.forceFlush(), + loggerProvider.forceFlush(), + tracerProvider.forceFlush(), + ]) + const failed = results.filter((r) => r.status === "rejected").length + if (failed > 0) { + await log("warn", "otel: flush incomplete", { reason, failed }) + } else { + await log("debug", "otel: flushed", { reason }) + } + } + process.on("SIGTERM", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) process.on("SIGINT", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) process.on("beforeExit", () => { shutdown().catch(() => {}) }) @@ -217,9 +241,13 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree break case "session.idle": handleSessionIdle(event as EventSessionIdle, ctx) + // Sessions shorter than one export interval lose all telemetry + // if the host kills the process right after idle — flush now. + await flushAll("session.idle") break case "session.error": handleSessionError(event as EventSessionError, ctx) + await flushAll("session.error") break case "session.status": handleSessionStatus(event as EventSessionStatus, ctx)