Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
- [Quick start](#quick-start)
- [Headers and resource attributes](#headers-and-resource-attributes)
- [Dynamic headers](#dynamic-headers)
- [Outbound headers](#outbound-headers)
- [Disabling specific metrics](#disabling-specific-metrics)
- [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs)
- [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces)
Expand Down Expand Up @@ -107,6 +108,8 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba
| `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. |
| `OPENCODE_OUTBOUND_HEADERS` | *(unset)* | Comma-separated `key=value` headers injected into outgoing LLM requests via the `chat.headers` hook. Example: `x-enable-phoenix-tracing=true` |
| `OPENCODE_OUTBOUND_ENDPOINTS` | *(unset)* | Comma-separated endpoint URLs. When set, outbound headers are only injected for requests whose resolved URL matches one of these by hostname. When unset, headers are sent to every provider. |

### Plugin options (opencode.json)

Expand Down Expand Up @@ -148,6 +151,8 @@ Option keys mirror the resolved config and map to the environment variables:
| `metricsTemporality` | `OPENCODE_OTLP_METRICS_TEMPORALITY` |
| `disabledMetrics` | `OPENCODE_DISABLE_METRICS` (array, not a comma string) |
| `disabledTraces` | `OPENCODE_DISABLE_TRACES` (array, not a comma string) |
| `outboundHeaders` | `OPENCODE_OUTBOUND_HEADERS` |
| `outboundEndpoints` | `OPENCODE_OUTBOUND_ENDPOINTS` (array, not a comma string) |

> **Security note:** `opencode.json` is frequently committed to version control. Keep secrets such as `otlpHeaders` in an environment variable or an opencode `{env:VAR}` substitution (e.g. `"otlpHeaders": "{env:OTEL_HEADERS}"`) rather than inline.

Expand Down Expand Up @@ -209,6 +214,22 @@ For a Cloud Run collector using IAM authentication, `get-token.sh` might be `gcl

If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static headers with the same name. Header values are never logged.

### Outbound headers

`OPENCODE_OTLP_HEADERS` above controls headers sent to your *collector*. `OPENCODE_OUTBOUND_HEADERS` is separate: it injects custom headers into the outgoing *LLM* requests opencode makes, via the `chat.headers` hook. This is useful for LLM gateways that require specific headers — feature flags, routing hints, or tracing toggles such as Arize Phoenix's `x-enable-phoenix-tracing`.

```bash
export OPENCODE_OUTBOUND_HEADERS="x-enable-phoenix-tracing=true"
```

By default these headers are sent on every LLM request, including requests to third-party providers such as Anthropic and OpenAI. Use `OPENCODE_OUTBOUND_ENDPOINTS` to restrict injection to your own gateways:

```bash
export OPENCODE_OUTBOUND_ENDPOINTS="https://litellm.example.com"
```

When set, headers are only injected when the resolved request URL — `provider.options.baseURL` if you have overridden it, otherwise the provider's default `model.api.url` — matches one of the listed endpoints by hostname. Scope your endpoints if any outbound header carries a value you would not want sent to a third-party provider.

### 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).
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type PluginConfig = {
metricsTemporality: MetricsTemporality | undefined
disabledMetrics: Set<string>
disabledTraces: Set<string>
outboundHeaders: string | undefined
outboundEndpoints: string[]
}

export function parseAttributePairs(raw: string | undefined): Record<string, string> {
Expand Down Expand Up @@ -69,6 +71,8 @@ export type OtelPluginOptions = {
metricsTemporality?: MetricsTemporality
disabledMetrics?: string[]
disabledTraces?: string[]
outboundHeaders?: string
outboundEndpoints?: string[]
}

const VALID_PROTOCOLS = new Set<PluginConfig["protocol"]>(["grpc", "http/protobuf", "http/json"])
Expand Down Expand Up @@ -182,6 +186,10 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
const optionTraces = pickStringList(resolvedOptions.disabledTraces)
const disabledTraces = expandDisabledTraces(optionTraces ?? splitList(process.env["OPENCODE_DISABLE_TRACES"]))

const outboundHeaders = pickString(resolvedOptions.outboundHeaders) ?? process.env["OPENCODE_OUTBOUND_HEADERS"]
const optionEndpoints = pickStringList(resolvedOptions.outboundEndpoints)
const outboundEndpoints = normalizeList(optionEndpoints ?? splitList(process.env["OPENCODE_OUTBOUND_ENDPOINTS"]))

return {
enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"),
logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"),
Expand All @@ -199,6 +207,8 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
metricsTemporality,
disabledMetrics,
disabledTraces,
outboundHeaders,
outboundEndpoints,
}
}

Expand Down
51 changes: 51 additions & 0 deletions src/handlers/chat-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { ProviderContext } from "@opencode-ai/plugin"
import type { Model, UserMessage } from "@opencode-ai/sdk"
import type { HandlerContext } from "../types.ts"

/**
* Resolves the URL an LLM request will be sent to.
*
* Mirrors opencode's own resolution: a non-empty `baseURL` override from the
* user's provider config wins, otherwise the provider's default `model.api.url`
* is used.
*/
export function getResolvedURL(provider: ProviderContext, model: Model): string {
const baseURL = provider.options?.["baseURL"]
if (typeof baseURL === "string" && baseURL !== "") return baseURL
return model.api?.url ?? ""
}

/**
* Returns `true` when `resolvedURL`'s hostname matches one of `allowed`.
*
* An empty `allowed` list matches everything. An unparseable URL matches
* nothing, so a configured allowlist fails closed.
*/
export function matchesEndpoint(resolvedURL: string, allowed: string[]): boolean {
if (allowed.length === 0) return true
try {
const host = new URL(resolvedURL).hostname
return allowed.some((ep) => {
try {
return new URL(ep).hostname === host
} catch {
return false
}
})
} catch {
return false
}
}

/** Injects the configured outbound headers into an LLM request whose endpoint is in scope. */
export function handleChatHeaders(
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: { headers: Record<string, string> },
ctx: HandlerContext,
): void {
const resolvedURL = getResolvedURL(input.provider, input.model)
if (!matchesEndpoint(resolvedURL, ctx.outboundEndpoints)) return
for (const [key, value] of Object.entries(ctx.outboundHeaders)) {
output.headers[key] = value
}
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
import { handleChatHeaders } from "./handlers/chat-headers.ts"
import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "./util.ts"
import type { SessionTotals } from "./types.ts"

Expand Down Expand Up @@ -157,6 +158,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
sessionSpanContexts,
messageSpans,
messageOutputs,
outboundHeaders: parseAttributePairs(config.outboundHeaders),
outboundEndpoints: config.outboundEndpoints,
}

let shuttingDown = false
Expand Down Expand Up @@ -206,6 +209,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}
},

"chat.headers": safe("chat.headers", async (input, output) => {
handleChatHeaders(input, output, ctx)
}),

"chat.message": safe("chat.message", async (input, output) => {
const agent = input.agent ?? "unknown"
const startTime = Date.now()
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,6 @@ export type HandlerContext = {
sessionSpanContexts: Map<string, SpanContext>
messageSpans: Map<string, Span>
messageOutputs: Map<string, string>
outboundHeaders: Record<string, string>
outboundEndpoints: string[]
}
99 changes: 99 additions & 0 deletions tests/chat-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, test, expect } from "bun:test"
import { handleChatHeaders, getResolvedURL, matchesEndpoint } from "../src/handlers/chat-headers.ts"
import { makeCtx } from "./helpers.ts"

describe("getResolvedURL", () => {
test("returns provider.options.baseURL when set", () => {
const provider = { options: { baseURL: "https://litellm.example.com" } } as any
const model = { api: { url: "https://api.openai.com" } } as any
expect(getResolvedURL(provider, model)).toBe("https://litellm.example.com")
})

test("falls back to model.api.url when baseURL unset", () => {
expect(getResolvedURL({ options: {} } as any, { api: { url: "https://api.openai.com" } } as any))
.toBe("https://api.openai.com")
})

test("falls back to model.api.url when baseURL is an empty string", () => {
expect(getResolvedURL({ options: { baseURL: "" } } as any, { api: { url: "https://api.openai.com" } } as any))
.toBe("https://api.openai.com")
})

test("returns empty string when both unset", () => {
expect(getResolvedURL({ options: {} } as any, {} as any)).toBe("")
})
})

describe("matchesEndpoint", () => {
test("matches everything when the allowlist is empty", () => {
expect(matchesEndpoint("https://api.openai.com", [])).toBe(true)
})

test("matches by hostname, ignoring path", () => {
expect(matchesEndpoint("https://litellm.example.com/v1/chat", ["https://litellm.example.com"])).toBe(true)
})

test("rejects a non-matching hostname", () => {
expect(matchesEndpoint("https://api.openai.com/v1/chat", ["https://litellm.example.com"])).toBe(false)
})

test("fails closed on an unparseable resolved URL", () => {
expect(matchesEndpoint("not-a-url", ["https://litellm.example.com"])).toBe(false)
})

test("ignores unparseable allowlist entries", () => {
expect(matchesEndpoint("https://litellm.example.com", ["", "https://litellm.example.com"])).toBe(true)
})
})

describe("handleChatHeaders", () => {
const makeInput = (providerOpts: Record<string, any> = {}, api: any = { url: "https://api.openai.com" }) => ({
sessionID: "sess-1",
agent: "test",
model: { api } as any,
provider: { options: providerOpts } as any,
message: { id: "msg-1" } as any,
})
const makeOutput = () => ({ headers: {} as Record<string, string> })

test("injects headers when no endpoint filter is configured", () => {
const { ctx } = makeCtx()
ctx.outboundHeaders = { "x-test": "value" }
const output = makeOutput()
handleChatHeaders(makeInput(), output, ctx)
expect(output.headers["x-test"]).toBe("value")
})

test("injects headers when the endpoint matches", () => {
const { ctx } = makeCtx()
ctx.outboundHeaders = { "x-test": "value" }
ctx.outboundEndpoints = ["https://litellm.example.com"]
const output = makeOutput()
handleChatHeaders(makeInput({ baseURL: "https://litellm.example.com" }), output, ctx)
expect(output.headers["x-test"]).toBe("value")
})

test("skips headers when the endpoint does not match", () => {
const { ctx } = makeCtx()
ctx.outboundHeaders = { "x-test": "value" }
ctx.outboundEndpoints = ["https://litellm.example.com"]
const output = makeOutput()
handleChatHeaders(makeInput(), output, ctx) // defaults to api.openai.com
expect(output.headers["x-test"]).toBeUndefined()
})

test("injects multiple headers", () => {
const { ctx } = makeCtx()
ctx.outboundHeaders = { "x-a": "1", "x-b": "2" }
const output = makeOutput()
handleChatHeaders(makeInput(), output, ctx)
expect(output.headers).toEqual({ "x-a": "1", "x-b": "2" })
})

test("is a no-op when no headers are configured", () => {
const { ctx } = makeCtx()
const output = makeOutput()
handleChatHeaders(makeInput(), output, ctx)
expect(Object.keys(output.headers).length).toBe(0)
})
})
33 changes: 33 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ describe("loadConfig", () => {
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
"OPENCODE_DISABLE_TRACES",
"OPENCODE_OUTBOUND_HEADERS",
"OPENCODE_OUTBOUND_ENDPOINTS",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE",
Expand Down Expand Up @@ -325,6 +327,24 @@ describe("loadConfig", () => {
process.env["OPENCODE_DISABLE_TRACES"] = "1"
expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES))
})

test("outboundHeaders is undefined when unset", () => {
expect(loadConfig().outboundHeaders).toBeUndefined()
})

test("reads OPENCODE_OUTBOUND_HEADERS as a raw string", () => {
process.env["OPENCODE_OUTBOUND_HEADERS"] = "x-enable-phoenix-tracing=true"
expect(loadConfig().outboundHeaders).toBe("x-enable-phoenix-tracing=true")
})

test("outboundEndpoints is empty when unset", () => {
expect(loadConfig().outboundEndpoints).toEqual([])
})

test("parses OPENCODE_OUTBOUND_ENDPOINTS into a trimmed list", () => {
process.env["OPENCODE_OUTBOUND_ENDPOINTS"] = " https://a.example.com , https://b.example.com "
expect(loadConfig().outboundEndpoints).toEqual(["https://a.example.com", "https://b.example.com"])
})
})

describe("loadConfig options", () => {
Expand All @@ -341,6 +361,8 @@ describe("loadConfig options", () => {
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
"OPENCODE_DISABLE_TRACES",
"OPENCODE_OUTBOUND_HEADERS",
"OPENCODE_OUTBOUND_ENDPOINTS",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE",
Expand Down Expand Up @@ -452,6 +474,17 @@ describe("loadConfig options", () => {
expect(disabledTraces).toEqual(new Set(["llm", "tool"]))
})

test("option outboundHeaders overrides the env var", () => {
process.env["OPENCODE_OUTBOUND_HEADERS"] = "x-env=1"
expect(loadConfig({ outboundHeaders: "x-option=1" }).outboundHeaders).toBe("x-option=1")
})

test("option outboundEndpoints trims entries and overrides the env var", () => {
process.env["OPENCODE_OUTBOUND_ENDPOINTS"] = "https://env.example.com"
expect(loadConfig({ outboundEndpoints: [" https://option.example.com "] }).outboundEndpoints)
.toEqual(["https://option.example.com"])
})

test("env values still apply when no options are passed", () => {
process.env["OPENCODE_ENABLE_TELEMETRY"] = "1"
process.env["OPENCODE_OTLP_ENDPOINT"] = "http://env:4317"
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ export function makeCtx(
sessionSpanContexts: new Map(),
messageSpans: new Map(),
messageOutputs: new Map(),
outboundHeaders: {},
outboundEndpoints: [],
}

return {
Expand Down
Loading