Skip to content
Draft
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
16 changes: 11 additions & 5 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dash0-agent-plugin",
"version": "0.1.5",
"version": "0.2.0",
"description": "OpenTelemetry observability for Claude Code sessions. Captures tool calls, LLM invocations, token usage, and errors as OTel traces.",
"author": {
"name": "Dash0"
Expand All @@ -11,17 +11,23 @@
"keywords": ["observability", "tracing", "opentelemetry", "dash0", "otel"],
"userConfig": {
"OTLP_URL": {
"title": "OTLP Endpoint URL",
"description": "Dash0 OTLP endpoint URL (e.g. https://ingress.us1.dash0.com:4318)",
"title": "OTLP Endpoint URL (optional)",
"description": "Override the Dash0 OTLP endpoint URL (e.g. https://ingress.us1.dash0.com:4318). Leave blank to use the ingestion URL recorded by /dash0-agent-plugin:login.",
"type": "string",
"sensitive": false
},
"AUTH_TOKEN": {
"title": "Auth Token",
"description": "Dash0 authentication token",
"title": "Auth Token (optional — use /dash0-agent-plugin:login instead)",
"description": "Dash0 authentication token. Prefer running /dash0-agent-plugin:login; only set this manually for CI or shared environments where the browser flow is not possible.",
"type": "string",
"sensitive": true
},
"AUTH_URL": {
"title": "OAuth Authorization Server URL (advanced)",
"description": "Override the OAuth authorization server used by /dash0-agent-plugin:login. Defaults to the Dash0 regional API derived from OTLP_URL (e.g. https://api.eu-west-1.aws.dash0.com). Only set this for non-standard Dash0 deployments.",
"type": "string",
"sensitive": false
},
"DATASET": {
"title": "Dataset",
"description": "Dash0 dataset name (optional)",
Expand Down
47 changes: 32 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,36 @@ jobs:
fi
go test -tags=e2e -v -timeout=60s ./test/e2e/

auth-e2e:
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: Run mocked auth-flow tests
run: go test -tags=e2e -count=1 -timeout=60s -run TestAuthFlow ./test/e2e/ -v

consistency-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Version consistency (plugin.json == on-event.sh)
- name: Version consistency (plugin.json == on-event.sh == login.sh)
run: |
PLUGIN_VERSION=$(jq -r '.version' .claude-plugin/plugin.json)
SCRIPT_VERSION=$(grep '^VERSION=' scripts/on-event.sh | sed 's/VERSION="//' | sed 's/"//')
EVENT_VERSION=$(grep '^VERSION=' scripts/on-event.sh | sed 's/VERSION="//' | sed 's/"//')
LOGIN_VERSION=$(grep '^VERSION=' scripts/login.sh | sed 's/VERSION="//' | sed 's/"//')
echo "plugin.json: $PLUGIN_VERSION"
echo "on-event.sh: $SCRIPT_VERSION"
if [ "$PLUGIN_VERSION" != "$SCRIPT_VERSION" ]; then
echo "::error::Version mismatch: plugin.json=$PLUGIN_VERSION, on-event.sh=$SCRIPT_VERSION"
echo "on-event.sh: $EVENT_VERSION"
echo "login.sh: $LOGIN_VERSION"
if [ "$PLUGIN_VERSION" != "$EVENT_VERSION" ] || [ "$PLUGIN_VERSION" != "$LOGIN_VERSION" ]; then
echo "::error::Version mismatch: plugin.json=$PLUGIN_VERSION, on-event.sh=$EVENT_VERSION, login.sh=$LOGIN_VERSION"
exit 1
fi

Expand Down Expand Up @@ -155,16 +171,17 @@ jobs:

- name: Hook script is valid
run: |
# Check on-event.sh is executable and has valid shebang
if [ ! -x scripts/on-event.sh ]; then
echo "::error::scripts/on-event.sh is not executable"
exit 1
fi
HEAD=$(head -1 scripts/on-event.sh)
if [[ "$HEAD" != "#!/"* ]]; then
echo "::error::scripts/on-event.sh missing shebang"
exit 1
fi
for SCRIPT in scripts/on-event.sh scripts/login.sh; do
if [ ! -x "$SCRIPT" ]; then
echo "::error::$SCRIPT is not executable"
exit 1
fi
HEAD=$(head -1 "$SCRIPT")
if [[ "$HEAD" != "#!/"* ]]; then
echo "::error::$SCRIPT missing shebang"
exit 1
fi
done
# Check hooks.json references an existing script
HOOK_CMD=$(jq -r '.hooks.SessionStart[0].hooks[0].command' hooks/hooks.json)
EXPECTED='${CLAUDE_PLUGIN_ROOT}/scripts/on-event.sh'
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@
.conductor/
events.jsonl
dist/
bin/
bin/

# Session-scoped data directories created by the hook when CLAUDE_PLUGIN_DATA
# is set to the repo root (e.g. during local dev via .claude/settings.json).
[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*-[0-9a-f]*/
52 changes: 40 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,35 @@ Claude Code plugin that captures agent activity as OpenTelemetry traces — tool

### First-time setup

After installing, **the plugin does not start sending telemetry until you complete two steps**:
After installing, sign in with your browser:

1. **Configure credentials.** Run `/plugin` → **Installed** → **dash0** → **Configure**. Enter:
- `OTLP_URL` — your Dash0 OTLP endpoint, e.g. `https://ingress.us1.dash0.com:4318`
- `AUTH_TOKEN` — your Dash0 auth token (sensitive — stored in your OS keychain, not in `settings.json`)
- `DATASET` *(optional)*
- `AGENT_NAME` *(optional)*
2. **Reload the running session.** Run `/reload-plugins`. Without this, the current session's hooks still have empty config and silently emit nothing.
```
/dash0-agent-plugin:login
```

A Dash0 sign-in page opens in your browser. **New to Dash0?** Click **Sign up** on the next page to start a free trial. The plugin signs in against `https://api.eu-west-1.aws.dash0.com` (the EU-West regional API) by default; override with `--auth-url <url>` or `DASH0_AUTH_URL=<url>` (e.g. for a dev environment or a different region).

Once you've signed in:
- A long-lived ingestion token is minted and saved to your OS config dir under `dash0/credentials.json` (mode `0600`): `~/Library/Application Support/dash0/` on macOS, `$XDG_CONFIG_HOME/dash0` or `~/.config/dash0` on Linux, `%AppData%\dash0\` on Windows.
- Your organization's ingestion URL is recorded automatically; you don't need to set `OTLP_URL` unless you're self-hosting.
- The next time a Claude Code session starts, you'll see `dash0: connected`.

If you start a session before completing setup, the plugin writes this line to stderr on `SessionStart`:
If you start a session before logging in, the plugin prints the hint:

```
dash0: not configuredno OTLP_URL set. In Claude Code: /plugin → Installed → dash0 → Configure, then /reload-plugins.
dash0: not authenticatedrun /dash0-agent-plugin:login to sign in or start a free trial.
```

#### Advanced: pre-existing token (CI, shared machines)

If you have a Dash0 ingestion token already and prefer not to use the browser flow, you can paste it via `/plugin` → **Installed** → **dash0** → **Configure**:

- `AUTH_TOKEN` — your Dash0 token (sensitive — stored in your OS keychain)
- `OTLP_URL` — explicit OTLP endpoint (overrides what `/dash0-agent-plugin:login` recorded)
- `DATASET`, `AGENT_NAME` — optional

Manually-set values take precedence over what `/dash0-agent-plugin:login` wrote.

### Local development

```bash
Expand Down Expand Up @@ -128,18 +142,29 @@ 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:4318`) | Yes | No |
| `AUTH_TOKEN` | Dash0 authentication token | Yes | Yes (stored in keychain) |
| `OTLP_URL` | Override the Dash0 OTLP endpoint URL (e.g. `https://ingress.us1.dash0.com:4318`). Populated automatically by `/dash0-agent-plugin:login`; only set this manually for self-hosting or custom endpoints. | No (set via login) | No |
| `AUTH_TOKEN` | Dash0 authentication token. Prefer `/dash0-agent-plugin:login` over setting this manually. | No (set via login) | 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 |

### Authentication storage

`/dash0-agent-plugin:login` writes two files (mode `0600`) under the OS config dir (`~/Library/Application Support/dash0/` on macOS, `$XDG_CONFIG_HOME/dash0` or `~/.config/dash0` on Linux, `%AppData%\dash0\` on Windows):

| File | Contents |
|---|---|
| `credentials.json` | Minted machine token, organization ID, auth URL, and ingestion URL. Read by the hook on every event — **no `/reload-plugins` required** after re-login. |
| `clients.json` | OAuth Dynamic Client Registration result, keyed by auth URL. Reused across logins so the plugin doesn't re-register on every run. |

Delete `credentials.json` from your OS config dir and re-run `/dash0-agent-plugin:login` to switch organizations.

After changing any value via Configure, run `/reload-plugins` to apply it to the current session.

### Environment variable fallback

For non-sensitive options, the plugin falls back to `DASH0_*` environment variables when the `userConfig` value is not set. This is useful for `--plugin-dir` development or CI.

> **Note:** `AUTH_TOKEN` has no env var fallback — it must be configured via `/plugin → Configure` (stored in the OS keychain). This prevents the token from leaking into tool-spawned shell environments where other tools (e.g. Dash0 CLI) might pick it up.
> **Note:** `AUTH_TOKEN` has no env var fallback — it must be configured via `/plugin → Configure` (stored in the OS keychain) or by running `/dash0-agent-plugin:login`. This prevents the token from leaking into tool-spawned shell environments where other tools (e.g. Dash0 CLI) might pick it up.

| Variable | Description |
|---|---|
Expand All @@ -150,6 +175,9 @@ For non-sensitive options, the plugin falls back to `DASH0_*` environment variab
| `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_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`) |
| `DASH0_CONFIG_DIR` | Override the directory for `credentials.json` / `clients.json` (defaults to OS config dir / `dash0`). Mainly for testing. |
| `DASH0_AUTH_URL` | Dash0 regional API URL for `/dash0-agent-plugin:login` (default: `https://api.eu-west-1.aws.dash0.com`). Inferred as `https://api.eu-west-1.aws.dash0-dev.com` when `DASH0_OTLP_URL` points at a `.dash0-dev.com` host. |
| `DASH0_AUTH_NO_BROWSER` | When `1`, `/dash0-agent-plugin:login` prints the authorize URL instead of opening a browser. Useful for headless setups. |

### Per-project overrides

Expand Down
86 changes: 86 additions & 0 deletions cmd/on-event/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.

package main

import (
"context"
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"

"github.com/dash0hq/dash0-agent-plugin/internal/auth"
"github.com/dash0hq/dash0-agent-plugin/internal/version"
)

const (
// Dash0 regional OAuth server endpoints. The regional API hosts the
// OAuth server, issues dash0_at_* tokens, and supports DCR (RFC 7591).
defaultAuthURL = "https://api.eu-west-1.aws.dash0.com"
defaultAuthURLDev = "https://api.eu-west-1.aws.dash0-dev.com"
)

func runLogin(args []string) error {
fs := flag.NewFlagSet("login", flag.ContinueOnError)
authURLFlag := fs.String("auth-url", "", "OAuth authorization server root (default: inferred from OTLP_URL or DASH0_AUTH_URL, else "+defaultAuthURL+")")
timeout := fs.Duration("timeout", 5*time.Minute, "How long to wait for the browser redirect")
if err := fs.Parse(args); err != nil {
return err
}

authURL := resolveAuthURLForLogin(*authURLFlag)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

opts := auth.LoginOptions{
AuthURL: authURL,
ClientName: "Dash0 Claude Code Plugin",
ClientURI: "https://github.com/dash0hq/dash0-agent-plugin",
CallbackTimeout: *timeout,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
if v := version.Version; v != "" {
opts.ClientName = fmt.Sprintf("Dash0 Claude Code Plugin %s", v)
}

if _, err := auth.Login(ctx, opts); err != nil {
return err
}
return nil
}

// resolveAuthURLForLogin picks the OAuth host based on (in order):
// 1. --auth-url flag
// 2. CLAUDE_PLUGIN_OPTION_AUTH_URL / DASH0_AUTH_URL env
// 3. OTLP_URL hostname: replaces "ingress." prefix with "api." to get the
// regional API OAuth server (e.g. ingress.eu-west-1.aws.dash0.com →
// api.eu-west-1.aws.dash0.com)
// 4. defaultAuthURL (EU prod)
func resolveAuthURLForLogin(flagValue string) string {
if v := strings.TrimSpace(flagValue); v != "" {
return v
}
if v := strings.TrimSpace(os.Getenv("CLAUDE_PLUGIN_OPTION_AUTH_URL")); v != "" {
return v
}
if v := strings.TrimSpace(os.Getenv("DASH0_AUTH_URL")); v != "" {
return v
}
otlp := os.Getenv("CLAUDE_PLUGIN_OPTION_OTLP_URL")
if otlp == "" {
otlp = os.Getenv("DASH0_OTLP_URL")
}
if otlp != "" {
if u, err := url.Parse(otlp); err == nil {
host := u.Hostname()
if strings.HasPrefix(host, "ingress.") {
return "https://api." + strings.TrimPrefix(host, "ingress.")
}
}
}
return defaultAuthURL
}
Loading