Phoenix Observability Architecture
Context
We need to monitor token consumption, cost, and eventually carbon impact of our AI agent usage. OpenCode (our coding agent) does not natively support OTEL tracing or any push-based observability. It stores session data in a local SQLite database (opencode.db).
We need a solution that:
- Provides a queryable UI to filter by time range, model, provider
- Does not interfere with OpenCode's runtime stability
- Works within our containerized architecture (agent + sidecar)
- Supports future extensions (CO2 reporting, multi-model comparison)
Options considered
Option A - In-process OTEL plugin
Register an OTEL TracerProvider directly inside the OpenCode plugin runtime (Bun). Trace each LLM call in real-time.
Pros: True real-time, no batch delay. Cons: OTEL's global TracerProvider pollutes OpenCode's async runtime, causing UI instability (tested: terminal freezes, escape key unresponsive). The plugin runtime (Bun) has different module resolution than Node.js, complicating dependency management.
Option B - Batch sync script (on-demand)
A standalone Node.js script reads opencode.db and exports spans to Phoenix via OTEL protobuf. Triggered manually or by a Taskfile command.
Pros: Fully isolated, no runtime interference, reliable. Cons: Not automatic, requires manual invocation.
Option C - Lightweight plugin + detached child process (hybrid)
An OpenCode plugin listens for session.idle events and spawns the batch sync script as a detached child process. No OTEL in the plugin itself.
Pros: Automatic (fires after each assistant response), no runtime pollution (spawn is fire-and-forget), uses the same proven batch script. Cons: Slight delay (script startup + 5s OTEL flush), traces appear ~10s after session idle.
Decision
Option C - Hybrid approach with lightweight plugin triggering a detached sync process.
The batch script (phoenix-sync.js) runs in its own Node.js process with full access to global modules via createRequire. The plugin is minimal (~20 lines, no heavy imports) and only spawns the script on session.idle.
The Taskfile sync task remains available for manual/recovery scenarios (--reset to re-export all data).
Architecture
┌───────────────────────────────────────────────────┐
│ OpenCode (Bun runtime) │
│ │
│ plugin: phoenix-sync-plugin.js │
│ on session.idle → spawn("node", [sync-script]) │
└──────────────┬────────────────────────────────────┘
│ detached child process
▼
┌───────────────────────────────────────────────────┐
│ node phoenix-sync.js │
│ │
│ 1. Read opencode.db (SQLite, readonly) │
│ 2. Query sessions updated since last watermark │
│ 3. For each session: │
│ - Create CHAIN span (session-level) │
│ - For each message (from JSON data blob): │
│ - Create LLM/CHAIN span (message-level) │
│ - For each part where type=tool: │
│ - Create TOOL span │
│ 4. Flush to Phoenix via OTLP protobuf │
│ 5. Save watermark │
└──────────────┬────────────────────────────────────┘
│ HTTP POST /v1/traces
▼
┌─────────────────────────────────────────────────────┐
│ Phoenix (arizephoenix/phoenix:18.0.0) │
│ - OTLP HTTP collector on :6006 │
│ - Web UI on :6006 (host: localhost:6006) │
│ - Persistent storage in env/<ENV>/services/phoenix │
└─────────────────────────────────────────────────────┘
Key files
| File | Role |
|---|---|
workspace/agent/plugins/phoenix-sync-plugin.js |
Event hook (session.idle → spawn sync) |
workspace/agent/.opencode/phoenix-sync.js |
Batch sync script (CJS, createRequire) |
engine/deps/modules/autograph.pkg.agent/infra/compose.yaml |
Phoenix container definition |
engine/deps/modules/autograph.pkg.agent/Taskfile.yaml |
sync task for manual/recovery |
Modes of operation
| Mode | Trigger | Use case |
|---|---|---|
| Automatic | session.idle event |
Normal workflow - traces appear ~10s after each response |
| Manual batch | task augr:pkg:agent:sync |
Recovery, first-time backfill, debugging |
| Full reset | task augr:pkg:agent:sync -- --reset |
After Phoenix data purge, re-export everything |
Idempotency
- A watermark (last sync timestamp) prevents re-exporting already-synced sessions
--resetclears the watermark - safe only if Phoenix data was also purged- Without
--reset, running sync multiple times is a no-op (idempotent)
OTEL span attributes
| Level | Span kind | Key attributes |
|---|---|---|
| Session | CHAIN | session.id, session.title, llm.model_name, llm.cost, llm.token_count.* |
| Message | LLM (assistant) / CHAIN (user) | message.id, message.role, llm.cost, llm.token_count.* |
| Tool | TOOL | tool.name, tool.call_id |
Target capabilities
The observability system must answer these questions:
| Question | Dimension | Source |
|---|---|---|
| How many tokens did I consume? | By time range, model, provider, session | OpenCode DB → Phoenix spans |
| How much did it cost? | $ (from provider) and € (converted) | llm.cost attribute + config rate |
| What is the carbon impact? | kgCO2eq, kWh | EcoLogits impact factors applied to token counts |
| Which model consumes most? | Comparative per model family | Phoenix filtering on llm.model_name |
| What's my trend over time? | Daily/weekly aggregation | Phoenix time-range queries |
Cost in €
A conversion factor AUGR_COST_EUR_PER_USD (env var or config) is applied to the llm.cost value provided by OpenCode (which reports in USD). This can be a fixed rate or periodically updated.
Carbon impact (CO2)
Based on EcoLogits methodology:
CO2 (gCO2eq) = tokens × energy_per_token (Wh) × carbon_intensity (gCO2/kWh)
- Energy per token: varies by model family (extracted from EcoLogits/Boavizta data)
- Carbon intensity: varies by datacenter region (e.g., AWS eu-north-1 ≈ 8 gCO2/kWh, us-east-1 ≈ 380 gCO2/kWh)
- Configurable via
AUGR_CARBON_INTENSITY_GRAMS_PER_KWHand model-specific energy factors - These are estimates, not measurements - methodology and limitations must be documented
Per-project filtering
Already supported via PHOENIX_PROJECT env var - each project's traces are isolated in Phoenix.
Consequences
- Phoenix is a dev-only sidecar (profile: dev) - zero overhead in production
- Slight startup cost (~15s for Phoenix container boot)
- The batch script depends on OpenCode's SQLite schema - schema changes require script updates
- Module resolution uses
createRequirehack (Node.js global modules not in default CJS path) - Plugin uses the catch-all
eventhandler format (named hooks don't fire in OpenCode 1.18)