// phoenix-bridge — register an OpenTelemetry SDK so the spans OpenCode // already emits (when experimental.openTelemetry=true) actually leave // the process and land in Arize Phoenix on the Framework Desktop. // // What it does: // - Boots @opentelemetry/sdk-node with OTLP/HTTP exporter pointed at // http://framework:4318/v1/traces (override via PHOENIX_OTLP_ENDPOINT). // - Inserts @arizeai/openinference-vercel's OpenInferenceSpanProcessor so // Phoenix's LLM/tool-aware UI gets richer attributes than vanilla OTel. // - Opens a parent span around each OpenCode session, with sub-sessions // (subagents spawned via the Task tool) nested under the parent. This // gives Phoenix a unified tree per top-level conversation. // // Caveats (May 2026): // - Subagent OTel context propagation across session boundaries is best // effort; OpenCode's experimental.chat.system.transform doesn't expose // sessionID yet (issue sst/opencode#6142). When that lands, swap the // manual stitching here for proper traceparent injection. // - Dynamic imports below are deliberate — per OpenCode's plugin guide, // direct imports of optional deps freeze the harness if they're absent. // Run `npm install` in this directory first. export const PhoenixBridge = async ({ project, directory, worktree }) => { // Direct file logging — OpenCode swallows stdout/stderr from plugin // code, so this is how we get visibility into init progress. Tail with: // tail -f /tmp/phoenix-bridge.log const { appendFileSync } = await import("node:fs"); const log = (msg) => { try { appendFileSync( "/tmp/phoenix-bridge.log", `[${new Date().toISOString()}] ${msg}\n`, ); } catch (_) { /* best effort */ } }; log("plugin function entered"); // Phoenix 15.x serves OTLP/HTTP at /v1/traces on the same port as the UI // (6006). Earlier versions used a separate 4318 — override here if you // ever pin Phoenix < 15.0. const endpoint = process.env.PHOENIX_OTLP_ENDPOINT || "http://framework:6006/v1/traces"; const serviceName = process.env.PHOENIX_SERVICE_NAME || "opencode"; // NodeSDK's `serviceName` constructor option is ignored in some // versions; setting OTEL_SERVICE_NAME forces the resource attribute // through the standard env-var path. Has to happen before sdk.start(). if (!process.env.OTEL_SERVICE_NAME) { process.env.OTEL_SERVICE_NAME = serviceName; } log(`endpoint=${endpoint} serviceName=${serviceName}`); // Dynamic imports so a missing dep produces a warning, not a freeze. let NodeSDK, OTLPTraceExporter, BatchSpanProcessor, SimpleSpanProcessor, trace, context, diag, DiagLogLevel; try { ({ NodeSDK } = await import("@opentelemetry/sdk-node")); // Use the protobuf-over-HTTP exporter — Phoenix's OTLP receiver // only speaks protobuf (returns 415 for the JSON variant from // @opentelemetry/exporter-trace-otlp-http). ({ OTLPTraceExporter } = await import( "@opentelemetry/exporter-trace-otlp-proto" )); ({ BatchSpanProcessor, SimpleSpanProcessor } = await import( "@opentelemetry/sdk-trace-base" )); ({ trace, context, diag, DiagLogLevel } = await import( "@opentelemetry/api" )); log("OTel imports resolved"); } catch (err) { console.warn( "[phoenix-bridge] OTel deps not installed — skipping. " + "Run `npm install` in .opencode/plugin/ to enable.", err && err.message, ); return {}; } // Pipe OTel's internal diagnostics into our log so export errors are // visible. Without this, BatchSpanProcessor swallows exporter failures. // Default to WARN — bump to DEBUG via PHOENIX_OTEL_DEBUG=1 when chasing // a regression. const otelLevel = process.env.PHOENIX_OTEL_DEBUG === "1" ? DiagLogLevel.DEBUG : DiagLogLevel.WARN; diag.setLogger( { verbose: (m) => log(`[otel:verbose] ${m}`), debug: (m) => log(`[otel:debug] ${m}`), info: (m) => log(`[otel:info] ${m}`), warn: (m) => log(`[otel:warn] ${m}`), error: (m) => log(`[otel:error] ${m}`), }, otelLevel, ); // NodeSDK accepts `serviceName` directly, sidestepping the Resource API // (which broke between @opentelemetry/resources v1.x and v2.x). // SimpleSpanProcessor (vs BatchSpanProcessor) exports each span // immediately — easier to debug while we sort out the pipeline. const exporter = new OTLPTraceExporter({ url: endpoint }); const sdk = new NodeSDK({ serviceName, spanProcessors: [new SimpleSpanProcessor(exporter)], }); sdk.start(); log("sdk.start() returned"); const tracer = trace.getTracer("opencode.phoenix-bridge"); log("tracer obtained"); // Smoke-test span. If this one doesn't show up in Phoenix, the export // path is broken and there's no point chasing missing session spans. // Should appear under service "opencode" within ~5 s of OpenCode start. const bootSpan = tracer.startSpan("phoenix-bridge.boot", { attributes: { "opencode.endpoint": endpoint, "opencode.service_name": serviceName, }, }); bootSpan.end(); log("boot span emitted (will flush within ~5s)"); // sessionId -> { span, ctx } const sessions = new Map(); const closeSession = (sessionId) => { const rec = sessions.get(sessionId); if (!rec) return; rec.span.end(); sessions.delete(sessionId); }; const onShutdown = async () => { for (const id of [...sessions.keys()]) closeSession(id); try { await sdk.shutdown(); } catch (_) { /* ignore */ } }; process.once("beforeExit", onShutdown); process.once("SIGINT", onShutdown); process.once("SIGTERM", onShutdown); return { event: async ({ event }) => { if (!event || !event.type) return; switch (event.type) { case "session.created": { const session = event.properties?.session || event.properties || {}; const id = session.id; const parentID = session.parentID; if (!id) return; const parentRec = parentID ? sessions.get(parentID) : null; const parentCtx = parentRec ? parentRec.ctx : context.active(); const span = tracer.startSpan( parentID ? `subagent ${id}` : `session ${id}`, { attributes: { "opencode.session.id": id, "opencode.session.parent_id": parentID || "", "opencode.directory": directory || "", "opencode.worktree": worktree || "", "opencode.project": project?.name || "", }, }, parentCtx, ); sessions.set(id, { span, ctx: trace.setSpan(parentCtx, span) }); break; } case "session.idle": case "session.deleted": case "session.compacted": { const id = event.properties?.session?.id || event.properties?.id || null; if (id) closeSession(id); break; } default: break; } }, }; };