Files
localgenai/opencode/.opencode/plugin/phoenix-bridge.js
noisedestroyers 2c4bfefa95 Initial commit: localgenai stack
Containerized local LLM stack for the Framework Desktop / Strix Halo,
plus the OpenCode harness on the Mac side.

- pyinfra/framework/: pyinfra deploy targeting the box
  - llama.cpp (Vulkan), vLLM (ROCm), Ollama (ROCm with HSA override
    for gfx1151), OpenWebUI
  - Beszel (host + container + AMD GPU dashboard via sysfs)
  - OpenLIT (LLM fleet metrics)
  - Phoenix (per-trace agent waterfall)
  - OpenHands (autonomous agent in a Docker sandbox)
- opencode/: OpenCode config + Phoenix bridge plugin (OTel exporter)
  - install.sh deploys to ~/.config/opencode/
- StrixHaloSetup.md / StrixHaloMemory.md / Roadmap.md / TODO.md:
  documentation and planning
- testing/qwen3-coder-30b/: small evaluation harness

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:35:10 -04:00

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,
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;
}
},
};
};