Commit 62691fe
authored
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
0 commit comments