The OpenLIT secondary exporter regressed tool-call parsing in OpenCode: OpenLIT's image doesn't currently host an OTLP receiver on 4328, so the exporter retries failed silently and the failures cascaded into the AI SDK's telemetry pipeline. Symptom: model output came through as raw Qwen3-Coder XML tool-call text instead of being parsed into actual tool invocations. Re-add when openlit.yml gets an otel-collector sidecar that actually listens on the receiver ports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
7.0 KiB
JavaScript
199 lines
7.0 KiB
JavaScript
// 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,
|
|
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 } = 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).
|
|
// Single Phoenix destination — the dual-export to OpenLIT regressed
|
|
// tool-call parsing in OpenCode (the failing OpenLIT exporter cascaded
|
|
// into the AI SDK telemetry pipeline). Re-add once OpenLIT has a
|
|
// proper OTLP receiver (otel-collector sidecar in openlit.yml).
|
|
const sdk = new NodeSDK({
|
|
serviceName,
|
|
spanProcessors: [
|
|
new BatchSpanProcessor(new OTLPTraceExporter({ url: endpoint })),
|
|
],
|
|
});
|
|
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;
|
|
}
|
|
},
|
|
};
|
|
};
|