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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba
| `OPENCODE_METRIC_PREFIX` | `opencode.` | Prefix for all metric names (e.g. set to `claude_code.` for Claude Code dashboard compatibility) |
| `OPENCODE_DISABLE_METRICS` | *(unset)* | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) |
| `OPENCODE_DISABLE_LOGS` | *(unset)* | Set to any non-empty value to suppress all OTLP log events while leaving metrics and traces unchanged |
| `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | *(unset)* | Set to any non-empty value to include the full prompt text in the `prompt` attribute of `user_prompt` log events. **Log events only** — trace spans always carry the prompt in `input.value` regardless of this flag (disable span-level capture separately via `OPENCODE_DISABLE_TRACES`). **Off by default — prompts may contain secrets or PII; enable only for trusted collectors.** |
| `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | *(unset)* | Set to any non-empty value to include the full prompt text in the `prompt` attribute of `user_prompt` log events. **Log events only.** Off by default. |
| `OPENCODE_DISABLE_TRACE_CONTENT` | *(unset)* | Set to any non-empty value to omit prompts, completions, and tool payloads from **trace spans** (`input.value`, `output.value`, `llm.input_messages`, `llm.output_messages`, `tool.parameters`). Spans, metrics, and token/cost attributes are still exported. Content capture on traces is **on by default**. |
| `OPENCODE_DISABLE_TRACES` | *(unset)* | Comma-separated list of trace types to disable (`session`, `llm`, `tool`). Use `all`, `*`, `true`, or `1` to disable every trace type |
| `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`. |
Expand All @@ -113,6 +114,8 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba

Prompt logging remains disabled by default. Enable it only when the configured telemetry destination is trusted to receive potentially sensitive prompt contents.

Trace spans capture prompts, completions, and tool payloads by default. Set `OPENCODE_DISABLE_TRACE_CONTENT=1` (or `"captureContentInTraces": false` in plugin options) to keep traces without that content.

### Plugin options (opencode.json)

Every setting can also be passed inline through opencode's plugin **tuple form**, so nothing has to be exported in a shell. Options take precedence over the matching `OPENCODE_*` environment variable, which in turn wins over the built-in default.
Expand Down Expand Up @@ -140,6 +143,7 @@ Option keys mirror the resolved config and map to the environment variables:
| `enabled` | `OPENCODE_ENABLE_TELEMETRY` |
| `logsEnabled` | `OPENCODE_DISABLE_LOGS` (inverted) |
| `capturePromptInLogs` | `OPENCODE_CAPTURE_PROMPT_IN_LOGS` |
| `captureContentInTraces` | `OPENCODE_DISABLE_TRACE_CONTENT` (inverted) |
| `endpoint` | `OPENCODE_OTLP_ENDPOINT` |
| `protocol` | `OPENCODE_OTLP_PROTOCOL` |
| `metricsInterval` | `OPENCODE_OTLP_METRICS_INTERVAL` |
Expand Down Expand Up @@ -290,6 +294,16 @@ export OPENCODE_DISABLE_TRACES="all"

Accepted explicit "disable all traces" values are `all`, `*`, `true`, and `1`.

### Disabling trace content (prompts, completions, tool payloads)

Use `OPENCODE_DISABLE_TRACE_CONTENT` to keep session/llm/tool spans while omitting OpenInference payload attributes. Token counts, cost, duration, model, and tool name still appear.

```bash
export OPENCODE_DISABLE_TRACE_CONTENT=1
```

This does not change OTLP logs. `OPENCODE_CAPTURE_PROMPT_IN_LOGS` remains a separate opt-in for the `user_prompt` log event.

### SigNoz example

