Skip to content

Commit 198a2c5

Browse files
Merge pull request #39 from DEVtheOPS/feat/dynamic-otlp-headers
2 parents 1c50308 + 2810d31 commit 198a2c5

7 files changed

Lines changed: 485 additions & 9 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
1515
- [Configuration](#configuration)
1616
- [Quick start](#quick-start)
1717
- [Headers and resource attributes](#headers-and-resource-attributes)
18+
- [Dynamic headers](#dynamic-headers)
1819
- [Disabling specific metrics](#disabling-specific-metrics)
1920
- [Datadog example](#datadog-example)
2021
- [Honeycomb example](#honeycomb-example)
@@ -89,6 +90,7 @@ All configuration is via environment variables. Set them in your shell profile (
8990
| `OPENCODE_METRIC_PREFIX` | `opencode.` | Prefix for all metric names (e.g. set to `claude_code.` for Claude Code dashboard compatibility) |
9091
| `OPENCODE_DISABLE_METRICS` | _(unset)_ | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) |
9192
| `OPENCODE_OTLP_HEADERS` | _(unset)_ | Comma-separated `key=value` headers added to all OTLP exports. **Keep out of version control — may contain sensitive auth tokens.** |
93+
| `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`. |
9294
| `OPENCODE_RESOURCE_ATTRIBUTES` | _(unset)_ | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` |
9395
| `OPENCODE_OTLP_METRICS_TEMPORALITY` | _(unset)_ | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. |
9496

@@ -115,6 +117,25 @@ export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environmen
115117

116118
> **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.
117119
120+
### Dynamic headers
121+
122+
Use `OPENCODE_OTLP_HEADERS_HELPER` when your collector requires short-lived authentication tokens. The helper is run only after an OTLP export fails with an authentication error (`401`/`403` for HTTP or `UNAUTHENTICATED`/`PERMISSION_DENIED` for gRPC). The plugin refreshes headers, rebuilds the exporter, and retries the failed export once.
123+
124+
```bash
125+
export OPENCODE_OTLP_HEADERS_HELPER=/path/to/opencode-otel-headers.sh
126+
```
127+
128+
The helper must be executable and print a JSON object to stdout:
129+
130+
```bash
131+
#!/bin/sh
132+
printf '{"Authorization":"Bearer %s"}' "$(get-token.sh)"
133+
```
134+
135+
For a Cloud Run collector using IAM authentication, `get-token.sh` might be `gcloud auth print-identity-token`.
136+
137+
If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static headers with the same name. Header values are never logged.
138+
118139
### Disabling specific metrics
119140

120141
Use `OPENCODE_DISABLE_METRICS` to suppress individual metrics. The value is a comma-separated list of metric name suffixes (without the prefix).

src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type PluginConfig = {
1414
logsInterval: number
1515
metricPrefix: string
1616
otlpHeaders: string | undefined
17+
otlpHeadersHelper: string | undefined
1718
resourceAttributes: string | undefined
1819
metricsTemporality: MetricsTemporality | undefined
1920
disabledMetrics: Set<string>
@@ -38,6 +39,7 @@ export function parseEnvInt(key: string, fallback: number): number {
3839
*/
3940
export function loadConfig(): PluginConfig {
4041
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
42+
const otlpHeadersHelper = process.env["OPENCODE_OTLP_HEADERS_HELPER"]
4143
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]
4244
const rawTemporality = process.env["OPENCODE_OTLP_METRICS_TEMPORALITY"]
4345
const protocol = process.env["OPENCODE_OTLP_PROTOCOL"]
@@ -81,6 +83,7 @@ export function loadConfig(): PluginConfig {
8183
logsInterval: parseEnvInt("OPENCODE_OTLP_LOGS_INTERVAL", 5000),
8284
metricPrefix: process.env["OPENCODE_METRIC_PREFIX"] ?? "opencode.",
8385
otlpHeaders,
86+
otlpHeadersHelper,
8487
resourceAttributes,
8588
metricsTemporality,
8689
disabledMetrics,

src/headers.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { createRequire } from "module"
2+
import { ExportResultCode, type ExportResult } from "@opentelemetry/core"
3+
import type { PushMetricExporter, ResourceMetrics } from "@opentelemetry/sdk-metrics"
4+
import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"
5+
import type { LogRecordExporter, ReadableLogRecord } from "@opentelemetry/sdk-logs"
6+
import type { Metadata } from "@grpc/grpc-js"
7+
8+
const require = createRequire(import.meta.url)
9+
const DEFAULT_HELPER_TIMEOUT_MS = 5000
10+
11+
type Exporter<T> = {
12+
export(items: T, resultCallback: (result: ExportResult) => void): void
13+
shutdown(): Promise<void>
14+
forceFlush?(): Promise<void>
15+
}
16+
17+
type SelectAggregation = NonNullable<PushMetricExporter["selectAggregation"]>
18+
type SelectAggregationTemporality = NonNullable<PushMetricExporter["selectAggregationTemporality"]>
19+
20+
export type HeadersMap = Record<string, string>
21+
22+
export function parseOtlpHeaders(raw: string | undefined): HeadersMap {
23+
if (!raw) return {}
24+
const headers: HeadersMap = {}
25+
for (const pair of raw.split(",")) {
26+
const idx = pair.indexOf("=")
27+
if (idx > 0) {
28+
const key = pair.slice(0, idx).trim()
29+
const val = pair.slice(idx + 1).trim()
30+
if (key) headers[key] = val
31+
}
32+
}
33+
return headers
34+
}
35+
36+
export function createGrpcMetadata(headers: HeadersMap): Metadata {
37+
const { Metadata } = require("@grpc/grpc-js") as typeof import("@grpc/grpc-js")
38+
const metadata = new Metadata()
39+
for (const [key, value] of Object.entries(headers)) metadata.set(key, value)
40+
return metadata
41+
}
42+
43+
export function isAuthFailure(error: unknown): boolean {
44+
if (!error) return false
45+
const err = error as Record<string, unknown>
46+
const numericStatus = Number(err["status"] ?? err["statusCode"] ?? err["code"])
47+
if ([401, 403, 7, 16].includes(numericStatus)) return true
48+
const message = error instanceof Error ? error.message : String(error)
49+
return /\b(401|403)\b|unauthenticated|permission denied|unauthorized|forbidden/i.test(message)
50+
}
51+
52+
export class DynamicHeaders {
53+
private headers: HeadersMap
54+
private version = 0
55+
private refreshPromise: Promise<number> | undefined
56+
57+
constructor(
58+
private readonly staticHeaders: HeadersMap,
59+
private readonly helper: string | undefined,
60+
private readonly helperTimeoutMs = DEFAULT_HELPER_TIMEOUT_MS,
61+
) {
62+
this.headers = { ...staticHeaders }
63+
}
64+
65+
current(): HeadersMap {
66+
return { ...this.headers }
67+
}
68+
69+
currentVersion(): number {
70+
return this.version
71+
}
72+
73+
refresh(): Promise<number> {
74+
if (!this.helper) return Promise.resolve(this.version)
75+
if (!this.refreshPromise) {
76+
this.refreshPromise = this.runHelper()
77+
.then((helperHeaders) => {
78+
const next = { ...this.staticHeaders, ...helperHeaders }
79+
const changed = headerSignature(next) !== headerSignature(this.headers)
80+
this.headers = next
81+
if (changed) this.version += 1
82+
return this.version
83+
})
84+
.finally(() => {
85+
this.refreshPromise = undefined
86+
})
87+
}
88+
return this.refreshPromise
89+
}
90+
91+
private async runHelper(): Promise<HeadersMap> {
92+
const proc = Bun.spawn([this.helper!], {
93+
stdout: "pipe",
94+
stderr: "pipe",
95+
timeout: this.helperTimeoutMs,
96+
killSignal: "SIGTERM",
97+
})
98+
const [stdout, stderr, exitCode] = await Promise.all([
99+
new Response(proc.stdout).text(),
100+
new Response(proc.stderr).text(),
101+
proc.exited,
102+
])
103+
if (proc.signalCode) {
104+
throw new Error(`OTLP headers helper was terminated by ${proc.signalCode}`)
105+
}
106+
if (exitCode !== 0) {
107+
const detail = stderr.trim() || `exit code ${exitCode}`
108+
throw new Error(`OTLP headers helper failed: ${detail}`)
109+
}
110+
const parsed = JSON.parse(stdout) as unknown
111+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
112+
throw new Error("OTLP headers helper must return a JSON object")
113+
}
114+
const headers: HeadersMap = {}
115+
for (const [key, value] of Object.entries(parsed)) {
116+
if (typeof value !== "string") throw new Error(`OTLP headers helper returned non-string value for ${key}`)
117+
headers[key] = value
118+
}
119+
return headers
120+
}
121+
}
122+
123+
export class RefreshingMetricExporter implements PushMetricExporter {
124+
private exporter: PushMetricExporter
125+
private headersVersion: number
126+
127+
constructor(
128+
private readonly createExporter: (headers: HeadersMap) => PushMetricExporter,
129+
private readonly dynamicHeaders: DynamicHeaders,
130+
) {
131+
this.exporter = createExporter(dynamicHeaders.current())
132+
this.headersVersion = dynamicHeaders.currentVersion()
133+
}
134+
135+
export(metrics: ResourceMetrics, resultCallback: (result: ExportResult) => void): void {
136+
exportWithAuthRetry(this, metrics, resultCallback)
137+
}
138+
139+
forceFlush(): Promise<void> {
140+
return this.exporter.forceFlush()
141+
}
142+
143+
shutdown(): Promise<void> {
144+
return this.exporter.shutdown()
145+
}
146+
147+
selectAggregationTemporality(
148+
instrumentType: Parameters<SelectAggregationTemporality>[0],
149+
): ReturnType<SelectAggregationTemporality> {
150+
return this.exporter.selectAggregationTemporality!(instrumentType)
151+
}
152+
153+
selectAggregation(instrumentType: Parameters<SelectAggregation>[0]): ReturnType<SelectAggregation> {
154+
return this.exporter.selectAggregation!(instrumentType)
155+
}
156+
157+
_exporter(): PushMetricExporter {
158+
return this.exporter
159+
}
160+
161+
_replaceExporter(version: number): void {
162+
const old = this.exporter
163+
this.exporter = this.createExporter(this.dynamicHeaders.current())
164+
this.headersVersion = version
165+
old.shutdown().catch(() => {})
166+
}
167+
168+
_refreshHeaders(): Promise<number> {
169+
return this.dynamicHeaders.refresh()
170+
}
171+
172+
_headersVersion(): number {
173+
return this.headersVersion
174+
}
175+
}
176+
177+
export class RefreshingSpanExporter implements SpanExporter {
178+
private exporter: SpanExporter
179+
private headersVersion: number
180+
181+
constructor(
182+
private readonly createExporter: (headers: HeadersMap) => SpanExporter,
183+
private readonly dynamicHeaders: DynamicHeaders,
184+
) {
185+
this.exporter = createExporter(dynamicHeaders.current())
186+
this.headersVersion = dynamicHeaders.currentVersion()
187+
}
188+
189+
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
190+
exportWithAuthRetry(this, spans, resultCallback)
191+
}
192+
193+
forceFlush(): Promise<void> {
194+
return this.exporter.forceFlush?.() ?? Promise.resolve()
195+
}
196+
197+
shutdown(): Promise<void> {
198+
return this.exporter.shutdown()
199+
}
200+
201+
_exporter(): SpanExporter {
202+
return this.exporter
203+
}
204+
205+
_replaceExporter(version: number): void {
206+
const old = this.exporter
207+
this.exporter = this.createExporter(this.dynamicHeaders.current())
208+
this.headersVersion = version
209+
old.shutdown().catch(() => {})
210+
}
211+
212+
_refreshHeaders(): Promise<number> {
213+
return this.dynamicHeaders.refresh()
214+
}
215+
216+
_headersVersion(): number {
217+
return this.headersVersion
218+
}
219+
}
220+
221+
export class RefreshingLogExporter implements LogRecordExporter {
222+
private exporter: LogRecordExporter
223+
private headersVersion: number
224+
225+
constructor(
226+
private readonly createExporter: (headers: HeadersMap) => LogRecordExporter,
227+
private readonly dynamicHeaders: DynamicHeaders,
228+
) {
229+
this.exporter = createExporter(dynamicHeaders.current())
230+
this.headersVersion = dynamicHeaders.currentVersion()
231+
}
232+
233+
export(logs: ReadableLogRecord[], resultCallback: (result: ExportResult) => void): void {
234+
exportWithAuthRetry(this, logs, resultCallback)
235+
}
236+
237+
shutdown(): Promise<void> {
238+
return this.exporter.shutdown()
239+
}
240+
241+
_exporter(): LogRecordExporter {
242+
return this.exporter
243+
}
244+
245+
_replaceExporter(version: number): void {
246+
const old = this.exporter
247+
this.exporter = this.createExporter(this.dynamicHeaders.current())
248+
this.headersVersion = version
249+
old.shutdown().catch(() => {})
250+
}
251+
252+
_refreshHeaders(): Promise<number> {
253+
return this.dynamicHeaders.refresh()
254+
}
255+
256+
_headersVersion(): number {
257+
return this.headersVersion
258+
}
259+
}
260+
261+
function exportWithAuthRetry<T>(
262+
wrapper: {
263+
_exporter(): Exporter<T>
264+
_replaceExporter(version: number): void
265+
_refreshHeaders(): Promise<number>
266+
_headersVersion(): number
267+
},
268+
items: T,
269+
resultCallback: (result: ExportResult) => void,
270+
): void {
271+
wrapper._exporter().export(items, (result) => {
272+
if (result.code !== ExportResultCode.FAILED || !isAuthFailure(result.error)) {
273+
resultCallback(result)
274+
return
275+
}
276+
wrapper._refreshHeaders()
277+
.then((version) => {
278+
if (version !== wrapper._headersVersion()) wrapper._replaceExporter(version)
279+
wrapper._exporter().export(items, resultCallback)
280+
})
281+
.catch((error: unknown) => {
282+
resultCallback({ code: ExportResultCode.FAILED, error: error instanceof Error ? error : new Error(String(error)) })
283+
})
284+
})
285+
}
286+
287+
function headerSignature(headers: HeadersMap): string {
288+
return JSON.stringify(Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)))
289+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
5353
metricsInterval: config.metricsInterval,
5454
logsInterval: config.logsInterval,
5555
metricPrefix: config.metricPrefix,
56+
headersHelperSet: !!config.otlpHeadersHelper,
5657
})
5758

5859
await log("debug", "config loaded", {
5960
headersSet: !!config.otlpHeaders,
61+
headersHelperSet: !!config.otlpHeadersHelper,
6062
resourceAttributesSet: !!config.resourceAttributes,
6163
})
6264

@@ -76,6 +78,8 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
7678
config.metricsInterval,
7779
config.logsInterval,
7880
PLUGIN_VERSION,
81+
config.otlpHeaders,
82+
config.otlpHeadersHelper,
7983
)
8084
await log("info", "OTel SDK initialized")
8185

0 commit comments

Comments
 (0)