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
12 changes: 9 additions & 3 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)).

Expand All @@ -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.
Expand All @@ -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`) |

Expand All @@ -245,7 +250,8 @@ otlp_url: "https://ingress.<region>.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
---
```
Expand All @@ -261,7 +267,8 @@ otlp_url: "https://ingress.<region>.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
---
```
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions cmd/on-event/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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"),
}
Expand Down
66 changes: 48 additions & 18 deletions cmd/on-event/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -696,30 +696,60 @@ 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")

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", "<REDACTED>")
assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.result", "<REDACTED>")

// 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"`)
Expand Down Expand Up @@ -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"}`)
Expand All @@ -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"}`)
Expand Down Expand Up @@ -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"]}]}`

Expand All @@ -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"}`)
Expand All @@ -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"}`)
Expand Down
29 changes: 20 additions & 9 deletions internal/otlp/otlp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -257,15 +258,25 @@ const MaxContentBytes = 16 * 1024

const redactedValue = "<REDACTED>"

// 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{
Expand Down Expand Up @@ -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
Expand All @@ -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)})
Expand All @@ -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)
}
Expand Down
Loading
Loading