From 8f40e61bad47b586bc3d6b5ecfb496b29610efd1 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 12:40:56 -0400 Subject: [PATCH 1/7] feat(ci): add release workflow and clean up plugin packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .github/workflows/release.yml — publishes to npm on v* tag push - Remove root index.ts re-export; point package.json main/module at src/index.ts - Add files field to package.json to limit published content to src/ - Add version 0.0.1 to package.json - Rename OPENCODE_OTLP_HEADERS and OPENCODE_RESOURCE_ATTRIBUTES from bare OTEL_* names; loadConfig copies them to the standard OTEL_* vars before SDK init - Remove parseHeaders from otel.ts (SDK now reads OTEL_EXPORTER_OTLP_HEADERS natively) - Update tests and docs to reflect all changes --- .github/workflows/release.yml | 37 ++++++++++++++++++++++++++++++++++ AGENTS.md | 8 ++------ CONTRIBUTING.md | 3 +-- README.md | 4 +++- index.ts | 1 - package.json | 10 +++++---- src/config.ts | 10 +++++++++ src/otel.ts | 19 ++---------------- tests/config.test.ts | 38 +++++++++++++++++++++++++++-------- tests/otel.test.ts | 37 ++-------------------------------- 10 files changed, 93 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 index.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b8d421c --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index f5abb78..b881462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7bbac9..ea7dbf4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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"] } ``` @@ -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 diff --git a/README.md b/README.md index c73ca9b..bb81e63 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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"] } ``` @@ -60,6 +60,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 diff --git a/index.ts b/index.ts deleted file mode 100644 index 35ae1f7..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OtelPlugin } from "./src/index.ts" diff --git a/package.json b/package.json index f415e2c..8e78c56 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/config.ts b/src/config.ts index 29de27b..dfa7f04 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,8 @@ export type PluginConfig = { metricsInterval: number logsInterval: number metricPrefix: string + otlpHeaders: string | undefined + resourceAttributes: string | undefined } export function parseEnvInt(key: string, fallback: number): number { @@ -16,12 +18,20 @@ export function parseEnvInt(key: string, fallback: number): number { } 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, } } diff --git a/src/otel.ts b/src/otel.ts index d7f9323..08bd3e9 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -9,20 +9,6 @@ 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 { - if (!raw) return {} - const result: Record = {} - 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 -} - export function buildResource(version: string) { const attrs: Record = { [ATTR_SERVICE_NAME]: "opencode", @@ -55,14 +41,13 @@ export function setupOtel( 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, }), ], @@ -72,7 +57,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, }), ], diff --git a/tests/config.test.ts b/tests/config.test.ts index f7d2e3e..b4fc5a8 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -38,9 +38,13 @@ describe("parseEnvInt", () => { describe("loadConfig", () => { const vars = [ "OPENCODE_ENABLE_TELEMETRY", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_METRIC_EXPORT_INTERVAL", - "OTEL_LOGS_EXPORT_INTERVAL", + "OPENCODE_OTLP_ENDPOINT", + "OPENCODE_OTLP_METRICS_INTERVAL", + "OPENCODE_OTLP_LOGS_INTERVAL", + "OPENCODE_OTLP_HEADERS", + "OPENCODE_RESOURCE_ATTRIBUTES", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_RESOURCE_ATTRIBUTES", ] beforeEach(() => vars.forEach((k) => delete process.env[k])) afterEach(() => vars.forEach((k) => delete process.env[k])) @@ -59,25 +63,43 @@ describe("loadConfig", () => { }) test("reads custom endpoint", () => { - process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://collector:4317" + process.env["OPENCODE_OTLP_ENDPOINT"] = "http://collector:4317" expect(loadConfig().endpoint).toBe("http://collector:4317") }) test("reads custom intervals", () => { - process.env["OTEL_METRIC_EXPORT_INTERVAL"] = "30000" - process.env["OTEL_LOGS_EXPORT_INTERVAL"] = "2000" + process.env["OPENCODE_OTLP_METRICS_INTERVAL"] = "30000" + process.env["OPENCODE_OTLP_LOGS_INTERVAL"] = "2000" const cfg = loadConfig() expect(cfg.metricsInterval).toBe(30000) expect(cfg.logsInterval).toBe(2000) }) test("falls back to defaults for invalid interval values", () => { - process.env["OTEL_METRIC_EXPORT_INTERVAL"] = "notanumber" - process.env["OTEL_LOGS_EXPORT_INTERVAL"] = "0" + process.env["OPENCODE_OTLP_METRICS_INTERVAL"] = "notanumber" + process.env["OPENCODE_OTLP_LOGS_INTERVAL"] = "0" const cfg = loadConfig() expect(cfg.metricsInterval).toBe(60000) expect(cfg.logsInterval).toBe(5000) }) + + test("copies OPENCODE_OTLP_HEADERS to OTEL_EXPORTER_OTLP_HEADERS", () => { + process.env["OPENCODE_OTLP_HEADERS"] = "api-key=abc123" + loadConfig() + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("api-key=abc123") + }) + + test("copies OPENCODE_RESOURCE_ATTRIBUTES to OTEL_RESOURCE_ATTRIBUTES", () => { + process.env["OPENCODE_RESOURCE_ATTRIBUTES"] = "team=platform,env=prod" + loadConfig() + expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("team=platform,env=prod") + }) + + test("does not set OTEL_EXPORTER_OTLP_HEADERS when OPENCODE_OTLP_HEADERS is unset", () => { + delete process.env["OPENCODE_OTLP_HEADERS"] + loadConfig() + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined() + }) }) describe("resolveLogLevel", () => { diff --git a/tests/otel.test.ts b/tests/otel.test.ts index 38133b1..ab638f5 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -1,38 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { parseHeaders, buildResource } from "../src/otel.ts" - -describe("parseHeaders", () => { - test("returns empty object for undefined", () => { - expect(parseHeaders(undefined)).toEqual({}) - }) - - test("returns empty object for empty string", () => { - expect(parseHeaders("")).toEqual({}) - }) - - test("parses a single key=value pair", () => { - expect(parseHeaders("api-key=abc123")).toEqual({ "api-key": "abc123" }) - }) - - test("parses multiple comma-separated pairs", () => { - expect(parseHeaders("api-key=abc,x-tenant=foo")).toEqual({ - "api-key": "abc", - "x-tenant": "foo", - }) - }) - - test("trims whitespace around keys and values", () => { - expect(parseHeaders(" api-key = abc123 ")).toEqual({ "api-key": "abc123" }) - }) - - test("ignores pairs with no = sign", () => { - expect(parseHeaders("no-equals,api-key=abc")).toEqual({ "api-key": "abc" }) - }) - - test("handles values containing = signs", () => { - expect(parseHeaders("token=abc=def")).toEqual({ token: "abc=def" }) - }) -}) +import { describe, test, expect, afterEach } from "bun:test" +import { buildResource } from "../src/otel.ts" describe("buildResource", () => { const originalEnv = process.env["OTEL_RESOURCE_ATTRIBUTES"] From 92bf8df39a191467b989f40c136fe3bfe79be53e Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 12:52:51 -0400 Subject: [PATCH 2/7] docs: add JSDoc to all exported functions and types --- src/config.ts | 12 ++++++++++++ src/handlers/activity.ts | 2 ++ src/handlers/message.ts | 8 ++++++++ src/handlers/permission.ts | 2 ++ src/handlers/session.ts | 3 +++ src/index.ts | 5 +++++ src/otel.ts | 11 +++++++++++ src/probe.ts | 5 +++++ src/types.ts | 10 ++++++++++ src/util.ts | 5 +++++ 10 files changed, 63 insertions(+) diff --git a/src/config.ts b/src/config.ts index dfa7f04..e0d1149 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { LEVELS, type Level } from "./types.ts" +/** Configuration values resolved from `OPENCODE_*` environment variables. */ export type PluginConfig = { enabled: boolean endpoint: string @@ -10,6 +11,7 @@ export type PluginConfig = { 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 @@ -17,6 +19,12 @@ export function parseEnvInt(key: string, fallback: number): number { return Number.isFinite(n) && n > 0 ? 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"] @@ -35,6 +43,10 @@ export function loadConfig(): PluginConfig { } } +/** + * 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 diff --git a/src/handlers/activity.ts b/src/handlers/activity.ts index a099b49..fd43b52 100644 --- a/src/handlers/activity.ts +++ b/src/handlers/activity.ts @@ -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) { @@ -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 diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 4e767e2..a5d335f 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -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 @@ -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 diff --git a/src/handlers/permission.ts b/src/handlers/permission.ts index 2652bd1..cdc3a2f 100644 --- a/src/handlers/permission.ts +++ b/src/handlers/permission.ts @@ -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, { @@ -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) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 7370de8..9cb2f62 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -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 @@ -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) @@ -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) diff --git a/src/index.ts b/src/index.ts index b5d14c8..d0d57ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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" diff --git a/src/otel.ts b/src/otel.ts index 08bd3e9..1daa833 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -9,6 +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" +/** + * 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 = { [ATTR_SERVICE_NAME]: "opencode", @@ -30,11 +35,16 @@ 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, @@ -67,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 { diff --git a/src/probe.ts b/src/probe.ts index fdb8562..4c8ef91 100644 --- a/src/probe.ts +++ b/src/probe.ts @@ -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 { let host: string let port: number diff --git a/src/types.ts b/src/types.ts index 368dc7e..f5c11ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,31 +1,40 @@ import type { Counter, Histogram } from "@opentelemetry/api" import type { Logger as OtelLogger } from "@opentelemetry/api-logs" +/** Numeric priority map for log levels; higher value = higher severity. */ export const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const + +/** Union of supported log level names. */ export type Level = keyof typeof LEVELS +/** Maximum number of entries kept in `pendingToolSpans` and `pendingPermissions` maps. */ export const MAX_PENDING = 500 +/** Structured logger forwarded to the opencode `client.app.log` API. */ export type PluginLogger = ( level: Level, message: string, extra?: Record, ) => Promise +/** OTel resource attributes common to every emitted log and metric. */ export type CommonAttrs = { readonly "project.id": string } +/** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */ export type PendingToolSpan = { tool: string sessionID: string startMs: number } +/** Permission prompt tracked between `permission.updated` and `permission.replied`. */ export type PendingPermission = { type: string title: string sessionID: string } +/** OTel metric instruments created once at plugin startup and shared via `HandlerContext`. */ export type Instruments = { sessionCounter: Counter tokenCounter: Counter @@ -35,6 +44,7 @@ export type Instruments = { toolDurationHistogram: Histogram } +/** Shared context threaded through every event handler. */ export type HandlerContext = { logger: OtelLogger log: PluginLogger diff --git a/src/util.ts b/src/util.ts index 4e14769..d293d38 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ import { MAX_PENDING } from "./types.ts" +/** Returns a human-readable summary string from an opencode error object. */ export function errorSummary(err: { name: string; data?: unknown } | undefined): string { if (!err) return "unknown" if (err.data && typeof err.data === "object" && "message" in err.data) { @@ -8,6 +9,10 @@ export function errorSummary(err: { name: string; data?: unknown } | undefined): return err.name } +/** + * Inserts a key/value pair into `map`, evicting the oldest entry first when the map + * has reached `MAX_PENDING` capacity to prevent unbounded memory growth. + */ export function setBoundedMap(map: Map, key: K, value: V) { if (map.size >= MAX_PENDING) { const [firstKey] = map.keys() From e1f8c8ec464dac3840530a05022b52c88cd91a32 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 12:57:15 -0400 Subject: [PATCH 3/7] test(config): add regression tests for OTEL_* env var passthrough behaviour --- tests/config.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/config.test.ts b/tests/config.test.ts index b4fc5a8..cbf1e04 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -100,6 +100,28 @@ describe("loadConfig", () => { loadConfig() expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined() }) + + test("does not overwrite pre-existing OTEL_* vars when OPENCODE_* vars are unset", () => { + process.env["OTEL_EXPORTER_OTLP_HEADERS"] = "existing-header=value" + process.env["OTEL_RESOURCE_ATTRIBUTES"] = "existing=attr" + loadConfig() + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("existing-header=value") + expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("existing=attr") + }) + + test("OPENCODE_OTLP_HEADERS overwrites pre-existing OTEL_EXPORTER_OTLP_HEADERS", () => { + process.env["OTEL_EXPORTER_OTLP_HEADERS"] = "old-header=old" + process.env["OPENCODE_OTLP_HEADERS"] = "new-header=new" + loadConfig() + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("new-header=new") + }) + + test("OPENCODE_RESOURCE_ATTRIBUTES overwrites pre-existing OTEL_RESOURCE_ATTRIBUTES", () => { + process.env["OTEL_RESOURCE_ATTRIBUTES"] = "old=attr" + process.env["OPENCODE_RESOURCE_ATTRIBUTES"] = "new=attr" + loadConfig() + expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("new=attr") + }) }) describe("resolveLogLevel", () => { From 1165019af5dc2b447198bf889e9201907452e3fa Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 13:04:18 -0400 Subject: [PATCH 4/7] docs(readme): add table of contents --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index bb81e63..d79d2b7 100644 --- a/README.md +++ b/README.md @@ -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 From 92bb54adf4519469cfb9b2327c0411caf466a7e3 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 13:07:25 -0400 Subject: [PATCH 5/7] fix(config): reject partial numeric strings in parseEnvInt --- src/config.ts | 5 +++-- tests/config.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index e0d1149..c8db321 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,8 +15,9 @@ export type PluginConfig = { 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 } /** diff --git a/tests/config.test.ts b/tests/config.test.ts index cbf1e04..1f69ac8 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -29,7 +29,12 @@ describe("parseEnvInt", () => { test("returns fallback for float string", () => { process.env["TEST_INT"] = "1.5" - expect(parseEnvInt("TEST_INT", 42)).toBe(1) + expect(parseEnvInt("TEST_INT", 42)).toBe(42) + }) + + test("returns fallback for partial numeric string", () => { + process.env["TEST_INT"] = "5000ms" + expect(parseEnvInt("TEST_INT", 42)).toBe(42) }) afterEach(() => { delete process.env["TEST_INT"] }) From 65441b58ea48b6e6528ff9684191b7319e59d33b Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 13:12:13 -0400 Subject: [PATCH 6/7] docs(readme): add headers/resource-attributes examples and security note --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d79d2b7..fa545f0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [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) @@ -71,8 +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 (e.g. for auth tokens) | -| `OPENCODE_RESOURCE_ATTRIBUTES` | _(unset)_ | Comma-separated `key=value` pairs merged into the OTel resource | +| `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 From 3a711a269b8d8776c08c39eaac9a709a580cc9be Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Wed, 11 Mar 2026 13:17:21 -0400 Subject: [PATCH 7/7] docs: add CHANGELOG for v0.1.0 --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0acde61 --- /dev/null +++ b/CHANGELOG.md @@ -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.