3434from dataclasses import dataclass , field
3535from typing import Any , Literal , Protocol
3636
37- from .events import NodeEvent
37+ from .events import MetadataAugmentationEvent , NodeEvent
3838from .state import State
3939
4040
4141class Observer (Protocol ):
42- """The shape of a callable that receives node-boundary events.
42+ """The shape of a callable that receives observer events.
4343
4444 `Observer` is a structural Protocol; any async callable matching the
4545 signature qualifies, no subclass required. Plain functions, bound
4646 methods, and class instances with `__call__` all work::
4747
48- async def log_observer(event: NodeEvent) -> None:
49- print(event.node_name, event.phase)
48+ async def log_observer(event: NodeEvent | MetadataAugmentationEvent) -> None:
49+ if isinstance(event, NodeEvent):
50+ print(event.node_name, event.phase)
5051
5152 compiled.attach_observer(log_observer)
5253
@@ -63,6 +64,27 @@ async def log_observer(event: NodeEvent) -> None:
6364 conformance doesn't pin you to that name; any of `event`, `_event`,
6465 `e`, etc. matches.
6566
67+ Two event variants reach observers (graph-engine §6 + proposal
68+ 0040). The signature is the union; observers ``isinstance``-narrow
69+ on the first line and choose which variants they handle.
70+
71+ - :class:`NodeEvent` — the started/completed/checkpoint phase
72+ events. Subject to the ``phases`` filter on
73+ :class:`SubscribedObserver`; observers whose phase set excludes
74+ ``event.phase`` do NOT receive it.
75+ - :class:`MetadataAugmentationEvent` — emitted by
76+ :func:`openarmature.observability.metadata.set_invocation_metadata`
77+ when called mid-invocation. Carries the augmenting context's
78+ lineage tuple (``namespace``, ``attempt_index``,
79+ ``fan_out_index``, ``branch_name``) so rich backends can update
80+ their open observations in place
81+ (``span.set_attribute(openarmature.user.<key>, v)`` for OTel,
82+ ``observation.update(metadata=...)`` for Langfuse). Per spec §6
83+ this variant is NOT subject to the ``phases`` filter — every
84+ subscribed observer sees it and isinstance-narrows to decide
85+ whether to act. Simple user observers typically early-return
86+ after ``isinstance(event, NodeEvent)`` checks.
87+
6688 Optional ``prepare_sync`` extension
6789 -----------------------------------
6890 An observer MAY additionally define a synchronous method::
@@ -81,9 +103,13 @@ def prepare_sync(self, event: NodeEvent, /) -> None: ...
81103 the synchronous prep entirely; observers that do define it run
82104 only for ``"started"``-phase events, with errors warned-not-
83105 propagated (same isolation contract as the async path).
106+ ``prepare_sync`` is never invoked for
107+ :class:`MetadataAugmentationEvent` (the synchronous-prep contract
108+ is anchored on the ``started`` phase, which only ``NodeEvent``
109+ carries).
84110 """
85111
86- async def __call__ (self , event : NodeEvent , / ) -> None : ...
112+ async def __call__ (self , event : NodeEvent | MetadataAugmentationEvent , / ) -> None : ...
87113
88114
89115# Per spec v0.6.0 §6: the two valid phase strings. Used as the default
@@ -200,15 +226,22 @@ class _QueuedItem:
200226 receive it. The list is computed at dispatch time so events from
201227 different depths in nested subgraphs carry the correct observer chain
202228 without the worker needing to know the graph topology.
229+
230+ ``event`` is the union of ``NodeEvent`` (started / completed /
231+ checkpoint phases) and ``MetadataAugmentationEvent`` (proposal
232+ 0040, side-channel augmentation). The delivery worker branches by
233+ type to apply the right delivery contract (phase-filter for
234+ ``NodeEvent``, no filter for the augmentation event).
203235 """
204236
205- event : NodeEvent
237+ event : NodeEvent | MetadataAugmentationEvent
206238 observers : tuple [SubscribedObserver , ...]
207239
208240
209241# A sentinel value the engine puts on the queue to signal the worker to
210242# return after draining the events ahead of it. None is unambiguous —
211- # observers receive `NodeEvent` instances, never None.
243+ # the queue carries `NodeEvent` and `MetadataAugmentationEvent` instances
244+ # wrapped in `_QueuedItem`, never None.
212245_DRAIN_SENTINEL = None
213246
214247
@@ -587,16 +620,29 @@ def take_step(self) -> int:
587620 return n
588621
589622
590- def _dispatch (context : _InvocationContext , event : NodeEvent ) -> None :
591- """Enqueue a node event for the delivery worker.
592-
593- For ``"started"``-phase events, also call any subscribed observer's
594- optional ``prepare_sync(event)`` synchronously — in the engine task,
595- BEFORE queueing — so observers that need to publish per-event state
596- the engine itself reads in the same engine-task scope (e.g., the
597- OTel observer setting ``current_active_observer_span`` for the
598- engine to attach into the OTel context) can do so before the node
599- body runs.
623+ def _dispatch (
624+ context : _InvocationContext ,
625+ event : NodeEvent | MetadataAugmentationEvent ,
626+ ) -> None :
627+ """Enqueue an event for the delivery worker.
628+
629+ Handles two event variants:
630+
631+ - :class:`NodeEvent`: the started/completed/checkpoint pair model.
632+ For ``"started"``-phase events, also calls any subscribed
633+ observer's optional ``prepare_sync(event)`` synchronously — in
634+ the engine task, BEFORE queueing — so observers that need to
635+ publish per-event state the engine itself reads in the same
636+ engine-task scope (e.g., the OTel observer setting
637+ ``current_active_observer_span`` for the engine to attach into
638+ the OTel context) can do so before the node body runs.
639+ - :class:`MetadataAugmentationEvent` (proposal 0040): a side-
640+ channel augmentation event emitted by
641+ ``set_invocation_metadata`` mid-invocation. Bypasses the
642+ ``prepare_sync`` branch entirely — the sync-prep contract is
643+ anchored on ``"started"``, which only ``NodeEvent`` carries.
644+ Queued onto the same serial worker so observers see it in
645+ strict order with the surrounding node events.
600646
601647 Phase-gated forwarding: ``prepare_sync`` only fires when ``"started"``
602648 is in the subscribed observer's ``phases`` set, mirroring how the
@@ -616,7 +662,7 @@ def _dispatch(context: _InvocationContext, event: NodeEvent) -> None:
616662 observers = context .full_observers ()
617663 if not observers :
618664 return
619- if event .phase == "started" :
665+ if isinstance ( event , NodeEvent ) and event .phase == "started" :
620666 for subscribed in observers :
621667 if "started" not in subscribed .phases :
622668 continue
@@ -686,9 +732,15 @@ async def deliver_loop(
686732 each).
687733 - No observer receives event N+1 until everyone has finished N
688734 (the loop processes one item fully before pulling the next).
689- - Observers whose ``phases`` set excludes the event's phase do
690- NOT receive it. Phase filter applies at delivery, not dispatch;
691- the engine still produces both events for every attempt.
735+ - For :class:`NodeEvent`, observers whose ``phases`` set excludes
736+ the event's phase do NOT receive it. Phase filter applies at
737+ delivery, not dispatch; the engine still produces both events
738+ for every attempt.
739+ - For :class:`MetadataAugmentationEvent` (proposal 0040), the
740+ ``phases`` filter is bypassed entirely — the event isn't a
741+ node-phase event, so every subscribed observer receives it
742+ regardless of ``phases``. Observers ``isinstance``-narrow on
743+ the first line and choose whether to act.
692744 - Observer exceptions don't propagate, don't break siblings,
693745 don't block subsequent events. Reported via ``warnings.warn``.
694746
@@ -698,11 +750,12 @@ async def deliver_loop(
698750 item = await queue .get ()
699751 if item is None :
700752 return
753+ event = item .event
701754 for subscribed in item .observers :
702- if item . event .phase not in subscribed .phases :
755+ if isinstance ( event , NodeEvent ) and event .phase not in subscribed .phases :
703756 continue
704757 try :
705- await subscribed .observer (item . event )
758+ await subscribed .observer (event )
706759 except Exception as e :
707760 warnings .warn (
708761 f"observer raised { type (e ).__name__ } : { e } " ,
0 commit comments