Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Release

on:
push:
tags:
- "v*"

jobs:
release:
name: Publish to npm
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bun run typecheck

- name: Test
run: bun test

- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
8 changes: 2 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ Always run after making changes:
bun run typecheck
```

There is no separate build step needed for local development. For publishing:

```bash
bun run build
```
There is no build step. TypeScript source files are published directly and loaded natively by Bun.

## Testing

Expand Down Expand Up @@ -48,7 +44,7 @@ src/
- **`setBoundedMap`** — always use this instead of `Map.set` for `pendingToolSpans` and `pendingPermissions` to prevent unbounded growth.
- **Single source of truth for tokens/cost** — token and cost counters are incremented only in `message.updated` (`src/handlers/message.ts`), never in `step-finish`.
- **Shutdown** — OTel providers are flushed via `SIGTERM`/`SIGINT`/`beforeExit`. Do not use `process.on("exit")` for async flushing.
- **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`. Never use bare `OTEL_*` names for plugin config.
- **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`. Never use bare `OTEL_*` names for plugin config. `loadConfig` copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes.
- **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var. The plugin always loads regardless; only telemetry is disabled when unset.
- **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility.

Expand Down
3 changes: 1 addition & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Point your local opencode config at the repo so changes are picked up immediatel
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["/path/to/opencode-plugin-otel/index.ts"]
"plugin": ["/path/to/opencode-plugin-otel/src/index.ts"]
}
```

Expand All @@ -32,7 +32,6 @@ opencode loads TypeScript natively via Bun, so there is no build step required d
|---------|-------------|
| `bun run typecheck` | Type-check all sources without emitting |
| `bun test` | Run the test suite |
| `bun run build` | Compile to `dist/` for publishing |

## Project structure

Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemetry (OTLP/gRPC), mirroring the same signals as [Claude Code's monitoring](https://code.claude.com/docs/en/monitoring-usage).

- [What it instruments](#what-it-instruments)
- [Metrics](#metrics)
- [Log events](#log-events)
- [Installation](#installation)
- [Configuration](#configuration)
- [Quick start](#quick-start)
- [Datadog example](#datadog-example)
- [Honeycomb example](#honeycomb-example)
- [Claude Code dashboard compatibility](#claude-code-dashboard-compatibility)
- [Local development](#local-development)

## What it instruments

### Metrics
Expand Down Expand Up @@ -45,7 +56,7 @@ Or point directly at a local checkout for development:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["/path/to/opencode-plugin-otel/index.ts"]
"plugin": ["/path/to/opencode-plugin-otel/src/index.ts"]
}
```

Expand All @@ -60,6 +71,8 @@ All configuration is via environment variables. Set them in your shell profile (
| `OPENCODE_OTLP_METRICS_INTERVAL` | `60000` | Metrics export interval in milliseconds |
| `OPENCODE_OTLP_LOGS_INTERVAL` | `5000` | Logs export interval in milliseconds |
| `OPENCODE_METRIC_PREFIX` | `opencode.` | Prefix for all metric names (e.g. set to `claude_code.` for Claude Code dashboard compatibility) |
| `OPENCODE_OTLP_HEADERS` | _(unset)_ | Comma-separated `key=value` headers added to all OTLP exports (e.g. for auth tokens) |
| `OPENCODE_RESOURCE_ATTRIBUTES` | _(unset)_ | Comma-separated `key=value` pairs merged into the OTel resource |

### Quick start

Expand Down
1 change: 0 additions & 1 deletion index.ts

This file was deleted.

10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
{
"name": "opencode-plugin-otel",
"module": "index.ts",
"version": "0.0.1",
"module": "src/index.ts",
"main": "src/index.ts",
"type": "module",
"files": [
"src/"
],
"devDependencies": {
"@types/bun": "latest"
},
"scripts": {
"release:patch": "npm version patch && npm publish",
"release:minor": "npm version minor && npm publish",
"release:major": "npm version major && npm publish",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
import { LEVELS, type Level } from "./types.ts"

/** Configuration values resolved from `OPENCODE_*` environment variables. */
export type PluginConfig = {
enabled: boolean
endpoint: string
metricsInterval: number
logsInterval: number
metricPrefix: string
otlpHeaders: string | undefined
resourceAttributes: string | undefined
}

/** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */
export function parseEnvInt(key: string, fallback: number): number {
const raw = process.env[key]
if (!raw) return fallback
const n = parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : fallback
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* Reads all `OPENCODE_*` environment variables and returns the resolved plugin config.
* Copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and
* `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` so the OTel SDK
* picks them up automatically when initialised.
*/
export function loadConfig(): PluginConfig {
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]

if (otlpHeaders) process.env["OTEL_EXPORTER_OTLP_HEADERS"] = otlpHeaders
if (resourceAttributes) process.env["OTEL_RESOURCE_ATTRIBUTES"] = resourceAttributes

return {
enabled: !!process.env["OPENCODE_ENABLE_TELEMETRY"],
endpoint: process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317",
metricsInterval: parseEnvInt("OPENCODE_OTLP_METRICS_INTERVAL", 60000),
logsInterval: parseEnvInt("OPENCODE_OTLP_LOGS_INTERVAL", 5000),
metricPrefix: process.env["OPENCODE_METRIC_PREFIX"] ?? "opencode.",
otlpHeaders,
resourceAttributes,
}
}

/**
* Resolves an opencode log level string to a `Level`.
* Returns `current` unchanged when the input does not match a known level.
*/
export function resolveLogLevel(logLevel: string, current: Level): Level {
const candidate = logLevel.toLowerCase()
if (candidate in LEVELS) return candidate as Level
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SeverityNumber } from "@opentelemetry/api-logs"
import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk"
import type { HandlerContext } from "../types.ts"

/** Records lines-added and lines-removed metrics for each file in the diff. */
export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) {
const sessionID = e.properties.sessionID
for (const fileDiff of e.properties.diff) {
Expand All @@ -24,6 +25,7 @@ export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) {

const GIT_COMMIT_RE = /\bgit\s+commit(?![-\w])/

/** Detects `git commit` invocations in bash tool calls and increments the commit counter and emits a `commit` log event. */
export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerContext) {
if (e.properties.name !== "bash") return
if (!GIT_COMMIT_RE.test(e.properties.arguments)) return
Expand Down
8 changes: 8 additions & 0 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { errorSummary } from "../util.ts"
import { setBoundedMap } from "../util.ts"
import type { HandlerContext } from "../types.ts"

/**
* Handles a completed assistant message: increments token and cost counters and emits
* either an `api_request` or `api_error` log event depending on whether the message errored.
*/
export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext) {
const msg = e.properties.info
if (msg.role !== "assistant") return
Expand Down Expand Up @@ -77,6 +81,10 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
})
}

/**
* Tracks tool execution time between `running` and `completed`/`error` part updates,
* records a `tool.duration` histogram measurement, and emits a `tool_result` log event.
*/
export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: HandlerContext) {
const part = e.properties.part
if (part.type !== "tool") return
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EventPermissionUpdated, EventPermissionReplied } from "@opencode-a
import { setBoundedMap } from "../util.ts"
import type { HandlerContext } from "../types.ts"

/** Stores a pending permission prompt in the context map for later correlation with its reply. */
export function handlePermissionUpdated(e: EventPermissionUpdated, ctx: HandlerContext) {
const perm = e.properties
setBoundedMap(ctx.pendingPermissions, perm.id, {
Expand All @@ -12,6 +13,7 @@ export function handlePermissionUpdated(e: EventPermissionUpdated, ctx: HandlerC
})
}

/** Emits a `tool_decision` log event recording whether the permission was accepted or rejected. */
export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerContext) {
const { permissionID, sessionID, response } = e.properties
const pending = ctx.pendingPermissions.get(permissionID)
Expand Down
3 changes: 3 additions & 0 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EventSessionCreated, EventSessionIdle, EventSessionError } from "@
import { errorSummary } from "../util.ts"
import type { HandlerContext } from "../types.ts"

/** Increments the session counter and emits a `session.created` log event. */
export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) {
const sessionID = e.properties.info.id
const createdAt = e.properties.info.time.created
Expand All @@ -27,6 +28,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
}
}

/** Emits a `session.idle` log event and clears any pending tool spans and permissions for the session. */
export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
const sessionID = e.properties.sessionID
sweepSession(sessionID, ctx)
Expand All @@ -40,6 +42,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
})
}

/** Emits a `session.error` log event and clears any pending tool spans and permissions for the session. */
export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
const sessionID = e.properties.sessionID ?? "unknown"
sweepSession(sessionID, ctx)
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts

const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown"

/**
* OpenCode plugin that exports session telemetry via OpenTelemetry (OTLP/gRPC).
* Instruments metrics (sessions, tokens, cost, lines of code, commits, tool durations)
* and structured log events. All instrumentation is gated on `OPENCODE_ENABLE_TELEMETRY`.
*/
export const OtelPlugin: Plugin = async ({ project, client }) => {
const config = loadConfig()
let minLevel: Level = "info"
Expand Down
30 changes: 13 additions & 17 deletions src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,11 @@ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"
import { ATTR_HOST_ARCH } from "@opentelemetry/semantic-conventions/incubating"
import type { Instruments } from "./types.ts"

export function parseHeaders(raw: string | undefined): Record<string, string> {
if (!raw) return {}
const result: Record<string, string> = {}
for (const pair of raw.split(",")) {
const idx = pair.indexOf("=")
if (idx > 0) {
const key = pair.slice(0, idx).trim()
const val = pair.slice(idx + 1).trim()
if (key) result[key] = val
}
}
return result
}

/**
* Builds an OTel `Resource` seeded with `service.name`, `app.version`, `os.type`, and
* `host.arch`. Additional attributes from `OTEL_RESOURCE_ATTRIBUTES` are merged in and
* may override the defaults.
*/
export function buildResource(version: string) {
const attrs: Record<string, string> = {
[ATTR_SERVICE_NAME]: "opencode",
Expand All @@ -44,25 +35,29 @@ export function buildResource(version: string) {
return resourceFromAttributes(attrs)
}

/** Handles returned by `setupOtel`, used for graceful shutdown. */
export type OtelProviders = {
meterProvider: MeterProvider
loggerProvider: LoggerProvider
}

/**
* Initialises the OTel SDK — creates a `MeterProvider` and `LoggerProvider` backed by
* OTLP/gRPC exporters pointed at `endpoint`, and registers them as the global providers.
*/
export function setupOtel(
endpoint: string,
metricsInterval: number,
logsInterval: number,
version: string,
): OtelProviders {
const headers = parseHeaders(process.env["OTEL_EXPORTER_OTLP_HEADERS"])
const resource = buildResource(version)

const meterProvider = new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: endpoint, headers }),
exporter: new OTLPMetricExporter({ url: endpoint }),
exportIntervalMillis: metricsInterval,
}),
],
Expand All @@ -72,7 +67,7 @@ export function setupOtel(
const loggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor(new OTLPLogExporter({ url: endpoint, headers }), {
new BatchLogRecordProcessor(new OTLPLogExporter({ url: endpoint }), {
scheduledDelayMillis: logsInterval,
}),
],
Expand All @@ -82,6 +77,7 @@ export function setupOtel(
return { meterProvider, loggerProvider }
}

/** Creates all metric instruments using the global `MeterProvider`. Metric names are prefixed with `prefix`. */
export function createInstruments(prefix: string): Instruments {
const meter = metrics.getMeter("com.opencode")
return {
Expand Down
5 changes: 5 additions & 0 deletions src/probe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as net from "net"

/** Result of a TCP connectivity probe against the OTLP endpoint. */
export type ProbeResult = { ok: boolean; ms: number; error?: string }

/**
* Opens a TCP connection to the host and port parsed from `endpoint` to verify
* reachability before the OTel SDK initialises. Resolves within 5 seconds.
*/
export function probeEndpoint(endpoint: string): Promise<ProbeResult> {
let host: string
let port: number
Expand Down
Loading
Loading