Skip to content

Observability & logging

asgimachine takes the wide-event (canonical-log-line) view: instead of scattering narrow log lines through a request, it accumulates one structured event per request on ctx.event and emits it once at the boundary through a pluggable sink. The decision trace is its spine; you slice and aggregate the rest.

The framework owns the event and the emission seam; the sink — stdlib logging, structlog, OpenTelemetry, an error reporter — is yours to choose. Nothing is emitted until you wire one.

The event

ctx.event is a plain, mutable dict, present on every request. Write to it from anywhere that holds ctx; the core fills the owned fields and emits it once — at the request boundary for a buffered response, or at stream-close for a streamed body (so late work lands in it). Field names follow OpenTelemetry semantic conventions where they exist; graph-specific fields live under asgm..

Field When Meaning
http.request.method always the method
url.path always the concrete request path (/notes/42)
http.route when routed the route template (/notes/{id}) — the low-cardinality grouping key
http.response.status_code if a response was produced the status
duration_ms always wall time from entry to emit
asgm.lane always resource or command
asgm.resource / asgm.command always the class name
asgm.decision_path resource lane the trace node path (B13,B12,…,O18)
asgm.halted_at 4xx/5xx the last node — where it ended
asgm.outcome always ok / halt / error / propagated
asgm.media_type / asgm.language / asgm.encoding if negotiated the chosen variant
exception.type / exception.message / error.type on an exception the failure

Those namespaces (http., url., error., exception., asgm.) are reserved for the core — put your domain fields in your own.

Wiring a sink

No sink is configured by default. The reference sink logs via the stdlib:

from asgimachine.event import LoggingEventSink
from asgimachine.substrate.starlette import build_app

app = build_app(routes, event_sink=LoggingEventSink())

A sink is one synchronous method (emit(event)) — sync so it's safe to call on the exit path even under cancellation. Swap in your backend at the composition root:

# structlog
class StructlogSink:
    def __init__(self) -> None:
        self._log = structlog.get_logger("request")

    def emit(self, event):
        self._log.info("request", **event)

# OpenTelemetry — decorate the active server span with the (already OTel-named) fields
class SpanSink:
    def emit(self, event):
        span = trace.get_current_span()
        for key, value in event.items():
            span.set_attribute(key, value)

A sink that raises is swallowed (logged to asgimachine.event) — an observability failure never becomes a request failure.

Enriching from a resource

Any callback can add domain fields — high-cardinality is the point:

class Account(Resource[AccountCtx]):
    async def resource_exists(self, ctx: AccountCtx) -> bool:
        ctx.account = await self._store.load(ctx.request.path_params["id"])
        if ctx.account is not None:
            ctx.event["account.id"] = ctx.account.id       # a wide dimension
            ctx.event["account.plan"] = ctx.account.plan
        return ctx.account is not None

Now every request event carries the account — so "p95 latency by plan" or "404s by account" is a query, not a code change.

Enriching from deep code — the database

The hard part of wide events is contributing from code that doesn't hold ctx — a database layer three calls down. asgimachine's answer for the common case needs no ambient globals: the lifespan already puts the connection on ctx, and DB code always has the connection in hand. So carry a per-connection accumulator and merge it in the lifespan — where ctx and the connection meet.

This example uses asyncpg's per-connection query logger:

import asyncpg


class QueryLog:
    """Aggregates, not enumerates — a request doing 200 queries is three numbers,
    not a 200-element array. Never records bound parameters (a PII/fingerprint
    leak); log the parameterized statement shape only if you must."""

    __slots__ = ("count", "total_ms", "max_ms")

    def __init__(self) -> None:
        self.reset()

    def reset(self) -> None:
        self.count, self.total_ms, self.max_ms = 0, 0.0, 0.0

    def record(self, q: asyncpg.LoggedQuery) -> None:
        ms = q.elapsed * 1000
        self.count += 1
        self.total_ms += ms
        self.max_ms = max(self.max_ms, ms)

    def merge_into(self, event: dict) -> None:
        if self.count:
            event["db.query_count"] = self.count
            event["db.total_ms"] = round(self.total_ms, 3)
            event["db.max_ms"] = round(self.max_ms, 3)


class LoggingConnection(asyncpg.Connection):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.qlog = QueryLog()
        self.add_query_logger(self.qlog.record)  # asyncpg >= 0.29


pool = await asyncpg.create_pool(dsn, connection_class=LoggingConnection)

Then merge in the resource's lifespan:

class AccountsResource(Resource[AccountCtx]):
    async def lifespan(self, ctx: AccountCtx) -> AsyncGenerator[None]:
        async with self._pool.acquire() as conn:
            conn.qlog.reset()          # (!) pooled connections are reused
            ctx.conn = conn
            try:
                yield
            finally:
                conn.qlog.merge_into(ctx.event)   # lands before the event emits

Two things this gets right, both easy to get wrong:

Reset on acquire, not just merge on release

A pooled connection outlives one request. If you only merge at release, request N+1 inherits N's counters. Reset after acquire (or in the pool's setup= hook, which runs on every checkout).

The merge lands because the core emits the event after lifespan teardown — including the deferred teardown of a streamed response, so a feed that queries while streaming still reports its DB work.

Errors and report ids

The on_exception handler runs inside the walk with ctx in scope — which is exactly what lets an error report and the wide event share a report id. Report, stash the id, return, and the graph owns a clean 500 whose event carries the id:

from honeybadger import honeybadger

async def report(ctx, exc):
    ctx.event["honeybadger.notice_id"] = honeybadger.notify(exc)
    return None  # graph-owned 500 (negotiated problem+json), event carries the id

app = build_app(routes, on_exception=report, event_sink=LoggingEventSink())

This is why the catch-all lives in the core rather than an outer ASGI middleware: a middleware only sees the exception after the walk returns, so the report id is born too late to reach an event emitted inside run(). Co-locating the reporter with the event is the whole point. (Re-raise instead of returning to hand the exception to your outer reporter unchanged — but then the id won't be in the event.)

A crash that isn't handled still emits a propagated event (statusless) before it leaves the graph, so it's never invisible.

The command lane

Commands skip the graph, so they emit a thinner event (no decision path) through the same sink — the same OTel fields and outcomes, with asgm.lane = "command". Filter on asgm.lane to separate the two, or union them for a single request stream.

What the graph does — and doesn't

The graph owns the event: the fields only it knows (the decision path, the halt node, the negotiated variant, why a 406 happened). It does not own the backend — shipping to a log aggregator, a tracing system, or an APM is Layer 2, rented through the sink. And it doesn't turn RED metrics into a separate path: rate, errors, and duration all fall out of the one event stream at your backend, so there's a single source of truth per request.