From 966b4e8033bae71257a3e2473fdc8ee9b2fa1fd6 Mon Sep 17 00:00:00 2001 From: Guy Moses Date: Tue, 19 May 2026 14:41:21 +0300 Subject: [PATCH 1/5] feat: support global config file at ~/.claude/dash0-agent-plugin.local.md Add fallback to global config when project-level config doesn't exist. Precedence: project-level > global > environment variables. Closes #88 --- scripts/on-event.sh | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/scripts/on-event.sh b/scripts/on-event.sh index debef0c..a9a0673 100755 --- a/scripts/on-event.sh +++ b/scripts/on-event.sh @@ -2,26 +2,39 @@ set -euo pipefail -# Read plugin settings from .claude/dash0-agent-plugin.local.md if present. -SETTINGS_FILE=".claude/dash0-agent-plugin.local.md" -if [[ -f "$SETTINGS_FILE" ]]; then - FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$SETTINGS_FILE") +# Load settings from a config file. Returns 1 if file doesn't exist. +load_settings() { + local file="$1" + [[ -f "$file" ]] || return 1 + + local frontmatter + frontmatter=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$file") # Check enabled flag (default: true if file exists but field is absent). - ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//') - if [[ "$ENABLED" == "false" ]]; then + local enabled + enabled=$(echo "$frontmatter" | grep '^enabled:' | sed 's/enabled: *//' || true) + if [[ "$enabled" == "false" ]]; then exit 0 fi - val=$(echo "$FRONTMATTER" | grep '^otlp_url:' | sed 's/otlp_url: *//' | sed 's/^"\(.*\)"$/\1/') + local val + val=$(echo "$frontmatter" | grep '^otlp_url:' | sed 's/otlp_url: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_OTLP_URL="$val" - val=$(echo "$FRONTMATTER" | grep '^auth_token:' | sed 's/auth_token: *//' | sed 's/^"\(.*\)"$/\1/') + val=$(echo "$frontmatter" | grep '^auth_token:' | sed 's/auth_token: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export CLAUDE_PLUGIN_OPTION_AUTH_TOKEN="$val" - val=$(echo "$FRONTMATTER" | grep '^dataset:' | sed 's/dataset: *//' | sed 's/^"\(.*\)"$/\1/') + val=$(echo "$frontmatter" | grep '^dataset:' | sed 's/dataset: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_DATASET="$val" - val=$(echo "$FRONTMATTER" | grep '^agent_name:' | sed 's/agent_name: *//' | sed 's/^"\(.*\)"$/\1/') + val=$(echo "$frontmatter" | grep '^agent_name:' | sed 's/agent_name: *//' | sed 's/^"\(.*\)"$/\1/' || true) [[ -n "$val" ]] && export DASH0_AGENT_NAME="$val" -fi + + return 0 +} + +# Load settings: project-level takes precedence, then global. +PROJECT_SETTINGS=".claude/dash0-agent-plugin.local.md" +GLOBAL_SETTINGS="$HOME/.claude/dash0-agent-plugin.local.md" + +load_settings "$PROJECT_SETTINGS" || load_settings "$GLOBAL_SETTINGS" || true PLUGIN_DATA="${CLAUDE_PLUGIN_DATA:?CLAUDE_PLUGIN_DATA not set}" BIN_DIR="$PLUGIN_DATA/bin" From 324acd497b7d6d268f9a182d0ce2da2feb9b6269 Mon Sep 17 00:00:00 2001 From: Guy Moses Date: Tue, 19 May 2026 15:50:38 +0300 Subject: [PATCH 2/5] feat: add config validation with clear error messages - Show helpful message when OTLP URL or auth token is missing - Document global config file support in README - Update troubleshooting section with new error messages --- README.md | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a7185ce..69a7c43 100644 --- a/README.md +++ b/README.md @@ -151,21 +151,45 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab | `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`) | -### Per-project overrides +### Configuration file -For project-specific overrides (e.g. a different dataset per repo), create `.claude/dash0-agent-plugin.local.md`: +You can configure the plugin via a markdown file with YAML frontmatter. The plugin checks two locations: + +1. **Project-level**: `.claude/dash0-agent-plugin.local.md` (in current directory) +2. **Global**: `~/.claude/dash0-agent-plugin.local.md` (user home) + +Project-level config takes precedence over global config. + +**Global config (recommended for personal use)** + +Create `~/.claude/dash0-agent-plugin.local.md` to configure the plugin once for all projects: + +```markdown +--- +otlp_url: "https://ingress.us1.dash0.com" +auth_token: "your-dash0-auth-token" +dataset: "default" +agent_name: "claude-code" +--- +``` + +**Project-level config** + +Create `.claude/dash0-agent-plugin.local.md` in a project directory for project-specific overrides (e.g. a different dataset per repo): ```markdown --- enabled: true otlp_url: "https://ingress.us1.dash0.com" auth_token: "your-dash0-auth-token" -dataset: "your-dataset" +dataset: "my-project-dataset" agent_name: "my-coding-agent" --- ``` -The local file sets `DASH0_*` env vars for the hook subprocess, so it acts as the lowest-priority fallback. Set `enabled: false` to disable the plugin for a single project without uninstalling it. +Set `enabled: false` to disable the plugin for a single project without uninstalling it. + +The config file sets environment variables for the hook subprocess, so it acts as a fallback after `/plugin → Configure` values and before `DASH0_*` environment variables. ### Debug mode @@ -195,12 +219,10 @@ Output is prefixed with `[dash0:trace]` or `[dash0:log]` for filtering: **No spans in Dash0 after install.** The plugin was likely installed but not configured, or configured but not reloaded. Check: -1. Look for this line in Claude Code's stderr on `SessionStart`: - ``` - dash0: not configured — no OTLP_URL set. In Claude Code: /plugin → Installed → dash0 → Configure, then /reload-plugins. - ``` - If you see it, follow [First-time setup](#first-time-setup). -2. If you've already configured but spans still don't appear, run `/reload-plugins`. Saved values are not picked up by an already-running session until reload. +1. Look for a `dash0:` message in the Claude Code UI on session start: + - `dash0: telemetry is not active` — OTLP URL is not configured. Set it via `/plugin → Configure` or in the config file. + - `dash0: connectivity check failed` — URL is set but connection failed (e.g., invalid auth token returns 401). +2. If you've configured via `/plugin → Configure` but spans still don't appear, run `/reload-plugins`. Saved values are not picked up by an already-running session until reload. **More verbose debugging.** Run Claude Code with `--debug` to see plugin error messages: From 88a872e763968d9ae62c9a101a2cad3af0fad2cc Mon Sep 17 00:00:00 2001 From: Guy Moses Date: Tue, 19 May 2026 16:12:06 +0300 Subject: [PATCH 3/5] feat: support omit_io and omit_user_info in config files Add support for privacy settings in project-level and global config files. --- README.md | 16 ++++++++++++++++ scripts/on-event.sh | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 69a7c43..af73915 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ otlp_url: "https://ingress.us1.dash0.com" auth_token: "your-dash0-auth-token" dataset: "default" agent_name: "claude-code" +omit_io: true +omit_user_info: true --- ``` @@ -184,9 +186,23 @@ otlp_url: "https://ingress.us1.dash0.com" auth_token: "your-dash0-auth-token" dataset: "my-project-dataset" agent_name: "my-coding-agent" +omit_io: false +omit_user_info: false --- ``` +**Config file options** + +| Option | Description | Default | +|---|---|---| +| `enabled` | Enable/disable the plugin for this project | `true` | +| `otlp_url` | Dash0 OTLP endpoint URL | — | +| `auth_token` | Dash0 authentication token | — | +| `dataset` | Dash0 dataset name | — | +| `agent_name` | Agent name (used as `service.name`) | `claude-code` | +| `omit_io` | Omit prompt content and tool I/O | `true` | +| `omit_user_info` | Anonymize user identity | `true` | + Set `enabled: false` to disable the plugin for a single project without uninstalling it. The config file sets environment variables for the hook subprocess, so it acts as a fallback after `/plugin → Configure` values and before `DASH0_*` environment variables. diff --git a/scripts/on-event.sh b/scripts/on-event.sh index a9a0673..c3c14c5 100755 --- a/scripts/on-event.sh +++ b/scripts/on-event.sh @@ -26,6 +26,10 @@ 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_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" return 0 } From e12de6979c7cbf4dbe82bb543948e47a2d061d14 Mon Sep 17 00:00:00 2001 From: Guy Moses Date: Tue, 19 May 2026 17:30:58 +0300 Subject: [PATCH 4/5] fix: preserve JSON structure for redacted message attributes When OmitIO is true, wrap REDACTED placeholder in proper JSON structure so the UI can parse roles and show user/assistant entries in the conversation timeline. --- internal/otlp/otlp.go | 7 +++++-- internal/otlp/otlp_test.go | 20 ++++++++++++++++++-- internal/otlp/trace_test.go | 8 +++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/otlp/otlp.go b/internal/otlp/otlp.go index 79aa5bd..ad30217 100644 --- a/internal/otlp/otlp.go +++ b/internal/otlp/otlp.go @@ -324,9 +324,12 @@ func eventAttributes(event map[string]any, cfg Config) []Attribute { key = mapped } if t, ok := attrTransformMap[k]; ok { - key = t.key + // Apply transform with redacted placeholder to preserve JSON structure + redactedTransformed := t.transform(redactedValue) + attrs = append(attrs, Attribute{Key: t.key, Value: StringVal(redactedTransformed)}) + } else { + attrs = append(attrs, Attribute{Key: key, Value: StringVal(redactedValue)}) } - attrs = append(attrs, Attribute{Key: key, Value: StringVal(redactedValue)}) continue } if t, ok := attrTransformMap[k]; ok { diff --git a/internal/otlp/otlp_test.go b/internal/otlp/otlp_test.go index 441c589..0dd710a 100644 --- a/internal/otlp/otlp_test.go +++ b/internal/otlp/otlp_test.go @@ -237,10 +237,14 @@ func TestSendLogOmitIO(t *testing.T) { assertAttr(t, lr.Attributes, "gen_ai.tool.name", "Bash") // 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", "") - assertAttr(t, lr.Attributes, "gen_ai.output.messages", "") - assertAttr(t, lr.Attributes, "gen_ai.input.messages", "") + // 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`) } func TestTruncateContent(t *testing.T) { @@ -320,6 +324,18 @@ func assertAttr(t *testing.T, attrs []Attribute, key, want string) { t.Errorf("attribute %s not found", key) } +func assertAttrContains(t *testing.T, attrs []Attribute, key, substr string) { + t.Helper() + for _, a := range attrs { + if a.Key == key { + require.NotNil(t, a.Value.StringValue, "attribute %s: stringValue is nil", key) + assert.Contains(t, *a.Value.StringValue, substr, "attribute %s should contain %q", key, substr) + return + } + } + t.Errorf("attribute %s not found", key) +} + func assertIntAttr(t *testing.T, attrs []Attribute, key string, want int64) { t.Helper() for _, a := range attrs { diff --git a/internal/otlp/trace_test.go b/internal/otlp/trace_test.go index 37f4a6e..705b704 100644 --- a/internal/otlp/trace_test.go +++ b/internal/otlp/trace_test.go @@ -278,9 +278,11 @@ func TestNewLLMSpanOmitIO(t *testing.T) { // Model is still present. assertAttr(t, span.Attributes, "gen_ai.request.model", "claude-sonnet-4-20250514") - // Content attributes are present but redacted. - assertAttr(t, span.Attributes, "gen_ai.input.messages", "") - assertAttr(t, span.Attributes, "gen_ai.output.messages", "") + // 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 TestSendTraceSkipsWhenNotConfigured(t *testing.T) { From f41e2f06fa607f62d4375da3b4a9ad835ab132cd Mon Sep 17 00:00:00 2001 From: Guy Moses Date: Tue, 19 May 2026 17:34:35 +0300 Subject: [PATCH 5/5] fix: update main_test.go for JSON-wrapped redacted messages --- cmd/on-event/main_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/on-event/main_test.go b/cmd/on-event/main_test.go index 1b89e30..67a4a60 100644 --- a/cmd/on-event/main_test.go +++ b/cmd/on-event/main_test.go @@ -719,9 +719,11 @@ func TestOmitIOOmitsContentAttributes(t *testing.T) { 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. - assertStringAttr(t, chatSpan.Attributes, "gen_ai.input.messages", "") - assertStringAttr(t, chatSpan.Attributes, "gen_ai.output.messages", "") + // Chat span should have redacted prompt/response content but preserve JSON structure. + 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"`) + assertAttrContains(t, chatSpan.Attributes, "gen_ai.output.messages", `REDACTED`) } func TestUserPromptSubmitStampsChatSpanID(t *testing.T) { @@ -877,3 +879,15 @@ func assertStringAttr(t *testing.T, attrs []otlp.Attribute, key, want string) { } t.Errorf("attribute %q not found", key) } + +func assertAttrContains(t *testing.T, attrs []otlp.Attribute, key, substr string) { + t.Helper() + for _, a := range attrs { + if a.Key == key { + require.NotNil(t, a.Value.StringValue, "attribute %q should have string value", key) + assert.Contains(t, *a.Value.StringValue, substr, "attribute %q should contain %q", key, substr) + return + } + } + t.Errorf("attribute %q not found", key) +}