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:
| Tool | Use it for |
|---|---|
| Attributes | Dimensions you will filter or group in dashboards: user.id, http.route, db.shard, build.id. |
| Events | Instant facts that are not a duration: “validation failed”, “cache hit”, “relay publish”. |
Child spans / span.step | Intervals 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:
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)) yieldspan.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 onwraise — only side effects (Relay.publish, state updates,app.create_task) may follow.app.create_taskafter the response: the request span ends when the handler returns while UI may still update via SSE or other tasks — usechild = c.span.step(...),child.start(),c.app.create_task(...),child.end()in the task body (not bareasyncio.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(...)(andwith c.span.step(...)) create spans withparent_idset to the request span.c.span.new_trace(...)(ortracer.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
| Tracer | Typical use |
|---|---|
TTYTracer | Local dev—human-readable span tree on a TTY |
JsonTracer | Pipes, systemd, log aggregators—one JSON object per span line |
SqliteTracer | Local 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):
STARIO_TRACER=mypkg.telemetry:make_tracer uv run stario watch main:bootstrapBuilt-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