diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 30b51a3..ee8ab05 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -40,9 +40,15 @@ "type": "string", "sensitive": false }, - "OMIT_IO": { - "title": "Omit Prompts & Tool I/O", - "description": "When true (default), omits prompt content and tool call inputs/outputs. Set to false to include full content.", + "OMIT_PROMPTS": { + "title": "Omit Prompts & Assistant Responses", + "description": "When true (default), omits user prompt and assistant response content. Set to false to include full content.", + "type": "string", + "sensitive": false + }, + "OMIT_TOOL_IO": { + "title": "Omit Tool Call I/O", + "description": "When true (default), omits tool call inputs and responses. Set to false to include full content.", "type": "string", "sensitive": false }, diff --git a/README.md b/README.md index 53f6f00..e6cff66 100644 --- a/README.md +++ b/README.md @@ -163,11 +163,11 @@ The plugin emits OpenTelemetry spans following [GenAI semantic conventions](http |---|---| | `gen_ai.tool.name` | Tool name (e.g. `Bash`, `Read`, `mcp__server__tool`) | | `gen_ai.tool.type` | Always `function` for Claude Code tools | -| `gen_ai.tool.call.arguments` | Tool input (omitted when `OMIT_IO=true`, truncated to 16KB otherwise) | -| `gen_ai.tool.call.result` | Tool output (omitted when `OMIT_IO=true`, truncated to 16KB otherwise) | -| `dash0.gen_ai.vcs.pull_request.url` | PR/MR URL extracted from tool response (survives `OMIT_IO=true`). Supports GitHub, GitLab, and Bitbucket. | -| `dash0.gen_ai.vcs.issue.url` | Issue URL extracted from tool response (survives `OMIT_IO=true`). | -| `dash0.gen_ai.vcs.commit.sha` | Commit SHA extracted from `git commit` output (survives `OMIT_IO=true`). | +| `gen_ai.tool.call.arguments` | Tool input (omitted when `OMIT_TOOL_IO=true`, truncated to 16KB otherwise) | +| `gen_ai.tool.call.result` | Tool output (omitted when `OMIT_TOOL_IO=true`, truncated to 16KB otherwise) | +| `dash0.gen_ai.vcs.pull_request.url` | PR/MR URL extracted from tool response (survives `OMIT_TOOL_IO=true`). Supports GitHub, GitLab, and Bitbucket. | +| `dash0.gen_ai.vcs.issue.url` | Issue URL extracted from tool response (survives `OMIT_TOOL_IO=true`). | +| `dash0.gen_ai.vcs.commit.sha` | Commit SHA extracted from `git commit` output (survives `OMIT_TOOL_IO=true`). | ### Privacy defaults @@ -176,11 +176,14 @@ By default, the plugin sends real user identity and omits prompt/tool I/O conten | Setting | Default | Behavior | |---|---|---| | `OMIT_USER_INFO` | `false` | Real `user.name` and `user.email` are sent. When set to `true`, `user.name` is emitted as a SHA-256 hash, `user.email` is omitted, and working directory has its home directory prefix replaced with `~`. | -| `OMIT_IO` | `true` | Prompt content and tool call inputs/outputs are stripped from spans. | +| `OMIT_PROMPTS` | `true` | User prompts and assistant response content are stripped from spans. | +| `OMIT_TOOL_IO` | `true` | Tool call inputs and responses are stripped from spans. | **What is always collected** (regardless of settings): tool names, token counts, durations, model names, session structure, error status, VCS repository/branch info. -**What is omitted by default**: prompt text, tool call arguments and responses. +**What is omitted by default**: prompt text, assistant responses, tool call arguments and responses. + +To collect tool I/O without collecting prompts (or vice versa), set `OMIT_PROMPTS` and `OMIT_TOOL_IO` independently — e.g. `OMIT_PROMPTS=true`, `OMIT_TOOL_IO=false` keeps tool arguments/responses while still stripping prompts. To anonymize user identity, set `OMIT_USER_INFO` to `"true"` via `/plugin` → Installed → dash0 → Configure (the entry name depends on the marketplace — see [First-time setup](#first-time-setup)). @@ -194,8 +197,9 @@ The plugin declares its configuration via Claude Code's `userConfig` mechanism. | `AUTH_TOKEN` | Dash0 authentication token | Yes | Yes (stored in keychain) | | `DATASET` | Dash0 dataset name | No | No | | `AGENT_NAME` | Used as `service.name` and `gen_ai.agent.name` resource attributes (defaults to `claude-code`) | No | No | +| `OMIT_PROMPTS` | Omit user prompts and assistant responses (default `true`) — see [Privacy defaults](#privacy-defaults) | No | No | +| `OMIT_TOOL_IO` | Omit tool call inputs and responses (default `true`) — see [Privacy defaults](#privacy-defaults) | No | No | | `TEAM_NAME` | When set, all spans are tagged with the `dash0.team.name` attribute | No | No | -| `OMIT_IO` | Omit prompt content and tool I/O (default `true`) — see [Privacy defaults](#privacy-defaults) | No | No | | `OMIT_USER_INFO` | Anonymize user identity (default `false`) — see [Privacy defaults](#privacy-defaults) | No | No | After changing any value via Configure, run `/reload-plugins` to apply it to the current session. @@ -222,7 +226,8 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab | `DASH0_AGENT_NAME` | Agent name | | `DASH0_TEAM_NAME` | Team name — when set, all spans are tagged with the `dash0.team.name` attribute | | `DASH0_OMIT_USER_INFO` | Anonymize user identity (default: `false`). When true, `user.name` is emitted as a hash and `user.email` is omitted. | -| `DASH0_OMIT_IO` | Omit prompts and tool I/O (default: `true`). When true, prompt content and tool call inputs/outputs are stripped from spans. Set to `false` to include full content. | +| `DASH0_OMIT_PROMPTS` | Omit user prompts and assistant responses (default: `true`). Set to `false` to include full content. | +| `DASH0_OMIT_TOOL_IO` | Omit tool call inputs and responses (default: `true`). Set to `false` to include full content. | | `DASH0_DEBUG` | Print OTel payloads to stderr for local debugging (`true`/`false`) | | `DASH0_DEBUG_FILE` | Also write debug output to this file path (e.g. `/tmp/dash0-debug.log`) | @@ -245,7 +250,8 @@ otlp_url: "https://ingress..aws.dash0.com" auth_token: "your-dash0-auth-token" dataset: "default" agent_name: "claude-code" -omit_io: true +omit_prompts: true +omit_tool_io: true omit_user_info: false --- ``` @@ -261,7 +267,8 @@ otlp_url: "https://ingress..aws.dash0.com" auth_token: "your-dash0-auth-token" dataset: "my-project-dataset" agent_name: "my-coding-agent" -omit_io: false +omit_prompts: false +omit_tool_io: false omit_user_info: true --- ``` @@ -275,8 +282,9 @@ omit_user_info: true | `auth_token` | Dash0 authentication token | — | | `dataset` | Dash0 dataset name | — | | `agent_name` | Agent name (used as `service.name`) | `claude-code` | +| `omit_prompts` | Omit user prompts and assistant responses | `true` | +| `omit_tool_io` | Omit tool call inputs and responses | `true` | | `team_name` | Team name — when set, all spans are tagged with `dash0.team.name` | — | -| `omit_io` | Omit prompt content and tool I/O | `true` | | `omit_user_info` | Anonymize user identity | `false` | Set `enabled: false` to disable the plugin for a single project without uninstalling it. diff --git a/cmd/on-event/main.go b/cmd/on-event/main.go index 8be771b..f241e5b 100644 --- a/cmd/on-event/main.go +++ b/cmd/on-event/main.go @@ -102,13 +102,13 @@ func sendToolTrace(event map[string]any, cfg otlp.Config, ts time.Time, dataDir event["commit_sha"] = sha } - // Extract lines-of-code counts before OMIT_IO redacts tool_response. + // Extract lines-of-code counts before OMIT_TOOL_IO redacts tool_response. if added, removed := extractLinesCounts(resp); added > 0 || removed > 0 { event["lines_added"] = int64(added) event["lines_removed"] = int64(removed) } - // Extract tool metadata attributes before OMIT_IO redacts tool_input. + // Extract tool metadata attributes before OMIT_TOOL_IO redacts tool_input. toolInput := event["tool_input"] if toolName == "Bash" { if family := extractBashCommandFamily(toolInput); family != "" { @@ -599,7 +599,8 @@ func run() error { AgentName: pluginOption("AGENT_NAME"), TeamName: pluginOption("TEAM_NAME"), OmitUserInfo: pluginOptionBoolDefault("OMIT_USER_INFO", false), - OmitIO: pluginOptionBoolDefault("OMIT_IO", true), + OmitPrompts: pluginOptionBoolDefault("OMIT_PROMPTS", true), + OmitToolIO: pluginOptionBoolDefault("OMIT_TOOL_IO", true), Debug: pluginOptionBool("DEBUG"), DebugFile: pluginOption("DEBUG_FILE"), } diff --git a/cmd/on-event/main_test.go b/cmd/on-event/main_test.go index d46521d..3801e70 100644 --- a/cmd/on-event/main_test.go +++ b/cmd/on-event/main_test.go @@ -696,18 +696,19 @@ func TestHintNotEmittedOnNonSessionStartEvents(t *testing.T) { assert.NotContains(t, stdout, "systemMessage") } -func TestOmitIOOmitsContentAttributes(t *testing.T) { +func TestOmitToolIOOmitsToolContentAttributes(t *testing.T) { dataDir := t.TempDir() t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") + t.Setenv("DASH0_OMIT_PROMPTS", "false") - feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-omit","model":"claude-sonnet-4-20250514"}`) - feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-omit","prompt":"hello"}`) - feed(t, `{"hook_event_name":"PreToolUse","session_id":"sess-omit","tool_name":"Bash","tool_use_id":"tu-omit"}`) - feed(t, `{"hook_event_name":"PostToolUse","session_id":"sess-omit","tool_name":"Bash","tool_use_id":"tu-omit","tool_input":"ls","tool_response":"ok"}`) - feed(t, `{"hook_event_name":"Stop","session_id":"sess-omit","model":"claude-sonnet-4-20250514","last_assistant_message":"done","prompt":"hello"}`) + feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-tool","model":"claude-sonnet-4-20250514"}`) + feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-tool","prompt":"hello"}`) + feed(t, `{"hook_event_name":"PreToolUse","session_id":"sess-tool","tool_name":"Bash","tool_use_id":"tu-tool"}`) + feed(t, `{"hook_event_name":"PostToolUse","session_id":"sess-tool","tool_name":"Bash","tool_use_id":"tu-tool","tool_input":"ls","tool_response":"ok"}`) + feed(t, `{"hook_event_name":"Stop","session_id":"sess-tool","model":"claude-sonnet-4-20250514","last_assistant_message":"done","prompt":"hello"}`) toolSpan := findSpan(*spans, "execute_tool") chatSpan := findSpan(*spans, "chat") @@ -715,11 +716,40 @@ func TestOmitIOOmitsContentAttributes(t *testing.T) { require.NotNil(t, toolSpan) require.NotNil(t, chatSpan) - // Tool span should have redacted input/output content. + // Tool span: redacted. assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.arguments", "") assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.result", "") - // Chat span should have redacted prompt/response content but preserve JSON structure. + // Chat span: prompts preserved. + assertAttrContains(t, chatSpan.Attributes, "gen_ai.input.messages", "hello") + assertAttrContains(t, chatSpan.Attributes, "gen_ai.output.messages", "done") +} + +func TestOmitPromptsOmitsPromptContentAttributes(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) + srv, spans, _ := collectingServer(t) + t.Setenv("DASH0_OTLP_URL", srv.URL) + t.Setenv("DASH0_OMIT_PROMPTS", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "false") + + feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-prompts","model":"claude-sonnet-4-20250514"}`) + feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-prompts","prompt":"hello"}`) + feed(t, `{"hook_event_name":"PreToolUse","session_id":"sess-prompts","tool_name":"Bash","tool_use_id":"tu-prompts"}`) + feed(t, `{"hook_event_name":"PostToolUse","session_id":"sess-prompts","tool_name":"Bash","tool_use_id":"tu-prompts","tool_input":"ls","tool_response":"ok"}`) + feed(t, `{"hook_event_name":"Stop","session_id":"sess-prompts","model":"claude-sonnet-4-20250514","last_assistant_message":"done","prompt":"hello"}`) + + toolSpan := findSpan(*spans, "execute_tool") + chatSpan := findSpan(*spans, "chat") + + require.NotNil(t, toolSpan) + require.NotNil(t, chatSpan) + + // Tool span: I/O preserved. + assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.arguments", "ls") + assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.result", "ok") + + // Chat span: prompt/response redacted, JSON structure preserved. assertAttrContains(t, chatSpan.Attributes, "gen_ai.input.messages", `"role":"user"`) assertAttrContains(t, chatSpan.Attributes, "gen_ai.input.messages", `REDACTED`) assertAttrContains(t, chatSpan.Attributes, "gen_ai.output.messages", `"role":"assistant"`) @@ -984,12 +1014,12 @@ func TestExtractCommitSHA(t *testing.T) { } } -func TestPRURLSurvivesOmitIO(t *testing.T) { +func TestPRURLSurvivesOmitToolIO(t *testing.T) { dataDir := t.TempDir() t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-pr","model":"claude-sonnet-4-20250514"}`) feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-pr","prompt":"create PR"}`) @@ -1013,7 +1043,7 @@ func TestCommitSHAExtractedOnToolSpan(t *testing.T) { t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-sha","model":"claude-sonnet-4-20250514"}`) feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-sha","prompt":"commit"}`) @@ -1241,12 +1271,12 @@ func TestExtractMCPServer(t *testing.T) { } } -func TestLinesOfCodeSurvivesOmitIO(t *testing.T) { +func TestLinesOfCodeSurvivesOmitToolIO(t *testing.T) { dataDir := t.TempDir() t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") patchJSON := `{"structuredPatch":[{"filePath":"main.go","lines":[" ctx","- old1","- old2","+new1","+new2","+new3"," end"]}]}` @@ -1268,12 +1298,12 @@ func TestLinesOfCodeSurvivesOmitIO(t *testing.T) { assertIntAttr(t, toolSpan.Attributes, "dash0.gen_ai.code.lines_removed", 2) } -func TestBashCommandFamilySurvivesOmitIO(t *testing.T) { +func TestBashCommandFamilySurvivesOmitToolIO(t *testing.T) { dataDir := t.TempDir() t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-bash","model":"claude-sonnet-4-20250514"}`) feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-bash","prompt":"run git"}`) @@ -1287,12 +1317,12 @@ func TestBashCommandFamilySurvivesOmitIO(t *testing.T) { assertStringAttr(t, toolSpan.Attributes, "dash0.gen_ai.tool.bash.command_family", "git") } -func TestSkillNameSurvivesOmitIO(t *testing.T) { +func TestSkillNameSurvivesOmitToolIO(t *testing.T) { dataDir := t.TempDir() t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) srv, spans, _ := collectingServer(t) t.Setenv("DASH0_OTLP_URL", srv.URL) - t.Setenv("DASH0_OMIT_IO", "true") + t.Setenv("DASH0_OMIT_TOOL_IO", "true") feed(t, `{"hook_event_name":"SessionStart","session_id":"sess-skill","model":"claude-sonnet-4-20250514"}`) feed(t, `{"hook_event_name":"UserPromptSubmit","session_id":"sess-skill","prompt":"run skill"}`) diff --git a/internal/otlp/otlp.go b/internal/otlp/otlp.go index 689aabf..c8edc3f 100644 --- a/internal/otlp/otlp.go +++ b/internal/otlp/otlp.go @@ -94,7 +94,8 @@ type Config struct { AgentName string TeamName string // when set, tag all spans with the dash0.team.name attribute OmitUserInfo bool // when true, hash user.name and omit user.email (both span attributes) - OmitIO bool // when true (default), omit tool inputs/outputs and prompt/response content + OmitPrompts bool // when true (default), omit user prompt and assistant response content + OmitToolIO bool // when true (default), omit tool call inputs and responses Debug bool // when true, print OTel payloads to stderr (and DebugFile if set) DebugFile string // optional file path to append debug output to } @@ -257,15 +258,25 @@ const MaxContentBytes = 16 * 1024 const redactedValue = "" -// contentKeys lists event fields that contain input/output content. -// These are redacted when Config.OmitIO is true, or truncated when included. -var contentKeys = map[string]bool{ - "tool_input": true, - "tool_response": true, +// promptContentKeys lists event fields that contain prompt/response content. +// These are redacted when Config.OmitPrompts is true, or truncated when included. +var promptContentKeys = map[string]bool{ "last_assistant_message": true, "prompt": true, } +// toolContentKeys lists event fields that contain tool call I/O. +// These are redacted when Config.OmitToolIO is true, or truncated when included. +var toolContentKeys = map[string]bool{ + "tool_input": true, + "tool_response": true, +} + +// isContentKey reports whether an event field is content subject to redaction or truncation. +func isContentKey(k string) bool { + return promptContentKeys[k] || toolContentKeys[k] +} + // userInfoKeys lists event fields that contain user-identifying information. // These are redacted when Config.OmitUserInfo is true. var userInfoKeys = map[string]bool{ @@ -345,7 +356,7 @@ func eventAttributes(event map[string]any, cfg Config) []Attribute { } continue } - if cfg.OmitIO && contentKeys[k] { + if (cfg.OmitPrompts && promptContentKeys[k]) || (cfg.OmitToolIO && toolContentKeys[k]) { key := k if mapped, ok := attrKeyMap[k]; ok { key = mapped @@ -362,7 +373,7 @@ func eventAttributes(event map[string]any, cfg Config) []Attribute { if t, ok := attrTransformMap[k]; ok { s := t.transform(v) if s != "" { - if contentKeys[k] { + if isContentKey(k) { s = truncateContent(s) } attrs = append(attrs, Attribute{Key: t.key, Value: StringVal(s)}) @@ -374,7 +385,7 @@ func eventAttributes(event map[string]any, cfg Config) []Attribute { key = mapped } av := toAttrValue(v) - if av.StringValue != nil && contentKeys[k] { + if av.StringValue != nil && isContentKey(k) { truncated := truncateContent(*av.StringValue) av = StringVal(truncated) } diff --git a/internal/otlp/otlp_test.go b/internal/otlp/otlp_test.go index 0165ecb..403e9cc 100644 --- a/internal/otlp/otlp_test.go +++ b/internal/otlp/otlp_test.go @@ -208,16 +208,7 @@ func TestSendLogMinimalEvent(t *testing.T) { assertAttr(t, lr.Attributes, "foo", "bar") } -func TestSendLogOmitIO(t *testing.T) { - var received ExportLogsRequest - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &received) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - +func TestSendLogOmitPromptsAndToolIO(t *testing.T) { event := map[string]any{ "hook_event_name": "PostToolUse", "session_id": "sess-123", @@ -227,25 +218,67 @@ func TestSendLogOmitIO(t *testing.T) { "last_assistant_message": "Here are the files.", "prompt": "list files", } - cfg := Config{OTLPUrl: srv.URL, OmitIO: true} - require.NoError(t, SendLog(event, cfg)) - - lr := received.ResourceLogs[0].ScopeLogs[0].LogRecords[0] + t.Run("both flags redact everything", func(t *testing.T) { + var received ExportLogsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &received) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := Config{OTLPUrl: srv.URL, OmitPrompts: true, OmitToolIO: true} + require.NoError(t, SendLog(event, cfg)) + + lr := received.ResourceLogs[0].ScopeLogs[0].LogRecords[0] + assertAttr(t, lr.Attributes, "gen_ai.conversation.id", "sess-123") + assertAttr(t, lr.Attributes, "gen_ai.tool.name", "Bash") + assertAttr(t, lr.Attributes, "gen_ai.tool.call.arguments", "") + assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "") + assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `"role":"assistant"`) + assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `REDACTED`) + assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `"role":"user"`) + assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `REDACTED`) + }) - // Non-content attributes are still present. - assertAttr(t, lr.Attributes, "gen_ai.conversation.id", "sess-123") - assertAttr(t, lr.Attributes, "gen_ai.tool.name", "Bash") + t.Run("OmitPrompts only redacts prompts, leaves tool I/O", func(t *testing.T) { + var received ExportLogsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &received) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := Config{OTLPUrl: srv.URL, OmitPrompts: true, OmitToolIO: false} + require.NoError(t, SendLog(event, cfg)) + + lr := received.ResourceLogs[0].ScopeLogs[0].LogRecords[0] + assertAttrContains(t, lr.Attributes, "gen_ai.tool.call.arguments", "ls") + assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "file1.go\nfile2.go") + assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `REDACTED`) + assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `REDACTED`) + }) - // Content attributes are present but redacted. - // Tool I/O uses plain redaction. - assertAttr(t, lr.Attributes, "gen_ai.tool.call.arguments", "") - assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "") - // Message attributes preserve JSON structure for UI parsing. - assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `"role":"assistant"`) - assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `REDACTED`) - assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `"role":"user"`) - assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `REDACTED`) + t.Run("OmitToolIO only redacts tool I/O, leaves prompts", func(t *testing.T) { + var received ExportLogsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &received) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := Config{OTLPUrl: srv.URL, OmitPrompts: false, OmitToolIO: true} + require.NoError(t, SendLog(event, cfg)) + + lr := received.ResourceLogs[0].ScopeLogs[0].LogRecords[0] + assertAttr(t, lr.Attributes, "gen_ai.tool.call.arguments", "") + assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "") + assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", "Here are the files.") + assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", "list files") + }) } func TestTruncateContent(t *testing.T) { @@ -290,7 +323,7 @@ func TestToolIOTruncatedInSpan(t *testing.T) { "tool_response": largeOutput, } - cfg := Config{OmitIO: false} + cfg := Config{OmitPrompts: false, OmitToolIO: false} span := NewToolSpan("aabbccdd"+"eeff0011"+"22334455"+"66778899", "span1234span1234", "parentidparentid", time.Now().Add(-100*time.Millisecond), time.Now(), event, false, cfg) diff --git a/internal/otlp/trace_test.go b/internal/otlp/trace_test.go index f467cfb..e952bec 100644 --- a/internal/otlp/trace_test.go +++ b/internal/otlp/trace_test.go @@ -244,7 +244,7 @@ func TestSendTrace(t *testing.T) { assert.Equal(t, "0", s.EndTimeUnixNano) } -func TestNewToolSpanOmitIO(t *testing.T) { +func TestNewToolSpanOmitToolIO(t *testing.T) { startTime := time.Date(2025, 6, 15, 12, 0, 30, 0, time.UTC) endTime := time.Date(2025, 6, 15, 12, 1, 0, 0, time.UTC) event := map[string]any{ @@ -255,16 +255,34 @@ func TestNewToolSpanOmitIO(t *testing.T) { "tool_response": "file1.go", } - span := NewToolSpan("aabb"+"ccdd"+"eeff"+"0011"+"2233"+"4455"+"6677"+"8899", "span1234span1234", "parentidparentid", startTime, endTime, event, false, Config{OmitIO: true}) + // OmitPrompts is irrelevant for tool spans — they carry no prompt fields. + cfg := Config{OmitToolIO: true, OmitPrompts: false} + span := NewToolSpan("aabb"+"ccdd"+"eeff"+"0011"+"2233"+"4455"+"6677"+"8899", "span1234span1234", "parentidparentid", startTime, endTime, event, false, cfg) - // Tool name is still present. assertAttr(t, span.Attributes, "gen_ai.tool.name", "Bash") - // Content attributes are present but redacted. assertAttr(t, span.Attributes, "gen_ai.tool.call.arguments", "") assertAttr(t, span.Attributes, "gen_ai.tool.call.result", "") } -func TestNewLLMSpanOmitIO(t *testing.T) { +func TestNewToolSpanOmitPromptsLeavesToolIO(t *testing.T) { + startTime := time.Date(2025, 6, 15, 12, 0, 30, 0, time.UTC) + endTime := time.Date(2025, 6, 15, 12, 1, 0, 0, time.UTC) + event := map[string]any{ + "hook_event_name": "PostToolUse", + "session_id": "sess-123", + "tool_name": "Bash", + "tool_input": "ls -la", + "tool_response": "file1.go", + } + + cfg := Config{OmitPrompts: true, OmitToolIO: false} + span := NewToolSpan("aabb"+"ccdd"+"eeff"+"0011"+"2233"+"4455"+"6677"+"8899", "span1234span1234", "parentidparentid", startTime, endTime, event, false, cfg) + + assertAttr(t, span.Attributes, "gen_ai.tool.call.arguments", "ls -la") + assertAttr(t, span.Attributes, "gen_ai.tool.call.result", "file1.go") +} + +func TestNewLLMSpanOmitPrompts(t *testing.T) { startTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) endTime := time.Date(2025, 6, 15, 12, 0, 45, 0, time.UTC) event := map[string]any{ @@ -275,17 +293,35 @@ func TestNewLLMSpanOmitIO(t *testing.T) { "last_assistant_message": "hi there", } - span := NewLLMSpan("abc123traceabc123traceabc123tr", "span1234span1234", "parentidparentid", startTime, endTime, event, false, Config{OmitIO: true}) + // OmitToolIO is irrelevant for LLM spans — they carry no tool_input/response. + cfg := Config{OmitPrompts: true, OmitToolIO: false} + span := NewLLMSpan("abc123traceabc123traceabc123tr", "span1234span1234", "parentidparentid", startTime, endTime, event, false, cfg) - // Model is still present. assertAttr(t, span.Attributes, "gen_ai.request.model", "claude-sonnet-4-20250514") - // Content attributes are present but redacted, preserving JSON structure for UI parsing. assertAttrContains(t, span.Attributes, "gen_ai.input.messages", `"role":"user"`) assertAttrContains(t, span.Attributes, "gen_ai.input.messages", `REDACTED`) assertAttrContains(t, span.Attributes, "gen_ai.output.messages", `"role":"assistant"`) assertAttrContains(t, span.Attributes, "gen_ai.output.messages", `REDACTED`) } +func TestNewLLMSpanOmitToolIOLeavesPrompts(t *testing.T) { + startTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + endTime := time.Date(2025, 6, 15, 12, 0, 45, 0, time.UTC) + event := map[string]any{ + "hook_event_name": "Stop", + "session_id": "sess-123", + "model": "claude-sonnet-4-20250514", + "prompt": "hello", + "last_assistant_message": "hi there", + } + + cfg := Config{OmitPrompts: false, OmitToolIO: true} + span := NewLLMSpan("abc123traceabc123traceabc123tr", "span1234span1234", "parentidparentid", startTime, endTime, event, false, cfg) + + assertAttrContains(t, span.Attributes, "gen_ai.input.messages", "hello") + assertAttrContains(t, span.Attributes, "gen_ai.output.messages", "hi there") +} + func TestNewSessionSpanOmitUserInfoRedactsCwd(t *testing.T) { home, _ := os.UserHomeDir() ts := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) diff --git a/scripts/on-event.sh b/scripts/on-event.sh index 62b4262..89c031f 100755 --- a/scripts/on-event.sh +++ b/scripts/on-event.sh @@ -26,10 +26,12 @@ load_settings() { [[ -n "$val" ]] && export DASH0_DATASET="$val" val=$(echo "$frontmatter" | grep '^agent_name:' | sed 's/agent_name: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_AGENT_NAME="$val" + val=$(echo "$frontmatter" | grep '^omit_prompts:' | sed 's/omit_prompts: *//' | sed 's/^"\(.*\)"$/\1/' || true) + [[ -n "$val" ]] && export DASH0_OMIT_PROMPTS="$val" + val=$(echo "$frontmatter" | grep '^omit_tool_io:' | sed 's/omit_tool_io: *//' | sed 's/^"\(.*\)"$/\1/' || true) + [[ -n "$val" ]] && export DASH0_OMIT_TOOL_IO="$val" val=$(echo "$frontmatter" | grep '^team_name:' | sed 's/team_name: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_TEAM_NAME="$val" - val=$(echo "$frontmatter" | grep '^omit_io:' | sed 's/omit_io: *//' | sed 's/^"\(.*\)"$/\1/' || true) - [[ -n "$val" ]] && export DASH0_OMIT_IO="$val" val=$(echo "$frontmatter" | grep '^omit_user_info:' | sed 's/omit_user_info: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_OMIT_USER_INFO="$val" diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index b47916a..55f05dd 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -195,7 +195,8 @@ func runBinary(t *testing.T, binary, event, dataDir, otlpURL string) { "CLAUDE_PLUGIN_OPTION_OTLP_URL=" + otlpURL, "CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=e2e-test-token", "CLAUDE_PLUGIN_OPTION_OMIT_USER_INFO=false", - "CLAUDE_PLUGIN_OPTION_OMIT_IO=false", + "CLAUDE_PLUGIN_OPTION_OMIT_PROMPTS=false", + "CLAUDE_PLUGIN_OPTION_OMIT_TOOL_IO=false", "HOME=" + os.Getenv("HOME"), "PATH=" + os.Getenv("PATH"), }