Skip to content

Commit a8d1552

Browse files
Wire subgraph_identity through engine + observers (Option A)
Implements the coord-thread `clarify-subgraph-name-semantics` resolution (msg 02): `metadata.subgraph_name` carries the compiled subgraph's identity rather than the wrapper node name. Engine surface: - `SubgraphNode` gains `subgraph_identity: str | None = None` (optional, BC-preserving). - `FanOutConfig` gains `subgraph_identity: str | None = None` for the symmetric fan-out case; threaded through `GraphBuilder.add_fan_out_node`. - `_InvocationContext` gains `subgraph_identities: tuple[str | None, ...]`, parallel to `namespace_prefix` — index `i` is the identity of the wrapper at `namespace_prefix[i]`. `descend_into_subgraph` / `descend_into_fan_out_instance` / `descend_into_parallel_branch` extend the chain. - `NodeEvent.subgraph_identities: tuple[str | None, ...] = ()` carries the chain at event-emission time so observers can read the identity at each wrapper depth. Observer surface: - Langfuse `LangfuseObserver` and OTel `OTelObserver` both gain `_subgraph_identity_at(event, depth)` and emit identity (or empty string when None) as `metadata.subgraph_name` / `openarmature. subgraph.name` on wrapper observations / spans, per-instance fan-out dispatch observations, and detached-trace wrapper observations. - Detached subgraph's wrapper observation name now also uses the identity (falling back to the wrapper node name when None). - Langfuse wrapper observation synthesis now carries `namespace`, `step`, and `attempt_index` metadata (step from the first inner event; attempt_index hardcoded 0 since wrappers don't retry). Conformance adapter: - `_TracingSubgraphNode` and `_add_fan_out_node` set `subgraph_identity` from the fixture's `subgraphs:` block key when compiling, so every fixture-built subgraph carries its declared identity. Updates the pre-existing OTel-side test on fixture 002 to assert `openarmature.subgraph.name == "inner"` (identity) rather than `"outer_sub"` (wrapper node name) — the prior assertion was inconsistent with Option A semantics; the spec-side fixture 002 YAML doesn't assert this attribute, so the python test was a stricter local check that needed correction. Langfuse fixtures 031/032/033 stay deferred pending two additional spec/fixture ambiguities surfaced while wiring this up: (a) the `step` value on `outer_out` (fixture 031 says 2, but graph-engine §6 says inner-subgraph node executions increment the same counter so the engine emits 3) and (b) whether the `namespace` metadata on detached-trace inner observations should be rewritten to use the subgraph identity at the wrapper position. Both queued in coord thread `clarify-subgraph-name-semantics` msg 03.
1 parent 3b82216 commit a8d1552

11 files changed

Lines changed: 175 additions & 20 deletions

File tree

src/openarmature/graph/builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def add_fan_out_node[ChildT: State](
148148
instance_middleware: Iterable[Middleware] | None = None,
149149
errors_field: str | None = None,
150150
middleware: Iterable[Middleware] | None = None,
151+
subgraph_identity: str | None = None,
151152
) -> Self:
152153
"""Register a fan-out node.
153154
@@ -262,6 +263,7 @@ def add_fan_out_node[ChildT: State](
262263
extra_outputs=dict(extra_outputs or {}),
263264
instance_middleware=tuple(instance_middleware or ()),
264265
errors_field=errors_field,
266+
subgraph_identity=subgraph_identity,
265267
)
266268
# FanOutNode satisfies the Node[StateT] structural protocol (run
267269
# returns a partial update; name and middleware are present),

src/openarmature/graph/compiled.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,7 @@ def _dispatch_started(
19871987
fan_out_index=context.fan_out_index,
19881988
fan_out_config=fan_out_config,
19891989
branch_name=current_branch_name(),
1990+
subgraph_identities=context.subgraph_identities,
19901991
),
19911992
)
19921993

@@ -2020,6 +2021,7 @@ def _dispatch_completed(
20202021
fan_out_index=context.fan_out_index,
20212022
fan_out_config=fan_out_config,
20222023
branch_name=current_branch_name(),
2024+
subgraph_identities=context.subgraph_identities,
20232025
),
20242026
)
20252027

@@ -2202,5 +2204,6 @@ async def _maybe_save_checkpoint(
22022204
parent_states=context.parent_states_prefix,
22032205
attempt_index=attempt_index,
22042206
fan_out_index=None,
2207+
subgraph_identities=context.subgraph_identities,
22052208
),
22062209
)

src/openarmature/graph/events.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,20 @@ class NodeEvent:
191191
# simultaneously when a branch's subgraph contains a fan-out
192192
# (and vice versa).
193193
branch_name: str | None = None
194+
# Per observability §5.3 + the coord-thread
195+
# ``clarify-subgraph-name-semantics`` resolution: chain of
196+
# compiled-subgraph identities parallel to the wrapper-depth
197+
# positions of ``namespace``. Index ``i`` is the identity for
198+
# the wrapper at ``namespace[i]`` (or ``None`` when that
199+
# wrapper has no tracked identity); chain length equals the
200+
# depth of wrapper nesting (always ``< len(namespace)`` since
201+
# the last element of ``namespace`` is the current node, not
202+
# a wrapper). Observers read by depth and emit it as
203+
# ``observation.metadata.subgraph_name`` (Langfuse) /
204+
# ``openarmature.subgraph.name`` (OTel), falling back to the
205+
# empty string when ``None`` per §5.3's "if the implementation
206+
# tracks one" clause.
207+
subgraph_identities: tuple[str | None, ...] = ()
194208

195209

196210
__all__ = ["FanOutEventConfig", "NodeEvent"]

src/openarmature/graph/fan_out.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ class FanOutConfig:
8080
extra_outputs: Mapping[str, str] = field(default_factory=dict[str, str])
8181
instance_middleware: tuple[Middleware, ...] = ()
8282
errors_field: str | None = None
83+
# The identity of the compiled inner subgraph (the key under
84+
# which the subgraph is declared in a ``subgraphs:`` registry).
85+
# Threaded onto every per-instance event so observers can emit
86+
# ``observation.metadata.subgraph_name`` on each per-instance
87+
# dispatch observation (Langfuse) /
88+
# ``openarmature.subgraph.name`` on the corresponding span
89+
# (OTel). Optional and BC-preserving — direct callers that don't
90+
# supply it get the empty-string fallback per observability §5.3.
91+
subgraph_identity: str | None = None
8392

8493

8594
@dataclass(frozen=True)
@@ -271,6 +280,7 @@ async def run_instance(idx: int, instance_state: ChildT) -> Mapping[str, Any]:
271280
parent_state=state,
272281
sub_attached=tuple(cfg.subgraph._attached_observers), # noqa: SLF001
273282
fan_out_index=idx,
283+
subgraph_identity=cfg.subgraph_identity,
274284
)
275285

276286
async def innermost(s: ChildT) -> Mapping[str, Any]:

src/openarmature/graph/observer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ class _InvocationContext:
336336
step_counter: list[int] = field(default_factory=lambda: [0])
337337
namespace_prefix: tuple[str, ...] = ()
338338
parent_states_prefix: tuple[State, ...] = ()
339+
# Per observability §5.3 + the coord-thread `clarify-subgraph-name-
340+
# semantics` resolution. Parallel to ``namespace_prefix`` — index
341+
# ``i`` is the compiled-subgraph identity for the wrapper at
342+
# ``namespace_prefix[i]``, or ``None`` for wrappers constructed
343+
# without an identity. Used by observers to emit
344+
# ``metadata.subgraph_name`` (Langfuse) and
345+
# ``openarmature.subgraph.name`` (OTel) on the wrapper observation
346+
# / span at each depth. The chain shape lets nested subgraphs
347+
# carry distinct identities at distinct depths even though
348+
# v0.10.0's conformance fixtures only exercise single-level
349+
# nesting.
350+
subgraph_identities: tuple[str | None, ...] = ()
339351
# Per pipeline-utilities §9 + graph-engine §6: nodes inside a
340352
# fan-out instance fire events tagged with the instance's 0-based
341353
# index. Set when descending into a fan-out instance, inherited
@@ -426,6 +438,8 @@ def descend_into_subgraph(
426438
subgraph_node_name: str,
427439
parent_state: State,
428440
sub_attached: tuple[SubscribedObserver, ...],
441+
*,
442+
subgraph_identity: str | None = None,
429443
) -> _InvocationContext:
430444
"""Build the context for a subgraph-as-node call.
431445
@@ -447,6 +461,7 @@ def descend_into_subgraph(
447461
step_counter=self.step_counter,
448462
namespace_prefix=self.namespace_prefix + (subgraph_node_name,),
449463
parent_states_prefix=self.parent_states_prefix + (parent_state,),
464+
subgraph_identities=self.subgraph_identities + (subgraph_identity,),
450465
fan_out_index=self.fan_out_index,
451466
invocation_id=self.invocation_id,
452467
correlation_id=self.correlation_id,
@@ -466,6 +481,8 @@ def descend_into_fan_out_instance(
466481
parent_state: State,
467482
sub_attached: tuple[SubscribedObserver, ...],
468483
fan_out_index: int,
484+
*,
485+
subgraph_identity: str | None = None,
469486
) -> _InvocationContext:
470487
"""Build the context for one fan-out instance's subgraph invocation.
471488
@@ -491,6 +508,7 @@ def descend_into_fan_out_instance(
491508
step_counter=self.step_counter,
492509
namespace_prefix=self.namespace_prefix + (fan_out_node_name,),
493510
parent_states_prefix=self.parent_states_prefix + (parent_state,),
511+
subgraph_identities=self.subgraph_identities + (subgraph_identity,),
494512
fan_out_index=fan_out_index,
495513
invocation_id=self.invocation_id,
496514
correlation_id=self.correlation_id,
@@ -541,6 +559,12 @@ def descend_into_parallel_branch(
541559
step_counter=self.step_counter,
542560
namespace_prefix=self.namespace_prefix + (parallel_branches_node_name,),
543561
parent_states_prefix=self.parent_states_prefix + (parent_state,),
562+
# Parallel-branches don't reify a single inner subgraph
563+
# identity at the wrapper position — each branch can hold a
564+
# different subgraph — so we extend the chain with ``None``
565+
# at this depth. Per-branch identity handling (if ever
566+
# needed) is a future addition.
567+
subgraph_identities=self.subgraph_identities + (None,),
544568
fan_out_index=self.fan_out_index,
545569
invocation_id=self.invocation_id,
546570
correlation_id=self.correlation_id,

src/openarmature/graph/subgraph.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ class SubgraphNode[ParentT: State, ChildT: State]:
5050
default_factory=FieldNameMatching[ParentT, ChildT]
5151
)
5252
middleware: tuple[Middleware, ...] = field(default_factory=tuple[Middleware, ...])
53+
# The compiled subgraph's identity (the registry key under which
54+
# the subgraph is declared, distinct from the wrapper node's
55+
# ``name`` in the parent graph). Optional and BC-preserving:
56+
# callers that don't pass it get an empty string emitted as the
57+
# observability §5.3 ``subgraph_name`` attribute (matching the
58+
# spec's "if the implementation tracks one" fallback). Setting
59+
# it lets dashboards filter or aggregate across observations from
60+
# the same compiled subgraph wrapped under different node names
61+
# (e.g., a ``validator`` subgraph used as both ``validate_input``
62+
# and ``validate_output``).
63+
subgraph_identity: str | None = None
5364

5465
async def run(
5566
self,
@@ -116,6 +127,7 @@ async def run(
116127
subgraph_node_name=self.name,
117128
parent_state=state,
118129
sub_attached=tuple(self.compiled._attached_observers),
130+
subgraph_identity=self.subgraph_identity,
119131
)
120132
sub_final = await self.compiled._invoke(sub_initial, child_context)
121133
return self.projection.project_out(sub_final, state, self.compiled.state_cls)

src/openarmature/observability/langfuse/observer.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,28 @@ def _empty_str_frozenset() -> frozenset[str]:
8080
return frozenset()
8181

8282

83+
def _subgraph_identity_at(event: NodeEvent, depth: int) -> str:
84+
"""Return the compiled-subgraph identity for the wrapper at the
85+
given 1-based namespace depth, or the empty string when no
86+
identity is tracked at that depth.
87+
88+
Per observability §5.3 + the coord-thread
89+
``clarify-subgraph-name-semantics`` resolution: the empty-string
90+
fallback matches the spec's "if the implementation tracks one"
91+
clause for implementations / direct ``SubgraphNode(...)`` callers
92+
that don't wire an identity through. Conformance fixtures
93+
031/032/033 lock identity as the required value; the empty-string
94+
path keeps direct callers conformant with §5.3 but failing those
95+
fixtures.
96+
"""
97+
idx = depth - 1
98+
if 0 <= idx < len(event.subgraph_identities):
99+
identity = event.subgraph_identities[idx]
100+
if identity is not None:
101+
return identity
102+
return ""
103+
104+
83105
@dataclass
84106
class _InvState:
85107
"""Per-invocation state, isolated by invocation_id.
@@ -453,7 +475,7 @@ def _sync_subgraph_observations(
453475
# configured detached_subgraphs name → mint a fresh
454476
# detached Trace + open the dispatch observation in it.
455477
if depth == 1 and prefix[0] in self.detached_subgraphs:
456-
self._open_detached_subgraph_trace(inv_state, correlation_id, prefix)
478+
self._open_detached_subgraph_trace(inv_state, correlation_id, prefix, event)
457479
continue
458480
# Detached fan-out: the fan-out instance gets its own
459481
# Trace per spec §8.5. The fan-out node's Span observation
@@ -478,13 +500,14 @@ def _sync_subgraph_observations(
478500
self._open_fan_out_instance_dispatch_observation(inv_state, correlation_id, prefix, event)
479501
continue
480502
# Plain non-detached subgraph dispatch.
481-
self._open_subgraph_observation(inv_state, correlation_id, prefix)
503+
self._open_subgraph_observation(inv_state, correlation_id, prefix, event)
482504

483505
def _open_subgraph_observation(
484506
self,
485507
inv_state: _InvState,
486508
correlation_id: str | None,
487509
prefix: tuple[str, ...],
510+
event: NodeEvent,
488511
) -> None:
489512
# Parent is the nearest enclosing subgraph dispatch (if any),
490513
# else None (the Trace is the implicit parent for top-level
@@ -496,7 +519,18 @@ def _open_subgraph_observation(
496519
if sg is not None:
497520
parent_observation_id = sg.handle.id
498521
break
499-
metadata: dict[str, Any] = {"subgraph_name": prefix[-1]}
522+
# Subgraph wrappers don't dispatch their own events, so the
523+
# synthetic wrapper observation inherits its scalar metadata
524+
# from the FIRST inner event that triggered the synthesis.
525+
# ``attempt_index`` is hardcoded to 0: the wrapper has no
526+
# engine-managed retry counter of its own (inner nodes own
527+
# their own attempt_index independently).
528+
metadata: dict[str, Any] = {
529+
"namespace": list(prefix),
530+
"step": event.step,
531+
"attempt_index": 0,
532+
"subgraph_name": _subgraph_identity_at(event, len(prefix)),
533+
}
500534
if correlation_id is not None:
501535
metadata["correlation_id"] = correlation_id
502536
handle = self.client.span(
@@ -519,9 +553,16 @@ def _open_fan_out_instance_dispatch_observation(
519553
fan_out_open = self._find_fan_out_node_observation(inv_state, prefix)
520554
parent_observation_id = fan_out_open.handle.id if fan_out_open is not None else None
521555
parent_node_name = inv_state.fan_out_parent_node_name.get(prefix, prefix[-1])
556+
# Per-instance dispatch is synthesized from the first inner
557+
# event inside the instance subtree; inherit scalar metadata
558+
# from that event (same pattern as ``_open_subgraph_observation``).
522559
metadata: dict[str, Any] = {
560+
"namespace": list(prefix),
561+
"step": event.step,
562+
"attempt_index": 0,
523563
"fan_out_parent_node_name": parent_node_name,
524564
"fan_out_index": event.fan_out_index,
565+
"subgraph_name": _subgraph_identity_at(event, len(prefix)),
525566
}
526567
if correlation_id is not None:
527568
metadata["correlation_id"] = correlation_id
@@ -539,6 +580,7 @@ def _open_detached_subgraph_trace(
539580
inv_state: _InvState,
540581
correlation_id: str | None,
541582
prefix: tuple[str, ...],
583+
event: NodeEvent,
542584
) -> None:
543585
# Mint a fresh Trace for the detached subtree. The main Trace's
544586
# dispatch observation surfaces the link via
@@ -604,16 +646,26 @@ def _open_detached_subgraph_trace(
604646
detached_metadata: dict[str, Any] = {"detached_from_invocation_id": inv_state.trace_id}
605647
if correlation_id is not None:
606648
detached_metadata["correlation_id"] = correlation_id
607-
self.client.trace(id=detached_trace_id, name=prefix[-1], metadata=detached_metadata)
649+
identity = _subgraph_identity_at(event, len(prefix))
650+
# The detached trace's wrapper observation IS the migrated
651+
# SubgraphNode wrapper. Per the resolution in coord thread
652+
# ``clarify-subgraph-name-semantics`` and fixture 033's
653+
# expected shape, its name and ``metadata.subgraph_name`` use
654+
# the compiled-subgraph identity (e.g., ``"long_running_workflow"``)
655+
# rather than the wrapper node name. Falls back to the wrapper
656+
# node name when identity is empty (observability §5.3's
657+
# "if the implementation tracks one" clause).
658+
wrapper_obs_name = identity or prefix[-1]
659+
self.client.trace(id=detached_trace_id, name=wrapper_obs_name, metadata=detached_metadata)
608660
dispatch_metadata: dict[str, Any] = {
609-
"subgraph_name": prefix[-1],
661+
"subgraph_name": identity,
610662
"detached": True,
611663
}
612664
if correlation_id is not None:
613665
dispatch_metadata["correlation_id"] = correlation_id
614666
handle = self.client.span(
615667
trace_id=detached_trace_id,
616-
name=prefix[-1],
668+
name=wrapper_obs_name,
617669
metadata=dispatch_metadata,
618670
parent_observation_id=None,
619671
)

src/openarmature/observability/otel/observer.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ def _read_spec_version() -> str:
148148
return __spec_version__
149149

150150

151+
def _subgraph_identity_at(event: NodeEvent, depth: int) -> str:
152+
"""Return the compiled-subgraph identity for the wrapper at the
153+
given 1-based namespace depth, or the empty string when no
154+
identity is tracked at that depth.
155+
156+
Per observability §5.3 + the coord-thread
157+
``clarify-subgraph-name-semantics`` resolution: empty-string
158+
fallback matches the spec's "if the implementation tracks one"
159+
clause for callers using ``SubgraphNode(name=..., compiled=...)``
160+
without supplying ``subgraph_identity``.
161+
"""
162+
idx = depth - 1
163+
if 0 <= idx < len(event.subgraph_identities):
164+
identity = event.subgraph_identities[idx]
165+
if identity is not None:
166+
return identity
167+
return ""
168+
169+
151170
def _empty_str_frozenset() -> frozenset[str]:
152171
"""Typed empty frozenset factory for ``detached_subgraphs`` /
153172
``detached_fan_outs`` defaults."""
@@ -1033,7 +1052,7 @@ def _sync_subgraph_spans(
10331052
# If this prefix's first segment is configured as a
10341053
# detached subgraph, mint a fresh trace.
10351054
if depth == 1 and prefix[0] in self.detached_subgraphs:
1036-
self._open_detached_subgraph_root(inv_state, invocation_id, correlation_id, prefix)
1055+
self._open_detached_subgraph_root(inv_state, invocation_id, correlation_id, prefix, event)
10371056
continue
10381057
# If this is a fan-out instance namespace (event.fan_out_index
10391058
# populated, prefix == namespace[:1]), and the fan-out
@@ -1057,14 +1076,15 @@ def _sync_subgraph_spans(
10571076
):
10581077
self._open_fan_out_instance_dispatch_span(inv_state, correlation_id, prefix, event)
10591078
continue
1060-
self._open_subgraph_span(inv_state, invocation_id, correlation_id, prefix)
1079+
self._open_subgraph_span(inv_state, invocation_id, correlation_id, prefix, event)
10611080

10621081
def _open_subgraph_span(
10631082
self,
10641083
inv_state: _InvState,
10651084
invocation_id: str,
10661085
correlation_id: str | None,
10671086
prefix: tuple[str, ...],
1087+
event: NodeEvent,
10681088
) -> None:
10691089
"""Open a synthetic subgraph dispatch span for the given
10701090
namespace prefix. Parent is the next-outer subgraph span (or
@@ -1088,7 +1108,7 @@ def _open_subgraph_span(
10881108
parent_ctx = set_span_in_context(inv.span)
10891109
attrs: dict[str, Any] = {
10901110
"openarmature.node.name": prefix[-1],
1091-
"openarmature.subgraph.name": prefix[-1],
1111+
"openarmature.subgraph.name": _subgraph_identity_at(event, len(prefix)),
10921112
}
10931113
if correlation_id is not None:
10941114
attrs["openarmature.correlation_id"] = correlation_id
@@ -1114,6 +1134,7 @@ def _open_detached_subgraph_root(
11141134
invocation_id: str,
11151135
correlation_id: str | None,
11161136
prefix: tuple[str, ...],
1137+
event: NodeEvent,
11171138
) -> None:
11181139
"""Mint a fresh trace for a detached subgraph entry. The
11191140
detached root span lives in the new trace; the parent trace's
@@ -1141,7 +1162,7 @@ def _open_detached_subgraph_root(
11411162
parent_ctx_for_dispatch = set_span_in_context(inv.span)
11421163
attrs_parent: dict[str, Any] = {
11431164
"openarmature.node.name": prefix[-1],
1144-
"openarmature.subgraph.name": prefix[-1],
1165+
"openarmature.subgraph.name": _subgraph_identity_at(event, len(prefix)),
11451166
}
11461167
if correlation_id is not None:
11471168
attrs_parent["openarmature.correlation_id"] = correlation_id
@@ -1264,6 +1285,7 @@ def _open_fan_out_instance_dispatch_span(
12641285
"openarmature.node.name": prefix[-1],
12651286
"openarmature.fan_out.parent_node_name": parent_node_name,
12661287
"openarmature.node.fan_out_index": event.fan_out_index,
1288+
"openarmature.subgraph.name": _subgraph_identity_at(event, len(prefix)),
12671289
}
12681290
if correlation_id is not None:
12691291
attrs["openarmature.correlation_id"] = correlation_id

0 commit comments

Comments
 (0)