Skip to content
Merged
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
58 changes: 48 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,61 @@ 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"
omit_io: true
omit_user_info: true
---
```

**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"
omit_io: false
omit_user_info: false
---
```

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.
**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.

### Debug mode

Expand Down Expand Up @@ -195,12 +235,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:

Expand Down
20 changes: 17 additions & 3 deletions cmd/on-event/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,9 +719,11 @@ func TestOmitIOOmitsContentAttributes(t *testing.T) {
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.
assertStringAttr(t, chatSpan.Attributes, "gen_ai.input.messages", "<REDACTED>")
assertStringAttr(t, chatSpan.Attributes, "gen_ai.output.messages", "<REDACTED>")
// 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) {
Expand Down Expand Up @@ -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)
}
7 changes: 5 additions & 2 deletions internal/otlp/otlp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 18 additions & 2 deletions internal/otlp/otlp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<REDACTED>")
assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "<REDACTED>")
assertAttr(t, lr.Attributes, "gen_ai.output.messages", "<REDACTED>")
assertAttr(t, lr.Attributes, "gen_ai.input.messages", "<REDACTED>")
// 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) {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions internal/otlp/trace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<REDACTED>")
assertAttr(t, span.Attributes, "gen_ai.output.messages", "<REDACTED>")
// 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) {
Expand Down
39 changes: 28 additions & 11 deletions scripts/on-event.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,43 @@

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
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
}

# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if users will try to override just a single option, like dataset.
not urgent for now


PLUGIN_DATA="${CLAUDE_PLUGIN_DATA:?CLAUDE_PLUGIN_DATA not set}"
BIN_DIR="$PLUGIN_DATA/bin"
Expand Down
Loading