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
162 changes: 162 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,150 @@ jobs:
claude plugin marketplace add anthropics/claude-plugins-official --scope user
claude plugin install dash0@claude-plugins-official --scope user

# Contract tests mapping to the documented install/credential paths (README
# "Installation" + "Configuration"). They pin the behavior the docs depend on,
# so a Claude Code change or a wrong doc assumption fails CI, not a rollout:
# A — settings.json alone does NOT install (the Fleet "not enough" callout)
# B — `claude plugin install --config` (Fleet --config credential option)
# C — credential delivery: config file AND DASH0_*/CLAUDE_PLUGIN_OPTION_* env vars
# Marketplace installs (official / Dash0 / local PR code) are covered by the
# build-and-test job; the binary reading CLAUDE_PLUGIN_OPTION_* by Go unit tests.
install-config-contract:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Go
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5
with:
go-version-file: go.mod

- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code

- name: Force HTTPS for marketplace clones (runner has no SSH key)
run: git config --global url."https://github.com/".insteadOf "git@github.com:"

- name: "Contract A — settings.json alone does NOT install the plugin"
env:
HOME: /tmp/home-neg
run: |
mkdir -p "$HOME/.claude"
git config --global url."https://github.com/".insteadOf "git@github.com:"
cat > "$HOME/.claude/settings.json" <<'JSON'
{
"extraKnownMarketplaces": {
"dash0": { "source": { "source": "github", "repo": "dash0hq/claude-marketplace" } }
},
"enabledPlugins": { "dash0-agent-plugin@dash0": true }
}
JSON
claude plugin list 2>&1 | tee /tmp/list-neg.txt || true
if [ -d "$HOME/.claude/plugins/cache" ] && ls "$HOME/.claude/plugins/cache" | grep -qi dash0; then
echo "::error::settings.json alone installed the plugin — fleet docs/assumptions must be revisited"
exit 1
fi
echo "PASS: settings.json (extraKnownMarketplaces + enabledPlugins) alone does NOT install; explicit install required"

- name: "Contract B — claude plugin install --config persists creds where the plugin reads them"
env:
HOME: /tmp/home-pos
run: |
mkdir -p "$HOME/.claude"
git config --global url."https://github.com/".insteadOf "git@github.com:"

# Use the published Dash0 marketplace. `--config` is a Claude Code
# behavior independent of the plugin's own code (PR-code install is
# already covered by the "workspace, PR code" step above), and a
# published marketplace installs into ~/.claude/plugins/cache — the
# cleanest signal to assert "installed" on. (A local/directory
# marketplace serves the plugin in place and never populates the cache.)
claude plugin marketplace add dash0hq/claude-marketplace --scope user
claude plugin install dash0-agent-plugin@dash0 --scope user \
--config OTLP_URL=https://probe.example.test \
--config AUTH_TOKEN=contract-token-xyz \
--config DATASET=contract-ds

echo "::group::resulting settings.json"; cat "$HOME/.claude/settings.json"; echo "::endgroup::"
echo "::group::resulting credentials.json"; cat "$HOME/.claude/.credentials.json" 2>/dev/null || echo "(none)"; echo "::endgroup::"

fail=0

# 1) Plugin actually installed to cache.
if ! ls "$HOME/.claude/plugins/cache" 2>/dev/null | grep -qi dash0; then
echo "::error::plugin was not installed to cache"; fail=1
fi

# 2) Non-sensitive --config values land in settings.json (pluginConfigs).
if ! grep -q "https://probe.example.test" "$HOME/.claude/settings.json"; then
echo "::error::OTLP_URL not persisted to settings.json"; fail=1
fi
if ! grep -q "contract-ds" "$HOME/.claude/settings.json"; then
echo "::error::DATASET not persisted to settings.json"; fail=1
fi

# 3) The sensitive token must NOT be in settings.json...
if grep -q "contract-token-xyz" "$HOME/.claude/settings.json"; then
echo "::error::AUTH_TOKEN leaked into settings.json (should be in keychain/credentials)"; fail=1
fi
# ...and MUST be in the secrets store (keychain -> .credentials.json fallback on Linux).
if ! grep -q "contract-token-xyz" "$HOME/.claude/.credentials.json" 2>/dev/null; then
echo "::error::AUTH_TOKEN not stored in the secrets store (.credentials.json pluginSecrets)"; fail=1
fi

