Skip to content

Commit 2da5577

Browse files
Guard 0084 orphan resolution for parallel branches
Add unit-test guards pinning the branch-side of the §5.5 orphan fallback in both observers -- an orphan provider call inside a parallel branch parents under the per-branch dispatch span / observation (the nearest enclosing wrapper), the branch analog of the fan-out orphan tests. The 0084 fixtures cover fan-out nesting only; a spec fixture for the branch case is flagged for the batched review (release-v0.17.0 coord).
1 parent 86cb818 commit 2da5577

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

tests/unit/test_observability_langfuse.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,6 +1883,71 @@ async def test_llm_event_parents_under_fan_out_instance_dispatch() -> None:
18831883
)
18841884

18851885

1886+
async def test_llm_event_parents_under_parallel_branch_dispatch() -> None:
1887+
# Proposal 0084 branch coverage: the orphan fallback resolves a per-branch
1888+
# dispatch observation as well as a fan-out instance observation, but the
1889+
# 0084 fixtures (132/133/134) are fan-out only. An orphan LLM event fired
1890+
# inside a parallel branch (calling node observation not open) MUST parent
1891+
# under the per-branch dispatch observation -- the nearest enclosing wrapper
1892+
# -- resolved via branch_name_chain. Guards the reference behavior pending a
1893+
# spec fixture (see release-v0.17.0 coord).
1894+
from openarmature.observability.correlation import (
1895+
_reset_invocation_id,
1896+
_set_invocation_id,
1897+
)
1898+
from openarmature.observability.langfuse.observer import (
1899+
_branch_dispatch_key,
1900+
_InvState,
1901+
_OpenObservation,
1902+
)
1903+
from tests._helpers.typed_event import make_failed_event
1904+
1905+
client = InMemoryLangfuseClient()
1906+
observer = LangfuseObserver(client=client)
1907+
invocation_id = "inv-pb-llm"
1908+
token = _set_invocation_id(invocation_id)
1909+
try:
1910+
client.trace(id=invocation_id, name="dispatcher")
1911+
observer._inv_states[invocation_id] = _InvState(trace_id=invocation_id) # noqa: SLF001
1912+
inv_state = observer._inv_states[invocation_id] # noqa: SLF001
1913+
# Per-branch dispatch for branch "fast" of pb node "dispatcher". No
1914+
# open_observation for the calling node ("dispatcher", "ask"), so the
1915+
# resolver reaches the per-branch dispatch fallback. The calling node
1916+
# sits in branch "fast": branch_name_chain=(None, "fast") aligned to
1917+
# namespace.
1918+
fi_chain: tuple[int | None, ...] = (None, None)
1919+
bn_chain: tuple[str | None, ...] = (None, "fast")
1920+
dispatch_handle = client.span(trace_id=invocation_id, name="fast")
1921+
branch_key = _branch_dispatch_key(("dispatcher",), fi_chain, bn_chain, "fast")
1922+
inv_state.parallel_branches_branch_spans[branch_key] = _OpenObservation(handle=dispatch_handle)
1923+
await observer(
1924+
make_failed_event(
1925+
invocation_id=invocation_id,
1926+
node_name="ask",
1927+
namespace=("dispatcher", "ask"),
1928+
attempt_index=0,
1929+
fan_out_index=None,
1930+
branch_name="fast",
1931+
fan_out_index_chain=fi_chain,
1932+
branch_name_chain=bn_chain,
1933+
model="m-test",
1934+
error_category="provider_unavailable",
1935+
error_type="ProviderUnavailable",
1936+
error_message="503 from upstream",
1937+
call_id="cc-pb",
1938+
)
1939+
)
1940+
finally:
1941+
_reset_invocation_id(token)
1942+
1943+
trace = client.traces[invocation_id]
1944+
error_gens = [o for o in trace.observations if o.type == "generation" and o.level == "ERROR"]
1945+
assert len(error_gens) == 1
1946+
assert error_gens[0].parent_observation_id == dispatch_handle.id, (
1947+
"LLM Generation must parent under the per-branch dispatch observation"
1948+
)
1949+
1950+
18861951
# ---------------------------------------------------------------------------
18871952
# Proposal 0063 — tool-execution Tool observation (asType "tool")
18881953
# ---------------------------------------------------------------------------

tests/unit/test_observability_otel.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,68 @@ async def test_llm_span_parents_under_fan_out_instance_dispatch() -> None:
619619
)
620620

621621

622+
async def test_llm_span_parents_under_parallel_branch_dispatch() -> None:
623+
# Proposal 0084 branch coverage: the §5.5 orphan fallback is defined for a
624+
# per-branch dispatch span as well as a fan-out instance span, but the 0084
625+
# fixtures (132/133/134) are fan-out only. An orphan LLM call (no open
626+
# calling-node span) fired inside a parallel branch MUST parent under the
627+
# per-branch dispatch span -- the nearest enclosing wrapper -- resolved via
628+
# branch_name_chain, the branch-side analog of the fan-out case above. Guards
629+
# the reference behavior pending a spec fixture (see release-v0.17.0 coord).
630+
from openarmature.observability.correlation import (
631+
_reset_invocation_id,
632+
_set_invocation_id,
633+
)
634+
from openarmature.observability.otel.observer import (
635+
_branch_dispatch_key,
636+
_InvState,
637+
_OpenSpan,
638+
)
639+
from tests._helpers.typed_event import make_retry_attempt_event
640+
641+
exporter = InMemorySpanExporter()
642+
observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter))
643+
invocation_id = "inv-pb-llm"
644+
token = _set_invocation_id(invocation_id)
645+
try:
646+
# Per-branch dispatch span for branch "fast" of pb node "dispatcher". No
647+
# open_spans entry for the calling node ("dispatcher", "ask"), so the
648+
# resolver must reach the per-branch dispatch fallback. The calling node
649+
# sits in branch "fast": branch_name_chain=(None, "fast") aligned to
650+
# namespace (None at the "dispatcher" pb boundary, "fast" at the "ask"
651+
# branch descent).
652+
fi_chain: tuple[int | None, ...] = (None, None)
653+
bn_chain: tuple[str | None, ...] = (None, "fast")
654+
observer._inv_states[invocation_id] = _InvState() # noqa: SLF001
655+
dispatch_span = observer._tracer.start_span("fast") # noqa: SLF001
656+
branch_key = _branch_dispatch_key(("dispatcher",), fi_chain, bn_chain, "fast")
657+
observer._inv_states[invocation_id].parallel_branches_branch_spans[branch_key] = _OpenSpan( # noqa: SLF001
658+
span=dispatch_span
659+
)
660+
await observer(
661+
make_retry_attempt_event(
662+
invocation_id=invocation_id,
663+
node_name="ask",
664+
namespace=("dispatcher", "ask"),
665+
attempt_index=0,
666+
fan_out_index=None,
667+
branch_name="fast",
668+
fan_out_index_chain=fi_chain,
669+
branch_name_chain=bn_chain,
670+
)
671+
)
672+
finally:
673+
_reset_invocation_id(token)
674+
675+
observer.shutdown()
676+
llm_spans = [s for s in exporter.get_finished_spans() if s.name == "openarmature.llm.complete"]
677+
assert len(llm_spans) == 1
678+
assert llm_spans[0].parent is not None, "LLM span must have a parent, not be a trace root"
679+
assert cast("Any", llm_spans[0].parent).span_id == dispatch_span.get_span_context().span_id, (
680+
"orphan LLM span must parent under the per-branch dispatch span"
681+
)
682+
683+
622684
async def test_llm_span_has_no_prompt_attributes_when_no_active_prompt() -> None:
623685
"""Without ``with_active_prompt``, the LLM-call span MUST NOT carry
624686
``openarmature.prompt.*`` attributes."""

0 commit comments

Comments
 (0)