Skip to content

Rewrite plugin from Python to TypeScript (running on Bun)#1

Open
jannikmaierhoefer wants to merge 9 commits into
mainfrom
typescript-rewrite
Open

Rewrite plugin from Python to TypeScript (running on Bun)#1
jannikmaierhoefer wants to merge 9 commits into
mainfrom
typescript-rewrite

Conversation

@jannikmaierhoefer

@jannikmaierhoefer jannikmaierhoefer commented Jun 9, 2026

Copy link
Copy Markdown
Member

What

Rewrites the Claude Code observability plugin from Python to a self-contained, pre-bundled TypeScript hook. The plugin now runs on Bun (>= 1.2) instead of requiring python3 and the langfuse Python SDK.

The structure and conventions mirror the sibling codex-observability-plugin: one self-contained dist/index.mjs invoked directly by the hook, deps bundled at build time, Langfuse TS SDK over OpenTelemetry.

Why

The Python version was painful to get running on macOS — it depended on a system python3 having langfuse>=4.0,<5 importable, and silently no-op'd when the SDK wasn't available. Shipping a committed bundle means zero install/build step for users; Bun runs it directly.

What changed

  • src/ — hook entry (index.ts), config resolution (config.ts), transcript parsing (parse.ts), Langfuse emit (trace.ts), incremental sidecar state (state.ts), OTel setup (instrumentation.ts), helpers (utils.ts, types.ts).
  • dist/index.mjs — committed self-contained bundle (bun build, single ESM file with dynamic imports inlined). All runtime deps (Langfuse SDK, OpenTelemetry, zod) bundled; only Node built-ins (which Bun provides) external. Run directly by the hooks, no install step.
  • hooks/hooks.json — invokes bun "${CLAUDE_PLUGIN_ROOT}/dist/index.mjs" on Stop and SessionEnd (was python3 .../langfuse_hook.py), with a 30s timeout.
  • Dedup/state — replaced the global ~/.claude/state/langfuse_state.json + file lock with an incremental byte-offset reader and a per-transcript sidecar (.jsonl.langfuse) storing {offset, turnCount}, so each turn uploads exactly once.
  • Removed hooks/langfuse_hook.py.
  • Merged main and ported its Python-hook features to TS (no merged functionality is dropped): the langfuse-observability plugin rename, LANGFUSE_USER_ID option, isMeta row handling (slash-command/skill injections no longer create phantom turns), skill:<name> trace tags (CC_LANGFUSE_SKILL_TAGS, default on), optional capture of injected skill instructions on the Skill tool span (CC_LANGFUSE_CAPTURE_SKILL_CONTENT, default off), and cwd/git-branch trace metadata.
  • Tooling — Bun end to end: bun install (bun.lock), bun build for bundling, native bun test for the test suite, tsc --noEmit + prettier for linting. .gitignore keeps dist/, ignores *.jsonl.langfuse.
  • Docs — README rewritten for the Bun/TS setup; plugin bumped to 3.0.0.

Trace structure (unchanged semantics)

One trace per turn, grouped into a Langfuse session by session id; one generation per assistant message with model + token usage (including cache read/write); nested tool observations matched tool_usetool_result; subagent transcripts expanded inline under the spawning tool call. Original timestamps preserved on every span. Fails open — a tracing error never blocks the session.

Config

Resolved as defaults → ~/.claude/langfuse.json<cwd>/.claude/langfuse.json → env. For each setting, the Claude Code plugin-config form (CLAUDE_PLUGIN_OPTION_<NAME>) wins, then plain LANGFUSE_*, then CC_LANGFUSE_* aliases — preserving the keys the existing userConfig in plugin.json already exposes. Optional knobs: environment, user_id, tags, metadata, max_chars, skill_tags, capture_skill_content, fail_on_error.

Testing

  • bun run lint (prettier + tsc --noEmit + dist-is-current check) and bun test (24 tests) pass.
  • Verified the original Node bundle end-to-end against Langfuse Cloud: a turn with a tool call produced a trace with the turn span, two generations, and the nested tool observation, with correct input/output, token usage, cost, and backdated timestamps.
  • Verified the Bun runtime end-to-end against a mock Langfuse OTLP endpoint (real wire payload): same span tree, session.id/user.id/tags propagated to every span (AsyncLocalStorage context propagation works on Bun), correct usage and backdated timestamps, exactly-once sidecar dedup, and fail-open confirmed under a real network failure (exit 0 by default, exit 1 with CC_LANGFUSE_FAIL_ON_ERROR=true).
  • The ported main features verified the same way: skill:<name> tag on every span of the trace, injected skill instructions only on the Skill tool span, cwd/git-branch metadata on the turn, and isMeta rows no longer splitting turns.
  • bun build output confirmed byte-for-byte deterministic (keeps the lint:dist CI guard meaningful).

Reviewer notes

  • dist/index.mjs is intentionally committed (large diff) — that's the artifact the hook runs. CI guard (lint:dist) rebuilds and fails if it drifts from src/. Rebuild + commit after any src/ change.
  • Breaking: requires Bun >= 1.2 instead of Python; hence the major version bump. Existing installs tracking this should be re-pointed once merged (e.g. to main or a tag).
  • The bundle is built with --target node, so it also runs under Node.js >= 20 unchanged — only the hook command and tooling assume Bun.
  • The git-commit-message / session-init features from some users' manual Python hook setups are out of scope here — this plugin only does Stop/SessionEnd-hook tracing, matching the original langfuse_hook.py.

