Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

All notable changes to this project will be documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.1.0] — 2026-03-11

### Added

- **Release workflow** — `.github/workflows/release.yml` publishes to npm automatically when a `v*` tag is pushed, gated by typecheck and tests.
- **`OPENCODE_OTLP_HEADERS`** — new env var for comma-separated `key=value` OTLP auth headers (e.g. `x-honeycomb-team=abc,x-tenant=org`). Copied to `OTEL_EXPORTER_OTLP_HEADERS` before the SDK initialises.
- **`OPENCODE_RESOURCE_ATTRIBUTES`** — new env var for comma-separated `key=value` OTel resource attributes (e.g. `service.version=1.2.3,deployment.environment=production`). Copied to `OTEL_RESOURCE_ATTRIBUTES` before the SDK initialises.
- JSDoc on all exported functions, types, and constants.
- Regression tests covering `OTEL_*` passthrough behaviour — pre-existing values are preserved when `OPENCODE_*` vars are unset; `OPENCODE_*` vars overwrite when set.
- README table of contents, usage examples for headers and resource attributes, and a security note advising that `OPENCODE_OTLP_HEADERS` may contain sensitive tokens and should not be committed to version control.

### Changed

- `package.json` `main`/`module` now point directly at `src/index.ts`; root `index.ts` re-export removed.
- `files` field added to `package.json` — published package contains only `src/`, reducing install size.
- All user-facing env vars are now consistently `OPENCODE_`-prefixed. `loadConfig` copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` so the OTel SDK picks them up natively.
- `parseEnvInt` now rejects partial numeric strings such as `"1.5"` or `"5000ms"`, returning the fallback instead of silently truncating.

### Removed

- `parseHeaders` removed from `src/otel.ts` — the OTel SDK reads `OTEL_EXPORTER_OTLP_HEADERS` natively once `loadConfig` copies the value across.
- Manual `release:patch` / `release:minor` / `release:major` npm scripts removed in favour of the tag-based CI workflow.
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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

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)
- [Headers and resource attributes](#headers-and-resource-attributes)
- [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 +57,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 +72,20 @@ 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. Example: `api-key=abc123,x-tenant=my-org`. **Keep out of version control — may contain sensitive auth tokens.** |
| `OPENCODE_RESOURCE_ATTRIBUTES` | _(unset)_ | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` |

### Headers and resource attributes

```bash
# Auth token for a managed collector (e.g. Honeycomb, Grafana Cloud)
export OPENCODE_OTLP_HEADERS="x-honeycomb-team=your-api-key,x-honeycomb-dataset=opencode"

# Tag every metric and log with deployment context
export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environment=production"
```

> **Security note:** `OPENCODE_OTLP_HEADERS` typically contains auth tokens. Set it in your shell profile (`~/.zshrc`, `~/.bashrc`) or a secrets manager — never commit it to version control or print it in CI logs.

### 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
27 changes: 25 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
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
if (!/^[1-9]\d*$/.test(raw)) return fallback
const n = Number(raw)
return Number.isSafeInteger(n) ? n : fallback
}

/**
* 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
Loading
Loading