Skip to content

Commit 62691fe

Browse files
Langfuse observer: fan-out / subgraph / detached parenting (#81)
* Add fan-out / subgraph / detached parenting to Langfuse observer Extends LangfuseObserver with synthetic dispatch observations matching the OTel observer's structure. Spec §8.3 mandates Span observations for subgraph wrappers, fan-out nodes, and per-instance fan-out spans; §8.5 covers detached-trace mode where a configured subgraph or fan-out gets its own Langfuse Trace and the parent's dispatch observation surfaces metadata.detached_child_trace_ids. _InvState gains six new fields: - subgraph_observations: synthetic dispatch Span observations keyed by namespace prefix (lives in main Trace for non-detached, or in the detached Trace for detached subgraphs) - fan_out_instance_observations: per-instance dispatch Span observations keyed by prefix + (str(fan_out_index),) - detached_traces: prefix -> detached trace_id mapping that switches descendant observations onto the detached Trace - fan_out_instance_root_prefixes: tracks detached fan-out instance prefixes for the close path - fan_out_parent_node_name: cache populated from fan_out_config on the fan-out node's started event, bridging the lookup for the per-instance attribution metadata - detached_child_trace_ids: side-cache accumulator for the link-ids array on dispatch observations spawning detached children (the Protocol doesn't expose a metadata read accessor) LangfuseObserver gains two constructor kwargs (detached_subgraphs, detached_fan_outs) mirroring the OTel observer's surface. _resolve_parent_observation_id rewritten with full precedence: per-instance fan-out dispatch > subgraph dispatch (walked longest- first) > leaf-node ancestor walk > Trace root. Same precedence applied to _resolve_llm_parent_observation_id so an LLM call from inside a subgraph/fan-out parents correctly. _trace_id_for picks the right Trace (main or detached) for an observation by walking ancestor prefixes longest-first against the detached_traces map. Per-instance detached fan-out Traces are keyed by prefix + (str(fan_out_index),) and checked first. _sync_subgraph_observations is the synthesis driver: opens any ancestor dispatch observation the leaf event needs, closes any whose subtree we've left. Called before opening the leaf so the parent resolver sees them. Detached fan-out instance roots are exempted from cursor-move close — they close with the fan-out node's own completed event. Asymmetry documented in the helper docstrings: detached subgraphs synthesize a second "link" observation in the main Trace (subgraphs have no per-subgraph node event of their own, so there's no pre-existing observation to attach the link metadata to). Detached fan-out instances accumulate the link metadata on the fan-out node's pre-existing leaf observation instead. Four new unit tests cover the synthesis paths (no Langfuse spec fixtures exist for these in v0.23.0; spec considering a follow-on proposal to add them before v0.10.0 release): - subgraph dispatch parents inner-node observations - non-detached fan-out per-instance dispatches parent worker nodes - detached subgraph splits into two Traces with the metadata link - detached fan-out per-instance Traces with accumulating detached_child_trace_ids array on the fan-out node All 3 existing conformance fixtures (022-024) still pass; no regression on linear/LLM/prompt cases. * Address CoPilot review feedback on Langfuse observer Five real catches from the PR #81 review, all behavioral or correctness fixes — no spec-touching changes. 1. Detached-subgraph link observation in main Trace was opened but never ended. Capture the handle returned by client.span(...) and call .end() immediately — the link observation is intentionally zero-duration metadata-only, mirroring the OTel observer's checkpoint-event synthetic-span pattern. 2. Synthetic subgraph dispatch / fan-out per-instance dispatch observations only close on namespace-cursor moves. A subgraph at the tail of an invocation never gets its close trigger fired. Added close_invocation(invocation_id) and shutdown() drain methods mirroring the OTel observer's lifecycle. close_invocation walks per-invocation state in child→parent order (LLM observations → leaf nodes sorted deepest-first → per-instance fan-out dispatches → subgraph dispatches), ending each. shutdown() iterates every in-flight invocation_id and calls close_invocation. Idempotent. 3. detached_child_trace_ids side-cache was never cleared on fan-out node completion. Cyclic graphs re-entering the same fan-out would accumulate prior-iteration trace ids into the next iteration's list, overwriting the link metadata. Pop the entry in _handle_completed's fan-out-completion branch alongside the existing fan_out_parent_node_name pop. 4. _resolve_llm_parent_observation_id docstring claimed a leaf-ancestor walk fallback that the impl doesn't have. The actual precedence (exact-leaf > per-instance fan-out dispatch > subgraph dispatch longest-prefix-first > None) is correct because the dispatch fallbacks cover the wrapped-call cases an ancestor walk would have caught. Docstring updated to describe the real chain. 5. cast("list[str]", link_ids) used the string-form unnecessarily. With from __future__ import annotations on, the type expression form cast(list[str], link_ids) is the right shape and survives strict pyright settings. New unit test verifies the close_invocation drain path: a graph whose last subtree is a subgraph leaves the dispatch observation in-flight after invoke + drain; shutdown() ends it cleanly.
1 parent 885fee5 commit 62691fe

2 files changed

Lines changed: 772 additions & 24 deletions

File tree

0 commit comments

Comments
 (0)