Skip to content

Commit 718d03a

Browse files
Add OTel LLM-parent fan-out instance fallback
Align OTel's _resolve_llm_parent with the spec ruling (review-nested-fan-out-lineage): an orphaned LLM span (calling node has no open span) inside a fan-out instance now parents under the per-instance fan-out dispatch span, matching the Langfuse observer, instead of falling through to the subgraph or invocation span. Top-level instance parity only. The generalized nearest-open-ancestor walk -- nested instances at any depth (where the parity key can mis-resolve to a sibling top-level instance) and the instance-vs-deeper-subgraph ordering -- rides the pending spec observability fallback clause and fixture.
1 parent 27e9b99 commit 718d03a

3 files changed

Lines changed: 75 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
88

99
### Fixed
1010

11+
- **OTel: an orphaned LLM span inside a fan-out instance now parents under the per-instance dispatch span** (observability §5.5). An LLM provider span whose calling node has no open span (for example a provider call originating from middleware or a wrapper) and which fires inside a fan-out instance now parents under the per-instance fan-out dispatch span, matching the Langfuse observer, instead of falling through to the subgraph or invocation span. This resolves a divergence between the two observers. The generalized nearest-open-ancestor fallback (nested instances at any depth, and the instance-vs-deeper-subgraph ordering) is pending a spec clause and fixture; this aligns the top-level instance case now.
1112
- **`current_fan_out_index()` inside fan-out instance middleware** now returns the executing instance's index (and `current_fan_out_index_chain()` its lineage) instead of `None`. The engine set the fan-out lineage ContextVars per-node, inside the inner subgraph, which left them unset in `instance_middleware` that wraps the subgraph from outside; they are now set around the instance-middleware chain. The documented `instance_middleware` use (`RetryMiddleware`) does not read the index, so no shipped behavior changes. This corrects the value seen by custom instance middleware that reads the index or calls `set_invocation_metadata`.
1213
- **Langfuse per-branch dispatch-span observation** (observability §4.3 / §8.4.2, proposals 0042 / 0044). The Langfuse observer now synthesizes a per-branch Span observation under a `parallel_branches` dispatcher node, so each branch's inner observations nest under their own branch span (a three-level dispatcher / per-branch-span / inner-nodes tree) instead of parenting directly under the dispatcher. The per-branch observation carries the OA-emitted `branch_name` alongside the caller baseline metadata and any per-branch augmentation, and the Generation observation now carries `branch_name` too. The OTel observer already produced this shape (proposal 0044 shipped OTel-only in v0.11.0); this brings the Langfuse mapping into line. Callable branches (proposal 0075) are unchanged.
1314
- **Augmentation no longer lands on a shared-parent fan-out / parallel-branches node** (observability §3.4, proposal 0045). A key set via `set_invocation_metadata` inside a fan-out instance or a parallel-branches branch was incorrectly applied to the shared fan-out / dispatcher NODE span (the fork point) when the augmenting context executed at that node's own namespace, in addition to the per-instance / per-branch dispatch span where it belongs. Both the OTel and Langfuse observers now skip the shared-parent node in that case, matching the behavior already applied to strict-ancestor shared parents. The per-instance / per-branch dispatch spans and the lineage ancestors that carry the augmentation are unaffected.

src/openarmature/observability/otel/observer.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,7 +1764,23 @@ def _resolve_llm_parent(
17641764
calling = inv_state.open_spans.get(calling_key)
17651765
if calling is not None:
17661766
return set_span_in_context(calling.span)
1767-
# 2. Walk up the calling namespace prefix for a synthetic
1767+
# 2. Per-instance fan-out dispatch (spec ruling,
1768+
# review-nested-fan-out-lineage): an orphaned LLM span inside a
1769+
# fan-out instance parents under the per-instance dispatch span, not
1770+
# the subgraph / invocation span. Mirrors the LangfuseObserver
1771+
# fallback. Top-level instance ONLY (namespace[:1] + the scalar
1772+
# fan_out_index): for a NESTED fan-out instance the innermost index
1773+
# can coincide with a sibling top-level instance's, so this may
1774+
# mis-resolve to that sibling rather than miss. The nearest-open-
1775+
# ancestor-at-any-depth generalization (which also reorders this ahead
1776+
# of the subgraph walk below) fixes that and rides the spec §5.5
1777+
# fixture.
1778+
if calling_fan_out_index is not None and calling_namespace_prefix:
1779+
instance_key = _dispatch_key(calling_namespace_prefix[:1], (calling_fan_out_index,), (None,))
1780+
dispatch = inv_state.fan_out_instance_spans.get(instance_key)
1781+
if dispatch is not None:
1782+
return set_span_in_context(dispatch.span)
1783+
# 3. Walk up the calling namespace prefix for a synthetic
17681784
# subgraph dispatch span at any ancestor — covers LLM
17691785
# calls from inside subgraph wrapper middleware.
17701786
for plen in range(len(calling_namespace_prefix), 0, -1):
@@ -1775,12 +1791,12 @@ def _resolve_llm_parent(
17751791
dr = inv_state.detached_roots.get(ancestor)
17761792
if dr is not None:
17771793
return set_span_in_context(dr.span)
1778-
# 3. Invocation span — ``complete()`` called outside any
1794+
# 4. Invocation span — ``complete()`` called outside any
17791795
# node body but inside an ``invoke()``.
17801796
inv = self._invocation_span.get(invocation_id)
17811797
if inv is not None:
17821798
return set_span_in_context(inv.span)
1783-
# 4. No invocation in scope — return a fresh empty Context.
1799+
# 5. No invocation in scope — return a fresh empty Context.
17841800
# The span will live in its own trace.
17851801
return otel_context.Context()
17861802

tests/unit/test_observability_otel.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,61 @@ async def test_active_prompt_propagates_to_llm_span_attributes() -> None:
559559
assert attrs.get("openarmature.prompt.group_name") == "classifier_chain"
560560

561561

562+
async def test_llm_span_parents_under_fan_out_instance_dispatch() -> None:
563+
# Gap 2 (review-nested-fan-out-lineage): an LLM span whose calling node has
564+
# no open span and fires inside a top-level fan-out instance MUST parent
565+
# under the per-instance fan-out dispatch span (matching the Langfuse
566+
# observer), not fall through to the subgraph / invocation span. Before this
567+
# OTel had no fan-out-instance fallback in _resolve_llm_parent.
568+
from openarmature.observability.correlation import (
569+
_reset_invocation_id,
570+
_set_invocation_id,
571+
)
572+
from openarmature.observability.otel.observer import (
573+
_dispatch_key,
574+
_InvState,
575+
_OpenSpan,
576+
)
577+
from tests._helpers.typed_event import make_retry_attempt_event
578+
579+
exporter = InMemorySpanExporter()
580+
observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter))
581+
invocation_id = "inv-fanout-llm"
582+
token = _set_invocation_id(invocation_id)
583+
try:
584+
# Per-instance dispatch span for top-level fan-out "fan", instance 0,
585+
# keyed by the lineage-aware _dispatch_key. No open_spans entry for the
586+
# calling node ("fan", "ask"), so the resolver must reach the fan-out
587+
# dispatch fallback.
588+
observer._inv_states[invocation_id] = _InvState() # noqa: SLF001
589+
dispatch_span = observer._tracer.start_span("fan") # noqa: SLF001
590+
instance_key = _dispatch_key(("fan",), (0,), (None,))
591+
observer._inv_states[invocation_id].fan_out_instance_spans[instance_key] = _OpenSpan( # noqa: SLF001
592+
span=dispatch_span
593+
)
594+
await observer(
595+
make_retry_attempt_event(
596+
invocation_id=invocation_id,
597+
node_name="ask",
598+
namespace=("fan", "ask"),
599+
attempt_index=0,
600+
fan_out_index=0,
601+
branch_name=None,
602+
)
603+
)
604+
dispatch_span.end()
605+
finally:
606+
_reset_invocation_id(token)
607+
608+
observer.shutdown()
609+
llm_spans = [s for s in exporter.get_finished_spans() if s.name == "openarmature.llm.complete"]
610+
assert len(llm_spans) == 1
611+
assert llm_spans[0].parent is not None, "LLM span must have a parent, not be a trace root"
612+
assert cast("Any", llm_spans[0].parent).span_id == dispatch_span.get_span_context().span_id, (
613+
"LLM span must parent under the per-instance fan-out dispatch span"
614+
)
615+
616+
562617
async def test_llm_span_has_no_prompt_attributes_when_no_active_prompt() -> None:
563618
"""Without ``with_active_prompt``, the LLM-call span MUST NOT carry
564619
``openarmature.prompt.*`` attributes."""

0 commit comments

Comments
 (0)