Skip to content

Commit eb61188

Browse files
authored
feat: support global config file at ~/.claude/dash0-agent-plugin.local.md (#89)
1 parent f80d4ad commit eb61188

6 files changed

Lines changed: 121 additions & 31 deletions

File tree

README.md

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,21 +151,61 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab
151151
| `DASH0_DEBUG` | Print OTel payloads to stderr for local debugging (`true`/`false`) |
152152
| `DASH0_DEBUG_FILE` | Also write debug output to this file path (e.g. `/tmp/dash0-debug.log`) |
153153

154-
### Per-project overrides
154+
### Configuration file
155155

156-
For project-specific overrides (e.g. a different dataset per repo), create `.claude/dash0-agent-plugin.local.md`:
156+
You can configure the plugin via a markdown file with YAML frontmatter. The plugin checks two locations:
157+
158+
1. **Project-level**: `.claude/dash0-agent-plugin.local.md` (in current directory)
159+
2. **Global**: `~/.claude/dash0-agent-plugin.local.md` (user home)
160+
161+
Project-level config takes precedence over global config.
162+
163+
**Global config (recommended for personal use)**
164+
165+
Create `~/.claude/dash0-agent-plugin.local.md` to configure the plugin once for all projects:
166+
167+
```markdown
168+
---
169+
otlp_url: "https://ingress.us1.dash0.com"
170+
auth_token: "your-dash0-auth-token"
171+
dataset: "default"
172+
agent_name: "claude-code"
173+
omit_io: true
174+
omit_user_info: true
175+
---
176+
```
177+
178+
**Project-level config**
179+
180+
Create `.claude/dash0-agent-plugin.local.md` in a project directory for project-specific overrides (e.g. a different dataset per repo):
157181

158182
```markdown
159183
---
160184
enabled: true
161185
otlp_url: "https://ingress.us1.dash0.com"
162186
auth_token: "your-dash0-auth-token"
163-
dataset: "your-dataset"
187+
dataset: "my-project-dataset"
164188
agent_name: "my-coding-agent"
189+
omit_io: false
190+
omit_user_info: false
165191
---
166192
```
167193

168-
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.
194+
**Config file options**
195+
196+
| Option | Description | Default |
197+
|---|---|---|
198+
| `enabled` | Enable/disable the plugin for this project | `true` |
199+
| `otlp_url` | Dash0 OTLP endpoint URL ||
200+
| `auth_token` | Dash0 authentication token ||
201+
| `dataset` | Dash0 dataset name ||
202+
| `agent_name` | Agent name (used as `service.name`) | `claude-code` |
203+
| `omit_io` | Omit prompt content and tool I/O | `true` |
204+
| `omit_user_info` | Anonymize user identity | `true` |
205+
206+
Set `enabled: false` to disable the plugin for a single project without uninstalling it.
207+
208+
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.
169209

170210
### Debug mode
171211

@@ -195,12 +235,10 @@ Output is prefixed with `[dash0:trace]` or `[dash0:log]` for filtering:
195235

196236
**No spans in Dash0 after install.** The plugin was likely installed but not configured, or configured but not reloaded. Check:
197237

198-
1. Look for this line in Claude Code's stderr on `SessionStart`:
199-
```
200-
dash0: not configured — no OTLP_URL set. In Claude Code: /plugin → Installed → dash0 → Configure, then /reload-plugins.
201-
```
202-
If you see it, follow [First-time setup](#first-time-setup).
203-
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.
238+
1. Look for a `dash0:` message in the Claude Code UI on session start:
239+
- `dash0: telemetry is not active` — OTLP URL is not configured. Set it via `/plugin → Configure` or in the config file.
240+
- `dash0: connectivity check failed` — URL is set but connection failed (e.g., invalid auth token returns 401).
241+
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.
204242

205243
**More verbose debugging.** Run Claude Code with `--debug` to see plugin error messages:
206244

cmd/on-event/main_test.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -719,9 +719,11 @@ func TestOmitIOOmitsContentAttributes(t *testing.T) {
719719
assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.arguments", "<REDACTED>")
720720
assertStringAttr(t, toolSpan.Attributes, "gen_ai.tool.call.result", "<REDACTED>")
721721

722-
// Chat span should have redacted prompt/response content.
723-
assertStringAttr(t, chatSpan.Attributes, "gen_ai.input.messages", "<REDACTED>")
724-
assertStringAttr(t, chatSpan.Attributes, "gen_ai.output.messages", "<REDACTED>")
722+
// Chat span should have redacted prompt/response content but preserve JSON structure.
723+
assertAttrContains(t, chatSpan.Attributes, "gen_ai.input.messages", `"role":"user"`)
724+
assertAttrContains(t, chatSpan.Attributes, "gen_ai.input.messages", `REDACTED`)
725+
assertAttrContains(t, chatSpan.Attributes, "gen_ai.output.messages", `"role":"assistant"`)
726+
assertAttrContains(t, chatSpan.Attributes, "gen_ai.output.messages", `REDACTED`)
725727
}
726728

727729
func TestUserPromptSubmitStampsChatSpanID(t *testing.T) {
@@ -877,3 +879,15 @@ func assertStringAttr(t *testing.T, attrs []otlp.Attribute, key, want string) {
877879
}
878880
t.Errorf("attribute %q not found", key)
879881
}
882+
883+
func assertAttrContains(t *testing.T, attrs []otlp.Attribute, key, substr string) {
884+
t.Helper()
885+
for _, a := range attrs {
886+
if a.Key == key {
887+
require.NotNil(t, a.Value.StringValue, "attribute %q should have string value", key)
888+
assert.Contains(t, *a.Value.StringValue, substr, "attribute %q should contain %q", key, substr)
889+
return
890+
}
891+
}
892+
t.Errorf("attribute %q not found", key)
893+
}

internal/otlp/otlp.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,12 @@ func eventAttributes(event map[string]any, cfg Config) []Attribute {
324324
key = mapped
325325
}
326326
if t, ok := attrTransformMap[k]; ok {
327-
key = t.key
327+
// Apply transform with redacted placeholder to preserve JSON structure
328+
redactedTransformed := t.transform(redactedValue)
329+
attrs = append(attrs, Attribute{Key: t.key, Value: StringVal(redactedTransformed)})
330+
} else {
331+
attrs = append(attrs, Attribute{Key: key, Value: StringVal(redactedValue)})
328332
}
329-
attrs = append(attrs, Attribute{Key: key, Value: StringVal(redactedValue)})
330333
continue
331334
}
332335
if t, ok := attrTransformMap[k]; ok {

internal/otlp/otlp_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,14 @@ func TestSendLogOmitIO(t *testing.T) {
237237
assertAttr(t, lr.Attributes, "gen_ai.tool.name", "Bash")
238238

239239
// Content attributes are present but redacted.
240+
// Tool I/O uses plain redaction.
240241
assertAttr(t, lr.Attributes, "gen_ai.tool.call.arguments", "<REDACTED>")
241242
assertAttr(t, lr.Attributes, "gen_ai.tool.call.result", "<REDACTED>")
242-
assertAttr(t, lr.Attributes, "gen_ai.output.messages", "<REDACTED>")
243-
assertAttr(t, lr.Attributes, "gen_ai.input.messages", "<REDACTED>")
243+
// Message attributes preserve JSON structure for UI parsing.
244+
assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `"role":"assistant"`)
245+
assertAttrContains(t, lr.Attributes, "gen_ai.output.messages", `REDACTED`)
246+
assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `"role":"user"`)
247+
assertAttrContains(t, lr.Attributes, "gen_ai.input.messages", `REDACTED`)
244248
}
245249

