Skip to content

feat(hook): name traces after the session title - #104

Open
a-j-i-n-k-y-a wants to merge 2 commits into
langfuse:mainfrom
a-j-i-n-k-y-a:feat/session-title-as-trace-name
Open

a-j-i-n-k-y-a wants to merge 2 commits into
langfuse:mainfrom
a-j-i-n-k-y-a:feat/session-title-as-trace-name

Conversation

@a-j-i-n-k-y-a

Copy link
Copy Markdown

Problem

Every trace the hook emits is named Claude Code Turn. With more than a handful
of sessions the Traces list becomes unnavigable — each row looks identical, and
since Langfuse sessions have no name field there is nothing else to recognise a
session by. Filtering on metadata.project narrows things to a directory, but
within a directory the rows are still indistinguishable.

Claude Code already knows the answer. It writes the session title into the
transcript as its own row type:

{"type": "custom-title", "customTitle": "Meeting Module",            "sessionId": "..."}
{"type": "ai-title",     "aiTitle":     "Langfuse port 5432 conflict", "sessionId": "..."}

custom-title is what /rename writes; ai-title is the auto-generated one
shown in the /resume picker. The hook already reads this transcript — it just
never looks at these rows.

Change

Use the session title as the trace name, falling back to the existing constant.

-            trace_name=TRACE_NAME,
+            trace_name=session_title or TRACE_NAME,

A rename now shows up in Langfuse under the same name the user chose in Claude
Code, so the Traces and Sessions lists become readable without any new
configuration.

Three details drove the rest of the diff:

  • Titles have to persist across firings. read_new_jsonl returns only rows
    appended since the last run, so a title row seen on turn 1 is behind the read
    offset by turn 20. Titles are kept in SessionState and round-trip through
    the state file alongside turn_numbers / turn_progress.
  • custom-title must beat ai-title regardless of row order. The two can
    arrive in different firings, so a single remembered string would let a late
    ai-title overwrite an explicit rename. State holds
    titles_by_type: Dict[str, str] and the preference order is applied at read
    time.
  • Extraction runs after the copied-history filter. Forked and resumed
    transcripts copy history verbatim, title rows included; extracting earlier
    would let a fork inherit its parent's title.

Titles are trimmed and capped at 200 characters so a pathological value cannot
bloat every trace. Attached mode (parent_context is not None) is untouched —
it deliberately propagates no trace-level fields, and
test_parent_trace_attachment.py still passes unchanged.

No new dependencies, env vars, or network calls, and nothing changes about what
conversation data is captured, truncated or transmitted. The only new log line
names the row type, never the title text, and only under CC_LANGFUSE_DEBUG.

Second commit: unrelated test rot

test_entry_of_another_session_survives_the_emit fails on main today,
independently of this change:

$ git checkout main && uv run --group dev pytest tests/integration/test_state_lock_contention.py
1 failed, 7 passed

It plants a state entry with a hardcoded "updated": "2026-09-10T00:00:00+00:00",
and save_hook_state prunes entries older than 30 days — so the entry is now
swept and the assertion fires for a reason the test was not written to catch.
The fix uses _now_iso(), a helper already used elsewhere in that same file.

It is a separate commit because it is a separate problem; happy to split it into
its own PR if you would rather land it independently. Without it the test gate
cannot be green.

Tests

Eight tests added to tests/unit/test_transcript_reader.py, which already owns
"rows read out of the transcript into session state across firings": preference
order in both row orders, last-occurrence-wins per type, the
ai-title-then-rename sequence, unusable values (empty, whitespace, None,
non-string, missing field, and a non-title row that happens to carry
customTitle), trimming and the length cap, state-file round-trip, and a
two-firing test where the title row sits behind the second firing's read offset
so the title can only come from persisted state. Plus the fallback case
asserting TRACE_NAME when no title row exists.

Both load-bearing tests were confirmed to fail against the unfixed behaviour.

uv run --group dev pytest        ->  287 passed
uvx ruff check --select F,E9 .   ->  All checks passed!
echo '{}' | uv run --script hooks/langfuse_hook.py  ->  exit 0

Also verified end to end against a self-hosted Langfuse v4 (events_only)
instance with the patched plugin installed from a local marketplace.

What a reviewer should doubt

  • Keeping titles_by_type through a transcript rotation. read_new_jsonl drops
    persisted turn state when the transcript shrinks; titles are deliberately kept
    on the grounds that a title is a session-level fact and the rows get re-read
    from byte 0 anyway. The alternative — clearing it and briefly falling back to
    the constant — is defensible.
  • Whether ai-title should be a fallback at all, or whether only an explicit
    /rename should change the trace name. Auto-titles are derived from
    conversation content; that content already reaches Langfuse via
    metadata.user_text, but a trace name is more likely to end up in a
    screenshot or an export. Easy to drop if you would rather be conservative.
  • The 200-character cap is a judgement call, not a measured limit.

🤖 Generated with Claude Code

a-j-i-n-k-y-a and others added 2 commits October 12, 2026 12:39
Every trace was named "Claude Code Turn", so traces were
indistinguishable in Langfuse. Claude Code writes the session title into
the transcript as its own row ("custom-title" from /rename, "ai-title"
auto-generated); use it as the trace name and keep the constant as the
fallback for sessions without one.

A rename wins over the auto-generated title, and within a type the last
row read wins. Titles are kept in session state because each firing only
reads the rows appended since the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry it plants carried a hardcoded timestamp. Once that date fell
outside the 30-day retention window, save_hook_state pruned the entry
and the test failed for a reason it was not written to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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