Skip to content

feat(Opencode): add OpenTelemetry instrumentation plugin for Opencode#48

Open
whoIam0987 wants to merge 39 commits into
alibaba:mainfrom
whoIam0987:lzy/opencode-logs
Open

feat(Opencode): add OpenTelemetry instrumentation plugin for Opencode#48
whoIam0987 wants to merge 39 commits into
alibaba:mainfrom
whoIam0987:lzy/opencode-logs

Conversation

@whoIam0987

@whoIam0987 whoIam0987 commented May 18, 2026

Copy link
Copy Markdown

feat(opencode): add OpenTelemetry instrumentation plugin for OpenCode

Summary

Add an OpenTelemetry observability plugin for OpenCode, enabling session-level Trace, Metrics, and Logs collection. Follows the same observability architecture as the existing Claude Code and OpenClaw plugins, but integrates as a native OpenCode Plugin (no hooks or shell aliases required).


What's included

  • Plugin-driven event capture: Registers as an OpenCode plugin in ~/.config/opencode/plugins/, zero-intrusion integration with no code changes required
  • Complete trace hierarchy: Entry → Agent → Step → LLM / Tool full parent-child span tree following the ReAct pattern (Permission recorded as an event on TOOL Span, not an independent Span)
  • Metrics: Session count, token usage (by type), cost (USD), lines of code, git commits, tool duration, cache activity, session duration, model usage, retries
  • Structured OTel Logs: User prompts, session lifecycle events, API requests/errors, tool decisions exported via OTLP Logs
  • JSONL event logging: Outputs event_t schema records (consumed by loongsuite-pilot), with daily rotation
  • Full OTLP export: Traces, Metrics, and Logs exported to any OTLP-compatible backend (Jaeger, Grafana Tempo, Alibaba Cloud ARMS, etc.)
  • Per-signal endpoints: Traces, Metrics, and Logs can each be configured with independent OTLP endpoints
  • Semconv dialect auto-detection: Automatically switches to ALIBABA_GROUP dialect (gen_ai.span_kind_name) when Sunfire endpoint detected
  • One-line install: curl -fsSL <url>/remote-install.sh | bash with --endpoint and --service-name options
  • Install/uninstall/pack scripts: Full lifecycle management
  • Process exit safety: Intercepts process.exit(), SIGTERM, SIGINT, and beforeExit to guarantee final span flush
  • Endpoint probing: TCP connectivity check before SDK initialization with clear diagnostic output

Key differences from existing plugins

Aspect Claude Code OpenClaw OpenCode (this PR)
Integration method Shell alias + NODE_OPTIONS injection Native gateway plugin Native OpenCode Plugin (file in ~/.config/opencode/plugins/)
Config format JSON (settings.json) JSON (openclaw.plugin.json) Environment variables only (zero config files)
HTTP interception intercept.js via NODE_OPTIONS Not needed Not needed (plugin receives events directly)
Shell alias Required for NODE_OPTIONS injection Not needed Not needed
Build JavaScript (no build step) TypeScript → tsc TypeScript (source-loaded by Bun runtime, no build step)
Test runner Jest Vitest Bun test
Trace export Optional OTLP OTLP (traces + metrics) OTLP (traces + metrics + logs, per-signal endpoints)
Span hierarchy Session → Turn → Tool/LLM Invocation-based Entry → Agent → Step → LLM / Tool
JSONL logging Yes No Yes (daily-rotated, event_t schema)
Metrics Basic (session, token, cost) Traces + metrics only 13+ instruments (histograms, counters, gauges)
Graceful shutdown Hook-based flush N/A process.exit interception + signal handlers

Event flow

OpenCode runtime
  → plugin event bus (session.*, message.*, permission.*, command.*)
    → OtelPlugin (src/index.ts)
      → HandlerContext (shared state across all handlers)
        → OTel TracerProvider → OTLP Traces export
        → OTel MeterProvider  → OTLP Metrics export (periodic)
        → OTel LoggerProvider  → OTLP Logs export (batched)
        → JSONL log writer     → ~/.loongsuite-pilot/logs/opencode/ (daily rotation)

Trace hierarchy

🚪 enter (ENTRY)
└── 🤖 invoke_agent <agent-name> (AGENT)
    └── 📋 react step (STEP)
        ├── 🧠 chat <model> (LLM)
        │     gen_ai.input.messages, gen_ai.output.messages
        │     gen_ai.usage.input_tokens, gen_ai.usage.output_tokens
        │     gen_ai.response.time_to_first_token, gen_ai.response.reasoning_time
        └── 🔧 execute_tool <tool-name> (TOOL)
              gen_ai.tool.name, gen_ai.tool.call.arguments, gen_ai.tool.call.result

Test plan

Unit tests (bun test)

Run all tests via bun test in the plugin directory, covering the following modules:

  • Config parsing and environment variable fallback
  • OTel SDK initialization and metric instrument creation
  • Endpoint connectivity probing
  • Utility functions (truncate, bounded map, span attribute builders)
  • Session lifecycle (counters, duration, error cleanup)
  • Message/LLM Span (token metrics, TTFT, ReAct step)
  • Permission decision events
  • Activity metrics (lines of code, git commit detection)
  • Metric disabling logic

End-to-end tests

  • bash scripts/install.sh --endpoint <url> correctly writes plugin entry + env config block
  • bash scripts/uninstall.sh cleanly removes all artifacts
  • End-to-end: OpenCode session → spans appear in OTLP backend with correct hierarchy
  • JSONL log file generates paired llm.request/llm.response + tool.call/tool.result events
  • Token data captured correctly (input/output/reasoning/cache non-zero)
  • process.exit interception ensures final span batch is flushed
  • Resource attributes include acs.arms.service.feature: "genai_app"

Configuration

All configuration via environment variables (no config files required):

Variable Description Default
OTEL_EXPORTER_OTLP_ENDPOINT Base OTLP endpoint (auto-enables telemetry when set) http://localhost:4318
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT Per-signal traces endpoint (full URL)
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT Per-signal metrics endpoint (full URL)
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT Per-signal logs endpoint (full URL)
OTEL_EXPORTER_OTLP_HEADERS Auth headers, comma-separated key=value
OTEL_SERVICE_NAME Service name in resource opencode
OTEL_RESOURCE_ATTRIBUTES Additional resource attributes
OTEL_TRACES_EXPORTER Set to none to disable traces
OTEL_LOGS_EXPORTER Set to otlp to enable, none to disable disabled
OTEL_METRIC_EXPORT_INTERVAL Metrics export interval (ms) 60000
OTEL_METRIC_PREFIX Metric name prefix opencode.
OTEL_DISABLE_METRICS Comma-separated metric names to disable
OTEL_TRACE_MAX_CONTENT_SIZE Max chars per message content (0 = unlimited) 2048
OTEL_BLRP_SCHEDULE_DELAY Log batch export delay (ms) 5000
LOONGSUITE_SEMCONV_DIALECT_NAME Set to ALIBABA_GROUP for ARMS compatibility auto-detected

Checklist

  • 13+ metric instruments with correct units and descriptions
  • Complete trace hierarchy (Entry → Agent → Step → LLM/Tool)
  • JSONL event logging (llm.request/response, tool.call/result)
  • Install/uninstall/pack/remote-install scripts
  • Unit tests covering all handlers, config, and utility functions
  • README with full documentation (Chinese)
  • CHANGELOG with version history

mktest1234 added 30 commits May 18, 2026 20:43
…vocations handling

P0 fix — process.exit concurrency race (src/index.ts):
- Extract interceptedExit() as a named function that checks exitIntercepted
- SIGTERM/SIGINT handlers now call interceptedExit() instead of calling
  shutdown() directly, so the exitIntercepted guard protects all entry points
- Prevents concurrent SIGTERM+process.exit or SIGTERM+SIGINT from calling
  originalExit multiple times or running shutdown() twice

