Observers¶
An observer receives hooks during a run: step start, cache hit, step finished, fan-out branch.
Pass one with execute(observer=...).
LoggingObserver: the default. Logs each step with structlog.NullObserver: silent. Subclass it and override only the hooks you need.RunTreeObserver: collects a timing tree; print it withrender_tree().
"""Observers: watch a run. RunTreeObserver prints a timing tree with cache hits."""
import tempfile
from dataclasses import dataclass
from pathlib import Path
from pydantic import BaseModel
from adag import Pipeline, RunTreeObserver, Step
class Number(BaseModel):
value: int
@dataclass(frozen=True)
class Load(Step[Number]):
def run(self) -> Number:
return Number(value=4)
@dataclass(frozen=True)
class Square(Step[Number]):
def run(self, n: Number) -> Number:
return Number(value=n.value**2)
graph = Square()(n=Load()())
cache = Path(tempfile.mkdtemp())
for label in ["first run", "second run"]:
observer = RunTreeObserver()
Pipeline(graph, cache_dir=cache).execute(observer=observer)
print(f"{label}:\n{observer.render_tree()}")
The hooks are the methods of PipelineObserver: on_step_start, on_step_cached,
on_step_finished, on_fan_out_branch, on_fan_out_branch_failed, and on_event for
progress events (step.saved, batch.*, item_cache.*, branch.*).
NullObserverdrops all of them, so a run prints nothing.- Warnings and errors (a timed-out branch, a cache invariant violation) are always logged with structlog, so an observer cannot hide a problem.
- Inside a step body, send your own event with
adag.deferred.emit("my.event", n=3).
Typed hooks for your own values¶
Use a typed hook when your code consumes a value from a run (a score, a vector, a model name), instead of logging it. adag has no domain hooks of its own. Declare one yourself:
- A
runtime_checkableProtocolwith your method, hereon_score(score: float). - One function that calls the method when the observer has it, and does nothing otherwise.
- An observer that implements the method. It subclasses
NullObserverfor the other hooks.
Call the function from either place:
- Inside a step, with
context.observer(overrideexecute, callsuper().execute). - From any helper a step calls, with
current_observer().
"""A typed domain hook: a step sends a value, a custom observer receives it."""
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from pydantic import BaseModel
from adag import InputParam, NullObserver, Pipeline, Step
from adag.deferred import PipelineObserver, ResolveContext, current_observer
@runtime_checkable
class ScoreObserver(Protocol):
"""Declares the extra hook. Any observer that has this method receives scores."""
def on_score(self, score: float) -> None: ...
def report_score(observer: PipelineObserver, score: float) -> None:
"""Call ``on_score`` when the observer has it; do nothing otherwise."""
if isinstance(observer, ScoreObserver):
observer.on_score(score)
def normalise(x: float) -> float:
"""A helper deep in the call stack: it reaches the observer of the current run."""
score = x / 10
report_score(current_observer(), score)
return score
class Number(BaseModel):
value: float
@dataclass(frozen=True)
class Score(Step[Number]):
def run(self, x: float) -> Number:
return Number(value=normalise(x))
@dataclass(frozen=True)
class Double(Step[Number]):
def execute(self, context: ResolveContext, **inputs: object) -> Number:
result = super().execute(context, **inputs)
report_score(context.observer, result.value) # a step with the context in hand
return result
def run(self, n: Number) -> Number:
return Number(value=n.value * 2)
@dataclass
class CollectScores(NullObserver):
scores: list[float] = field(default_factory=list[float])
def on_score(self, score: float) -> None:
self.scores.append(score)
pipeline = Pipeline(Double()(n=Score()(x=InputParam[float]("x"))))
observer = CollectScores()
pipeline.execute(x=7.0, observer=observer)
print(observer.scores)
pipeline.execute(x=7.0, observer=NullObserver()) # a plain observer: report_score does nothing
(output trimmed: log lines removed)
A strict type checker sees float at both ends. A plain NullObserver or LoggingObserver
keeps working, because the function skips an observer without the method.
on_event(event, fields) stays untyped (dict[str, Any]): it carries progress events, not
values a caller consumes.
The deprecated NullObserver.on_embedding and on_model_refused do nothing. Replace each with a
typed hook as above.