Observability
The engine accepts an injected observer that receives every evaluation result without blocking the evaluation itself.
Built-in LoggingObserver
from airules import LoggingObserver
router = TicketRouter(observer=LoggingObserver())
router.run(ticket)
# INFO TicketRouter: rule='billing' result=<Team.BILLING: 'billing'>
# WARNING TicketRouter: default fired for subject='Unexpected $9.99 on my statement'
LoggingObserver logs successful rule matches at INFO and default hits at
WARNING.
Custom observers
Implement the OutcomeObserver protocol for custom metrics - Prometheus
counters, Datadog traces, Slack alerts, or anything else:
from airules import OutcomeObserver, Outcome, KnowledgeEngine
class DefaultRateObserver(OutcomeObserver[Ticket, Team]):
def __init__(self) -> None:
self.total = 0
self.defaults = 0
def observe(
self,
outcome: Outcome[Ticket, Team],
engine: KnowledgeEngine[Ticket, Team],
) -> None:
self.total += 1
if outcome.is_default:
self.defaults += 1
@property
def default_rate(self) -> float:
return self.defaults / self.total if self.total else 0.0
The Outcome object
Every observe call receives an Outcome with:
| Attribute | Type | Description |
|---|---|---|
outcome.fact | FactType | The input that was evaluated |
outcome.matched | bool | Whether any rule fired |
outcome.rule_name | str | None | Name of the rule that fired |
outcome.result | ReturnType | None | The return value |
outcome.is_default | bool | Whether the @Default rule fired |
The default rate metric
The most important health signal is the default rate - the percentage of
evaluations that fell through to @Default. A rising default rate means new
input patterns are arriving that no rule covers.
- 10% default rate → rules handle 90% of traffic
- 40% default rate → rules are missing major categories or predicates are too narrow