P1 fix — handleSessionError double-processing activeInvocations (src/handlers/session.ts):
- sweepSession() now accepts an optional errorMessage parameter
- In the error path, sweepSession() adds 'session.error' events to both
  agentSpan and entrySpan before ending them (previously done in handleSessionError)
- handleSessionError no longer manually handles activeInvocations after
  calling sweepSession() — fixes double setStatus(ERROR)/end() on spans

Update session-traces.test.ts to verify session.error events are added
by sweepSession (now 1 event on both agent and entry spans).
…nd attribute

Add SPAN_KIND_ATTR constant to util.ts (exported):
- Checks LOONGSUITE_SEMCONV_DIALECT_NAME=ALIBABA_GROUP env var
- Checks if OTEL_EXPORTER_OTLP_ENDPOINT contains 'sunfire'
- When either is true: uses 'gen_ai.span_kind_name' (ALIBABA_GROUP dialect)
- Otherwise: uses 'gen_ai.span.kind' (OTel standard default)

Replace the single hardcoded 'gen_ai.span.kind' in genAiSpanAttrs()
with SPAN_KIND_ATTR so all spans (ENTRY/AGENT/STEP/LLM/TOOL) use the
correct attribute name for the configured dialect.

Update tests to import and use SPAN_KIND_ATTR so they pass regardless
of the environment's dialect setting.
The legacy endpoint env var OPENCODE_OTLP_ENDPOINT should also trigger
the Sunfire/ALIBABA_GROUP semconv dialect auto-detection, not just
OTEL_EXPORTER_OTLP_ENDPOINT.
…t protocol