if [ "$fail" -ne 0 ]; then exit 1; fi
echo "PASS: --config installs + persists non-sensitive->settings.json, AUTH_TOKEN->secrets store"

- name: "Contract C — credential delivery reaches a real OTLP request (config file + env vars)"
env:
CLAUDE_PLUGIN_DATA: /tmp/pdata
run: |
# Build the hook binary where on-event.sh expects it (skip release download),
# and stand up the mock OTLP server (records requests + auth header on :4319).
VERSION=$(grep '^VERSION=' scripts/on-event.sh | sed 's/VERSION="//;s/"//')
mkdir -p "$CLAUDE_PLUGIN_DATA/bin"
go build -o "$CLAUDE_PLUGIN_DATA/bin/on-event-${VERSION}-linux-amd64" ./cmd/on-event
go build -o /tmp/mock-otlp ./test/e2e/mock-otlp-server
/tmp/mock-otlp &
sleep 1

# C1 — credentials from a user-level config file (~/.claude/dash0-agent-plugin.local.md).
# Run from a clean cwd so the repo's own .env / .claude can't interfere.
export HOME=/tmp/home-cfg
mkdir -p "$HOME/.claude"
cat > "$HOME/.claude/dash0-agent-plugin.local.md" <<'MD'
---
otlp_url: "http://localhost:4319"
auth_token: "cfg-file-token"
dataset: "cfg-file-ds"
---
MD
( cd "$(mktemp -d)" \
&& echo '{"hook_event_name":"SessionStart","session_id":"contract-c1","model":"opus"}' \
| bash "$GITHUB_WORKSPACE/scripts/on-event.sh" )

# C2 — credentials from env vars only (no config file present).
export HOME=/tmp/home-env
mkdir -p "$HOME/.claude"
( cd "$(mktemp -d)" \
&& echo '{"hook_event_name":"SessionStart","session_id":"contract-c2","model":"opus"}' \
| DASH0_OTLP_URL=http://localhost:4319 \
CLAUDE_PLUGIN_OPTION_AUTH_TOKEN=env-token \
DASH0_DATASET=env-ds \
bash "$GITHUB_WORKSPACE/scripts/on-event.sh" )

