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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemetry (OTLP over gRPC or HTTP/protobuf), mirroring the same signals as [Claude Code's monitoring](https://code.claude.com/docs/en/monitoring-usage).

- [OpenCode V2 support](#opencode-v2-support)
- [What it instruments](#what-it-instruments)
- [Metrics](#metrics)
- [Log events](#log-events)
Expand All @@ -29,6 +30,23 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
- [Local development](#local-development)
- [GitHub Discord notifications](#github-discord-notifications)

## OpenCode V2 support

This plugin supports **OpenCode V1 and V2 from one package**:

- **V1** uses the named `OtelPlugin` export.
- **V2** uses the default export (`id: devtheops.otel`, `setup()`), which reads V2's granular
event stream — `session.step.*`, `session.tool.*`, `session.usage.*`, `session.execution.*`,
and `session.retry.scheduled`.

V2 support covers session, LLM-step and tool spans, token/cost/cache metrics, the retry
counter, execution-failure handling, and `model.request` trace-context injection. The V1
handlers for `message.updated`, `message.part.updated`, `permission.*`, `command.executed`,
and `session.diff` have no V2 equivalent and are not ported.

V2 additionally supports optional prompt capture (`capturePromptInLogs`) and best-effort
secret redaction (`redactSecrets`, `redactValues`) — see the plugin options below.

## What it instruments

### Metrics
Expand Down
24 changes: 24 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type PluginConfig = {
disabledMetrics: Set<string>
disabledTraces: Set<string>
tracePropagationProviders: Set<string>
redactSecrets: boolean
redactValues: string[]
}

export function parseAttributePairs(raw: string | undefined): Record<string, string> {
Expand Down Expand Up @@ -73,6 +75,8 @@ export type OtelPluginOptions = {
disabledMetrics?: string[]
disabledTraces?: string[]
tracePropagationProviders?: string[]
redactSecrets?: boolean
redactValues?: string[]
}

const VALID_PROTOCOLS = new Set<PluginConfig["protocol"]>(["grpc", "http/protobuf", "http/json"])
Expand Down Expand Up @@ -212,9 +216,29 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
disabledMetrics,
disabledTraces,
tracePropagationProviders,
redactSecrets: pickBoolean(resolvedOptions.redactSecrets) ?? !hasNonEmptyEnv("OPENCODE_NO_REDACT"),
redactValues: collectRedactValues(resolvedOptions.redactValues),
}
}

/**
* Exact values to mask verbatim: any configured `redactValues` plus the values of
* secret-looking environment variables (e.g. `LOGFIRE_TOKEN`, `*_API_KEY`). Short values
* are ignored so common words are not over-redacted.
*/
function collectRedactValues(configured: string[] | undefined): string[] {
const values = new Set<string>()
for (const value of configured ?? []) {
if (typeof value === "string" && value.length >= 6) values.add(value)
}
const secretEnv = /(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CLIENT_?SECRET|CREDENTIAL)/i
for (const [key, value] of Object.entries(process.env)) {
if (!value || value.length < 6) continue
if (secretEnv.test(key)) values.add(value)
}
return [...values]
}

export function resolveHelperPath(
helper: string | undefined,
directory: string | undefined,
Expand Down
37 changes: 20 additions & 17 deletions src/headers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRequire } from "module"
import { execFile } from "node:child_process"
import { ExportResultCode, type ExportResult } from "@opentelemetry/core"
import type { PushMetricExporter, ResourceMetrics } from "@opentelemetry/sdk-metrics"
import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"
Expand Down Expand Up @@ -89,24 +90,26 @@ export class DynamicHeaders {
}

private async runHelper(): Promise<HeadersMap> {
const proc = Bun.spawn([this.helper!], {
stdout: "pipe",
stderr: "pipe",
timeout: this.helperTimeoutMs,
killSignal: "SIGTERM",
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
this.helper!,
[],
{ timeout: this.helperTimeoutMs, killSignal: "SIGTERM", maxBuffer: 1024 * 1024 },
(error, out, err) => {
if (error) {
const signal = (error as NodeJS.ErrnoException & { signal?: string | null }).signal
if (signal) {
reject(new Error(`OTLP headers helper was terminated by ${signal}`))
return
}
const detail = (err ?? "").trim() || error.message
reject(new Error(`OTLP headers helper failed: ${detail}`))
return
}
resolve(out)
},
)
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (proc.signalCode) {
throw new Error(`OTLP headers helper was terminated by ${proc.signalCode}`)
}
if (exitCode !== 0) {
const detail = stderr.trim() || `exit code ${exitCode}`
throw new Error(`OTLP headers helper failed: ${detail}`)
}
const parsed = JSON.parse(stdout) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("OTLP headers helper must return a JSON object")
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}),
}
}

// OpenCode V2 entrypoint. V2 reads the default export's `id` and `setup()`; V1 keeps using
// the named `OtelPlugin` export above. See src/v2/index.ts.
export { default } from "./v2/index.ts"
37 changes: 37 additions & 0 deletions src/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Masks credential-shaped substrings in captured text. Intentionally conservative:
// it targets known token formats and secret-looking key/value pairs, and leaves normal
// prompt/tool text untouched.

const REPLACEMENT = "[REDACTED]"

/** `[pattern, replacement]` pairs applied in order. */
const RULES: ReadonlyArray<readonly [RegExp, string]> = [
// Authorization headers / bearer tokens.
[/(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s"',;]+/gi, `$1${REPLACEMENT}`],
[/\bbearer\s+[A-Za-z0-9._\-]+/gi, `Bearer ${REPLACEMENT}`],
// Known token prefixes.
[/\bsk-[A-Za-z0-9_\-]{16,}\b/g, REPLACEMENT], // OpenAI-style
[/\bpylf_v\d+_[A-Za-z0-9._\-]+/g, REPLACEMENT], // Logfire
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, REPLACEMENT], // GitHub
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, REPLACEMENT], // GitHub fine-grained PAT
[/\bAKIA[0-9A-Z]{16}\b/g, REPLACEMENT], // AWS access key id
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, REPLACEMENT], // Slack
[/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}/g, REPLACEMENT], // JWT
// Secret-looking key/value pairs (ENV=..., "password": "...", api_key: ...).
[
/([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET)[A-Za-z0-9_]*\s*[:=]\s*)(["']?)([^\s"',;]+)\2/gi,
`$1$2${REPLACEMENT}$2`,
],
]

/** Returns `text` with credential-shaped substrings replaced by `[REDACTED]`. */
export function redactSecrets(text: string, literals: readonly string[] = []): string {
if (!text) return text
let out = text
// Exact known values first (handles opaque tokens with no recognisable shape).
for (const literal of literals) {
if (literal && literal.length >= 6) out = out.split(literal).join(REPLACEMENT)
}
for (const [pattern, replacement] of RULES) out = out.replace(pattern, replacement)
return out
}
28 changes: 28 additions & 0 deletions src/v2/handlers/chat-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { HandlerContext } from "../types.ts"
import { injectTraceContext } from "../../trace-context.ts"

/** Injects the matching LLM step span context for explicitly enabled providers. */
export function handleModelRequest(
event: {
sessionID: string
agent: string
model: { id: string; providerID: string }
headers: Record<string, string>
},
ctx: HandlerContext,
): void {
const providerID = event.model.providerID
if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return

const request = ctx.llmRequestContexts
.get(event.sessionID)
?.findLast(
(candidate) =>
candidate.agent === event.agent &&
candidate.modelID === event.model.id &&
candidate.providerID === providerID,
)
if (!request) return

injectTraceContext(request.spanContext, event.headers)
}
Loading
Loading