Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "logfire-session-capture",
"source": "./",
"description": "Captures Claude Code sessions and exports pydantic-ai compatible OTel traces to Pydantic Logfire, with local JSONL fallback.",
"version": "0.4.6",
"version": "0.5.0",
"author": {
"name": "Pydantic"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "logfire-session-capture",
"version": "0.4.6",
"version": "0.5.0",
"description": "Captures Claude Code sessions and exports pydantic-ai compatible OTel traces to Pydantic Logfire, with local JSONL fallback."
}
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ A Claude Code plugin that captures sessions and exports pydantic-ai compatible O

## Architecture

The entire plugin is a single Python script (`scripts/log-event.py`) invoked by every hook event defined in `hooks/hooks.json`. The plugin manifest lives at `.claude-plugin/plugin.json`.
The plugin has three Python scripts (all stdlib only) and an `hooks/hooks.json`:

**Data flow:** Claude Code hook fires -> stdin JSON piped to `log-event.py` -> appends JSONL locally -> if `LOGFIRE_TOKEN` set and event is SessionStart/Stop/SubagentStop/SessionEnd, builds OTLP/HTTP JSON payload and sends via `urllib`.
- `scripts/log-event.py` — invoked by every hook event; builds and ships OTLP spans.
- `scripts/oauth_token.py` — shared module: load/save/refresh OAuth token bundle at `~/.logfire/claude-code-logfire-plugin.json`. Used by both the hook and the CLI.
- `scripts/auth.py` — user-facing CLI for the OAuth Device Authorization Grant (RFC 8628) login / logout / status. Invoked by the `/logfire-session-capture:login`, `/logfire-session-capture:logout`, `/logfire-session-capture:status` slash commands under `commands/`.

The plugin manifest lives at `.claude-plugin/plugin.json`.

**Data flow:** Claude Code hook fires -> stdin JSON piped to `log-event.py` -> appends JSONL locally -> if a token is available (either `LOGFIRE_TOKEN` env var or a stored OAuth bundle that gets auto-refreshed inline) and event is SessionStart/Stop/SubagentStop/SessionEnd, builds OTLP/HTTP JSON payload and sends via `urllib`.

**Auth resolution order:** `LOGFIRE_TOKEN` env var (highest precedence, backwards-compatible; uses `$LOGFIRE_BASE_URL` for the OTLP endpoint) → stored OAuth bundle (a single bundle at `~/.logfire/claude-code-logfire-plugin.json` recording its own `base_url`; refreshed if within 60s of expiry; refresh is serialised via an `os.mkdir` lock so concurrent sessions don't race refresh-token rotation). Only one OAuth bundle is stored at a time — re-running login overwrites the previous one. If neither auth source is available, the OTel export path is skipped silently.

**Session state:** A temp file (`$TMPDIR/claude-logfire-{session_id}.json`) persists the root span ID, start time, transcript line offset, accumulated messages, usage totals, and cost details between hook invocations. Created on `SessionStart`, deleted on `SessionEnd`.

Expand Down
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ From within Claude Code, run:
/plugin update logfire-session-capture@pydantic-claude-code-logfire-plugin
```

### Set your Logfire token
### Authenticate to Logfire

The plugin supports two authentication modes. Pick whichever fits your setup —
they're checked in order, so you can have both configured and `LOGFIRE_TOKEN`
will win.

#### Option A: Fixed write token (simplest)

```bash
export LOGFIRE_TOKEN="your-logfire-write-token"
Expand All @@ -43,18 +49,64 @@ For the EU region:
export LOGFIRE_BASE_URL="https://logfire-eu.pydantic.dev"
```

#### Option B: OAuth with automatic refresh (no long-lived secret in your shell)

Run the device flow once — the plugin then refreshes the access token silently
on every hook invocation until you log out:

```bash
# From inside a Claude Code session:
/logfire-session-capture:login

# Or directly from your shell:
python3 ~/.claude/plugins/.../scripts/auth.py login
```

You'll see a one-time user code and a browser opens to authorize the plugin
against your Logfire org / project (RFC 8628 Device Authorization Grant with
PKCE; the access token carries the `project:write_otlp` scope and is bound to
the Fusionfire OTLP intake via the RFC 8707 `resource` parameter). The
`client_id` is a Client ID Metadata Document URL — by default
`https://logfire.pydantic.dev/clients/claude-code-logfire.json` for production
hosts and `https://logfire.pydantic.info/...` for staging — so the
authorization server fetches the canonical client metadata directly and no
per-install registration is needed. On success the access + refresh tokens
are written to `~/.logfire/claude-code-logfire-plugin.json` (mode 0600), along
with the chosen `base_url`. The plugin reads the bundle on every hook event and
exchanges the refresh token whenever the access token is within 60s of expiry;
the new bundle is written back atomically and shared across all your Claude
Code sessions.

Only **one** bundle is stored at a time — re-running `/logfire-session-capture:login`
overwrites it. `logout` / `status` / `refresh` operate on whatever's stored, so
they don't need a `--base-url` argument.

Four slash commands ship with the plugin:

- `/logfire-session-capture:login` — start (or re-run) the device flow (pass `--base-url ...` for EU / self-hosted)
- `/logfire-session-capture:status` — print expiry, scope, and client info for the stored bundle
- `/logfire-session-capture:refresh` — force-exchange the refresh token (debugging / manual rotation; hooks refresh lazily already)
- `/logfire-session-capture:logout` — delete the stored bundle

Unset `LOGFIRE_TOKEN` to make the plugin use the OAuth bundle. If both are
present, `LOGFIRE_TOKEN` wins (no surprise migrations).

#### Configuration reference

| Variable | Required | Default | Description |
|---|---|---|---|
| `LOGFIRE_TOKEN` | Yes | _(none)_ | Logfire write token |
| `LOGFIRE_BASE_URL` | No | `https://logfire-us.pydantic.dev` | Logfire ingest endpoint |
| `LOGFIRE_TOKEN` | One of token/OAuth | _(none)_ | Logfire write token. Takes precedence over the OAuth bundle when both are present. |
| `LOGFIRE_BASE_URL` | No | `https://logfire-us.pydantic.dev` | Default Logfire ingest endpoint when using `LOGFIRE_TOKEN`; default `--base-url` for `/logfire-session-capture:login`. In OAuth mode the stored bundle's `base_url` wins over this variable. |
| `LOGFIRE_LOCAL_LOG` | No | `false` | Set to `true` to write JSONL event logs locally |
| `LOGFIRE_DIAGNOSTICS` | No | `false` | Set to `true` to write diagnostic logs (enabled automatically when `LOGFIRE_LOCAL_LOG` is set) |
| `LOGFIRE_ENVIRONMENT` | No | _(none)_ | Sets `deployment.environment.name` on every trace (e.g. `production`, `dev`) |
| `LOGFIRE_SESSION_LABEL` | No | `Claude Code session` | Label for the root session span — useful when running multiple CC sessions in one trace |
| `OTEL_SERVICE_NAME` | No | `claude-code-plugin` | Overrides the `service.name` resource attribute |
| `OTEL_RESOURCE_ATTRIBUTES` | No | _(none)_ | Standard OTel env var for additional resource attributes, e.g. `deployment.environment.name=prod,service.instance.id=worker-1` |

Without `LOGFIRE_TOKEN`, no traces are sent. The plugin does nothing unless at least one of `LOGFIRE_TOKEN` or `LOGFIRE_LOCAL_LOG` is set.
Without either `LOGFIRE_TOKEN` or a stored OAuth bundle, no traces are sent.
The plugin does nothing unless at least one auth mode or `LOGFIRE_LOCAL_LOG`
is configured.

## What you get

Expand Down Expand Up @@ -118,8 +170,9 @@ Diagnostic logs are written to `.claude/logs/diagnostics.jsonl` in the project d

**Common issues:**

- **No traces appearing in Logfire** -- Check that `LOGFIRE_TOKEN` is set and valid. Enable diagnostics to see if OTLP exports are failing.
- **Export errors (HTTP 401/403)** -- Your Logfire token may be invalid or expired. Generate a new write token in the Logfire console.
- **No traces appearing in Logfire** -- Check that `LOGFIRE_TOKEN` is set and valid, or that `/logfire-session-capture:status` reports a stored OAuth bundle. Enable diagnostics to see if OTLP exports are failing.
- **Export errors (HTTP 401/403)** -- Your Logfire token may be invalid or expired. Generate a new write token in the Logfire console, or run `/logfire-session-capture:login` to refresh the OAuth bundle.
- **OAuth refresh keeps failing** -- The refresh token may have been revoked or expired. Run `/logfire-session-capture:logout` then `/logfire-session-capture:login` to start a fresh flow.
- **Export errors (HTTP 4xx/5xx)** -- Check `LOGFIRE_BASE_URL` if using a non-default region. The plugin logs HTTP status codes to stderr and diagnostics.

## Development
Expand Down
10 changes: 10 additions & 0 deletions bin/logfire-auth
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Plugin-PATH wrapper for scripts/auth.py.
#
# Claude Code adds this plugin's bin/ to PATH, so slash commands can invoke
# `logfire-auth ...` directly without an explicit $CLAUDE_PLUGIN_ROOT expansion
# (which the slash-command permission checker rejects as "simple_expansion").
# $CLAUDE_PLUGIN_ROOT isn't exported into the spawned subshell, so we locate
# the plugin root from the wrapper's own path: bin/<this script>'s parent dir.
PLUGIN_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)
exec python3 "$PLUGIN_ROOT/scripts/auth.py" "$@"
21 changes: 21 additions & 0 deletions commands/login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
description: Log in to Logfire via OAuth device flow (alternative to LOGFIRE_TOKEN)
allowed-tools: Bash(logfire-auth *)
---

Run the OAuth device authorization flow against Logfire and persist a token
bundle the plugin will auto-refresh on every Claude Code session.

Use this instead of setting a fixed `LOGFIRE_TOKEN`. Once logged in, the plugin
silently refreshes the access token whenever it's near expiry (no manual
re-login until the refresh token itself is revoked).

The script reads `$LOGFIRE_BASE_URL` for the default region (US if unset). Any
slash-command arguments are forwarded, so you can override with
`--base-url http://localhost:3000` (or pass `--no-browser`, `--client-id ...`,
etc). The chosen base URL is recorded inside the stored bundle, so subsequent
`/logfire-session-capture:logout`, `:status`, `:refresh` don't need any
arguments — only one base URL is stored at a time, and re-running login
overwrites the previous bundle.

!`logfire-auth login $ARGUMENTS`
10 changes: 10 additions & 0 deletions commands/logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: Remove the stored Logfire OAuth token
allowed-tools: Bash(logfire-auth *)
---

Delete the stored OAuth token bundle at `~/.logfire/claude-code-logfire-plugin.json`.
The bundle's own base URL is used — no extra argument needed. After logout the
plugin falls back to `LOGFIRE_TOKEN` (or stays silent if no token is configured).

!`logfire-auth logout`
12 changes: 12 additions & 0 deletions commands/refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
description: Force-refresh the stored Logfire OAuth access token
allowed-tools: Bash(logfire-auth *)
---

Force an OAuth refresh against the Logfire authorization server using the
stored refresh token, even if the access token isn't near expiry. Operates on
the stored bundle's own base URL. The plugin's hooks already refresh lazily on
every invocation, so this is mostly useful for debugging or to deliberately
cycle the access token without re-running the device flow.

!`logfire-auth refresh`
11 changes: 11 additions & 0 deletions commands/status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
description: Show stored Logfire OAuth token status (expiry, scope, etc.)
allowed-tools: Bash(logfire-auth *)
---

Print metadata about the stored OAuth token bundle: the base URL, scope,
client_id, time until expiry, and whether a refresh token is present. Does not
display the access token itself. Operates on whatever's in the store — only one
bundle is kept at a time.

!`logfire-auth status`
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "claude-code-logfire-plugin"
version = "0.4.1"
version = "0.5.0"
requires-python = ">=3.7"

[dependency-groups]
Expand Down
Loading