Skip to content

Commit 5c07830

Browse files
feat(config): support OPENCODE_SPAN_ATTRIBUTES
1 parent 7153352 commit 5c07830

11 files changed

Lines changed: 84 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ src/
4444
- **`setBoundedMap`** — always use this instead of `Map.set` for `pendingToolSpans` and `pendingPermissions` to prevent unbounded growth.
4545
- **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`.
4646
- **Shutdown** — OTel providers are flushed via `SIGTERM`/`SIGINT`/`beforeExit`. Do not use `process.on("exit")` for async flushing.
47-
- **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.
47+
- **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.
4848
- **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var. The plugin always loads regardless; only telemetry is disabled when unset.
4949
- **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility.
5050

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ All configuration is via environment variables. Set them in your shell profile (
100100
| `OPENCODE_OTLP_HEADERS` | *(unset)* | Comma-separated `key=value` headers added to all OTLP exports. **Keep out of version control — may contain sensitive auth tokens.** |
101101
| `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`. |
102102
| `OPENCODE_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` |
103+
| `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` |
103104
| `OPENCODE_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. |
104105
| `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. |
105106
| `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=
125126

126127
# Tag every metric and log with deployment context
127128
export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environment=production"
129+
130+
# Tag every span, log event, and metric point with filterable attributes
131+
export OPENCODE_SPAN_ATTRIBUTES="team=platform,deployment.environment=production"
128132
```
129133

130134
> **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.
131135
136+
`OPENCODE_RESOURCE_ATTRIBUTES` and `OPENCODE_SPAN_ATTRIBUTES` are independent:
137+
138+
- Use `OPENCODE_RESOURCE_ATTRIBUTES` for producer metadata on the OTel Resource.
139+
- 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.
140+
132141
### Dynamic headers
133142

134143
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.

src/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,30 @@ export type PluginConfig = {
2121
otlpHeaders: string | undefined
2222
otlpHeadersHelper: string | undefined
2323
resourceAttributes: string | undefined
24+
spanAttributes: string | undefined
2425
traceparent: string | undefined
2526
tracestate: string | undefined
2627
metricsTemporality: MetricsTemporality | undefined
2728
disabledMetrics: Set<string>
2829
disabledTraces: Set<string>
2930
}
3031

32+
export function parseAttributePairs(raw: string | undefined): Record<string, string> {
33+
const attrs: Record<string, string> = {}
34+
if (!raw) return attrs
35+
36+
for (const pair of raw.split(",")) {
37+
const idx = pair.indexOf("=")
38+
if (idx <= 0) continue
39+
const key = pair.slice(0, idx).trim()
40+
const value = pair.slice(idx + 1).trim()
41+
if (!key) continue
42+
attrs[key] = value
43+
}
44+
45+
return attrs
46+
}
47+
3148
/** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */
3249
export function parseEnvInt(key: string, fallback: number): number {
3350
const raw = process.env[key]
@@ -67,6 +84,7 @@ export function loadConfig(): PluginConfig {
6784
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
6885
const otlpHeadersHelper = process.env["OPENCODE_OTLP_HEADERS_HELPER"]
6986
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]
87+
const spanAttributes = process.env["OPENCODE_SPAN_ATTRIBUTES"]
7088
const traceparent = process.env["OPENCODE_TRACEPARENT"]
7189
const tracestate = process.env["OPENCODE_TRACESTATE"]
7290
const rawTemporality = process.env["OPENCODE_OTLP_METRICS_TEMPORALITY"]
@@ -113,6 +131,7 @@ export function loadConfig(): PluginConfig {
113131
otlpHeaders,
114132
otlpHeadersHelper,
115133
resourceAttributes,
134+
spanAttributes,
116135
traceparent,
117136
tracestate,
118137
metricsTemporality,

src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
EventCommandExecuted,
1818
} from "@opencode-ai/sdk"
1919
import { LEVELS, type Level, type HandlerContext } from "./types.ts"
20-
import { loadConfig, resolveHelperPath, resolveLogLevel } from "./config.ts"
20+
import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel } from "./config.ts"
2121
import { probeEndpoint } from "./probe.ts"
2222
import { setupOtel, createInstruments } from "./otel.ts"
2323
import { remoteParentContext } from "./trace-context.ts"
@@ -62,6 +62,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
6262
headersSet: !!config.otlpHeaders,
6363
headersHelperSet: !!config.otlpHeadersHelper,
6464
resourceAttributesSet: !!config.resourceAttributes,
65+
spanAttributesSet: !!config.spanAttributes,
6566
})
6667

6768
const probe = await probeEndpoint(config.endpoint)
@@ -106,7 +107,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
106107
const sessionInputs = new Map()
107108
const messageOutputs = new Map()
108109
const { disabledMetrics, disabledTraces } = config
109-
const commonAttrs = { "project.id": project.id } as const
110+
const commonAttrs = {
111+
"project.id": project.id,
112+
...parseAttributePairs(config.spanAttributes),
113+
} as const
110114

111115
if (disabledMetrics.size > 0) {
112116
await log("info", "metrics disabled", { disabled: [...disabledMetrics] })

src/otel.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources"
1616
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"
1717
import { ATTR_HOST_ARCH } from "@opentelemetry/semantic-conventions/incubating"
1818
import type { Instruments } from "./types.ts"
19+
import { parseAttributePairs } from "./config.ts"
1920
import {
2021
createGrpcMetadata,
2122
DynamicHeaders,
@@ -37,17 +38,7 @@ export function buildResource(version: string) {
3738
"app.version": version,
3839
"os.type": process.platform,
3940
[ATTR_HOST_ARCH]: process.arch,
40-
}
41-
const raw = process.env["OTEL_RESOURCE_ATTRIBUTES"]
42-
if (raw) {
43-
for (const pair of raw.split(",")) {
44-
const idx = pair.indexOf("=")
45-
if (idx > 0) {
46-
const key = pair.slice(0, idx).trim()
47-
const val = pair.slice(idx + 1).trim()
48-
if (key) attrs[key] = val
49-
}
50-
}
41+
...parseAttributePairs(process.env["OTEL_RESOURCE_ATTRIBUTES"]),
5142
}
5243
return resourceFromAttributes(attrs)
5344
}

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export type PluginLogger = (
1717
extra?: Record<string, unknown>,
1818
) => Promise<void>
1919

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

2323
/** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */
2424
export type PendingToolSpan = {

tests/config.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2-
import { parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts"
2+
import { parseAttributePairs, parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts"
3+
4+
describe("parseAttributePairs", () => {
5+
test("parses comma-separated key=value pairs", () => {
6+
expect(parseAttributePairs("team=platform,env=prod")).toEqual({ team: "platform", env: "prod" })
7+
})
8+
9+
test("trims whitespace and keeps empty values", () => {
10+
expect(parseAttributePairs(" team = platform , empty = ")).toEqual({ team: "platform", empty: "" })
11+
})
12+
13+
test("uses only the first equals sign as the separator", () => {
14+
expect(parseAttributePairs("auth=Bearer abc=123")).toEqual({ auth: "Bearer abc=123" })
15+
})
16+
17+
test("ignores malformed pairs", () => {
18+
expect(parseAttributePairs("missingequals,=novalue,,valid=yes")).toEqual({ valid: "yes" })
19+
})
20+
})
321

422
describe("parseEnvInt", () => {
523
test("returns fallback when env var is unset", () => {
@@ -50,6 +68,7 @@ describe("loadConfig", () => {
5068
"OPENCODE_OTLP_HEADERS",
5169
"OPENCODE_OTLP_HEADERS_HELPER",
5270
"OPENCODE_RESOURCE_ATTRIBUTES",
71+
"OPENCODE_SPAN_ATTRIBUTES",
5372
"OPENCODE_TRACEPARENT",
5473
"OPENCODE_TRACESTATE",
5574
"OPENCODE_OTLP_METRICS_TEMPORALITY",
@@ -144,6 +163,11 @@ describe("loadConfig", () => {
144163
expect(cfg.tracestate).toBe("vendor=value")
145164
})
146165

166+
test("reads OPENCODE_SPAN_ATTRIBUTES", () => {
167+
process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform,env=prod"
168+
expect(loadConfig().spanAttributes).toBe("team=platform,env=prod")
169+
})
170+
147171
test("does not set OTEL_EXPORTER_OTLP_HEADERS when OPENCODE_OTLP_HEADERS is unset", () => {
148172
delete process.env["OPENCODE_OTLP_HEADERS"]
149173
loadConfig()

tests/handlers/message.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe("handleMessageUpdated", () => {
125125
})
126126

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

145146
test("increments cost counter", async () => {
@@ -210,10 +211,11 @@ describe("handleMessageUpdated", () => {
210211
})
211212

212213
test("emits api_request log record on success", async () => {
213-
const { ctx, logger } = makeCtx()
214+
const { ctx, logger } = makeCtx("proj_test", [], [], true, { team: "platform" })
214215
await handleMessageUpdated(makeAssistantMessageUpdated({}), ctx)
215216
expect(logger.records).toHaveLength(1)
216217
expect(logger.records.at(0)!.body).toBe("api_request")
218+
expect(logger.records.at(0)!.attributes?.["team"]).toBe("platform")
217219
})
218220

219221
test("emits api_error log record on error", async () => {

tests/handlers/spans.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,11 @@ describe("session spans", () => {
107107
})
108108

109109
test("session span carries session.id attribute", () => {
110-
const { ctx, tracer } = makeCtx()
110+
const { ctx, tracer } = makeCtx("proj_test", [], [], true, { team: "platform" })
111111
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
112112
expect(tracer.spans[0]!.attributes["session.id"]).toBe("ses_1")
113113
expect(tracer.spans[0]!.attributes[SESSION_ID]).toBe("ses_1")
114+
expect(tracer.spans[0]!.attributes["team"]).toBe("platform")
114115
})
115116

116117
test("session span is tagged as an OpenInference agent span", () => {

tests/helpers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,13 @@ export type MockContext = {
148148
tracer: SpyTracer
149149
}
150150

151-
export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [], disabledTraces: string[] = [], logsEnabled = true): MockContext {
151+
export function makeCtx(
152+
projectID = "proj_test",
153+
disabledMetrics: string[] = [],
154+
disabledTraces: string[] = [],
155+
logsEnabled = true,
156+
extraCommonAttrs: Record<string, string> = {},
157+
): MockContext {
152158
const session = makeCounter()
153159
const token = makeCounter()
154160
const cost = makeCounter()
@@ -193,7 +199,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
193199
logger.emit(record)
194200
},
195201
instruments,
196-
commonAttrs: { "project.id": projectID },
202+
commonAttrs: { "project.id": projectID, ...extraCommonAttrs },
197203
pendingToolSpans: new Map(),
198204
pendingPermissions: new Map(),
199205
sessionTotals: new Map(),

0 commit comments

Comments
 (0)