You write an app against the requests you imagine. It runs against the requests that arrive. The gap between the two is where most surprises live, and telemetry is how you see into it. Stario treats it as part of the framework: every request is a span, startup is a span, and a tracer decides where the spans go.
What a span is#
A span is a record of one piece of work with a start, an end, and facts attached. For a request, the span starts when the headers arrive and ends when the handler finishes. It carries the route pattern, the status, the duration, and whatever you add.
You add three kinds of thing. Attributes are facts you will filter on later: a user id, a report kind, a row count. Events are moments: "player connected", "cache miss". Steps are nested spans around one part of the work, with their own duration.
async def report(c: Context, w: Writer) -> None: c.span.attrs({"user.id": user_id, "report.kind": kind}) with c.span.step("db.load_report") as step: rows = await load_report(kind) step.attr("rows", len(rows)) w.html(report_view(rows))The span passed to bootstrap is the same kind of object, so startup work speaks the same vocabulary. The methods are in Telemetry.
Why it is built in#
When telemetry is a library you add later, it tends to arrive after the first outage, and it covers the parts someone remembered to wrap. When the framework opens a span for every request and hands it to you as c.span, the request is already measured. You only add what is specific to your app.
The cost is small: a timestamp at the start, one at the end, and a record when the span closes. That is cheaper than one wrong guess about why a page is slow.
Measure, then decide#
Telemetry does not tell you a page must load in 200 milliseconds. It tells you how long it took, step by step, for the requests that actually came in. You look at that and decide: split a step, cache a query, or leave it alone. The decision is yours; the evidence is real.
The same record is your context when something fails. Which route, which step, what the attributes said at that moment. Without it you guess. With it you read.
Where spans go#
A tracer receives finished spans. Which one runs is a deployment choice, not a code change, so handlers never know.
During development you want a live tree in the terminal. That is the default when stdout is a TTY. On a host you may want one JSON line per span for a log collector. When you want to ask questions later, you write spans to SQLite and query them with SQL.
STARIO_TRACER=sqlite STARIO_TRACERS_SQLITE=traces.sqlite3 uv run python main.pySTARIO_TRACER also accepts a module with a make_tracer() function or a module:callable path, so you can send spans anywhere that implements the tracer protocol.
By the way#
The TTY tracer prints the request tree you saw in Hello world. Every line in it is a span or an event.
Status is what went on the wire. The span is what happened. A stream that failed on its tenth write still has status 200 and a span marked as failed.
Next: Traces in SQLite writes spans to a file and queries them.