Skip to content

Commit 348c9d5

Browse files
Add 0084 event lineage chains and OTel exact-match (#240)
* Add 0084 event lineage chains and OTel exact-match Proposal 0084 (nested-fan-out span lineage), first of the Wave 3 PRs. Add fan_out_index_chain / branch_name_chain (the enclosing fan-out / branch lineage, outermost to innermost) to the provider and tool observer events and the framework failure-isolation event, mirroring the fields already on NodeEvent. They are populated from the enclosing- lineage ContextVars at each event's build site. Key the OTel node-span registry by those chains in addition to the innermost scalars, so an inner node running under two concurrent outer fan-out instances no longer collides on the span key, and the provider span parents under its own lineage-disambiguated calling-node span rather than a coincidentally-indexed sibling in the other outer instance. The fan-out node-span finder used for dispatch-span parenting is likewise made chain-aware. Un-defer observability fixture 132 (nested keying + nested-LLM exact match) with a runner that forces the concurrent interleaving the fix relies on. The orphan-fallback path (a provider call with no open calling-node span) and the Langfuse resolver are unchanged; they follow in a later PR. Also fix a pre-existing test-isolation leak where three tests set invocation metadata outside an invocation without restoring the ContextVar. * Comment the rendezvous timeout fallback (CodeQL empty-except)
1 parent b875ea6 commit 348c9d5

11 files changed

Lines changed: 465 additions & 48 deletions

File tree

conformance.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -834,10 +834,11 @@ note = "Advisory, observability-only per-prompt token budget. PR A (completion p
834834

835835
# Spec v0.81.0 (proposal 0084). Nested-fan-out span lineage chain
836836
# (graph-engine §6 fan_out_index_chain / branch_name_chain +
837-
# observability §4 / §5.5 lineage-resolved parent). Not-yet; graph-
838-
# engine fixture 039 and observability fixtures 132-134 defer with it.
837+
# observability §4 / §5.5 lineage-resolved parent).
839838
[proposals."0084"]
840-
status = "not-yet"
839+
status = "partial"
840+
since = "0.17.0"
841+
note = "The event-surface chains + the nested-LLM exact-match shipped. graph-engine §6: fan_out_index_chain / branch_name_chain (mirroring NodeEvent) now ride the provider/tool events (LlmCompletionEvent / LlmFailedEvent / LlmRetryAttemptEvent / EmbeddingEvent / EmbeddingFailedEvent / RerankEvent / RerankFailedEvent / ToolCallEvent / ToolCallFailedEvent) and the framework FailureIsolatedEvent, populated from the enclosing-lineage ContextVars at call time. observability §4.3 / §5.5: the OTel node-span key carries the scalars AND the enclosing chains, so an inner node under two concurrent outer fan-out instances no longer collides (no dropped / mis-closed spans), and the LLM-parent exact-match (§5.5 Lineage-resolved parent, fallback 1) resolves under the lineage-disambiguated calling-node span rather than the innermost scalar. observability fixture 132 (nested keying + nested-LLM exact-match, OTel) is un-deferred and driven. partial because the orphan fallback (a wrapper / middleware-issued provider call with no open calling-node span, §5.5) is not yet generalized to nearest-open-ancestor-at-any-depth and the Langfuse resolver is unchanged: observability fixtures 133 (OTel orphan) + 134 (Langfuse) and the graph-engine event-chain fixture 039 tightening defer with the orphan-call conformance-adapter primitive (a later PR)."
841842

842843
# Spec v0.80.0 (proposal 0085). Nested-fan-out checkpoint resume
843844
# lineage (pipeline-utilities §10.11 enclosing_fan_out_lineage).

src/openarmature/graph/events.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,16 @@ class LlmCompletionEvent:
602602
# Defaulted (default_factory) so existing kwargs-constructors that
603603
# predate this field keep working; the provider always populates it.
604604
output_tool_calls: list["ToolCall"] = field(default_factory=list["ToolCall"])
605+
# Proposal 0084 (graph-engine §6, spec v0.81.0): per-depth enclosing
606+
# fan-out / branch lineage chains, parallel to ``namespace`` -- position
607+
# ``i`` is the fan_out_index (or branch_name) at the dispatch boundary
608+
# leading to namespace depth ``i+1``, or ``None`` at a non-fan-out /
609+
# non-branch boundary. Mirror of NodeEvent's chains; observers resolve the
610+
# lineage-disambiguated span parent from these rather than the innermost
611+
# scalar (which coincides across concurrent enclosing instances). Empty for
612+
# a top-level (non-nested) call; the scalars above carry the innermost value.
613+
fan_out_index_chain: tuple[int | None, ...] = ()
614+
branch_name_chain: tuple[str | None, ...] = ()
605615

606616

607617
# Spec: realizes proposal 0058's second spec-normatively-typed event
@@ -709,6 +719,10 @@ class LlmFailedEvent:
709719
# (which carries the response-side usage) the budget eval fires as it does
710720
# on a completion; None-carrying every other category leaves it inert.
711721
token_budget: Any = None
722+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
723+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
724+
fan_out_index_chain: tuple[int | None, ...] = ()
725+
branch_name_chain: tuple[str | None, ...] = ()
712726

713727

714728
# Python-internal per-attempt LLM event. NOT a spec-normative event type
@@ -799,6 +813,10 @@ class LlmRetryAttemptEvent:
799813
# tool-call span attributes from this field (the per-attempt event is
800814
# the LLM-span source).
801815
output_tool_calls: list["ToolCall"] = field(default_factory=list["ToolCall"])
816+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
817+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
818+
fan_out_index_chain: tuple[int | None, ...] = ()
819+
branch_name_chain: tuple[str | None, ...] = ()
802820

803821

804822
# Spec: realizes graph-engine §6 + observability §5.5.9 -- the typed
@@ -877,6 +895,10 @@ class EmbeddingEvent:
877895
active_prompt_group: Any
878896
call_id: str
879897
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
898+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
899+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
900+
fan_out_index_chain: tuple[int | None, ...] = ()
901+
branch_name_chain: tuple[str | None, ...] = ()
880902

881903

882904
# Spec: the failure sibling of EmbeddingEvent (proposal 0059). Dispatched
@@ -926,6 +948,10 @@ class EmbeddingFailedEvent:
926948
error_message: str
927949
error_type: str | None = None
928950
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
951+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
952+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
953+
fan_out_index_chain: tuple[int | None, ...] = ()
954+
branch_name_chain: tuple[str | None, ...] = ()
929955

930956

931957
# Spec: realizes graph-engine §6 -- the typed RerankEvent / RerankFailedEvent
@@ -1010,6 +1036,10 @@ class RerankEvent:
10101036
active_prompt_group: Any
10111037
call_id: str
10121038
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
1039+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
1040+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
1041+
fan_out_index_chain: tuple[int | None, ...] = ()
1042+
branch_name_chain: tuple[str | None, ...] = ()
10131043

10141044

10151045
# Spec: the failure sibling of RerankEvent (proposal 0060). Dispatched
@@ -1062,6 +1092,10 @@ class RerankFailedEvent:
10621092
error_message: str
10631093
error_type: str | None = None
10641094
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
1095+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
1096+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
1097+
fan_out_index_chain: tuple[int | None, ...] = ()
1098+
branch_name_chain: tuple[str | None, ...] = ()
10651099

10661100

10671101
# Spec: realizes pipeline-utilities §6.3 failure-isolation middleware
@@ -1107,6 +1141,15 @@ class FailureIsolatedEvent:
11071141
pre_state: Any
11081142
post_state: Mapping[str, Any]
11091143
caught_exception: CaughtException
1144+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage chains
1145+
# parallel to ``namespace`` (see LlmCompletionEvent). Carried for surface
1146+
# consistency with the provider events. The marker's calling-node span is
1147+
# already closed by delivery time (its completed event precedes this one on
1148+
# the serial queue), so it parents under the invocation span, not via the
1149+
# lineage exact-match -- the chains only disambiguate in the rare case that
1150+
# calling-node span is still open.
1151+
fan_out_index_chain: tuple[int | None, ...] = ()
1152+
branch_name_chain: tuple[str | None, ...] = ()
11101153

11111154

11121155
# Spec: realizes graph-engine §6 tool-execution observer events
@@ -1169,6 +1212,10 @@ class ToolCallEvent:
11691212
result: Any
11701213
latency_ms: float | None
11711214
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
1215+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
1216+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
1217+
fan_out_index_chain: tuple[int | None, ...] = ()
1218+
branch_name_chain: tuple[str | None, ...] = ()
11721219

11731220

11741221
# Spec: the failure variant (proposal 0063). Mirrors ToolCallEvent's
@@ -1217,6 +1264,10 @@ class ToolCallFailedEvent:
12171264
error_type: str | None
12181265
error_message: str
12191266
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
1267+
# Proposal 0084 (spec v0.81.0): enclosing fan-out / branch lineage
1268+
# chains parallel to ``namespace`` (see LlmCompletionEvent).
1269+
fan_out_index_chain: tuple[int | None, ...] = ()
1270+
branch_name_chain: tuple[str | None, ...] = ()
12201271

12211272

12221273
__all__ = [

src/openarmature/graph/middleware/failure_isolation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@
4545
_set_terminal_attempt_index,
4646
current_attempt_index,
4747
current_branch_name,
48+
current_branch_name_chain,
4849
current_dispatch,
4950
current_fan_out_index,
51+
current_fan_out_index_chain,
5052
current_namespace_prefix,
5153
)
5254

@@ -233,6 +235,9 @@ def _emit_event(self, state: Any, classification: CaughtException, degraded: Map
233235
attempt_index=attempt_index,
234236
fan_out_index=current_fan_out_index(),
235237
branch_name=current_branch_name(),
238+
# Proposal 0084: enclosing fan-out / branch lineage chains.
239+
fan_out_index_chain=current_fan_out_index_chain(),
240+
branch_name_chain=current_branch_name_chain(),
236241
pre_state=state,
237242
post_state=degraded,
238243
caught_exception=classification,

src/openarmature/llm/providers/openai.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
from openarmature.observability.correlation import (
7373
current_attempt_index,
7474
current_branch_name,
75+
current_branch_name_chain,
7576
current_correlation_id,
7677
current_dispatch,
7778
current_fan_out_index,
79+
current_fan_out_index_chain,
7880
current_invocation_id,
7981
current_namespace_prefix,
8082
)
@@ -702,6 +704,9 @@ def _build_llm_completion_event(
702704
attempt_index=current_attempt_index(),
703705
fan_out_index=current_fan_out_index(),
704706
branch_name=current_branch_name(),
707+
# Proposal 0084: enclosing fan-out / branch lineage chains.
708+
fan_out_index_chain=current_fan_out_index_chain(),
709+
branch_name_chain=current_branch_name_chain(),
705710
provider=self._genai_system,
706711
model=self.model,
707712
response_id=response.response_id,
@@ -792,6 +797,9 @@ def _build_llm_failed_event(
792797
attempt_index=current_attempt_index(),
793798
fan_out_index=current_fan_out_index(),
794799
branch_name=current_branch_name(),
800+
# Proposal 0084: enclosing fan-out / branch lineage chains.
801+
fan_out_index_chain=current_fan_out_index_chain(),
802+
branch_name_chain=current_branch_name_chain(),
795803
provider=self._genai_system,
796804
model=self.model,
797805
latency_ms=latency_ms,
@@ -857,6 +865,9 @@ def _build_llm_retry_attempt_event(
857865
"attempt_index": current_attempt_index(),
858866
"fan_out_index": current_fan_out_index(),
859867
"branch_name": current_branch_name(),
868+
# Proposal 0084: enclosing fan-out / branch lineage chains.
869+
"fan_out_index_chain": current_fan_out_index_chain(),
870+
"branch_name_chain": current_branch_name_chain(),
860871
"provider": self._genai_system,
861872
"model": self.model,
862873
"call_id": call_id,

src/openarmature/observability/otel/observer.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,28 @@
137137
# across sibling parallel-branches branches (pipeline-utilities §11);
138138
# without it the two inner ``ask`` nodes of two branches with the
139139
# same namespace + fan_out_index would collide on the same key.
140-
_StackKey = tuple[tuple[str, ...], int, int | None, str | None]
140+
# Proposal 0084 (spec v0.81.0): a node span is keyed by the innermost scalars
141+
# AND the full enclosing fan-out / branch lineage chains. The chains are the
142+
# addition: an inner node under two concurrent outer fan-out instances shares
143+
# the same innermost scalar across the outer instances, so a scalar-only key
144+
# would collide (last-writer-wins drops / mis-closes the second) and the
145+
# LLM-parent exact-match would resolve to a sibling's span. The scalars are
146+
# RETAINED (not replaced by the chains) because ``_key_for`` is also used to
147+
# LOOK UP a callable-parallel-branch event in ``open_spans`` on the publish
148+
# path (``_publish_active_span``): a callable branch carries its branch_name on
149+
# the event but never extends branch_name_chain (no subgraph descent), so on
150+
# the chains alone its key would EQUAL its own parallel-branches NODE's key --
151+
# the lookup would return that NODE instead of missing, and the branch's
152+
# per-branch dispatch span would never be synthesized (verified: blanking the
153+
# scalars drops the dispatch span). The scalar branch_name (key[3]) keeps the
154+
# two distinct. Past that lookup the scalars are redundant with the chains for
155+
# storage uniqueness (each equals its chain's innermost non-None entry); the
156+
# merged spec keys the driving span chains-only, so the retained scalar is a
157+
# harmless superset kept for the callable-branch lookup. The common
158+
# single-level case keys as before plus the (empty / length-1) chains.
159+
_StackKey = tuple[
160+
tuple[str, ...], int, int | None, str | None, tuple[int | None, ...], tuple[str | None, ...]
161+
]
141162

142163
# Lineage-aware dispatch keys (proposal 0045): the fan-out / pb NODE namespace
143164
# prefix plus the fan-out instance index / branch name chain slices along the
@@ -1304,7 +1325,7 @@ def _collect_augmentation_targets(
13041325
# identified structurally by their presence in the
13051326
# parent_node_name caches)
13061327
for key, open_span in inv_state.open_spans.items():
1307-
ns, _ai, _fi, _bn = key
1328+
ns = key[0]
13081329
if ns != aug_ns and not is_strict_prefix(ns, aug_ns):
13091330
continue
13101331
# Shared-parent check: a fan-out NODE or parallel-branches NODE
@@ -1587,6 +1608,8 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
15871608
calling_attempt_index=event.attempt_index,
15881609
calling_fan_out_index=event.fan_out_index,
15891610
calling_branch_name=event.branch_name,
1611+
calling_fan_out_index_chain=event.fan_out_index_chain,
1612+
calling_branch_name_chain=event.branch_name_chain,
15901613
)
15911614
attrs: dict[str, Any] = {
15921615
"openarmature.llm.model": event.model,
@@ -1808,6 +1831,8 @@ def _handle_tool_call(self, event: ToolCallEvent | ToolCallFailedEvent) -> None:
18081831
calling_attempt_index=event.attempt_index,
18091832
calling_fan_out_index=event.fan_out_index,
18101833
calling_branch_name=event.branch_name,
1834+
calling_fan_out_index_chain=event.fan_out_index_chain,
1835+
calling_branch_name_chain=event.branch_name_chain,
18111836
)
18121837
attrs: dict[str, Any] = {"openarmature.tool.name": event.tool_name}
18131838
if event.tool_call_id is not None:
@@ -1926,6 +1951,8 @@ def _handle_embedding(self, event: EmbeddingEvent | EmbeddingFailedEvent) -> Non
19261951
calling_attempt_index=event.attempt_index,
19271952
calling_fan_out_index=event.fan_out_index,
19281953
calling_branch_name=event.branch_name,
1954+
calling_fan_out_index_chain=event.fan_out_index_chain,
1955+
calling_branch_name_chain=event.branch_name_chain,
19291956
)
19301957
attrs: dict[str, Any] = {}
19311958
cid = current_correlation_id()
@@ -2036,6 +2063,8 @@ def _handle_rerank(self, event: RerankEvent | RerankFailedEvent) -> None:
20362063
calling_attempt_index=event.attempt_index,
20372064
calling_fan_out_index=event.fan_out_index,
20382065
calling_branch_name=event.branch_name,
2066+
calling_fan_out_index_chain=event.fan_out_index_chain,
2067+
calling_branch_name_chain=event.branch_name_chain,
20392068
)
20402069
attrs: dict[str, Any] = {}
20412070
cid = current_correlation_id()
@@ -2147,6 +2176,8 @@ def _handle_failure_isolated(self, event: FailureIsolatedEvent) -> None:
21472176
calling_attempt_index=event.attempt_index,
21482177
calling_fan_out_index=event.fan_out_index,
21492178
calling_branch_name=event.branch_name,
2179+
calling_fan_out_index_chain=event.fan_out_index_chain,
2180+
calling_branch_name_chain=event.branch_name_chain,
21502181
)
21512182
attrs: dict[str, Any] = {
21522183
"openarmature.failure_isolation.event_name": event.event_name,
@@ -2182,16 +2213,25 @@ def _resolve_llm_parent(
21822213
calling_attempt_index: int,
21832214
calling_fan_out_index: int | None,
21842215
calling_branch_name: str | None,
2216+
calling_fan_out_index_chain: tuple[int | None, ...],
2217+
calling_branch_name_chain: tuple[str | None, ...],
21852218
) -> object:
21862219
"""Look up the calling node's span using the calling-node
21872220
identity, falling back through the per-instance fan-out dispatch
21882221
span, subgraph dispatch / detached root, and the invocation span."""
2189-
# 1. Direct match on the calling node's ``_StackKey``.
2222+
# 1. Direct match on the calling node's ``_StackKey`` -- the
2223+
# lineage-disambiguated calling-node span (proposal 0084 §5.5
2224+
# "Lineage-resolved parent"). Keyed by the full enclosing fan-out /
2225+
# branch chain so a nested provider call parents under ITS OWN
2226+
# calling node's span, not a sibling in another concurrent outer
2227+
# instance that shares the innermost scalar fan_out_index.
21902228
calling_key: _StackKey = (
21912229
calling_namespace_prefix,
21922230
calling_attempt_index,
21932231
calling_fan_out_index,
21942232
calling_branch_name,
2233+
calling_fan_out_index_chain,
2234+
calling_branch_name_chain,
21952235
)
21962236
calling = inv_state.open_spans.get(calling_key)
21972237
if calling is not None:
@@ -2264,7 +2304,17 @@ def _open_invocation_span(
22642304
self._invocation_span[invocation_id] = _OpenSpan(span=span)
22652305

22662306
def _key_for(self, event: NodeEvent) -> _StackKey:
2267-
return (event.namespace, event.attempt_index, event.fan_out_index, event.branch_name)
2307+
# Proposal 0084: scalars (retained) + enclosing lineage chains (added),
2308+
# so concurrent nested fan-out node spans don't collide while a callable
2309+
# parallel-branch still stays distinct from its parallel-branches node.
2310+
return (
2311+
event.namespace,
2312+
event.attempt_index,
2313+
event.fan_out_index,
2314+
event.branch_name,
2315+
event.fan_out_index_chain,
2316+
event.branch_name_chain,
2317+
)
22682318

22692319
def _resolve_parent_context(
22702320
self,
@@ -2955,23 +3005,21 @@ def _find_fan_out_node_span(
29553005
self, inv_state: _InvState, prefix: tuple[str, ...], event: NodeEvent
29563006
) -> _OpenSpan | None:
29573007
"""Find the currently-open fan-out / pb NODE span at ``prefix`` on the
2958-
given event's ENCLOSING lineage. When the NODE is itself nested inside an
2959-
outer fan-out instance / branch, several instances of the same NODE
2960-
namespace are open at once under concurrency, so a namespace-only scan
2961-
would bind the wrong one. The NODE's own event carries the instance /
2962-
branch it sits in as its fan_out_index / branch_name (key[2] / key[3]);
2963-
that equals the synthesizing event's chain entry at the level above this
2964-
NODE."""
2965-
n = len(prefix)
2966-
enclosing_fi = (
2967-
event.fan_out_index_chain[n - 2] if n >= 2 and n - 2 < len(event.fan_out_index_chain) else None
2968-
)
2969-
enclosing_bn = (
2970-
event.branch_name_chain[n - 2] if n >= 2 and n - 2 < len(event.branch_name_chain) else None
2971-
)
3008+
given event's ENCLOSING lineage. When the NODE is itself nested inside
3009+
one or more outer fan-out instances / branches, several instances of the
3010+
same NODE namespace are open at once under concurrency, so a
3011+
namespace-only scan would bind the wrong one. Disambiguate by the full
3012+
enclosing chain (proposal 0084): the NODE span sits on the synthesizing
3013+
event's call-stack ancestor path iff its stored lineage chain is a
3014+
prefix of the event's -- the same lineage-boundary rule the augmentation
3015+
scoping uses (``_span_chain_on_path``). Matching the innermost scalar
3016+
alone is ambiguous at >=3 levels, where an intermediate instance index
3017+
repeats across concurrent outer instances (both would match the scalar
3018+
but only one is on the event's path)."""
29723019
for key, open_span in inv_state.open_spans.items():
2973-
ns, _attempt, fan_idx, bn = key
2974-
if ns == prefix and fan_idx == enclosing_fi and bn == enclosing_bn:
3020+
if key[0] == prefix and _span_chain_on_path(
3021+
open_span, event.fan_out_index_chain, event.branch_name_chain
3022+
):
29753023
return open_span
29763024
return None
29773025

0 commit comments

Comments
 (0)