246250
func TestTruncateContent(t *testing.T) {
@@ -320,6 +324,18 @@ func assertAttr(t *testing.T, attrs []Attribute, key, want string) {
320324
t.Errorf("attribute %s not found", key)
321325
}
322326

327+
func assertAttrContains(t *testing.T, attrs []Attribute, key, substr string) {
328+
t.Helper()
329+
for _, a := range attrs {
330+
if a.Key == key {
331+
require.NotNil(t, a.Value.StringValue, "attribute %s: stringValue is nil", key)
332+
assert.Contains(t, *a.Value.StringValue, substr, "attribute %s should contain %q", key, substr)
333+
return
334+
}
335+
}
336+
t.Errorf("attribute %s not found", key)
337+
}
338+
323339
func assertIntAttr(t *testing.T, attrs []Attribute, key string, want int64) {
324340
t.Helper()
325341
for _, a := range attrs {

internal/otlp/trace_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,11 @@ func TestNewLLMSpanOmitIO(t *testing.T) {
278278

279279
// Model is still present.
280280
assertAttr(t, span.Attributes, "gen_ai.request.model", "claude-sonnet-4-20250514")
281-
// Content attributes are present but redacted.
282-
assertAttr(t, span.Attributes, "gen_ai.input.messages", "<REDACTED>")
283-
assertAttr(t, span.Attributes, "gen_ai.output.messages", "<REDACTED>")
281+
// Content attributes are present but redacted, preserving JSON structure for UI parsing.
282+
assertAttrContains(t, span.Attributes, "gen_ai.input.messages", `"role":"user"`)
283+
assertAttrContains(t, span.Attributes, "gen_ai.input.messages", `REDACTED`)
284+
assertAttrContains(t, span.Attributes, "gen_ai.output.messages", `"role":"assistant"`)
285+
assertAttrContains(t, span.Attributes, "gen_ai.output.messages", `REDACTED`)
284286
}
285287

286288
func TestSendTraceSkipsWhenNotConfigured(t *testing.T) {

scripts/on-event.sh

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,43 @@
22

33
set -euo pipefail
44

5-
# Read plugin settings from .claude/dash0-agent-plugin.local.md if present.
6-
SETTINGS_FILE=".claude/dash0-agent-plugin.local.md"
7-
if [[ -f "$SETTINGS_FILE" ]]; then
8-
FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$SETTINGS_FILE")
5+
# Load settings from a config file. Returns 1 if file doesn't exist.
6+
load_settings() {
7+
local file="$1"
8+
[[ -f "$file" ]] || return 1
9+
10+
local frontmatter
11+
frontmatter=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$file")
912

1013
# Check enabled flag (default: true if file exists but field is absent).
11-
ENABLED=$(echo "$FRONTMATTER" | grep '^enabled:' | sed 's/enabled: *//')
12-
if [[ "$ENABLED" == "false" ]]; then
14+
local enabled
15+
enabled=$(echo "$frontmatter" | grep '^enabled:' | sed 's/enabled: *//' || true)
16+
if [[ "$enabled" == "false" ]]; then
1317
exit 0
1418
fi
1519

16-
val=$(echo "$FRONTMATTER" | grep '^otlp_url:' | sed 's/otlp_url: *//' | sed 's/^"\(.*\)"$/\1/')
20+
local val
21+
val=$(echo "$frontmatter" | grep '^otlp_url:' | sed 's/otlp_url: *//' | sed 's/^"\(.*\)"$/\1/' || true)
1722
[[ -n "$val" ]] && export DASH0_OTLP_URL="$val"
18-
val=$(echo "$FRONTMATTER" | grep '^auth_token:' | sed 's/auth_token: *//' | sed 's/^"\(.*\)"$/\1/')
23+
val=$(echo "$frontmatter" | grep '^auth_token:' | sed 's/auth_token: *//' | sed 's/^"\(.*\)"$/\1/' || true)
1924
[[ -n "$val" ]] && export CLAUDE_PLUGIN_OPTION_AUTH_TOKEN="$val"
20-
val=$(echo "$FRONTMATTER" | grep '^dataset:' | sed 's/dataset: *//' | sed 's/^"\(.*\)"$/\1/')
25+
val=$(echo "$frontmatter" | grep '^dataset:' | sed 's/dataset: *//' | sed 's/^"\(.*\)"$/\1/' || true)
2126
[[ -n "$val" ]] && export DASH0_DATASET="$val"
22-
val=$(echo "$FRONTMATTER" | grep '^agent_name:' | sed 's/agent_name: *//' | sed 's/^"\(.*\)"$/\1/')
27+
val=$(echo "$frontmatter" | grep '^agent_name:' | sed 's/agent_name: *//' | sed 's/^"\(.*\)"$/\1/' || true)
2328
[[ -n "$val" ]] && export DASH0_AGENT_NAME="$val"
24-
fi
29+
val=$(echo "$frontmatter" | grep '^omit_io:' | sed 's/omit_io: *//' | sed 's/^"\(.*\)"$/\1/' || true)
30+
[[ -n "$val" ]] && export DASH0_OMIT_IO="$val"
31+
val=$(echo "$frontmatter" | grep '^omit_user_info:' | sed 's/omit_user_info: *//' | sed 's/^"\(.*\)"$/\1/' || true)
32+
[[ -n "$val" ]] && export DASH0_OMIT_USER_INFO="$val"
33+
34+
return 0
35+
}
36+
37+
# Load settings: project-level takes precedence, then global.
38+
PROJECT_SETTINGS=".claude/dash0-agent-plugin.local.md"
39+
GLOBAL_SETTINGS="$HOME/.claude/dash0-agent-plugin.local.md"
40+
41+
load_settings "$PROJECT_SETTINGS" || load_settings "$GLOBAL_SETTINGS" || true
2542

2643
PLUGIN_DATA="${CLAUDE_PLUGIN_DATA:?CLAUDE_PLUGIN_DATA not set}"
2744
BIN_DIR="$PLUGIN_DATA/bin"

0 commit comments

Comments
 (0)