diff --git a/AGENTS.md b/AGENTS.md index ffca2c4..a2160b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index fe1251f..272a9e6 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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. diff --git a/src/config.ts b/src/config.ts index 9c48314..6b110df 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ export type PluginConfig = { otlpHeaders: string | undefined otlpHeadersHelper: string | undefined resourceAttributes: string | undefined + spanAttributes: string | undefined traceparent: string | undefined tracestate: string | undefined metricsTemporality: MetricsTemporality | undefined @@ -28,6 +29,22 @@ export type PluginConfig = { disabledTraces: Set } +export function parseAttributePairs(raw: string | undefined): Record { + const attrs: Record = {} + 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] @@ -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"] @@ -113,6 +131,7 @@ export function loadConfig(): PluginConfig { otlpHeaders, otlpHeadersHelper, resourceAttributes, + spanAttributes, traceparent, tracestate, metricsTemporality, diff --git a/src/index.ts b/src/index.ts index da597ca..1116872 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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" @@ -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) @@ -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 if (disabledMetrics.size > 0) { await log("info", "metrics disabled", { disabled: [...disabledMetrics] }) diff --git a/src/otel.ts b/src/otel.ts index efca636..8829900 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -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, @@ -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) } diff --git a/src/types.ts b/src/types.ts index 9af6281..586ae1f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,8 +17,8 @@ export type PluginLogger = ( extra?: Record, ) => Promise -/** 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> /** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */ export type PendingToolSpan = { diff --git a/tests/config.test.ts b/tests/config.test.ts index 3cae2b5..4be5d99 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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", () => { @@ -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", @@ -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() diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts index 8c58746..b09396e 100644 --- a/tests/handlers/message.test.ts +++ b/tests/handlers/message.test.ts @@ -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 } }, @@ -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 () => { @@ -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 () => { diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index b87ae40..fa62e07 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -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", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index ce81acf..0113660 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 = {}, +): MockContext { const session = makeCounter() const token = makeCounter() const cost = makeCounter() @@ -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(), diff --git a/tests/otel.test.ts b/tests/otel.test.ts index 88cf6b3..22ddde1 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -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")