Skip to content

Commit 940a798

Browse files
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 835b8e1 commit 940a798

2 files changed

Lines changed: 103 additions & 7 deletions

File tree

src/openarmature/observability/langfuse/observer.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,12 @@ def _handle_completed(self, event: NodeEvent) -> None:
306306
if len(prefix) > len(event.namespace) and prefix[: len(event.namespace)] == event.namespace:
307307
self._close_fan_out_instance_dispatch_observation(inv_state, prefix)
308308
inv_state.fan_out_parent_node_name.pop(event.namespace, None)
309+
# Clear the detached-child-trace-ids accumulator for this
310+
# fan-out node — cyclic execution that re-enters the same
311+
# fan-out starts the next iteration with a fresh list
312+
# rather than appending to the previous iteration's
313+
# accumulator and overwriting the prior link metadata.
314+
inv_state.detached_child_trace_ids.pop(event.namespace, None)
309315

310316
key = self._key_for(event)
311317
observation = inv_state.open_observations.pop(key, None)
@@ -566,12 +572,18 @@ def _open_detached_subgraph_trace(
566572
if sg is not None:
567573
parent_observation_id = sg.handle.id
568574
break
569-
self.client.span(
575+
# Zero-duration link observation in the main Trace — it
576+
# exists only to surface the cross-Trace reference via
577+
# metadata.detached_child_trace_ids; close it immediately so
578+
# nothing perceives it as in-flight. Mirrors the OTel
579+
# observer's synthetic-event zero-duration spans.
580+
link_handle = self.client.span(
570581
trace_id=inv_state.trace_id,
571582
name=prefix[-1],
572583
metadata=link_metadata,
573584
parent_observation_id=parent_observation_id,
574585
)
586+
link_handle.end()
575587
# Open the detached Trace + the dispatch observation that
576588
# subtree descendants parent under.
577589
detached_metadata: dict[str, Any] = {"detached_from_invocation_id": inv_state.trace_id}
@@ -685,6 +697,59 @@ def _find_fan_out_node_observation(
685697
return observation
686698
return None
687699

700+
# ------------------------------------------------------------------
701+
# Lifecycle: close_invocation / shutdown
702+
# ------------------------------------------------------------------
703+
704+
def close_invocation(self, invocation_id: str) -> None:
705+
"""Drain still-open observations for ``invocation_id``.
706+
707+
Synthetic dispatch observations only close on cursor-move when
708+
a subsequent event arrives with a different namespace prefix.
709+
For a subgraph or fan-out that's the last subtree of an
710+
invocation, no follow-up event triggers the close — this
711+
method walks the per-invocation state and ends anything left
712+
in child→parent order so the Langfuse-side observations don't
713+
stay perpetually in-flight.
714+
715+
Idempotent: calling twice (or for an invocation_id with no
716+
open state) is a no-op.
717+
"""
718+
inv_state = self._inv_states.pop(invocation_id, None)
719+
if inv_state is None:
720+
return
721+
# Order: deepest leaves first so parents see all children
722+
# closed before they end. LLM observations → leaf nodes
723+
# (sorted deepest-first by namespace length) → per-instance
724+
# fan-out dispatches → subgraph dispatches.
725+
for call_id in list(inv_state.open_llm_observations.keys()):
726+
obs = inv_state.open_llm_observations.pop(call_id, None)
727+
if obs is not None:
728+
obs.handle.end()
729+
for key in sorted(
730+
inv_state.open_observations.keys(),
731+
key=lambda k: -len(k[0]),
732+
):
733+
obs = inv_state.open_observations.pop(key, None)
734+
if obs is not None:
735+
obs.handle.end()
736+
for prefix in list(inv_state.fan_out_instance_observations.keys()):
737+
self._close_fan_out_instance_dispatch_observation(inv_state, prefix)
738+
for prefix in sorted(
739+
inv_state.subgraph_observations.keys(),
740+
key=lambda p: -len(p),
741+
):
742+
self._close_subgraph_observation(inv_state, prefix)
743+
744+
def shutdown(self) -> None:
745+
"""Drain every in-flight invocation. Use for long-lived
746+
observers shared across requests; CLI / one-shot processes
747+
typically call this from a ``finally`` block alongside
748+
``compiled.drain()``.
749+
"""
750+
for invocation_id in list(self._inv_states.keys()):
751+
self.close_invocation(invocation_id)
752+
688753
def _observation_metadata(self, event: NodeEvent, correlation_id: str | None) -> dict[str, Any]:
689754
# §8.4.2 observation-level mapping. Fields below mirror the
690755
# OTel observer's _node_attrs() output, renamed for Langfuse's
@@ -783,11 +848,16 @@ def _resolve_llm_parent_observation_id(
783848
self, inv_state: _InvState, payload: LlmEventPayload
784849
) -> str | None:
785850
# Calling-node identity comes from the payload (set at
786-
# dispatch time per llm-provider §5.5). Try the exact-match
787-
# leaf node first; if absent (LLM call fired from a wrapper
788-
# node only, or the calling node already completed), fall
789-
# back to the per-instance / subgraph dispatch chain, then to
790-
# the leaf ancestor walk.
851+
# dispatch time per llm-provider §5.5). Precedence:
852+
# 1. Exact-match leaf node at the calling key.
853+
# 2. Per-instance fan-out dispatch observation when the
854+
# call originated inside a fan-out instance.
855+
# 3. Subgraph dispatch observations along the calling
856+
# namespace prefix, walked longest-prefix-first.
857+
# 4. None — Trace becomes the implicit parent.
858+
# The dispatch fallbacks cover the wrapped-call cases the
859+
# exact-match miss would otherwise need a leaf-ancestor walk
860+
# to handle.
791861
key: _StackKey = (
792862
payload.calling_namespace_prefix,
793863
payload.calling_attempt_index,

tests/unit/test_observability_langfuse.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,37 @@ async def _worker(_s: _WorkerState) -> Any:
314314
# The fan-out node's metadata accumulates all 3 detached trace ids.
315315
link_ids = fan_node.metadata.get("detached_child_trace_ids")
316316
assert isinstance(link_ids, list)
317-
assert set(cast("list[str]", link_ids)) == {t.id for t in detached_traces}
317+
assert set(cast(list[str], link_ids)) == {t.id for t in detached_traces}
318318

319319
# Each detached Trace has its own per-instance dispatch with a
320320
# worker observation under it.
321321
for t in detached_traces:
322322
dispatch = _find_observation(t, "fan")
323323
worker = _find_observation(t, "worker")
324324
assert worker.parent_observation_id == dispatch.id
325+
326+
327+
async def test_subgraph_dispatch_observation_ended_on_invocation_close() -> None:
328+
# Synthetic dispatch observations close on cursor-move; without
329+
# the close_invocation drain a subgraph at the tail of an
330+
# invocation would leave its dispatch in-flight forever. Verifies
331+
# the drain path ends everything.
332+
inner = (
333+
GraphBuilder(_S)
334+
.add_node("inner_a", lambda _s: _record("inner_a"))
335+
.add_edge("inner_a", END)
336+
.set_entry("inner_a")
337+
.compile()
338+
)
339+
parent = GraphBuilder(_S).add_subgraph_node("sub", inner).add_edge("sub", END).set_entry("sub").compile()
340+
graph, client, observer = _attach(parent)
341+
342+
await graph.invoke(_S())
343+
await graph.drain()
344+
# Without explicit close_invocation, the sub dispatch would still
345+
# be in-flight (ended=False). Call shutdown() to drain.
346+
observer.shutdown()
347+
348+
trace = next(iter(client.traces.values()))
349+
for obs in trace.observations:
350+
assert obs.ended, f"observation {obs.name!r} not ended after shutdown()"

0 commit comments

Comments
 (0)