From 2df13762a406040cca464caa5b6b7866bbcc516d Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 14 Mar 2026 19:49:05 -0400 Subject: [PATCH 1/3] feat(config): add OPENCODE_DISABLE_METRICS to suppress individual metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a comma-separated env var to disable any subset of metrics by name suffix (without prefix), e.g. 'cache.count,retry.count'. - Parse OPENCODE_DISABLE_METRICS into a Set in loadConfig() - Thread disabledMetrics through HandlerContext - Add isMetricEnabled() helper in util.ts - Guard all 13 instrument call sites across session, message, activity handlers - Disabling a metric skips the counter/histogram only — log events still emit - Add 23 tests covering parse, per-metric disable, and all-disabled scenario - Update README with env var docs and opencode-only metrics table --- .npmrc | 1 - README.md | 70 ++++-- src/config.ts | 9 + src/handlers/activity.ts | 37 ++-- src/handlers/message.ts | 57 +++-- src/handlers/session.ts | 22 +- src/index.ts | 6 + src/types.ts | 1 + src/util.ts | 8 + tests/handlers/disabled-metrics.test.ts | 276 ++++++++++++++++++++++++ tests/helpers.ts | 3 +- 11 files changed, 431 insertions(+), 59 deletions(-) delete mode 100644 .npmrc create mode 100644 tests/handlers/disabled-metrics.test.ts diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b392cc7..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -//registry.npmjs.org/:_authToken="${NPM_TOKEN}" diff --git a/README.md b/README.md index db6d67c..e45d370 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [Configuration](#configuration) - [Quick start](#quick-start) - [Headers and resource attributes](#headers-and-resource-attributes) + - [Disabling specific metrics](#disabling-specific-metrics) - [Datadog example](#datadog-example) - [Honeycomb example](#honeycomb-example) - [Claude Code dashboard compatibility](#claude-code-dashboard-compatibility) @@ -18,23 +19,29 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet ### Metrics -| Metric | Description | -|--------|-------------| -| `opencode.session.count` | Counter — incremented on each `session.created` event | -| `opencode.token.usage` | Counter — per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` | -| `opencode.cost.usage` | Counter — USD cost per completed assistant message | -| `opencode.lines_of_code.count` | Counter — lines added/removed per `session.diff` event | -| `opencode.commit.count` | Counter — git commits detected via bash tool | -| `opencode.tool.duration` | Histogram — tool execution time in milliseconds | +| Metric | Type | Description | +|--------|------|-------------| +| `opencode.session.count` | Counter | Incremented on each `session.created` event | +| `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` | +| `opencode.cost.usage` | Counter | USD cost per completed assistant message | +| `opencode.lines_of_code.count` | Counter | Lines added/removed per `session.diff` event | +| `opencode.commit.count` | Counter | Git commits detected via bash tool | +| `opencode.tool.duration` | Histogram | Tool execution time in milliseconds | +| `opencode.cache.count` | Counter | Cache activity per message: `type=cacheRead` or `type=cacheCreation` | +| `opencode.session.duration` | Histogram | Session duration from created to idle in milliseconds | +| `opencode.message.count` | Counter | Completed assistant messages per session | +| `opencode.session.token.total` | Histogram | Total tokens consumed per session, recorded on idle | +| `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded on idle | +| `opencode.model.usage` | Counter | Messages per model and provider | +| `opencode.retry.count` | Counter | API retries observed via `session.status` events | ### Log events | Event | Description | |-------|-------------| | `session.created` | Session started | -| `session.idle` | Session went idle | +| `session.idle` | Session went idle (includes total tokens, cost, messages) | | `session.error` | Session error | -| `user_prompt` | User sent a message (includes `prompt_length`, `model`, `agent`) | | `api_request` | Completed assistant message (tokens, cost, duration) | | `api_error` | Failed assistant message (error summary, duration) | | `tool_result` | Tool completed or errored (duration, success, output size) | @@ -72,9 +79,18 @@ 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_DISABLE_METRICS` | _(unset)_ | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) | +| `OPENCODE_OTLP_HEADERS` | _(unset)_ | Comma-separated `key=value` headers added to all OTLP exports. **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` | +### Quick start + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=http://localhost:4317 +opencode +``` + ### Headers and resource attributes ```bash @@ -87,14 +103,38 @@ export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environmen > **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 +### Disabling specific metrics + +Use `OPENCODE_DISABLE_METRICS` to suppress individual metrics. The value is a comma-separated list of metric name suffixes (without the prefix). + +Disabling a metric only stops the counter/histogram from being incremented — the corresponding log events are still emitted. ```bash -export OPENCODE_ENABLE_TELEMETRY=1 -export OPENCODE_OTLP_ENDPOINT=http://localhost:4317 -opencode +# Disable a single metric +export OPENCODE_DISABLE_METRICS="retry.count" + +# Disable multiple metrics +export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count" +``` + +#### opencode-only metrics + +The following metrics are specific to opencode and have no equivalent in Claude Code's built-in monitoring. If you are using a Claude Code dashboard and want to avoid cluttering it with opencode-only metrics, you can disable them: + +```bash +export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count" ``` +| Metric suffix | Why it's opencode-only | +|---------------|------------------------| +| `cache.count` | Tracks cache read/write activity as occurrence counts — not a Claude Code signal | +| `session.duration` | Session wall-clock duration — not emitted by Claude Code | +| `session.token.total` | Per-session token histogram — not emitted by Claude Code | +| `session.cost.total` | Per-session cost histogram — not emitted by Claude Code | +| `model.usage` | Per-model message counter — not emitted by Claude Code | +| `retry.count` | API retry counter — not emitted by Claude Code | +| `message.count` | Completed message counter — not emitted by Claude Code | + ### Datadog example ```bash diff --git a/src/config.ts b/src/config.ts index c8db321..971f08b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ export type PluginConfig = { metricPrefix: string otlpHeaders: string | undefined resourceAttributes: string | undefined + disabledMetrics: Set } /** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */ @@ -33,6 +34,13 @@ export function loadConfig(): PluginConfig { if (otlpHeaders) process.env["OTEL_EXPORTER_OTLP_HEADERS"] = otlpHeaders if (resourceAttributes) process.env["OTEL_RESOURCE_ATTRIBUTES"] = resourceAttributes + const disabledMetrics = new Set( + (process.env["OPENCODE_DISABLE_METRICS"] ?? "") + .split(",") + .map(s => s.trim()) + .filter(Boolean), + ) + return { enabled: !!process.env["OPENCODE_ENABLE_TELEMETRY"], endpoint: process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317", @@ -41,6 +49,7 @@ export function loadConfig(): PluginConfig { metricPrefix: process.env["OPENCODE_METRIC_PREFIX"] ?? "opencode.", otlpHeaders, resourceAttributes, + disabledMetrics, } } diff --git a/src/handlers/activity.ts b/src/handlers/activity.ts index 507f1a5..b961e77 100644 --- a/src/handlers/activity.ts +++ b/src/handlers/activity.ts @@ -1,5 +1,6 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" +import { isMetricEnabled } from "../util.ts" import type { HandlerContext } from "../types.ts" /** Records lines-added and lines-removed metrics for each file in the diff. */ @@ -9,19 +10,23 @@ export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) { let totalRemoved = 0 for (const fileDiff of e.properties.diff) { if (fileDiff.additions > 0) { - ctx.instruments.linesCounter.add(fileDiff.additions, { - ...ctx.commonAttrs, - "session.id": sessionID, - type: "added", - }) + if (isMetricEnabled("lines_of_code.count", ctx)) { + ctx.instruments.linesCounter.add(fileDiff.additions, { + ...ctx.commonAttrs, + "session.id": sessionID, + type: "added", + }) + } totalAdded += fileDiff.additions } if (fileDiff.deletions > 0) { - ctx.instruments.linesCounter.add(fileDiff.deletions, { - ...ctx.commonAttrs, - "session.id": sessionID, - type: "removed", - }) + if (isMetricEnabled("lines_of_code.count", ctx)) { + ctx.instruments.linesCounter.add(fileDiff.deletions, { + ...ctx.commonAttrs, + "session.id": sessionID, + type: "removed", + }) + } totalRemoved += fileDiff.deletions } } @@ -41,11 +46,13 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte ctx.log("debug", "otel: command.executed (bash)", { sessionID: e.properties.sessionID, argumentsLength: e.properties.arguments.length }) if (!GIT_COMMIT_RE.test(e.properties.arguments)) return - ctx.instruments.commitCounter.add(1, { - ...ctx.commonAttrs, - "session.id": e.properties.sessionID, - }) - ctx.log("debug", "otel: commit counter incremented", { sessionID: e.properties.sessionID }) + if (isMetricEnabled("commit.count", ctx)) { + ctx.instruments.commitCounter.add(1, { + ...ctx.commonAttrs, + "session.id": e.properties.sessionID, + }) + ctx.log("debug", "otel: commit counter incremented", { sessionID: e.properties.sessionID }) + } ctx.logger.emit({ severityNumber: SeverityNumber.INFO, severityText: "INFO", diff --git a/src/handlers/message.ts b/src/handlers/message.ts index db1b4cf..5531d8e 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -1,6 +1,6 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, ToolPart } from "@opencode-ai/sdk" -import { errorSummary, setBoundedMap, accumulateSessionTotals } from "../util.ts" +import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled } from "../util.ts" import type { HandlerContext } from "../types.ts" /** @@ -15,26 +15,39 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext const { sessionID, modelID, providerID } = assistant const duration = assistant.time.completed - assistant.time.created - const { tokenCounter, costCounter } = ctx.instruments const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning + assistant.tokens.cache.read + assistant.tokens.cache.write - tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "input" }) - tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "output" }) - tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "reasoning" }) - tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) - tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) - costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) - - if (assistant.tokens.cache.read > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) + + if (isMetricEnabled("token.usage", ctx)) { + const { tokenCounter } = ctx.instruments + tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "input" }) + tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "output" }) + tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "reasoning" }) + tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) + tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) + } + + if (isMetricEnabled("cost.usage", ctx)) { + ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) + } + + if (isMetricEnabled("cache.count", ctx)) { + if (assistant.tokens.cache.read > 0) { + ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) + } + if (assistant.tokens.cache.write > 0) { + ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) + } } - if (assistant.tokens.cache.write > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) + + if (isMetricEnabled("message.count", ctx)) { + ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) } - ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) - ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID }) + if (isMetricEnabled("model.usage", ctx)) { + ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID }) + } accumulateSessionTotals(sessionID, totalTokens, assistant.cost, ctx) @@ -136,12 +149,14 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle const duration_ms = end - start const success = toolPart.state.status === "completed" - ctx.instruments.toolDurationHistogram.record(duration_ms, { - ...ctx.commonAttrs, - "session.id": toolPart.sessionID, - tool_name: toolPart.tool, - success, - }) + if (isMetricEnabled("tool.duration", ctx)) { + ctx.instruments.toolDurationHistogram.record(duration_ms, { + ...ctx.commonAttrs, + "session.id": toolPart.sessionID, + tool_name: toolPart.tool, + success, + }) + } const sizeAttr = success ? { tool_result_size_bytes: Buffer.byteLength((toolPart.state as { output: string }).output, "utf8") } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 3cb3d67..75dfa61 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -1,13 +1,15 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" -import { errorSummary, setBoundedMap } from "../util.ts" +import { errorSummary, setBoundedMap, isMetricEnabled } from "../util.ts" import type { HandlerContext } from "../types.ts" /** Increments the session counter, records start time, 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 - ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + if (isMetricEnabled("session.count", ctx)) { + ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + } setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0 }) ctx.logger.emit({ severityNumber: SeverityNumber.INFO, @@ -41,9 +43,15 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { if (totals) { duration_ms = Date.now() - totals.startMs - ctx.instruments.sessionDurationHistogram.record(duration_ms, attrs) - ctx.instruments.sessionTokenGauge.record(totals.tokens, attrs) - ctx.instruments.sessionCostGauge.record(totals.cost, attrs) + if (isMetricEnabled("session.duration", ctx)) { + ctx.instruments.sessionDurationHistogram.record(duration_ms, attrs) + } + if (isMetricEnabled("session.token.total", ctx)) { + ctx.instruments.sessionTokenGauge.record(totals.tokens, attrs) + } + if (isMetricEnabled("session.cost.total", ctx)) { + ctx.instruments.sessionCostGauge.record(totals.cost, attrs) + } } ctx.logger.emit({ @@ -95,6 +103,8 @@ export function handleSessionStatus(e: EventSessionStatus, ctx: HandlerContext) if (e.properties.status.type !== "retry") return const { sessionID, status } = e.properties const { attempt, message: retryMessage } = status - ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + if (isMetricEnabled("retry.count", ctx)) { + ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + } ctx.log("debug", "otel: retry counter incremented", { sessionID, attempt, retryMessage }) } diff --git a/src/index.ts b/src/index.ts index f3890cc..a3dffb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,8 +80,13 @@ export const OtelPlugin: Plugin = async ({ project, client }) => { const pendingToolSpans = new Map() const pendingPermissions = new Map() const sessionTotals = new Map() + const { disabledMetrics } = config const commonAttrs = { "project.id": project.id } as const + if (disabledMetrics.size > 0) { + await log("info", "metrics disabled", { disabled: [...disabledMetrics].join(",") }) + } + const ctx: HandlerContext = { logger, log, @@ -90,6 +95,7 @@ export const OtelPlugin: Plugin = async ({ project, client }) => { pendingToolSpans, pendingPermissions, sessionTotals, + disabledMetrics, } async function shutdown() { diff --git a/src/types.ts b/src/types.ts index 720b197..b394fb6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,4 +68,5 @@ export type HandlerContext = { pendingToolSpans: Map pendingPermissions: Map sessionTotals: Map + disabledMetrics: Set } diff --git a/src/util.ts b/src/util.ts index a9b7975..4a74b6d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -22,6 +22,14 @@ export function setBoundedMap(map: Map, key: K, value: V) { map.set(key, value) } +/** + * Returns `true` if the metric name (without prefix) is not in the disabled set. + * The `name` should be the suffix after the metric prefix, e.g. `"session.count"`. + */ +export function isMetricEnabled(name: string, ctx: { disabledMetrics: Set }): boolean { + return !ctx.disabledMetrics.has(name) +} + /** * Accumulates token and cost totals for a session, and increments the message count. * Uses `setBoundedMap` to produce a new object rather than mutating in-place. diff --git a/tests/handlers/disabled-metrics.test.ts b/tests/handlers/disabled-metrics.test.ts new file mode 100644 index 0000000..11b28ce --- /dev/null +++ b/tests/handlers/disabled-metrics.test.ts @@ -0,0 +1,276 @@ +import { describe, test, expect } from "bun:test" +import { handleSessionCreated, handleSessionIdle, handleSessionStatus } from "../../src/handlers/session.ts" +import { handleMessageUpdated, handleMessagePartUpdated } from "../../src/handlers/message.ts" +import { handleSessionDiff, handleCommandExecuted } from "../../src/handlers/activity.ts" +import { loadConfig } from "../../src/config.ts" +import { makeCtx } from "../helpers.ts" +import type { EventSessionCreated, EventSessionIdle, EventSessionStatus, EventMessageUpdated, EventMessagePartUpdated, EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" + +function makeSessionCreated(sessionID: string): EventSessionCreated { + return { + type: "session.created", + properties: { info: { id: sessionID, projectID: "proj_test", directory: "/tmp", time: { created: 1000 } } }, + } as unknown as EventSessionCreated +} + +function makeSessionIdle(sessionID: string): EventSessionIdle { + return { type: "session.idle", properties: { sessionID } } as EventSessionIdle +} + +function makeSessionStatus(sessionID: string): EventSessionStatus { + return { + type: "session.status", + properties: { sessionID, status: { type: "retry", attempt: 1, message: "rate limited", next: 5000 } }, + } as unknown as EventSessionStatus +} + +function makeAssistantMessage(sessionID = "ses_1"): EventMessageUpdated { + return { + type: "message.updated", + properties: { + info: { + id: "msg_1", role: "assistant", sessionID, + modelID: "claude-3-5-sonnet", providerID: "anthropic", + cost: 0.01, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 20, write: 5 } }, + time: { created: 1000, completed: 2000 }, + }, + }, + } as unknown as EventMessageUpdated +} + +function makeToolPart(status: "running" | "completed"): EventMessagePartUpdated { + return { + type: "message.part.updated", + properties: { + part: { + type: "tool", sessionID: "ses_1", callID: "call_1", tool: "bash", + state: status === "running" + ? { status: "running", time: { start: 1000 } } + : { status: "completed", time: { start: 1000, end: 1500 }, output: "ok" }, + }, + }, + } as unknown as EventMessagePartUpdated +} + +function makeSessionDiff(): EventSessionDiff { + return { + type: "session.diff", + properties: { sessionID: "ses_1", diff: [{ file: "a.ts", additions: 10, deletions: 3 }] }, + } as unknown as EventSessionDiff +} + +function makeCommandExecuted(cmd: string): EventCommandExecuted { + return { + type: "command.executed", + properties: { sessionID: "ses_1", name: "bash", arguments: cmd }, + } as unknown as EventCommandExecuted +} + +describe("OPENCODE_DISABLE_METRICS", () => { + describe("loadConfig parses disabled metrics correctly", () => { + test("empty string produces empty set", () => { + delete process.env["OPENCODE_DISABLE_METRICS"] + const config = loadConfig() + expect(config.disabledMetrics.size).toBe(0) + }) + + test("single metric name", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count" + const config = loadConfig() + expect(config.disabledMetrics.has("session.count")).toBe(true) + delete process.env["OPENCODE_DISABLE_METRICS"] + }) + + test("comma-separated list", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count,cache.count,retry.count" + const config = loadConfig() + expect(config.disabledMetrics.has("session.count")).toBe(true) + expect(config.disabledMetrics.has("cache.count")).toBe(true) + expect(config.disabledMetrics.has("retry.count")).toBe(true) + delete process.env["OPENCODE_DISABLE_METRICS"] + }) + + test("trims whitespace around names", () => { + process.env["OPENCODE_DISABLE_METRICS"] = " session.count , cache.count " + const config = loadConfig() + expect(config.disabledMetrics.has("session.count")).toBe(true) + expect(config.disabledMetrics.has("cache.count")).toBe(true) + delete process.env["OPENCODE_DISABLE_METRICS"] + }) + + test("ignores empty segments from trailing commas", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count," + const config = loadConfig() + expect(config.disabledMetrics.size).toBe(1) + delete process.env["OPENCODE_DISABLE_METRICS"] + }) + }) + + describe("session.count disabled", () => { + test("does not increment session counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["session.count"]) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + expect(counters.session.calls).toHaveLength(0) + }) + + test("still emits session.created log record", async () => { + const { ctx, logger } = makeCtx("proj_test", ["session.count"]) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + expect(logger.records.at(0)!.body).toBe("session.created") + }) + }) + + describe("session.duration disabled", () => { + test("does not record duration histogram on idle", async () => { + const { ctx, histograms } = makeCtx("proj_test", ["session.duration"]) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + expect(histograms.sessionDuration.calls).toHaveLength(0) + }) + }) + + describe("session.token.total disabled", () => { + test("does not record token gauge on idle", async () => { + const { ctx, gauges } = makeCtx("proj_test", ["session.token.total"]) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + expect(gauges.sessionToken.calls).toHaveLength(0) + }) + }) + + describe("session.cost.total disabled", () => { + test("does not record cost gauge on idle", async () => { + const { ctx, gauges } = makeCtx("proj_test", ["session.cost.total"]) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + expect(gauges.sessionCost.calls).toHaveLength(0) + }) + }) + + describe("retry.count disabled", () => { + test("does not increment retry counter", () => { + const { ctx, counters } = makeCtx("proj_test", ["retry.count"]) + handleSessionStatus(makeSessionStatus("ses_1"), ctx) + expect(counters.retry.calls).toHaveLength(0) + }) + }) + + describe("token.usage disabled", () => { + test("does not increment token counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["token.usage"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(counters.token.calls).toHaveLength(0) + }) + + test("still emits api_request log", async () => { + const { ctx, logger } = makeCtx("proj_test", ["token.usage"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(logger.records.at(0)!.body).toBe("api_request") + }) + }) + + describe("cost.usage disabled", () => { + test("does not increment cost counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["cost.usage"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(counters.cost.calls).toHaveLength(0) + }) + }) + + describe("cache.count disabled", () => { + test("does not increment cache counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["cache.count"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(counters.cache.calls).toHaveLength(0) + }) + }) + + describe("message.count disabled", () => { + test("does not increment message counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["message.count"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(counters.message.calls).toHaveLength(0) + }) + }) + + describe("model.usage disabled", () => { + test("does not increment model usage counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["model.usage"]) + await handleMessageUpdated(makeAssistantMessage(), ctx) + expect(counters.modelUsage.calls).toHaveLength(0) + }) + }) + + describe("tool.duration disabled", () => { + test("does not record tool duration histogram", async () => { + const { ctx, histograms } = makeCtx("proj_test", ["tool.duration"]) + await handleMessagePartUpdated(makeToolPart("running"), ctx) + await handleMessagePartUpdated(makeToolPart("completed"), ctx) + expect(histograms.tool.calls).toHaveLength(0) + }) + + test("still emits tool_result log", async () => { + const { ctx, logger } = makeCtx("proj_test", ["tool.duration"]) + await handleMessagePartUpdated(makeToolPart("running"), ctx) + await handleMessagePartUpdated(makeToolPart("completed"), ctx) + expect(logger.records.at(0)!.body).toBe("tool_result") + }) + }) + + describe("lines_of_code.count disabled", () => { + test("does not increment lines counter", () => { + const { ctx, counters } = makeCtx("proj_test", ["lines_of_code.count"]) + handleSessionDiff(makeSessionDiff(), ctx) + expect(counters.lines.calls).toHaveLength(0) + }) + }) + + describe("commit.count disabled", () => { + test("does not increment commit counter", () => { + const { ctx, counters } = makeCtx("proj_test", ["commit.count"]) + handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) + expect(counters.commit.calls).toHaveLength(0) + }) + + test("still emits commit log record", () => { + const { ctx, logger } = makeCtx("proj_test", ["commit.count"]) + handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) + expect(logger.records.at(0)!.body).toBe("commit") + }) + }) + + describe("multiple disabled at once", () => { + test("disabling all metrics stops all counter/histogram calls", async () => { + const all = [ + "session.count", "token.usage", "cost.usage", "lines_of_code.count", + "commit.count", "tool.duration", "cache.count", "session.duration", + "message.count", "session.token.total", "session.cost.total", + "model.usage", "retry.count", + ] + const { ctx, counters, histograms, gauges } = makeCtx("proj_test", all) + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + await handleMessageUpdated(makeAssistantMessage(), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + handleSessionStatus(makeSessionStatus("ses_1"), ctx) + handleSessionDiff(makeSessionDiff(), ctx) + handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) + await handleMessagePartUpdated(makeToolPart("running"), ctx) + await handleMessagePartUpdated(makeToolPart("completed"), ctx) + + expect(counters.session.calls).toHaveLength(0) + expect(counters.token.calls).toHaveLength(0) + expect(counters.cost.calls).toHaveLength(0) + expect(counters.cache.calls).toHaveLength(0) + expect(counters.message.calls).toHaveLength(0) + expect(counters.modelUsage.calls).toHaveLength(0) + expect(counters.retry.calls).toHaveLength(0) + expect(counters.lines.calls).toHaveLength(0) + expect(counters.commit.calls).toHaveLength(0) + expect(histograms.tool.calls).toHaveLength(0) + expect(histograms.sessionDuration.calls).toHaveLength(0) + expect(gauges.sessionToken.calls).toHaveLength(0) + expect(gauges.sessionCost.calls).toHaveLength(0) + }) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 5d1b28d..f219edf 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -70,7 +70,7 @@ export type MockContext = { pluginLog: SpyPluginLog } -export function makeCtx(projectID = "proj_test"): MockContext { +export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = []): MockContext { const session = makeCounter() const token = makeCounter() const cost = makeCounter() @@ -112,6 +112,7 @@ export function makeCtx(projectID = "proj_test"): MockContext { pendingToolSpans: new Map(), pendingPermissions: new Map(), sessionTotals: new Map(), + disabledMetrics: new Set(disabledMetrics), } return { From 1929327d29eb130280625b41d2a3d36be1cdc52f Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 14 Mar 2026 20:05:03 -0400 Subject: [PATCH 2/3] fix(config): address code review findings on disable-metrics feature - Move retry debug log inside isMetricEnabled guard (was firing even when retry.count was disabled, giving a misleading message) - Hoist lines_of_code.count guard outside the diff loop in activity.ts (was evaluated twice per file; now evaluated once per event) - Pass disabled metrics as array in startup log (more queryable in log backends than a comma-joined string) - Restore user_prompt to README log events table (was accidentally dropped in the docs rewrite) - Add OPENCODE_DISABLE_METRICS to config.test.ts env var cleanup list (prevents stale env state leaking across test files) - Move loadConfig parsing tests for disabledMetrics into config.test.ts where env isolation is managed via beforeEach/afterEach - Add direct unit tests for isMetricEnabled in util.test.ts including unknown metric name case --- README.md | 1 + src/handlers/activity.ts | 5 ++-- src/handlers/session.ts | 2 +- src/index.ts | 2 +- tests/config.test.ts | 30 +++++++++++++++++++ tests/handlers/disabled-metrics.test.ts | 40 ------------------------- tests/util.test.ts | 24 ++++++++++++++- 7 files changed, 59 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e45d370..102f15c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet | `session.created` | Session started | | `session.idle` | Session went idle (includes total tokens, cost, messages) | | `session.error` | Session error | +| `user_prompt` | User sent a message (includes `prompt_length`, `model`, `agent`) | | `api_request` | Completed assistant message (tokens, cost, duration) | | `api_error` | Failed assistant message (error summary, duration) | | `tool_result` | Tool completed or errored (duration, success, output size) | diff --git a/src/handlers/activity.ts b/src/handlers/activity.ts index b961e77..090b17e 100644 --- a/src/handlers/activity.ts +++ b/src/handlers/activity.ts @@ -6,11 +6,12 @@ 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 + const linesEnabled = isMetricEnabled("lines_of_code.count", ctx) let totalAdded = 0 let totalRemoved = 0 for (const fileDiff of e.properties.diff) { if (fileDiff.additions > 0) { - if (isMetricEnabled("lines_of_code.count", ctx)) { + if (linesEnabled) { ctx.instruments.linesCounter.add(fileDiff.additions, { ...ctx.commonAttrs, "session.id": sessionID, @@ -20,7 +21,7 @@ export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) { totalAdded += fileDiff.additions } if (fileDiff.deletions > 0) { - if (isMetricEnabled("lines_of_code.count", ctx)) { + if (linesEnabled) { ctx.instruments.linesCounter.add(fileDiff.deletions, { ...ctx.commonAttrs, "session.id": sessionID, diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 75dfa61..0e8b473 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -105,6 +105,6 @@ export function handleSessionStatus(e: EventSessionStatus, ctx: HandlerContext) const { attempt, message: retryMessage } = status if (isMetricEnabled("retry.count", ctx)) { ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + ctx.log("debug", "otel: retry counter incremented", { sessionID, attempt, retryMessage }) } - ctx.log("debug", "otel: retry counter incremented", { sessionID, attempt, retryMessage }) } diff --git a/src/index.ts b/src/index.ts index a3dffb6..b1efd70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,7 @@ export const OtelPlugin: Plugin = async ({ project, client }) => { const commonAttrs = { "project.id": project.id } as const if (disabledMetrics.size > 0) { - await log("info", "metrics disabled", { disabled: [...disabledMetrics].join(",") }) + await log("info", "metrics disabled", { disabled: [...disabledMetrics] }) } const ctx: HandlerContext = { diff --git a/tests/config.test.ts b/tests/config.test.ts index 1f69ac8..a718c93 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -48,6 +48,7 @@ describe("loadConfig", () => { "OPENCODE_OTLP_LOGS_INTERVAL", "OPENCODE_OTLP_HEADERS", "OPENCODE_RESOURCE_ATTRIBUTES", + "OPENCODE_DISABLE_METRICS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", ] @@ -127,6 +128,35 @@ describe("loadConfig", () => { loadConfig() expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("new=attr") }) + + test("disabledMetrics is empty set when OPENCODE_DISABLE_METRICS is unset", () => { + expect(loadConfig().disabledMetrics.size).toBe(0) + }) + + test("disabledMetrics parses a single metric name", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count" + expect(loadConfig().disabledMetrics).toEqual(new Set(["session.count"])) + }) + + test("disabledMetrics parses a comma-separated list", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count,cache.count,retry.count" + const { disabledMetrics } = loadConfig() + expect(disabledMetrics.has("session.count")).toBe(true) + expect(disabledMetrics.has("cache.count")).toBe(true) + expect(disabledMetrics.has("retry.count")).toBe(true) + }) + + test("disabledMetrics trims whitespace around names", () => { + process.env["OPENCODE_DISABLE_METRICS"] = " session.count , cache.count " + const { disabledMetrics } = loadConfig() + expect(disabledMetrics.has("session.count")).toBe(true) + expect(disabledMetrics.has("cache.count")).toBe(true) + }) + + test("disabledMetrics ignores empty segments from trailing commas", () => { + process.env["OPENCODE_DISABLE_METRICS"] = "session.count," + expect(loadConfig().disabledMetrics.size).toBe(1) + }) }) describe("resolveLogLevel", () => { diff --git a/tests/handlers/disabled-metrics.test.ts b/tests/handlers/disabled-metrics.test.ts index 11b28ce..9b0cf54 100644 --- a/tests/handlers/disabled-metrics.test.ts +++ b/tests/handlers/disabled-metrics.test.ts @@ -2,7 +2,6 @@ import { describe, test, expect } from "bun:test" import { handleSessionCreated, handleSessionIdle, handleSessionStatus } from "../../src/handlers/session.ts" import { handleMessageUpdated, handleMessagePartUpdated } from "../../src/handlers/message.ts" import { handleSessionDiff, handleCommandExecuted } from "../../src/handlers/activity.ts" -import { loadConfig } from "../../src/config.ts" import { makeCtx } from "../helpers.ts" import type { EventSessionCreated, EventSessionIdle, EventSessionStatus, EventMessageUpdated, EventMessagePartUpdated, EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" @@ -68,45 +67,6 @@ function makeCommandExecuted(cmd: string): EventCommandExecuted { } describe("OPENCODE_DISABLE_METRICS", () => { - describe("loadConfig parses disabled metrics correctly", () => { - test("empty string produces empty set", () => { - delete process.env["OPENCODE_DISABLE_METRICS"] - const config = loadConfig() - expect(config.disabledMetrics.size).toBe(0) - }) - - test("single metric name", () => { - process.env["OPENCODE_DISABLE_METRICS"] = "session.count" - const config = loadConfig() - expect(config.disabledMetrics.has("session.count")).toBe(true) - delete process.env["OPENCODE_DISABLE_METRICS"] - }) - - test("comma-separated list", () => { - process.env["OPENCODE_DISABLE_METRICS"] = "session.count,cache.count,retry.count" - const config = loadConfig() - expect(config.disabledMetrics.has("session.count")).toBe(true) - expect(config.disabledMetrics.has("cache.count")).toBe(true) - expect(config.disabledMetrics.has("retry.count")).toBe(true) - delete process.env["OPENCODE_DISABLE_METRICS"] - }) - - test("trims whitespace around names", () => { - process.env["OPENCODE_DISABLE_METRICS"] = " session.count , cache.count " - const config = loadConfig() - expect(config.disabledMetrics.has("session.count")).toBe(true) - expect(config.disabledMetrics.has("cache.count")).toBe(true) - delete process.env["OPENCODE_DISABLE_METRICS"] - }) - - test("ignores empty segments from trailing commas", () => { - process.env["OPENCODE_DISABLE_METRICS"] = "session.count," - const config = loadConfig() - expect(config.disabledMetrics.size).toBe(1) - delete process.env["OPENCODE_DISABLE_METRICS"] - }) - }) - describe("session.count disabled", () => { test("does not increment session counter", async () => { const { ctx, counters } = makeCtx("proj_test", ["session.count"]) diff --git a/tests/util.test.ts b/tests/util.test.ts index 21242f5..7880765 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { errorSummary, setBoundedMap } from "../src/util.ts" +import { errorSummary, setBoundedMap, isMetricEnabled } from "../src/util.ts" import { MAX_PENDING } from "../src/types.ts" describe("errorSummary", () => { @@ -63,3 +63,25 @@ describe("setBoundedMap", () => { expect(map.size).toBe(1) }) }) + +describe("isMetricEnabled", () => { + test("returns true when disabled set is empty", () => { + expect(isMetricEnabled("session.count", { disabledMetrics: new Set() })).toBe(true) + }) + + test("returns false when metric is in the disabled set", () => { + expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["session.count"]) })).toBe(false) + }) + + test("returns true when a different metric is disabled", () => { + expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["cache.count"]) })).toBe(true) + }) + + test("is case-sensitive — does not match mismatched case", () => { + expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["Session.Count"]) })).toBe(true) + }) + + test("unknown metric names in disabled set do not affect known metrics", () => { + expect(isMetricEnabled("retry.count", { disabledMetrics: new Set(["does.not.exist"]) })).toBe(true) + }) +}) From 4c87afa5dca5e4e2275e6518ecac4bacfd746d21 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 14 Mar 2026 20:11:07 -0400 Subject: [PATCH 3/3] ci: add pre-commit hooks for standard checks Adds .pre-commit-config.yaml with: - pre-commit-hooks: trailing whitespace, end-of-file, merge conflicts, case conflicts, JSON validation, JSON formatting, YAML validation, LF line endings - markdownlint: MD013 (line length) and MD041 (first-line-heading) disabled via .markdownlint.yaml; MD024 set to siblings_only for CHANGELOG compatibility - local TypeScript typecheck hook via bun run typecheck Also fixes all MD040 (missing fenced code language) violations in AGENTS.md and CONTRIBUTING.md by adding 'text' language to plaintext code blocks. --- .markdownlint.yaml | 5 ++++ .pre-commit-config.yaml | 31 ++++++++++++++++++++++++ AGENTS.md | 6 ++--- CHANGELOG.md | 29 +++++++++++----------- CONTRIBUTING.md | 8 +++---- package.json | 38 ++++++++++++++--------------- release-please-config.json | 49 +++++++++++++++++++++++++++++--------- 7 files changed, 114 insertions(+), 52 deletions(-) create mode 100644 .markdownlint.yaml create mode 100644 .pre-commit-config.yaml diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..95b6d22 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,5 @@ +default: true +MD013: false # line-length — too strict for tables and long URLs +MD024: # no-duplicate-heading + siblings_only: true # allow duplicate headings in different sections (e.g. CHANGELOG) +MD041: false # first-line-heading — not all files need an H1 (e.g. CLAUDE.md) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d786f6c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-case-conflict + - id: check-json + exclude: tsconfig.json # tsconfig uses JSONC (comments allowed) + - id: pretty-format-json + args: [--autofix, --indent=2] + exclude: ^(bun\.lock|tsconfig\.json)$ + - id: check-yaml + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.44.0 + hooks: + - id: markdownlint + args: [--fix] + + - repo: local + hooks: + - id: typecheck + name: TypeScript typecheck + language: system + entry: bun run typecheck + pass_filenames: false + types: [ts] diff --git a/AGENTS.md b/AGENTS.md index b881462..ffca2c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ bun test ## Project layout -``` +```text src/ ├── index.ts — Plugin entrypoint ├── types.ts — Shared types @@ -52,7 +52,7 @@ src/ All commits must follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): -``` +```text [optional scope]: ``` @@ -62,7 +62,7 @@ Use `!` or a `BREAKING CHANGE:` footer for breaking changes. Examples: -``` +```text feat(handlers): add support for file.edited event fix(probe): handle malformed endpoint URL without throwing chore(deps): bump @opentelemetry/api to 1.10.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7f679..edc218e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ## [0.3.0](https://github.com/DEVtheOPS/opencode-plugin-otel/compare/v0.2.1...v0.3.0) (2026-03-14) - ### Features * **observability:** add debug logging and enhanced metrics ([a1b0a8c](https://github.com/DEVtheOPS/opencode-plugin-otel/commit/a1b0a8cf5263080cf9623355e5161fb88f20e2f1)) @@ -21,7 +20,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ### Changed -- **BREAKING** — Package renamed to `@devtheops/opencode-plugin-otel`. Update your opencode config from `"opencode-plugin-otel"` to `"@devtheops/opencode-plugin-otel"`. +* **BREAKING** — Package renamed to `@devtheops/opencode-plugin-otel`. Update your opencode config from `"opencode-plugin-otel"` to `"@devtheops/opencode-plugin-otel"`. --- @@ -29,7 +28,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ### Fixed -- Release workflow now uses npm trusted publishing (OIDC) with Node 22.14.0 and creates a GitHub release with changelog notes and npm package link. +* Release workflow now uses npm trusted publishing (OIDC) with Node 22.14.0 and creates a GitHub release with changelog notes and npm package link. --- @@ -37,21 +36,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ### 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. +* **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. +* `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. +* `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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea7dbf4..ffbd622 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ opencode loads TypeScript natively via Bun, so there is no build step required d ## Project structure -``` +```text src/ ├── index.ts — Plugin entrypoint, wires everything together ├── types.ts — Shared types (Level, HandlerContext, Instruments, etc.) @@ -65,7 +65,7 @@ Then set `OPENCODE_ENABLE_TELEMETRY=1` and start opencode. The collector will pr This project follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). All commits must be structured as: -``` +```text [optional scope]: [optional body] @@ -91,7 +91,7 @@ This project follows [Conventional Commits](https://www.conventionalcommits.org/ Append `!` after the type or add a `BREAKING CHANGE:` footer: -``` +```text feat!: drop support for OTLP HTTP BREAKING CHANGE: only OTLP/gRPC is supported going forward @@ -99,7 +99,7 @@ BREAKING CHANGE: only OTLP/gRPC is supported going forward ### Examples -``` +```text feat(handlers): add support for file.edited event fix(probe): handle malformed endpoint URL without throwing docs: update Datadog configuration example diff --git a/package.json b/package.json index 0e15aae..1719b79 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,4 @@ { - "name": "@devtheops/opencode-plugin-otel", - "version": "0.3.0", - "module": "src/index.ts", - "main": "src/index.ts", - "type": "module", - "repository": { - "type": "git", - "url": "https://github.com/DEVtheOPS/opencode-plugin-otel.git" - }, - "files": [ - "src/" - ], - "devDependencies": { - "@types/bun": "latest" - }, - "scripts": { - "typecheck": "tsc --noEmit" - }, "dependencies": { "@opencode-ai/plugin": "^1.2.23", "@opencode-ai/sdk": "^1.2.23", @@ -28,5 +10,23 @@ "@opentelemetry/sdk-metrics": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", "typescript": "^5.9.3" - } + }, + "devDependencies": { + "@types/bun": "latest" + }, + "files": [ + "src/" + ], + "main": "src/index.ts", + "module": "src/index.ts", + "name": "@devtheops/opencode-plugin-otel", + "repository": { + "type": "git", + "url": "https://github.com/DEVtheOPS/opencode-plugin-otel.git" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "type": "module", + "version": "0.3.0" } diff --git a/release-please-config.json b/release-please-config.json index c4ac757..5fc1c6d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,18 +2,45 @@ "bootstrap-sha": "b7bf72d1b8ee652acb714431087d24b475ca6460", "packages": { ".": { - "release-type": "node", - "package-name": "@devtheops/opencode-plugin-otel", "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance Improvements" }, - { "type": "refactor", "section": "Code Refactoring" }, - { "type": "docs", "section": "Documentation" }, - { "type": "ci", "section": "Continuous Integration", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "chore", "section": "Miscellaneous Chores", "hidden": true } - ] + { + "section": "Features", + "type": "feat" + }, + { + "section": "Bug Fixes", + "type": "fix" + }, + { + "section": "Performance Improvements", + "type": "perf" + }, + { + "section": "Code Refactoring", + "type": "refactor" + }, + { + "section": "Documentation", + "type": "docs" + }, + { + "hidden": true, + "section": "Continuous Integration", + "type": "ci" + }, + { + "hidden": true, + "section": "Build System", + "type": "build" + }, + { + "hidden": true, + "section": "Miscellaneous Chores", + "type": "chore" + } + ], + "package-name": "@devtheops/opencode-plugin-otel", + "release-type": "node" } } }