sleep 2
RESULT=$(curl -s http://localhost:4319/requests)
echo "::group::mock requests"; echo "$RESULT" | jq .; echo "::endgroup::"
fail=0
[ "$(echo "$RESULT" | jq '[.requests[]|select(.auth=="Bearer cfg-file-token")]|length')" -ge 1 ] \
|| { echo "::error::config-file token did not reach the OTLP request"; fail=1; }
[ "$(echo "$RESULT" | jq '[.requests[]|select(.auth=="Bearer env-token")]|length')" -ge 1 ] \
|| { echo "::error::env-var token did not reach the OTLP request"; fail=1; }
[ "$fail" -eq 0 ] || exit 1
echo "PASS: both config-file and env-var credentials flow through on-event.sh to real OTLP requests"

e2e:
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
Expand Down Expand Up @@ -153,6 +297,24 @@ jobs:
exit 1
fi

- name: README documents every userConfig option
run: |
# Every userConfig option declared in plugin.json must appear as a
# table row in the README (the backtick-wrapped `KEY` form), so the
# Configuration/Privacy tables can't silently fall out of sync.
DECLARED_KEYS=$(jq -r '.userConfig // {} | keys[]' .claude-plugin/plugin.json | sort -u)
MISSING=""
for key in $DECLARED_KEYS; do
if ! grep -qF "| \`$key\`" README.md; then
MISSING="$MISSING $key"
fi
done
if [ -n "$MISSING" ]; then
echo "::error::userConfig options declared in plugin.json but not documented as README table rows:$MISSING"
exit 1
fi
echo "PASS: README documents all $(echo "$DECLARED_KEYS" | wc -w | tr -d ' ') userConfig options"

- name: Hook script is valid
run: |
# Check on-event.sh is executable and has valid shebang
Expand Down
57 changes: 28 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ Claude Code plugin that captures agent activity as OpenTelemetry traces — tool

After installing, give the plugin your Dash0 credentials one of two ways. See [Configuration](#configuration) for the full reference (all options, precedence, and env-var equivalents).

Find your exact `otlp_url` (and auth token) in your Dash0 org settings — the region segment varies (e.g. `eu-west-1`, `us-west-2`).

**Config file (recommended)** — create `~/.claude/dash0-agent-plugin.local.md` (applies to all projects), or `.claude/dash0-agent-plugin.local.md` for a single project:

```markdown
---
otlp_url: "https://ingress.us1.dash0.com"
otlp_url: "https://ingress.<region>.aws.dash0.com"
auth_token: "your-dash0-auth-token"
dataset: "default"
---
Expand All @@ -55,38 +57,33 @@ claude plugin install dash0-agent-plugin@dash0 --scope user

> **Note:** Claude Code downloads marketplace plugins via SSH by default. If SSH keys are not configured for GitHub, the `git config` line above forces HTTPS. This is required in Docker containers, CI runners, or any environment without SSH access to GitHub.

This installs the plugin; configure credentials as in [First-time setup](#first-time-setup).

### Fleet / global deployment

To roll the plugin out across many machines non-interactively (MDM, golden image, dotfiles, config-management tooling), there are two independent pieces: **enabling the plugin** and **configuring credentials**. Neither requires the interactive `/plugin` UI.
Rolling the plugin out across many machines (MDM, golden image, dotfiles, config-management tooling) is two non-interactive steps: **install** the plugin, then give it **credentials**.

**1. Register the marketplace and enable the plugin** by writing `~/.claude/settings.json` on each device:
**1. Install + enable** with the non-interactive CLI shown in [Headless / CI installation](#headless--ci-installation) above (`claude plugin marketplace add` then `claude plugin install … --scope user`). The `on-event` binary is fetched from [GitHub Releases](https://github.com/dash0hq/dash0-agent-plugin/releases) on first run (checksum-verified), so each device needs outbound access to `github.com` and to your Dash0 ingress endpoint.

```json
{
"extraKnownMarketplaces": {
"dash0": { "source": { "source": "github", "repo": "dash0hq/claude-marketplace" } }
},
"enabledPlugins": { "dash0-agent-plugin@dash0": true }
}
```
> **Hand-writing `~/.claude/settings.json` is _not_ enough to install the plugin.** `enabledPlugins` only *enables* an already-installed plugin and `extraKnownMarketplaces` only *registers* the marketplace — neither downloads anything. Run `claude plugin install` (above) to actually install it.

Each device needs network access to `github.com` to download the marketplace and the `on-event` binary (fetched from [GitHub Releases](https://github.com/dash0hq/dash0-agent-plugin/releases) on first run, with checksum verification).
**2. Supply credentials** with any one of:

**2. Supply credentials** with either of the file/env mechanisms below (the interactive `/plugin → Configure` UI **cannot** be pre-seeded from a file):
- **A config file** at `~/.claude/dash0-agent-plugin.local.md` — format in [First-time setup](#first-time-setup), full options in [Configuration file](#configuration-file). The token is stored in cleartext, so `chmod 600` it and keep it user-owned.

- Push a user-level config file to `~/.claude/dash0-agent-plugin.local.md` (the simplest fleet-wide option — see [Configuration file](#configuration-file) for all keys):
- **`--config` on the install command**, which stores the token in the OS keychain (`~/.claude/.credentials.json` fallback on Linux) instead of cleartext:

```markdown
---
otlp_url: "https://ingress.us1.dash0.com"
auth_token: "your-dash0-auth-token"
dataset: "default"
---
```bash
claude plugin install dash0-agent-plugin@dash0 --scope user \
--config OTLP_URL=https://ingress.<region>.aws.dash0.com \
--config AUTH_TOKEN=<your-dash0-auth-token> \
--config DATASET=default
```
> Values passed via `--config` can appear in shell history and process listings during provisioning — if that's a concern, use the config file or a secret manager instead.

Ship it `chmod 600` and user-owned: the token is stored in cleartext (unlike the keychain), though it is only ever exposed to the plugin's hook process, never to tool-spawned shells.
- **Environment variables** — `DASH0_OTLP_URL` plus `CLAUDE_PLUGIN_OPTION_AUTH_TOKEN` (and optionally `DASH0_DATASET`); see [Environment variable fallback](#environment-variable-fallback).

- Or inject environment variables — `DASH0_OTLP_URL` plus `CLAUDE_PLUGIN_OPTION_AUTH_TOKEN` (and optionally `DASH0_DATASET`) — which suits containers and CI. The token is the only value with no `DASH0_*` form (see [Environment variable fallback](#environment-variable-fallback)).
After credentials are in place, start a Claude Code session — you should see `dash0: connected (v0.1.8)`.

### Local development

Expand Down Expand Up @@ -192,10 +189,12 @@ The plugin declares its configuration via Claude Code's `userConfig` mechanism.

| Option | Description | Required | Sensitive |
|---|---|---|---|
| `OTLP_URL` | Dash0 OTLP endpoint URL (e.g. `https://ingress.us1.dash0.com`) | Yes | No |
| `OTLP_URL` | Dash0 OTLP endpoint URL (e.g. `https://ingress.<region>.aws.dash0.com`) | Yes | No |
| `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_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 @@ -216,7 +215,7 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab

| Variable | Description |
|---|---|
| `DASH0_OTLP_URL` | Dash0 OTLP endpoint URL — must include scheme (e.g. `https://ingress.us1.dash0.com`) |
| `DASH0_OTLP_URL` | Dash0 OTLP endpoint URL — must include scheme (e.g. `https://ingress.<region>.aws.dash0.com`) |
| `DASH0_DATASET` | Dash0 dataset |
| `DASH0_AGENT_NAME` | Agent name |
| `DASH0_OMIT_USER_INFO` | Anonymize user identity (default: `false`). When true, `user.name` is emitted as a hash and `user.email` is omitted. |
Expand All @@ -239,7 +238,7 @@ Create `~/.claude/dash0-agent-plugin.local.md` to configure the plugin once for

```markdown
---
otlp_url: "https://ingress.us1.dash0.com"
otlp_url: "https://ingress.<region>.aws.dash0.com"
auth_token: "your-dash0-auth-token"
dataset: "default"
agent_name: "claude-code"
Expand All @@ -255,7 +254,7 @@ Create `.claude/dash0-agent-plugin.local.md` in a project directory for project-
```markdown
---
enabled: true
otlp_url: "https://ingress.us1.dash0.com"
otlp_url: "https://ingress.<region>.aws.dash0.com"
auth_token: "your-dash0-auth-token"
dataset: "my-project-dataset"
agent_name: "my-coding-agent"
Expand All @@ -282,11 +281,11 @@ The config file sets environment variables for the hook subprocess, so it acts a

### Mixing sources

A config file and `DASH0_*` environment variables compose **per key** (this is separate from the two config files, which do *not* merge with each other — see [Configuration file](#configuration-file)). The active config file only sets values for the keys it actually contains; any key it omits falls through to the environment — not to the other config file. So you can, for example, put just the `auth_token` in a config file and supply everything else via `DASH0_*` env vars:
A config file and `DASH0_*` environment variables compose **per key**: the active config file only sets the keys it actually contains, and any key it omits falls through to the environment. (The two config files themselves don't merge — see [Configuration file](#configuration-file).) So you can, for example, put just the `auth_token` in a config file and supply everything else via `DASH0_*` env vars:

```bash
# ~/.claude/dash0-agent-plugin.local.md contains only: auth_token: "…"
DASH0_OTLP_URL="https://ingress.us1.dash0.com" \
DASH0_OTLP_URL="https://ingress.<region>.aws.dash0.com" \
DASH0_DATASET="default" \
claude
```
Expand Down Expand Up @@ -329,7 +328,7 @@ Output is prefixed with `[dash0:trace]` or `[dash0:log]` for filtering:
**More verbose debugging.** Run Claude Code with `--debug` to see plugin error messages:

```bash
DASH0_OTLP_URL="https://ingress.us1.dash0.com" \
DASH0_OTLP_URL="https://ingress.<region>.aws.dash0.com" \
CLAUDE_PLUGIN_OPTION_AUTH_TOKEN="your-token" \
claude --debug --plugin-dir /path/to/dash0-agent-plugin 2>&1 | grep "on-event:\|dash0:"
```
Expand Down
Loading