Telemetry design

Stario’s telemetry layer is separate from routing but first-class: every request gets a span; bootstrap and shutdown get spans; you can plug TTY, JSON, SQLite, or a custom tracer that implements the Tracer protocol (Telemetry reference).

In handlers, c is the per-request Context and c.span is the request span created during dispatch (Handlers, context, and the writer).

Mental model

Think of telemetry as structured narration of what the code did:

ToolUse it for
AttributesDimensions you will filter or group in dashboards: user.id, http.route, db.shard, build.id.
EventsInstant facts that are not a duration: “validation failed”, “cache hit”, “relay publish”.
Child spans / span.stepIntervals inside a request or at startup: “load rows”, “static_assets”, “external HTTP call”.

Bootstrap spans and span.step

The span passed to bootstrap is the root startup trace. Annotate it before routes go live so production sinks show which binary and config actually booted:

python
async def bootstrap(app: App, span: Span):
    span.attrs({"app.name": "tiles", "tiles.grid_size": game.grid_size})
 
    with span.step("static_assets") as s:
        static = StaticAssets(ASSETS)
        s.attrs(static.stats)
    # register() wires routes on App; keep it outside the step so the span measures manifest work only
    static.register(app)
 
    app.get(HOME, home(game))
    yield

span.step creates a child interval on the bootstrap trace—use it for work you want timed separately (asset scan, database migration, warming caches). Handler code uses c.span.step the same way for per-request phases.

bootstrap(app, span) receives a ProxySpan over server.startup while the listener binds; after shutdown begins, the same handle becomes server.shutdown. Annotate startup before yield; teardown attrs and events after yield land on the shutdown span.

Startup attributes on the bootstrap span are especially valuable in production—which binary and config actually booted—see Application lifecycle.

Request span vs “user-visible latency”

The default request span covers the whole request exchange in App.__call__ (handler work plus w.end() in finally)—not “CPU inside the handler coroutine” alone. Two patterns behave differently:

  • Same coroutine after responses.empty: the client may finish reading early, but the request span stays open until the handler returns. Further HTTP writes on w raise — only side effects (Relay.publish, state updates, app.create_task) may follow.

  • app.create_task after the response: the request span ends when the handler returns while UI may still update via SSE or other tasks — use child = c.span.step(...), child.start(), c.app.create_task(...), child.end() in the task body (not bare asyncio.create_task).

If you care about a specific phase, record it:

  • with c.span.step("flush_sse"):

  • c.span.event("bytes_sent", {"n": n})

  • Child intervals under the request: c.span.step(...) (and with c.span.step(...)) create spans with parent_id set to the request span. c.span.new_trace(...) (or tracer.create(...) without a parent) creates a detached root span—use that for background work or separate traces, not as a synonym for “nest under the request.” All tracers record the tree; export detail depends on the sink.

Where telemetry is not a substitute for logs

Text logs are still fine for local printf debugging. Spans shine when you need traces across requests and consistent key/value fields in production sinks.

Built-in tracers

TracerTypical use
TTYTracerLocal dev—human-readable span tree on a TTY
JsonTracerPipes, systemd, log aggregators—one JSON object per span line
SqliteTracerLocal inspection / small dashboards—SqliteTracer

stario serve / watch default to auto: TTY tracer when stdout is a TTY, JSON otherwise. Set STARIO_TRACER to pick a built-in name or module:callable.

Tracer configuration (STARIO_TRACER, STARIO_TRACERS_JSON_*, STARIO_TRACERS_SQLITE*) is documented in Telemetry — bundled tracers and Runtime — Server.

Custom tracer

Implement the Tracer protocol (see stario.telemetry.core and existing tracers), then point the CLI at a zero-argument callable that returns a tracer—for example mypkg.telemetry:make_tracer (STARIO_TRACER imports the module and calls the factory with no parameters):

bash
STARIO_TRACER=mypkg.telemetry:make_tracer uv run stario watch main:bootstrap

Built-in STARIO_TRACER=sqlite is CLI sugar that reads STARIO_TRACERS_SQLITE*. Use custom factories to ship spans to OpenTelemetry, Honeycomb, internal gRPC, etc., without changing application handlers.


Reference: Telemetry · Bootstrap spans: Application lifecycle