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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,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`, `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.
- **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`, `OPENCODE_SPAN_ATTRIBUTES`. Never use bare `OTEL_*` names for plugin config. `loadConfig` copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes.
- **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var. The plugin always loads regardless; only telemetry is disabled when unset.
- **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility.

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ All configuration is via environment variables. Set them in your shell profile (
| `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` |
| `OPENCODE_SPAN_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs attached to every emitted span, log event, and metric data point. Example: `team=platform,deployment.environment=production` |
| `OPENCODE_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. |
| `OPENCODE_TRACEPARENT` | *(unset)* | W3C [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) string. When set, all spans are parented under this remote context so opencode traces nest inside a caller's trace (e.g. a CI job). Invalid values are logged and ignored. Note: with the default `ParentBased` sampler, a value with the sampled flag off (`...-00`) suppresses all trace export. |
| `OPENCODE_TRACESTATE` | *(unset)* | W3C [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) string, parsed alongside `OPENCODE_TRACEPARENT` and attached to the remote parent context. Ignored unless a valid `OPENCODE_TRACEPARENT` is also set. |
Expand All @@ -125,10 +126,18 @@ export OPENCODE_OTLP_HEADERS="x-honeycomb-team=your-api-key,x-honeycomb-dataset=

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

# Tag every span, log event, and metric point with filterable attributes
export OPENCODE_SPAN_ATTRIBUTES="team=platform,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.

`OPENCODE_RESOURCE_ATTRIBUTES` and `OPENCODE_SPAN_ATTRIBUTES` are independent:

- Use `OPENCODE_RESOURCE_ATTRIBUTES` for producer metadata on the OTel Resource.
- Use `OPENCODE_SPAN_ATTRIBUTES` for attributes that need to appear on each span, log event, and metric data point for filtering or grouping in backends.

### Dynamic headers

Use `OPENCODE_OTLP_HEADERS_HELPER` when your collector requires short-lived authentication tokens. When this is set, the plugin prewarms the helper once during startup so the first export can use fresh credentials. If a later OTLP export fails with an authentication error (`401`/`403` for HTTP or `UNAUTHENTICATED`/`PERMISSION_DENIED` for gRPC), the plugin refreshes headers again, rebuilds the exporter, and retries the failed export once.
Expand Down
19 changes: 19 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,30 @@ export type PluginConfig = {
otlpHeaders: string | undefined
otlpHeadersHelper: string | undefined
resourceAttributes: string | undefined
spanAttributes: string | undefined
traceparent: string | undefined
tracestate: string | undefined
metricsTemporality: MetricsTemporality | undefined
disabledMetrics: Set<string>
disabledTraces: Set<string>
}

export function parseAttributePairs(raw: string | undefined): Record<string, string> {
const attrs: Record<string, string> = {}
if (!raw) return attrs

for (const pair of raw.split(",")) {
const idx = pair.indexOf("=")
if (idx <= 0) continue
const key = pair.slice(0, idx).trim()
const value = pair.slice(idx + 1).trim()
if (!key) continue
attrs[key] = value
}

return attrs
}

/** 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]
Expand Down Expand Up @@ -67,6 +84,7 @@ export function loadConfig(): PluginConfig {
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
const otlpHeadersHelper = process.env["OPENCODE_OTLP_HEADERS_HELPER"]
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]
const spanAttributes = process.env["OPENCODE_SPAN_ATTRIBUTES"]
const traceparent = process.env["OPENCODE_TRACEPARENT"]
const tracestate = process.env["OPENCODE_TRACESTATE"]
const rawTemporality = process.env["OPENCODE_OTLP_METRICS_TEMPORALITY"]
Expand Down Expand Up @@ -113,6 +131,7 @@ export function loadConfig(): PluginConfig {
otlpHeaders,
otlpHeadersHelper,
resourceAttributes,
spanAttributes,
traceparent,
tracestate,
metricsTemporality,
Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
EventCommandExecuted,
} from "@opencode-ai/sdk"
import { LEVELS, type Level, type HandlerContext } from "./types.ts"
import { loadConfig, resolveHelperPath, resolveLogLevel } from "./config.ts"
import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel } from "./config.ts"
import { probeEndpoint } from "./probe.ts"
import { setupOtel, createInstruments } from "./otel.ts"
import { remoteParentContext } from "./trace-context.ts"
Expand Down Expand Up @@ -62,6 +62,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
headersSet: !!config.otlpHeaders,
headersHelperSet: !!config.otlpHeadersHelper,
resourceAttributesSet: !!config.resourceAttributes,
spanAttributesSet: !!config.spanAttributes,
})

const probe = await probeEndpoint(config.endpoint)
Expand Down Expand Up @@ -106,7 +107,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
const sessionInputs = new Map()
const messageOutputs = new Map()
const { disabledMetrics, disabledTraces } = config
const commonAttrs = { "project.id": project.id } as const
const commonAttrs = {
...parseAttributePairs(config.spanAttributes),
"project.id": project.id,
} as const
Comment thread
dialupdisaster marked this conversation as resolved.

if (disabledMetrics.size > 0) {
await log("info", "metrics disabled", { disabled: [...disabledMetrics] })
Expand Down
13 changes: 2 additions & 11 deletions src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources"
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"
import { ATTR_HOST_ARCH } from "@opentelemetry/semantic-conventions/incubating"
import type { Instruments } from "./types.ts"
import { parseAttributePairs } from "./config.ts"
import {
createGrpcMetadata,
DynamicHeaders,
Expand All @@ -37,17 +38,7 @@ export function buildResource(version: string) {
"app.version": version,
"os.type": process.platform,
[ATTR_HOST_ARCH]: process.arch,
}
const raw = process.env["OTEL_RESOURCE_ATTRIBUTES"]
if (raw) {
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) attrs[key] = val
}
}
...parseAttributePairs(process.env["OTEL_RESOURCE_ATTRIBUTES"]),
}
return resourceFromAttributes(attrs)
}
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export type PluginLogger = (
extra?: Record<string, unknown>,
) => Promise<void>

/** OTel resource attributes common to every emitted log and metric. */
export type CommonAttrs = { readonly "project.id": string }
/** OTel attributes common to every emitted span, log, and metric. */
export type CommonAttrs = Readonly<Record<string, string>>

/** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */
export type PendingToolSpan = {
Expand Down
26 changes: 25 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts"
import { parseAttributePairs, parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts"

describe("parseAttributePairs", () => {
test("parses comma-separated key=value pairs", () => {
expect(parseAttributePairs("team=platform,env=prod")).toEqual({ team: "platform", env: "prod" })
})

test("trims whitespace and keeps empty values", () => {
expect(parseAttributePairs(" team = platform , empty = ")).toEqual({ team: "platform", empty: "" })
})

test("uses only the first equals sign as the separator", () => {
expect(parseAttributePairs("auth=Bearer abc=123")).toEqual({ auth: "Bearer abc=123" })
})

test("ignores malformed pairs", () => {
expect(parseAttributePairs("missingequals,=novalue,,valid=yes")).toEqual({ valid: "yes" })
})
})

describe("parseEnvInt", () => {
test("returns fallback when env var is unset", () => {
Expand Down Expand Up @@ -50,6 +68,7 @@ describe("loadConfig", () => {
"OPENCODE_OTLP_HEADERS",
"OPENCODE_OTLP_HEADERS_HELPER",
"OPENCODE_RESOURCE_ATTRIBUTES",
"OPENCODE_SPAN_ATTRIBUTES",
"OPENCODE_TRACEPARENT",
"OPENCODE_TRACESTATE",
"OPENCODE_OTLP_METRICS_TEMPORALITY",
Expand Down Expand Up @@ -144,6 +163,11 @@ describe("loadConfig", () => {
expect(cfg.tracestate).toBe("vendor=value")
})

test("reads OPENCODE_SPAN_ATTRIBUTES", () => {
process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform,env=prod"
expect(loadConfig().spanAttributes).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()
Expand Down
6 changes: 4 additions & 2 deletions tests/handlers/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("handleMessageUpdated", () => {
})

test("increments all token counters", async () => {
const { ctx, counters } = makeCtx()
const { ctx, counters } = makeCtx("proj_test", [], [], true, { team: "platform" })
await handleMessageUpdated(
makeAssistantMessageUpdated({
tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } },
Expand All @@ -140,6 +140,7 @@ describe("handleMessageUpdated", () => {
expect(types).toContain("cacheCreation")
const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")!
expect(inputCall.value).toBe(100)
expect(inputCall.attrs["team"]).toBe("platform")
})

test("increments cost counter", async () => {
Expand Down Expand Up @@ -210,10 +211,11 @@ describe("handleMessageUpdated", () => {
})

test("emits api_request log record on success", async () => {
const { ctx, logger } = makeCtx()
const { ctx, logger } = makeCtx("proj_test", [], [], true, { team: "platform" })
await handleMessageUpdated(makeAssistantMessageUpdated({}), ctx)
expect(logger.records).toHaveLength(1)
expect(logger.records.at(0)!.body).toBe("api_request")
expect(logger.records.at(0)!.attributes?.["team"]).toBe("platform")
})

test("emits api_error log record on error", async () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/handlers/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,11 @@ describe("session spans", () => {
})

test("session span carries session.id attribute", () => {
const { ctx, tracer } = makeCtx()
const { ctx, tracer } = makeCtx("proj_test", [], [], true, { team: "platform" })
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
expect(tracer.spans[0]!.attributes["session.id"]).toBe("ses_1")
expect(tracer.spans[0]!.attributes[SESSION_ID]).toBe("ses_1")
expect(tracer.spans[0]!.attributes["team"]).toBe("platform")
})

test("session span is tagged as an OpenInference agent span", () => {
Expand Down
10 changes: 8 additions & 2 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ export type MockContext = {
tracer: SpyTracer
}

export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [], disabledTraces: string[] = [], logsEnabled = true): MockContext {
export function makeCtx(
projectID = "proj_test",
disabledMetrics: string[] = [],
disabledTraces: string[] = [],
logsEnabled = true,
extraCommonAttrs: Record<string, string> = {},
): MockContext {
const session = makeCounter()
const token = makeCounter()
const cost = makeCounter()
Expand Down Expand Up @@ -193,7 +199,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
logger.emit(record)
},
instruments,
commonAttrs: { "project.id": projectID },
commonAttrs: { "project.id": projectID, ...extraCommonAttrs },
pendingToolSpans: new Map(),
pendingPermissions: new Map(),
sessionTotals: new Map(),
Expand Down
6 changes: 6 additions & 0 deletions tests/otel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ describe("buildResource", () => {
expect(resource.attributes["team"]).toBe("platform")
})

test("resource attribute values may contain equals signs", () => {
process.env["OTEL_RESOURCE_ATTRIBUTES"] = "auth=Bearer abc=123"
const resource = buildResource("0.0.1")
expect(resource.attributes["auth"]).toBe("Bearer abc=123")
})

test("env resource attributes override defaults", () => {
process.env["OTEL_RESOURCE_ATTRIBUTES"] = "service.name=my-override"
const resource = buildResource("0.0.1")
Expand Down
Loading