```bash
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type PluginConfig = {
enabled: boolean
logsEnabled: boolean
capturePromptInLogs: boolean
captureContentInTraces: boolean
endpoint: string
protocol: "grpc" | "http/protobuf" | "http/json"
metricsInterval: number
Expand Down Expand Up @@ -58,6 +59,7 @@ export type OtelPluginOptions = {
enabled?: boolean
logsEnabled?: boolean
capturePromptInLogs?: boolean
captureContentInTraces?: boolean
endpoint?: string
protocol?: "grpc" | "http/protobuf" | "http/json"
metricsInterval?: number
Expand Down Expand Up @@ -197,6 +199,8 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"),
logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"),
capturePromptInLogs: pickBoolean(resolvedOptions.capturePromptInLogs) ?? hasNonEmptyEnv("OPENCODE_CAPTURE_PROMPT_IN_LOGS"),
captureContentInTraces: pickBoolean(resolvedOptions.captureContentInTraces)
?? !hasNonEmptyEnv("OPENCODE_DISABLE_TRACE_CONTENT"),
endpoint: pickString(resolvedOptions.endpoint) ?? process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317",
protocol,
metricsInterval: pickPositiveInt(resolvedOptions.metricsInterval) ?? parseEnvInt("OPENCODE_OTLP_METRICS_INTERVAL", 60000),
Expand Down
51 changes: 30 additions & 21 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isMetricEnabled,
isTraceEnabled,
resolveSessionTraceContext,
traceContentAttrs,
} from "../util.ts"
import type { HandlerContext } from "../types.ts"

Expand Down Expand Up @@ -133,11 +134,11 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
[LLM_FINISH_REASON]: assistant.error ? "error" : (assistant.finish ?? "stop"),
[LLM_COST_TOTAL]: assistant.cost,
...(outputText
? {
[OUTPUT_VALUE]: outputText,
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_OUTPUT_MESSAGES]: JSON.stringify([{ role: "assistant", content: outputText }]),
}
? traceContentAttrs(ctx, {
[OUTPUT_VALUE]: outputText,
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_OUTPUT_MESSAGES]: JSON.stringify([{ role: "assistant", content: outputText }]),
})
: {}),
cost_usd: assistant.cost,
duration_ms: duration,
Expand Down Expand Up @@ -235,6 +236,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
const part = e.properties.part

if (part.type === "text") {
if (!ctx.captureContentInTraces) return
const key = `${part.sessionID}:${part.messageID}`
ctx.messageOutputs.set(key, `${ctx.messageOutputs.get(key) ?? ""}${part.text}`)
return
Expand Down Expand Up @@ -290,9 +292,11 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
[SESSION_ID]: toolPart.sessionID,
[TOOL_ID]: toolPart.callID,
[TOOL_NAME]: toolPart.tool,
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
[INPUT_MIME_TYPE]: MimeType.JSON,
...traceContentAttrs(ctx, {
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
[INPUT_MIME_TYPE]: MimeType.JSON,
}),
[AGENT_NAME]: agentName,
"agent.type": agentType,
...ctx.commonAttrs,
Expand Down Expand Up @@ -346,9 +350,11 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
[SESSION_ID]: toolPart.sessionID,
[TOOL_ID]: toolPart.callID,
[TOOL_NAME]: toolPart.tool,
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
[INPUT_MIME_TYPE]: MimeType.JSON,
...traceContentAttrs(ctx, {
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
[INPUT_MIME_TYPE]: MimeType.JSON,
}),
...ctx.commonAttrs,
},
},
Expand All @@ -361,20 +367,23 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
toolSpan.setAttribute("tool.success", success)
if (success) {
const output = (toolPart.state as { output: string }).output
toolSpan.setAttributes({
toolSpan.setAttributes(traceContentAttrs(ctx, {
[OUTPUT_VALUE]: output,
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
})
}))
toolSpan.setAttribute("tool.result_size_bytes", Buffer.byteLength(output, "utf8"))
toolSpan.setStatus({ code: SpanStatusCode.OK })
} else {
const err = (toolPart.state as { error: string }).error
toolSpan.setAttributes({
toolSpan.setAttributes(traceContentAttrs(ctx, {
[OUTPUT_VALUE]: err,
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
"tool.error": err,
}))
toolSpan.setStatus({
code: SpanStatusCode.ERROR,
message: ctx.captureContentInTraces ? err : "tool failed",
})
toolSpan.setAttribute("tool.error", err)
toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: err })
}
toolSpan.end(end)
}
Expand Down Expand Up @@ -454,12 +463,12 @@ export function startMessageSpan(
[LLM_PROVIDER]: providerID,
"gen_ai.provider.name": genAiProviderName(providerID),
[LLM_MODEL_NAME]: modelID,
...(inputText
...traceContentAttrs(ctx, inputText
? {
[INPUT_VALUE]: inputText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: inputText }]),
}
[INPUT_VALUE]: inputText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: inputText }]),
}
: {}),
...ctx.commonAttrs,
},
Expand Down
23 changes: 12 additions & 11 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isMetricEnabled,
isTraceEnabled,
resolveSessionTraceContext,
traceContentAttrs,
} from "../util.ts"
import type { HandlerContext, SessionAgentType } from "../types.ts"

Expand All @@ -36,18 +37,18 @@ export function handleRunStarted(
) {
ctx.activeRuns.set(sessionID, runID)
ctx.pendingRuns.delete(sessionID)
if (promptText) setBoundedMap(ctx.runInputs, runID, promptText)
if (promptText && ctx.captureContentInTraces) setBoundedMap(ctx.runInputs, runID, promptText)
if (!isTraceEnabled("session", ctx)) return
const existing = ctx.runSpans.get(runID)
if (existing) {
existing.setAttributes({
[AGENT_NAME]: agent,
...(promptText
...traceContentAttrs(ctx, promptText
? {
[INPUT_VALUE]: promptText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]),
}
[INPUT_VALUE]: promptText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]),
}
: {}),
model,
})
Expand All @@ -64,12 +65,12 @@ export function handleRunStarted(
[AGENT_NAME]: agent,
"agent.type": "primary",
"session.is_subagent": false,
...(promptText
...traceContentAttrs(ctx, promptText
? {
[INPUT_VALUE]: promptText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]),
}
[INPUT_VALUE]: promptText,
[INPUT_MIME_TYPE]: MimeType.TEXT,
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]),
}
: {}),
model,
...ctx.commonAttrs,
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,14 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
if (config.capturePromptInLogs) {
await log(
"info",
"prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events (spans always carry the prompt regardless)",
"prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events",
)
}

if (!config.captureContentInTraces) {
await log(
"info",
"trace content capture disabled - spans omit prompts, completions, and tool payloads",
)
}

Expand All @@ -153,6 +160,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
sessionDiffTotals,
disabledMetrics,
disabledTraces,
captureContentInTraces: config.captureContentInTraces,
tracer,
tracePrefix: config.metricPrefix,
rootContext,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export type HandlerContext = {
sessionDiffTotals: Map<string, { additions: number; deletions: number }>
disabledMetrics: Set<string>
disabledTraces: Set<string>
/** When false, spans omit prompts, completions, and tool payloads. Default true. */
captureContentInTraces: boolean
tracer: Tracer
tracePrefix: string
rootContext: () => Context
Expand Down
19 changes: 19 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ export function isTraceEnabled(name: string, ctx: { disabledTraces: Set<string>
return !ctx.disabledTraces.has(name)
}

/**
* Returns `true` when span payloads (prompts, completions, tool args/results) should be exported.
* Independent of {@link isTraceEnabled}: traces can still be emitted without content.
*/
export function isTraceContentEnabled(ctx: { captureContentInTraces: boolean }): boolean {
return ctx.captureContentInTraces
}

/**
* Returns `attrs` when span content capture is enabled, otherwise an empty object.
* Use for OpenInference `input.*` / `output.*`, `llm.*_messages`, and `tool.parameters`.
*/
export function traceContentAttrs<T extends Record<string, unknown>>(
ctx: { captureContentInTraces: boolean },
attrs: T,
): T | Record<string, never> {
return isTraceContentEnabled(ctx) ? attrs : {}
}

/**
* Accumulates token and cost totals for a session, and increments the message count.
* Uses `setBoundedMap` to produce a new object rather than mutating in-place.
Expand Down
17 changes: 17 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe("loadConfig", () => {
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
"OPENCODE_CAPTURE_PROMPT_IN_LOGS",
"OPENCODE_DISABLE_TRACE_CONTENT",
"OPENCODE_DISABLE_TRACES",
"OPENCODE_TRACE_PROPAGATION_PROVIDERS",
"OTEL_EXPORTER_OTLP_HEADERS",
Expand All @@ -89,6 +90,7 @@ describe("loadConfig", () => {
expect(cfg.enabled).toBe(false)
expect(cfg.logsEnabled).toBe(true)
expect(cfg.capturePromptInLogs).toBe(false)
expect(cfg.captureContentInTraces).toBe(true)
expect(cfg.endpoint).toBe("http://localhost:4317")
expect(cfg.protocol).toBe("grpc")
expect(cfg.metricsInterval).toBe(60000)
Expand All @@ -110,6 +112,11 @@ describe("loadConfig", () => {
expect(loadConfig().capturePromptInLogs).toBe(true)
})

test("captureContentInTraces is false when OPENCODE_DISABLE_TRACE_CONTENT is set", () => {
process.env["OPENCODE_DISABLE_TRACE_CONTENT"] = "1"
expect(loadConfig().captureContentInTraces).toBe(false)
})

test("reads custom endpoint", () => {
process.env["OPENCODE_OTLP_ENDPOINT"] = "http://collector:4317"
expect(loadConfig().endpoint).toBe("http://collector:4317")
Expand Down Expand Up @@ -357,6 +364,7 @@ describe("loadConfig options", () => {
"OPENCODE_OTLP_METRICS_TEMPORALITY",
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
"OPENCODE_DISABLE_TRACE_CONTENT",
"OPENCODE_DISABLE_TRACES",
"OPENCODE_TRACE_PROPAGATION_PROVIDERS",
"OTEL_EXPORTER_OTLP_HEADERS",
Expand All @@ -379,6 +387,15 @@ describe("loadConfig options", () => {
expect(loadConfig({ logsEnabled: false }).logsEnabled).toBe(false)
})

test("option captureContentInTraces:false disables span payloads", () => {
expect(loadConfig({ captureContentInTraces: false }).captureContentInTraces).toBe(false)
})

test("option captureContentInTraces:true overrides DISABLE env", () => {
process.env["OPENCODE_DISABLE_TRACE_CONTENT"] = "1"
expect(loadConfig({ captureContentInTraces: true }).captureContentInTraces).toBe(true)
})

test("option endpoint overrides env var", () => {
process.env["OPENCODE_OTLP_ENDPOINT"] = "http://from-env:4317"
expect(loadConfig({ endpoint: "http://from-option:4317" }).endpoint).toBe("http://from-option:4317")
Expand Down
Loading
Loading