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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
- [Headers and resource attributes](#headers-and-resource-attributes)
- [Dynamic headers](#dynamic-headers)
- [Disabling specific metrics](#disabling-specific-metrics)
- [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs)
- [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces)
- [SigNoz example](#signoz-example)
- [Datadog example](#datadog-example)
- [Honeycomb example](#honeycomb-example)
- [Claude Code dashboard compatibility](#claude-code-dashboard-compatibility)
Expand Down Expand Up @@ -92,6 +95,8 @@ All configuration is via environment variables. Set them in your shell profile (
| `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_DISABLE_METRICS` | *(unset)* | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) |
| `OPENCODE_DISABLE_LOGS` | *(unset)* | Set to any non-empty value to suppress all OTLP log events while leaving metrics and traces unchanged |
| `OPENCODE_DISABLE_TRACES` | *(unset)* | Comma-separated list of trace types to disable (`session`, `llm`, `tool`). Use `all`, `*`, `true`, or `1` to disable every trace type |
| `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_OTLP_HEADERS_HELPER` | *(unset)* | Executable script/binary that returns dynamic OTLP headers as JSON after an auth failure. Helper headers override `OPENCODE_OTLP_HEADERS`. |
| `OPENCODE_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` |
Expand Down Expand Up @@ -180,6 +185,33 @@ export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.tota
| `retry.count` | API retry counter — not emitted by Claude Code |
| `message.count` | Completed message counter — not emitted by Claude Code |

### Disabling OTLP logs

Use `OPENCODE_DISABLE_LOGS` to suppress every OTLP log event emitted by the plugin.

```bash
export OPENCODE_DISABLE_LOGS=1
```

This only disables OTLP logs. Metrics and traces continue to be exported unless they are disabled separately.

### Disabling traces

Use `OPENCODE_DISABLE_TRACES` to suppress one or more trace types.

```bash
# Disable one trace type
export OPENCODE_DISABLE_TRACES="tool"

# Disable multiple trace types
export OPENCODE_DISABLE_TRACES="llm,tool"

# Disable every trace type explicitly
export OPENCODE_DISABLE_TRACES="all"
```

Accepted explicit "disable all traces" values are `all`, `*`, `true`, and `1`.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### SigNoz example

```bash
Expand Down
34 changes: 27 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { LEVELS, type Level } from "./types.ts"
/** Accepted values for `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. */
export type MetricsTemporality = "cumulative" | "delta" | "lowmemory"

/** Valid trace types emitted by the plugin. */
export const TRACE_TYPES = ["session", "llm", "tool"] as const

const VALID_TEMPORALITIES: ReadonlySet<MetricsTemporality> = new Set<MetricsTemporality>(["cumulative", "delta", "lowmemory"])
const TRACE_DISABLE_ALL_VALUES = new Set(["all", "*", "true", "1"])

/** Configuration values resolved from `OPENCODE_*` environment variables. */
export type PluginConfig = {
enabled: boolean
logsEnabled: boolean
endpoint: string
protocol: "grpc" | "http/protobuf"
metricsInterval: number
Expand All @@ -30,6 +35,25 @@ export function parseEnvInt(key: string, fallback: number): number {
return Number.isSafeInteger(n) ? n : fallback
}

/** Returns `true` when the environment variable is present and non-empty. */
function hasNonEmptyEnv(key: string): boolean {
return !!process.env[key]
}

/** Parses `OPENCODE_DISABLE_TRACES`, expanding explicit global values like `all`. */
function parseDisabledTraces(raw: string | undefined): Set<string> {
const values = (raw ?? "")
.split(",")
.map(s => s.trim().toLowerCase())
.filter(Boolean)

if (values.some(value => TRACE_DISABLE_ALL_VALUES.has(value))) {
return new Set(TRACE_TYPES)
}

return new Set(values)
}

/**
* Reads all `OPENCODE_*` environment variables and returns the resolved plugin config.
* Copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS`,
Expand Down Expand Up @@ -68,15 +92,11 @@ export function loadConfig(): PluginConfig {
.filter(Boolean),
)

const disabledTraces = new Set(
(process.env["OPENCODE_DISABLE_TRACES"] ?? "")
.split(",")
.map(s => s.trim())
.filter(Boolean),
)
const disabledTraces = parseDisabledTraces(process.env["OPENCODE_DISABLE_TRACES"])

return {
enabled: !!process.env["OPENCODE_ENABLE_TELEMETRY"],
enabled: hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"),
logsEnabled: !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"),
endpoint: process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317",
protocol: protocol === "http/protobuf" ? "http/protobuf" : "grpc",
metricsInterval: parseEnvInt("OPENCODE_OTLP_METRICS_INTERVAL", 60000),
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte
})
ctx.log("debug", "otel: commit counter incremented", { sessionID: e.properties.sessionID })
}
ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: Date.now(),
Expand Down
8 changes: 4 additions & 4 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
}

if (assistant.error) {
ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
timestamp: assistant.time.created,
Expand All @@ -165,7 +165,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
})
}

ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: assistant.time.created,
Expand Down Expand Up @@ -226,7 +226,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
agent: subtask.agent,
})
}
ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: Date.now(),
Expand Down Expand Up @@ -358,7 +358,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
? { tool_result_size_bytes: Buffer.byteLength((toolPart.state as { output: string }).output, "utf8") }
: { error: (toolPart.state as { error: string }).error }

ctx.logger.emit({
ctx.emitLog({
severityNumber: success ? SeverityNumber.INFO : SeverityNumber.ERROR,
severityText: success ? "INFO" : "ERROR",
timestamp: start,
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerC
ctx.pendingPermissions.delete(permissionID)
const decision = response === "allow" || response === "allowAlways" ? "accept" : "reject"
ctx.log("debug", "otel: tool_decision emitted", { permissionID, sessionID, decision, source: response, tool_name: pending?.title ?? "unknown" })
ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: Date.now(),
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
setBoundedMap(ctx.sessionSpans, sessionID, sessionSpan)
}

ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: createdAt,
Expand Down Expand Up @@ -119,7 +119,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
ctx.sessionSpans.delete(sessionID)
}

ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: Date.now(),
Expand Down Expand Up @@ -163,7 +163,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
}
}

ctx.logger.emit({
ctx.emitLog({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
timestamp: Date.now(),
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree

const instruments = createInstruments(config.metricPrefix)
const logger = logs.getLogger("com.opencode")
const emitLog: HandlerContext["emitLog"] = (record) => {
if (!config.logsEnabled) return
logger.emit(record)
}
const tracer = trace.getTracer("com.opencode")
const pendingToolSpans = new Map()
const pendingPermissions = new Map()
Expand All @@ -106,9 +110,13 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
await log("info", "traces disabled", { disabled: [...disabledTraces] })
}

if (!config.logsEnabled) {
await log("info", "OTLP log events disabled")
}

const ctx: HandlerContext = {
logger,
log,
emitLog,
instruments,
commonAttrs,
pendingToolSpans,
Expand Down Expand Up @@ -183,7 +191,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}).filter(Boolean).join("\n")
sessionInputs.set(input.sessionID, promptText)
const promptLength = promptText.length
logger.emit({
emitLog({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
timestamp: Date.now(),
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
import type { Logger as OtelLogger } from "@opentelemetry/api-logs"
import type { LogRecord } 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
Expand Down Expand Up @@ -65,8 +65,8 @@ export type SessionTotals = {

/** Shared context threaded through every event handler. */
export type HandlerContext = {
logger: OtelLogger
log: PluginLogger
emitLog: (record: LogRecord) => void
instruments: Instruments
commonAttrs: CommonAttrs
pendingToolSpans: Map<string, PendingToolSpan>
Expand Down
29 changes: 28 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel } from "../src/config.ts"
import { parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts"

describe("parseEnvInt", () => {
test("returns fallback when env var is unset", () => {
Expand Down Expand Up @@ -52,6 +52,7 @@ describe("loadConfig", () => {
"OPENCODE_RESOURCE_ATTRIBUTES",
"OPENCODE_OTLP_METRICS_TEMPORALITY",
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
"OPENCODE_DISABLE_TRACES",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_RESOURCE_ATTRIBUTES",
Expand All @@ -63,6 +64,7 @@ describe("loadConfig", () => {
test("defaults when no env vars set", () => {
const cfg = loadConfig()
expect(cfg.enabled).toBe(false)
expect(cfg.logsEnabled).toBe(true)
expect(cfg.endpoint).toBe("http://localhost:4317")
expect(cfg.protocol).toBe("grpc")
expect(cfg.metricsInterval).toBe(60000)
Expand All @@ -74,6 +76,11 @@ describe("loadConfig", () => {
expect(loadConfig().enabled).toBe(true)
})

test("logsEnabled is false when OPENCODE_DISABLE_LOGS is set", () => {
process.env["OPENCODE_DISABLE_LOGS"] = "1"
expect(loadConfig().logsEnabled).toBe(false)
})

test("reads custom endpoint", () => {
process.env["OPENCODE_OTLP_ENDPOINT"] = "http://collector:4317"
expect(loadConfig().endpoint).toBe("http://collector:4317")
Expand Down Expand Up @@ -259,6 +266,26 @@ describe("loadConfig", () => {
expect(disabledTraces.has("unknown_type")).toBe(true)
expect(disabledTraces.size).toBe(2)
})

test("disabledTraces expands all to every known trace type", () => {
process.env["OPENCODE_DISABLE_TRACES"] = "all"
expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES))
})

test("disabledTraces expands wildcard to every known trace type", () => {
process.env["OPENCODE_DISABLE_TRACES"] = "*"
expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES))
})

test("disabledTraces expands boolean-style values to every known trace type", () => {
process.env["OPENCODE_DISABLE_TRACES"] = "true"
expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES))
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('disabledTraces expands numeric-style value "1" to every known trace type', () => {
process.env["OPENCODE_DISABLE_TRACES"] = "1"
expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES))
})
})

describe("resolveLogLevel", () => {
Expand Down
Loading
Loading