Skip to content

Commit 95212aa

Browse files
prepare-sync: engine OTel attach around node bodies
Step 4 of PR-C.3. Adds a try-imported OTel attach helper pair in compiled.py: ``_attach_active_observer_span`` reads ``current_active_observer_span`` (set synchronously by an observer's ``prepare_sync`` before queueing) and splices the span into the OTel context via ``opentelemetry.context.attach(set_span_in_context(span))``; ``_detach_active_observer_span`` pairs the detach in ``finally``. Both ``_step_function_node``'s and ``_step_fan_out_node``'s ``innermost`` closures now attach right after ``_dispatch_started`` returns and detach in a ``finally`` around the ``await node.run(...)`` / ``await node.run_with_context(...)`` call. That puts the attach scope around exactly the user-code window — so logs emitted on the FIRST line of a node body, before any ``await``, pick up the right ``trace_id``/``span_id`` via OTel's ``LoggingHandler`` — and the detach fires before ``_dispatch_completed`` queues the completed event or the merge runs. The except branch binds the OTel names to ``None`` so pyright narrows on ``if _otel_attach is None: ...`` rather than flagging "possibly unbound." Engine stays no-OTel-dep at runtime: installs without ``[otel]`` get a no-op attach/detach, the ContextVar stays ``None``, and nothing changes. Drives the load-bearing log-correlation cases landing in steps 5 and 6.
1 parent c532b45 commit 95212aa

1 file changed

Lines changed: 116 additions & 44 deletions

File tree

src/openarmature/graph/compiled.py

Lines changed: 116 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
_set_fan_out_index,
7171
_set_invocation_id,
7272
_set_namespace_prefix,
73+
current_active_observer_span,
7374
)
7475

7576
from .edges import END, ConditionalEdge, EndSentinel, StaticEdge
@@ -99,6 +100,54 @@
99100
from .state import State
100101
from .subgraph import SubgraphNode
101102

103+
# Try-import OpenTelemetry attach primitives so the engine can splice an
104+
# observer-published span into the OTel context for the duration of a
105+
# node body. The engine treats the span value opaquely (writes by an
106+
# observer's ``prepare_sync``, reads via ``current_active_observer_span``)
107+
# and only touches OTel when both: (a) the extras are installed, and
108+
# (b) an observer actually published a span. Installs without ``[otel]``
109+
# get a no-op attach/detach pair; the observer ContextVar stays
110+
# ``None`` and nothing changes.
111+
#
112+
# The names are bound to ``None`` in the except branch so pyright
113+
# narrows correctly at call sites (``if _otel_attach is None: ...``)
114+
# rather than flagging "possibly unbound."
115+
try:
116+
from opentelemetry.context import attach as _otel_attach
117+
from opentelemetry.context import detach as _otel_detach
118+
from opentelemetry.trace.propagation import set_span_in_context as _otel_set_span_in_context
119+
except ImportError: # pragma: no cover — exercised only in non-otel installs
120+
_otel_attach = None # type: ignore[assignment]
121+
_otel_detach = None # type: ignore[assignment]
122+
_otel_set_span_in_context = None # type: ignore[assignment]
123+
124+
125+
def _attach_active_observer_span() -> object | None:
126+
"""Read ``current_active_observer_span``; if an observer published
127+
one and OTel is installed, attach the span into the OTel context
128+
so that any logs emitted from the next user-code scope (a node
129+
body) pick up the right ``trace_id``/``span_id`` via OTel's
130+
``LoggingHandler``.
131+
132+
Returns the OTel context token to hand back to
133+
:func:`_detach_active_observer_span` in ``finally``, or ``None``
134+
if no attach happened (no observer, no OTel, or both).
135+
"""
136+
if _otel_attach is None or _otel_set_span_in_context is None:
137+
return None
138+
span = current_active_observer_span()
139+
if span is None:
140+
return None
141+
return _otel_attach(_otel_set_span_in_context(cast("Any", span)))
142+
143+
144+
def _detach_active_observer_span(token: object | None) -> None:
145+
"""Pair to :func:`_attach_active_observer_span`. No-op when no
146+
attach was performed (token is ``None``)."""
147+
if token is None or _otel_detach is None:
148+
return
149+
_otel_detach(cast("Any", token))
150+
102151

103152
def _merge_partial[StateT: State](
104153
prior: StateT,
@@ -690,20 +739,30 @@ async def innermost(s: Any) -> Mapping[str, Any]:
690739
try:
691740
self._dispatch_started(context, current, namespace, step, s, attempt_index=attempt_index)
692741

742+
# Splice the observer-published span (if any) into the
743+
# OTel context so logs emitted from the FIRST line of
744+
# the node body — before any ``await`` — pick up the
745+
# right trace_id/span_id via OTel's LoggingHandler.
746+
# Detach in ``finally`` so retries / merge / completed
747+
# dispatch don't run with the span still active.
748+
otel_token = _attach_active_observer_span()
693749
try:
694-
partial = await node.run(s)
695-
except Exception as e:
696-
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
697-
self._dispatch_completed(
698-
context,
699-
current,
700-
namespace,
701-
step,
702-
s,
703-
error=wrapped,
704-
attempt_index=attempt_index,
705-
)
706-
raise
750+
try:
751+
partial = await node.run(s)
752+
except Exception as e:
753+
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
754+
self._dispatch_completed(
755+
context,
756+
current,
757+
namespace,
758+
step,
759+
s,
760+
error=wrapped,
761+
attempt_index=attempt_index,
762+
)
763+
raise
764+
finally:
765+
_detach_active_observer_span(otel_token)
707766

708767
try:
709768
merged = _merge_partial(s, partial, self.reducers, current)
@@ -1045,38 +1104,51 @@ async def innermost(s: Any) -> Mapping[str, Any]:
10451104
attempt_index=attempt_index,
10461105
fan_out_config=fan_out_event_config,
10471106
)
1107+
# Same OTel attach pattern as ``_step_function_node``'s
1108+
# ``innermost`` — splice the observer-published span
1109+
# into the OTel context so logs emitted from inside
1110+
# the fan-out node's own scope (middleware bodies,
1111+
# the dispatch machinery) carry the right
1112+
# trace_id/span_id. Per-instance bodies get their own
1113+
# attach inside their ``_step_function_node``
1114+
# innermost when the recursive invocation hits leaf
1115+
# nodes.
1116+
otel_token = _attach_active_observer_span()
10481117
try:
1049-
partial = await node.run_with_context(
1050-
s,
1051-
context,
1052-
pre_resolved_count=item_count,
1053-
pre_resolved_concurrency=(concurrency_resolved,),
1054-
)
1055-
except RuntimeGraphError as e:
1056-
self._dispatch_completed(
1057-
context,
1058-
current,
1059-
namespace,
1060-
step,
1061-
s,
1062-
error=e,
1063-
attempt_index=attempt_index,
1064-
fan_out_config=fan_out_event_config,
1065-
)
1066-
raise
1067-
except Exception as e:
1068-
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
1069-
self._dispatch_completed(
1070-
context,
1071-
current,
1072-
namespace,
1073-
step,
1074-
s,
1075-
error=wrapped,
1076-
attempt_index=attempt_index,
1077-
fan_out_config=fan_out_event_config,
1078-
)
1079-
raise wrapped from e
1118+
try:
1119+
partial = await node.run_with_context(
1120+
s,
1121+
context,
1122+
pre_resolved_count=item_count,
1123+
pre_resolved_concurrency=(concurrency_resolved,),
1124+
)
1125+
except RuntimeGraphError as e:
1126+
self._dispatch_completed(
1127+
context,
1128+
current,
1129+
namespace,
1130+
step,
1131+
s,
1132+
error=e,
1133+
attempt_index=attempt_index,
1134+
fan_out_config=fan_out_event_config,
1135+
)
1136+
raise
1137+
except Exception as e:
1138+
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
1139+
self._dispatch_completed(
1140+
context,
1141+
current,
1142+
namespace,
1143+
step,
1144+
s,
1145+
error=wrapped,
1146+
attempt_index=attempt_index,
1147+
fan_out_config=fan_out_event_config,
1148+
)
1149+
raise wrapped from e
1150+
finally:
1151+
_detach_active_observer_span(otel_token)
10801152

10811153
try:
10821154
merged = _merge_partial(s, partial, self.reducers, current)

0 commit comments

Comments
 (0)