feat(langfuse): attribute traces to the messaging end-user (userId) - #43491
feat(langfuse): attribute traces to the messaging end-user (userId)#43491kamonspecial wants to merge 1 commit into
Conversation
|
Verification review — clean ✅ Read the full diff. The approach is well-designed:
No issues found. |
austinpickett
left a comment
There was a problem hiding this comment.
Good technical implementation, deferring to maintainer for product sign-off on whether this feature is wanted.
Technical notes:
✅ Correct plumbing: sender_id is captured on the turn-scoped pre_llm_call (where it is available in the hook payload) into a _SENDER_BY_SESSION dict, then consumed on on_pre_llm_request where the root trace is actually created — a necessary two-phase approach since the request-scoped hook carries no user identity.
✅ TraceState gets a session_id field so _finish_trace can clean up the _SENDER_BY_SESSION entry, preventing a long-running / persistent-session memory leak.
✅ propagate_attributes user_id kwarg is passed with a graceful TypeError fallback for older Langfuse SDK versions that do not accept it.
✅ user_id is also stashed in trace metadata as a secondary record, which is useful for querying even when propagate_attributes fails.
✅ Well-tested: three new test cases cover the happy path, the two-phase stash/read path, and the no-sender case.
Minor concerns:
-
PII consideration:
sender_idis typically a messaging platform user ID (Slack user ID, Discord snowflake, etc.) — an opaque identifier, not a name or email. As long as the Langfuse instance is self-hosted or the operator has a DPA in place with Langfuse Cloud, this is fine. Worth a note in the plugin README or config docs that enablingsender_idattribution sends user identifiers to the configured Langfuse endpoint. -
_SENDER_BY_SESSIONgrows unbounded on session crash:_finish_tracecleans up the entry on normal completion, but if the process crashes or_finish_traceis never called (e.g. unhandled exception before turn end), the entry leaks for the lifetime of the process. A TTL-based eviction or size cap would be defensive. -
Thread safety on
_SENDER_BY_SESSION: Reads inon_pre_llm_request(_SENDER_BY_SESSION.get(session_id, "")) are outside the_STATE_LOCK. CPython dict reads are effectively atomic for single-key.get(), but this is an implicit assumption worth documenting or protecting explicitly.
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved (with notes)
Overview
Feature adding Langfuse userId attribution by capturing sender_id from the turn-scoped pre_llm_call hook and propagating it to root traces.
Looks Good
- Clean two-phase approach: stash sender_id on pre_llm_call (where available), read on pre_api_request (where trace is created)
- Proper TraceState.session_id field for cleanup
- Graceful fallback for older Langfuse SDK versions without propagate_attributes
- user_id also stored in metadata as secondary record
- Well-tested with three test cases
Notes (non-blocking)
- sender_id (messaging platform user ID, e.g. Slack snowflake) is sent to the configured Langfuse endpoint. Ensure Langfuse is self-hosted or a DPA is in place for cloud usage.
- _SENDER_BY_SESSION dict is cleaned on normal trace completion but can leak on process crash. TTL/size cap would be defensive but not required.
- Thread safety is an implicit assumption (CPython single-key dict.get is atomic). Worth documenting or adding explicit locking for belt-and-suspenders.
Reviewed by Hermes Agent
|
CI |
abc19c1 to
86484c9
Compare
|
Rebased onto current main to resolve the v0.17.0 conflict, and made end-user attribution opt-in: it's now gated behind HERMES_LANGFUSE_END_USER_ATTRIBUTION (default off), so the change is purely additive — when unset the trace carries no user and nothing changes vs current behavior or #26455. The token/cost fix landed separately in #40560. Tests were updated to the v0.17.0 scaffolding (full plugin suite: 56 passing). |
… (userId) Optionally set the Langfuse trace userId to the messaging end-user (sender_id) so gateway traffic can be attributed per person. The turn-scoped pre_llm_call hook already emits sender_id; capture it by session_id and read it back when the root trace is created from pre_api_request. No core change. Gated behind HERMES_LANGFUSE_END_USER_ATTRIBUTION (default off) so it is purely additive and never conflicts with other userId semantics (e.g. assigning the active profile); when unset the trace carries no user. The per-session sender stash is cleared on finalize and bounded by the same eviction as the trace-state map, so a turn that never finalizes does not leak its entry. Older Langfuse SDKs that predate the user_id kwarg on propagate_attributes retry without it so session grouping / trace_name / tags are preserved. Add TestEndUserAttribution covering the opt-in gate, sender->userId propagation, the stash/read/clear lifecycle, the no-sender case, the legacy-SDK fallback, eviction cleanup, and the propagate-unavailable fallbacks.
86484c9 to
58c73c7
Compare
|
Thanks for the careful two-phase tracing implementation and the cleanup/compatibility coverage. This automated hermes-sweeper review is closing this under the standing configuration policy:
The current-main plumbing does expose Closed as not-planned per standing maintainer policy ( |
Summary
Set the Langfuse trace
userIdto the messaging end-user (sender_id) so gateway traffic can be attributed per person.Problem
When Hermes runs as a gateway for a shared, multi-user chat (e.g. a team Slack/Discord), the bundled
observability/langfuseplugin groups every turn under the internal Hermessession_idonly. The traceuserIdis never set, so for gateway traffic there's no way to tell which human drove a given turn — the exact thinguserIdexists for (per-user cost/latency, "who asked for this?" when debugging).Solution
No core change — the data is already available via the documented hook contract:
pre_llm_callhook emitssender_id(fromagent._user_id←source.user_id, populated for every gateway platform).on_pre_llm_callpreviously discarded it: the turn-scoped variant early-returns to avoid an orphan trace, and the root trace is created later in_start_root_trace(driven bypre_api_request), which had no user identity.Contained to
plugins/observability/langfuse/__init__.py:on_pre_llm_callacceptssender_idand stashes it bysession_id(_SENDER_BY_SESSION) before its early return._start_root_traceacceptsuser_idand sets it viapropagate_attributes(user_id=...), mirrored into trace metadata. The legacymessages-list path passessender_iddirectly; thepre_api_requestpath reads it back from the stash._finish_traceclears the per-session entry. Cron/bot turns have no end-user, souserIdstays unset (intended).This stays platform-agnostic — it only uses the generic
sender_idalready provided by the hook contract, so it works for every gateway with no per-platform code. Older Langfuse SDKs whosepropagate_attributespredates theuser_idkwarg degrade gracefully (the call is retried without it, keeping session grouping/tags), with the user still recorded in trace metadata.Testing
uv run --extra dev --extra messaging pytest tests/plugins/test_langfuse_plugin.py -q→ 43 passed (4 new inTestEndUserAttribution).uv run --extra dev ruff check ...→ clean.