🤖 Generated with Claude Code

jannikmaierhoefer and others added 7 commits June 9, 2026 15:00
Replace the Python Stop hook with a self-contained, pre-bundled TypeScript
hook so the plugin runs on Node.js (>=20) instead of requiring Python and the
langfuse Python SDK — which was painful to set up on macOS.

- src/: hook entry, config resolution, transcript parsing, Langfuse emit,
  incremental sidecar state, OTel instrumentation
- dist/index.mjs: committed self-contained bundle (tsdown), run directly by the
  Stop hook with no install step
- Spans sent via the Langfuse TypeScript SDK over OpenTelemetry, batched
- Incremental byte-offset reader + per-transcript dedup sidecar replaces the
  global state file and file lock
- vitest tests for parsing and config resolution
- Updated hooks.json to invoke node, refreshed README, bumped to 2.0.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generation observations are now all named "Claude Generation" instead of
"Claude Generation 1", "Claude Generation 2", … The per-step index is still
available on the observation via the claude.step_index metadata field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ships the "Claude Generation" observation rename. Patch bump so installs
tracking the branch pick up the new bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… its name

The root turn observation is now an agent-type observation named "Claude Code
Turn" (was a span named "Claude Code - Turn N"); the trace name matches. The
turn number remains available via the claude.turn_number metadata field.

Release 2.0.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When no user_id is configured (via CC_LANGFUSE_USER_ID, env, or langfuse.json),
fall back to the email of the logged-in Claude Code user, read from
~/.claude.json (oauthAccount.emailAddress). This attaches the user to every
trace/observation so sessions can be filtered by user in Langfuse. An explicit
user_id still takes precedence.

Release 2.1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code records each subagent (spawned via the Agent/Task tool) as its own
transcript at <project>/<sessionId>/subagents/agent-<id>.jsonl, with a sibling
.meta.json whose toolUseId links it to the spawning tool_use block in the main
transcript.

Discover those subagent transcripts, parse them with the same turn builder, and
nest each subagent's generations and tool calls under the exact tool call that
launched it. Nesting recurses (a subagent can spawn subagents) and is guarded
against cycles via a visited set.

- src/subagents.ts: discover subagents and index them by spawning tool_use id
- src/state.ts: factor out parseRows / readAllRows for full-file reads
- src/trace.ts: async emit pipeline with a reusable emitSteps helper; expand
  subagents under their tool observation
- tests for discovery; README documents the new Subagents behavior

Release 2.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- hooks.json now runs the committed bundle with bun instead of node
- Bundle dist/index.mjs with 'bun build' (single self-contained ESM file,
  dynamic imports inlined) instead of tsdown; drop tsdown.config.ts
- Port tests from vitest to Bun's built-in test runner (bun:test)
- Replace pnpm with bun: bun.lock instead of pnpm-lock.yaml, scripts use
  bun, engines require bun >= 1.2, packageManager dropped
- Update README prerequisites and development docs to Bun
- Bump plugin version to 3.0.0 (runtime prerequisite change)

Verified end-to-end under Bun against a mock Langfuse OTLP endpoint:
turn/generation/tool span tree, session-id and user-id context
propagation via AsyncLocalStorage, token usage, backdated timestamps,
exactly-once sidecar dedup, and fail-open behavior all intact.

https://claude.ai/code/session_015es18wH2qWjsuPtZrXQCjA
@CodeCLS CodeCLS self-requested a review June 10, 2026 08:05
@jannikmaierhoefer jannikmaierhoefer changed the title Rewrite plugin from Python to TypeScript Rewrite plugin from Python to TypeScript (running on Bun) Jun 10, 2026
claude added 2 commits June 10, 2026 13:59
Keeps the TS/Bun hook as the implementation: hooks run the bundled
dist/index.mjs (now on SessionEnd as well as Stop, matching main), the
Python hook stays deleted, and main's plugin rename to
'langfuse-observability' plus the new userConfig options are taken.
The new Python-hook features merged on main (LANGFUSE_USER_ID, isMeta
row handling, skill tags, skill content capture, cwd/git-branch
metadata) are ported to the TypeScript hook in the follow-up commit.

https://claude.ai/code/session_015es18wH2qWjsuPtZrXQCjA
Brings the TS rewrite up to parity with the features merged to main
after the rewrite branched:

- LANGFUSE_USER_ID env/plugin option (CC_LANGFUSE_USER_ID still works)
- Skip isMeta transcript rows (slash-command expansions, skill
  instructions) so they no longer create phantom turns
- Tag traces skill:<name> per invoked skill (CC_LANGFUSE_SKILL_TAGS,
  default on)
- Optionally attach injected skill instructions to the Skill tool span
  (CC_LANGFUSE_CAPTURE_SKILL_CONTENT, default off)
- Surface the turn's cwd and git branch as trace metadata
- Run the hook on SessionEnd as well as Stop (done in the merge commit)

Adds bun tests for all of the above (24 total) and documents the new
options in the README. Verified end-to-end against a mock Langfuse
OTLP endpoint: skill tag propagated to every span, injected
instructions only on the Skill tool span, cwd/git_branch on the turn,
and the isMeta row no longer splits the turn.

https://claude.ai/code/session_015es18wH2qWjsuPtZrXQCjA
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants