Observability
The framework ships NO telemetry dependency. Observability is a pluggable
application concern reached through a single extension point —
chattice.observability.ObservabilityHooks — plus a small set of named
standard-library loggers. This page documents the hook contract, the
failure-isolation guarantee, the no-op default, and how to bridge to OpenTelemetry.
The ObservabilityHooks contract
ObservabilityHooks is a Protocol with two async methods, called around
each feed_update routing pass (every inbound event, regardless of which
handler wins):
class ObservabilityHooks(Protocol):
async def before_event(self, event: object, data: dict[str, object]) -> None: ...
async def after_event(
self,
event: object,
data: dict[str, object],
result: object,
error: BaseException | None,
) -> None: ...
Semantics:
before_eventruns before any routing/filter work for the event.after_eventalways runs (in afinally), even when routing raised; it receives the handlerresult(or the error-handler result) and the exception viaerror.error=Noneandresult=Noneboth mean "nothing produced" — a handler that returnsNonehas no error.datais the dispatch context: injected dependencies plus the pre-injectedstate(FSM) key — the same mapping handlers receive.- A handler failure is reported through the hook and ALSO routed to the error observer; the hook observes, it does not handle.
Configure on the Dispatcher:
from chattice import Dispatcher
from myapp.telemetry import OTelHooks
dispatcher = Dispatcher(observability_hooks=OTelHooks())
Failure isolation
A hook exception NEVER affects dispatch. Both hook calls are wrapped:
try:
await hooks.before_event(event, data)
except Exception:
_observability_logger.exception("before_event hook failed")
The exception is logged to the chattice.observability logger (so it is
never silently lost) and dispatch continues. The same guarantee applies to
after_event — a broken hook cannot corrupt the finally path. This is
pinned by tests/reliability/test_observability.py: a hook that raises
still yields a successful dispatch and a fully observed after_event.
Loggers used by the framework, all under the chattice namespace:
| Logger | Emits |
|---|---|
chattice.observability |
Hook failures (the failure-isolation path) |
chattice.routing |
Event receipt, observer selection, handler matches, and result types at DEBUG |
chattice.http |
Verification failures, invalid payloads, handler failures, sync-deadline warnings, per-interaction latency |
chattice.push |
Pub/Sub and Workspace Events payload/envelope errors, dedupe skips, handler failures |
chattice.pubsub |
Pull delivery lifecycle, answer mode/success, and failure diagnostics and DEBUG tracebacks |
The unconfigured default
With observability_hooks=None (the default) the dispatcher performs NO
hook calls — there is no hidden no-op object and no overhead per event. The
application opts in by construction; forgetting to configure costs nothing
and breaks nothing.
OTel bridge example (application-owned)
The following is an EXAMPLE bridge the application writes — it depends on
opentelemetry-api / opentelemetry-sdk, packages the FRAMEWORK does not
and will not depend on. The framework's only requirement is the three-line
protocol above.
# myapp/telemetry.py — application code, framework-independent
from __future__ import annotations
from typing import Any
from opentelemetry import trace # external dependency, application-owned
from opentelemetry.trace import Span, Status, StatusCode
from chattice.observability import ObservabilityHooks
_tracer = trace.get_tracer("myapp.chat")
class OTelHooks(ObservabilityHooks):
def __init__(self) -> None:
self._spans: dict[int, Span] = {}
async def before_event(self, event: object, data: dict[str, object]) -> None:
span = _tracer.start_span(
f"feed_update {type(event).__name__}",
attributes={"event.type": type(event).__name__},
)
span.set_attribute("event.raw_type", getattr(event, "event_type", "unknown"))
self._spans[id(event)] = span
async def after_event(
self,
event: object,
data: dict[str, object],
result: object,
error: BaseException | None,
) -> None:
span = self._spans.pop(id(event))
span.set_status(
Status(StatusCode.ERROR, type(error).__name__)
if error
else Status(StatusCode.OK)
)
if result is not None:
span.set_attribute("handler.result_type", type(result).__name__)
span.end()
Notes for the bridge author:
- The hooks are
asyncbut the tracer calls are synchronous — keep bridge methods small; a slow bridge delays dispatch (the failure-isolation guarantee covers exceptions, not latency). event,data,resultare plain objects; hook code must not mutatedata(dispatch shares the mapping with handlers).- Events expose their original payload through
.raw; the context, handler result, and exception can also contain application data. Export only the fields needed for diagnostics. The example records exception types without their messages. - Exporters, sampling, and resource attributes are application concerns; the bridge above only creates spans.
- Trace propagation across the HTTP boundary (extracting
traceparentfrom the inbound request) belongs in the FastAPI integration layer or a Starlette middleware — see http-transport and authentication for where the framework ends and the application begins.