From 1b53837c229afe7b9589a6512520272a44728eae Mon Sep 17 00:00:00 2001 From: Joseph Wang Date: Tue, 22 Sep 2026 13:50:34 -0700 Subject: [PATCH] feat(tracing): allow opting out of span content capture Traces currently always export prompts, completions, and tool payloads. Add OPENCODE_DISABLE_TRACE_CONTENT / captureContentInTraces=false so collectors can keep session, llm, and tool spans without that content. Co-authored-by: Cursor --- README.md | 16 +++++++- src/config.ts | 4 ++ src/handlers/message.ts | 51 ++++++++++++++----------- src/handlers/session.ts | 23 ++++++------ src/index.ts | 10 ++++- src/types.ts | 2 + src/util.ts | 19 ++++++++++ tests/config.test.ts | 17 +++++++++ tests/handlers/spans.test.ts | 73 ++++++++++++++++++++++++++++++++++-- tests/helpers.ts | 1 + tests/util.test.ts | 14 ++++++- 11 files changed, 191 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 36ae0a7..3489ed3 100644 --- a/README.md +++ b/README.md @@ -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`. | @@ -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. @@ -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` | @@ -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 diff --git a/src/config.ts b/src/config.ts index 6c235d5..ea32909 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 @@ -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 @@ -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), diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 248228a..cf29028 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -37,6 +37,7 @@ import { isMetricEnabled, isTraceEnabled, resolveSessionTraceContext, + traceContentAttrs, } from "../util.ts" import type { HandlerContext } from "../types.ts" @@ -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, @@ -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 @@ -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, @@ -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, }, }, @@ -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) } @@ -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, }, diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9a952dc..d450db3 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -19,6 +19,7 @@ import { isMetricEnabled, isTraceEnabled, resolveSessionTraceContext, + traceContentAttrs, } from "../util.ts" import type { HandlerContext, SessionAgentType } from "../types.ts" @@ -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, }) @@ -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, diff --git a/src/index.ts b/src/index.ts index 194270d..dbad74e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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", ) } @@ -153,6 +160,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree sessionDiffTotals, disabledMetrics, disabledTraces, + captureContentInTraces: config.captureContentInTraces, tracer, tracePrefix: config.metricPrefix, rootContext, diff --git a/src/types.ts b/src/types.ts index da0e816..1178e76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,8 @@ export type HandlerContext = { sessionDiffTotals: Map disabledMetrics: Set disabledTraces: Set + /** When false, spans omit prompts, completions, and tool payloads. Default true. */ + captureContentInTraces: boolean tracer: Tracer tracePrefix: string rootContext: () => Context diff --git a/src/util.ts b/src/util.ts index 27958a0..5053fca 100644 --- a/src/util.ts +++ b/src/util.ts @@ -84,6 +84,25 @@ export function isTraceEnabled(name: string, ctx: { disabledTraces: Set 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>( + ctx: { captureContentInTraces: boolean }, + attrs: T, +): T | Record { + 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. diff --git a/tests/config.test.ts b/tests/config.test.ts index 5500896..58bcaf1 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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", @@ -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) @@ -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") @@ -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", @@ -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") diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 18851d0..2361829 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -2,7 +2,10 @@ import { describe, test, expect } from "bun:test" import { context, SpanStatusCode, trace, TraceFlags } from "@opentelemetry/api" import { AGENT_NAME, + INPUT_VALUE, + LLM_INPUT_MESSAGES, LLM_MODEL_NAME, + LLM_OUTPUT_MESSAGES, LLM_PROVIDER, LLM_SYSTEM, LLM_TOKEN_COUNT_COMPLETION, @@ -11,9 +14,11 @@ import { LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ, LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE, OpenInferenceSpanKind, + OUTPUT_VALUE, SemanticConventions, SESSION_ID, TOOL_NAME, + TOOL_PARAMETERS, } from "@arizeai/openinference-semantic-conventions" import type { Span } from "@opentelemetry/api" import { handleSessionCreated, handleSessionIdle, handleSessionError, handleRunStarted } from "../../src/handlers/session.ts" @@ -80,19 +85,20 @@ function makeAssistantMessageUpdated(overrides: { function makeToolPartUpdated( status: "running" | "completed" | "error", - overrides: { sessionID?: string; messageID?: string; callID?: string; tool?: string; startMs?: number; endMs?: number; output?: string } = {}, + overrides: { sessionID?: string; messageID?: string; callID?: string; tool?: string; startMs?: number; endMs?: number; output?: string; input?: unknown } = {}, ): EventMessagePartUpdated { const sessionID = overrides.sessionID ?? "ses_1" const messageID = overrides.messageID ?? "msg_1" const callID = overrides.callID ?? "call_1" const start = overrides.startMs ?? 1000 const end = overrides.endMs ?? 2000 + const input = overrides.input ?? { command: "echo hi" } const state = status === "running" - ? { status: "running", time: { start } } + ? { status: "running", time: { start }, input } : status === "completed" - ? { status: "completed", time: { start, end }, output: overrides.output ?? "ok" } - : { status: "error", time: { start, end }, error: "fail" } + ? { status: "completed", time: { start, end }, output: overrides.output ?? "ok", input } + : { status: "error", time: { start, end }, error: "fail", input } return { type: "message.part.updated", properties: { part: { type: "tool", sessionID, messageID, callID, tool: overrides.tool ?? "bash", state } }, @@ -633,3 +639,62 @@ describe("OPENCODE_DISABLE_TRACES=tool", () => { expect(tracer.spans).toHaveLength(0) }) }) + +describe("OPENCODE_DISABLE_TRACE_CONTENT", () => { + test("run span omits prompt payloads but still records the turn", () => { + const { ctx, tracer } = makeCtx() + ctx.captureContentInTraces = false + handleRunStarted("user_1", "ses_1", "build", "secret prompt", "anthropic/claude", 1000, ctx) + expect(tracer.spans).toHaveLength(1) + expect(tracer.spans[0]!.name).toBe("opencode.session") + expect(tracer.spans[0]!.attributes[AGENT_NAME]).toBe("build") + expect(tracer.spans[0]!.attributes[INPUT_VALUE]).toBeUndefined() + expect(tracer.spans[0]!.attributes[LLM_INPUT_MESSAGES]).toBeUndefined() + expect(ctx.runInputs.has("user_1")).toBe(false) + }) + + test("llm span omits prompt and completion payloads", () => { + const { ctx, tracer } = makeCtx() + ctx.captureContentInTraces = false + handleRunStarted("user_1", "ses_1", "build", "secret prompt", "anthropic/claude", 1000, ctx) + startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1100, ctx) + handleMessagePartUpdated({ + type: "message.part.updated", + properties: { part: { type: "text", text: "secret completion", sessionID: "ses_1", messageID: "msg_1" } }, + } as EventMessagePartUpdated, ctx) + handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1" }), ctx) + const llm = tracer.spans.find(s => s.name === "opencode.llm")! + expect(llm.attributes[LLM_MODEL_NAME]).toBe("claude-3-5-sonnet") + expect(llm.attributes[INPUT_VALUE]).toBeUndefined() + expect(llm.attributes[LLM_INPUT_MESSAGES]).toBeUndefined() + expect(llm.attributes[OUTPUT_VALUE]).toBeUndefined() + expect(llm.attributes[LLM_OUTPUT_MESSAGES]).toBeUndefined() + expect(llm.ended).toBe(true) + }) + + test("tool span omits args and results but keeps name, success, and size", () => { + const { ctx, tracer } = makeCtx() + ctx.captureContentInTraces = false + handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1000, input: { command: "cat secret.env" } }), ctx) + handleMessagePartUpdated(makeToolPartUpdated("completed", { output: "secret stdout", endMs: 2000, input: { command: "cat secret.env" } }), ctx) + const span = tracer.spans[0]! + expect(span.attributes[TOOL_NAME]).toBe("bash") + expect(span.attributes[TOOL_PARAMETERS]).toBeUndefined() + expect(span.attributes[INPUT_VALUE]).toBeUndefined() + expect(span.attributes[OUTPUT_VALUE]).toBeUndefined() + expect(span.attributes["tool.success"]).toBe(true) + expect(span.attributes["tool.result_size_bytes"]).toBe(Buffer.byteLength("secret stdout", "utf8")) + expect(span.status.code).toBe(SpanStatusCode.OK) + }) + + test("tool error status does not include the error body", () => { + const { ctx, tracer } = makeCtx() + ctx.captureContentInTraces = false + handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) + handleMessagePartUpdated(makeToolPartUpdated("error"), ctx) + expect(tracer.spans[0]!.attributes[OUTPUT_VALUE]).toBeUndefined() + expect(tracer.spans[0]!.attributes["tool.error"]).toBeUndefined() + expect(tracer.spans[0]!.status.code).toBe(SpanStatusCode.ERROR) + expect(tracer.spans[0]!.status.message).toBe("tool failed") + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index a1594b9..bdae2ea 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -229,6 +229,7 @@ export function makeCtx( sessionDiffTotals: new Map(), disabledMetrics: new Set(disabledMetrics), disabledTraces: new Set(disabledTraces), + captureContentInTraces: true, tracer: tracer as unknown as Tracer, tracePrefix: "opencode.", rootContext: () => ROOT_CONTEXT, diff --git a/tests/util.test.ts b/tests/util.test.ts index 04fada1..3900c70 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { errorSummary, genAiProviderName, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../src/util.ts" +import { errorSummary, genAiProviderName, setBoundedMap, isMetricEnabled, isTraceEnabled, isTraceContentEnabled, traceContentAttrs } from "../src/util.ts" import { MAX_PENDING } from "../src/types.ts" describe("errorSummary", () => { @@ -148,3 +148,15 @@ describe("isTraceEnabled", () => { expect(isTraceEnabled("llm", { disabledTraces: new Set(["does_not_exist"]) })).toBe(true) }) }) + +describe("trace content capture", () => { + test("isTraceContentEnabled follows the context flag", () => { + expect(isTraceContentEnabled({ captureContentInTraces: true })).toBe(true) + expect(isTraceContentEnabled({ captureContentInTraces: false })).toBe(false) + }) + + test("traceContentAttrs returns attrs only when capture is enabled", () => { + expect(traceContentAttrs({ captureContentInTraces: true }, { "input.value": "secret" })).toEqual({ "input.value": "secret" }) + expect(traceContentAttrs({ captureContentInTraces: false }, { "input.value": "secret" })).toEqual({}) + }) +})