The OTLPLogExporter from exporter-logs-otlp-grpc was receiving an HTTP
endpoint (http://host:port/v1/logs) which gRPC cannot use — it silently
fails or ignores the URL. All three exporters now use HTTP/Protobuf:
- Logs:    exporter-logs-otlp-grpc  → exporter-logs-otlp-http
- Metrics: exporter-metrics-otlp-proto (unchanged, already HTTP)
- Traces:  exporter-trace-otlp-proto  (unchanged, already HTTP)
bun.lock and package-lock.json contained registry.anpm.alibaba-inc.com
URLs from the Alibaba internal npm mirror. Regenerated both files using
the public registry.npmjs.org so the package can be installed by external
users without internal network access.

Also picks up the latest package versions (@opencode-ai/sdk 1.4.6, etc.)
and removes the grpc exporter packages replaced in the previous commit.
Root cause: config.otlpHeaders was parsed from env but never forwarded
to the OTLP exporters, so authenticated backends silently rejected all
telemetry exports.

config.ts:
- Add parseOtlpHeaders(raw) — parses 'k=v,k=v' header string to object

otel.ts:
- setupOtel() gains optional headers parameter
- headers forwarded to OTLPMetricExporter, OTLPLogExporter, OTLPTraceExporter

index.ts:
- Call parseOtlpHeaders(config.otlpHeaders) and pass to setupOtel()
- Legacy: propagate OPENCODE_OTLP_HEADERS → OTEL_EXPORTER_OTLP_HEADERS
  when the standard var is unset, for SDK-level header resolution
service.name was hardcoded to 'opencode', ignoring the standard
OTEL_SERVICE_NAME env var. Now uses OTEL_SERVICE_NAME if set, falling
back to 'opencode'. OTEL_RESOURCE_ATTRIBUTES can still override it.
… through call chain

- config.ts: loadFileConfig() reads ~/.config/opencode/otel-plugin.json as fallback
  for endpoint, serviceName, headers, semconvDialect (works even without shell env)
- buildResource(version, serviceName?) accepts explicit serviceName
- setupOtel(..., serviceName?) passes it to buildResource
- index.ts: passes config.serviceName to setupOtel
- enabled = true when file.endpoint is set (no env needed)
…ripts

install.sh:
- Step 5: write $XDG_CONFIG_HOME/opencode/otel-plugin.json with
  endpoint, serviceName, headers, semconvDialect (only non-empty fields)

uninstall.sh:
- Step 4: delete otel-plugin.json if it exists
install.sh:
- Step 2: npm pack in package root → generate tgz
- Step 3: move tgz to ~/.config/opencode/, write file dep in package.json
- Step 4: register plugin name in opencode.json
- Steps 5-7: Sunfire detection, otel-plugin.json, env block (unchanged)
- opencode/bun auto-installs local tgz on next startup

uninstall.sh:
- Step 3: remove loongsuite-*.tgz from ~/.config/opencode/
           remove @loongsuite/... entry from package.json dependencies
install.sh:
- Step 2: npm install in source dir to ensure node_modules present
- Step 3: write ~/.config/opencode/plugins/opentelemetry-instrumentation-opencode.ts
  containing: import { OtelPlugin } from '<pkg>/src/index.ts' + export default
  Cleans up stale loose files from previous install methods
- Step 4: remove pkg name from opencode.json plugin array if present
  (local file plugins do not need to be declared there)
- Steps 5-7: Sunfire detection, otel-plugin.json, env block (unchanged)

uninstall.sh:
- Step 3: delete plugin entry .ts file
  Also cleans up stale tgz files from previous install method
…in.json

src/config.ts:
- Remove FileConfig type and loadFileConfig() function
- Remove node:fs, node:path, node:os imports
- Remove serviceName field from PluginConfig
- loadConfig() reads env vars only (OTEL_* / OPENCODE_* fallback)

src/otel.ts:
- buildResource(version) — remove serviceName parameter
- setupOtel(...) — remove serviceName parameter
- service.name resolved from process.env.OTEL_SERVICE_NAME directly

src/index.ts:
- Remove config.serviceName from setupOtel call

scripts/install.sh:
- Remove step 6 (write otel-plugin.json); env vars in shell profiles are enough
  (.zshenv is loaded by all zsh processes including opencode plugin worker)

scripts/uninstall.sh:
- Remove step 4 (delete otel-plugin.json)
scripts/pack.sh:
- Packs src/, scripts/install.sh, scripts/uninstall.sh,
  package.json, README.md, LICENSE into dist/*.tar.gz
- Handles macOS xattr via cp -rX + xattr -cr + COPYFILE_DISABLE=1
- Prints ossutil upload command and verification steps

scripts/remote-install.sh:
- Downloads tarball from configurable URL (DEFAULT_TARBALL_URL placeholder)
- Extracts to ~/.cache/opentelemetry.instrumentation.opencode/package
- Forwards --endpoint, --service-name, --headers, --debug, --lang to install.sh
- Bilingual output (zh/en auto-detected), --lang override
- Checks for node/npm/curl/tar dependencies
- Remove stepSpan (react.step) which only marked round number
- Chat span and tool span are now direct children of invocation agentContext
- Add gen_ai.loop.id and gen_ai.loop.iteration to chat and tool spans
- Move gen_ai.react.round and gen_ai.react.finish_reason onto chat span
- Remove stepSpan/stepContext from ActiveMessageSpan type

Span hierarchy before:
  agentContext → stepSpan → span [chat] / toolSpan

Span hierarchy after:
  agentContext → span [chat] (with loop attrs)
  agentContext → toolSpan   (with loop attrs)

Update message-traces.test.ts: expect 3 spans (ENTRY/AGENT/LLM)
instead of 4, and check finish_reason on LLM span.
Previously install.sh only wrote env vars to shell profiles (bashrc/zshrc),
which are not sourced in non-interactive shells or when opencode spawns
plugin workers directly.

Now the generated plugin .ts file includes process.env ||= defaults for
each configured value (endpoint, service-name, headers, semconv dialect,
debug flag, OTEL_LOGS_EXPORTER). These apply only if the var is not
already set, so explicit shell env always takes priority.
Sunfire backend only accepts application/json (HTTP), not protobuf.
All three exporters now use the http variant:
- exporter-metrics-otlp-proto → exporter-metrics-otlp-http
- exporter-trace-otlp-proto   → exporter-trace-otlp-http
- exporter-logs-otlp-http     (unchanged)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants