|
70 | 70 | _set_fan_out_index, |
71 | 71 | _set_invocation_id, |
72 | 72 | _set_namespace_prefix, |
| 73 | + current_active_observer_span, |
73 | 74 | ) |
74 | 75 |
|
75 | 76 | from .edges import END, ConditionalEdge, EndSentinel, StaticEdge |
|
99 | 100 | from .state import State |
100 | 101 | from .subgraph import SubgraphNode |
101 | 102 |
|
| 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 | + |
102 | 151 |
|
103 | 152 | def _merge_partial[StateT: State]( |
104 | 153 | prior: StateT, |
@@ -690,20 +739,30 @@ async def innermost(s: Any) -> Mapping[str, Any]: |
690 | 739 | try: |
691 | 740 | self._dispatch_started(context, current, namespace, step, s, attempt_index=attempt_index) |
692 | 741 |
|
| 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() |
693 | 749 | 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) |
707 | 766 |
|
708 | 767 | try: |
709 | 768 | merged = _merge_partial(s, partial, self.reducers, current) |
@@ -1045,38 +1104,51 @@ async def innermost(s: Any) -> Mapping[str, Any]: |
1045 | 1104 | attempt_index=attempt_index, |
1046 | 1105 | fan_out_config=fan_out_event_config, |
1047 | 1106 | ) |
| 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() |
1048 | 1117 | 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) |
1080 | 1152 |
|
1081 | 1153 | try: |
1082 | 1154 | merged = _merge_partial(s, partial, self.reducers, current) |
|
0 commit comments