Telemetry
At a glance
Per request —
c.spanis the root span. Useattr/attrs/eventfor dimensions and point-in-time notes;step()for nested timed work (DB, outbound HTTP).No implicit current span — relationships are explicit (
parent_idoncreate/step), so nested and concurrent asyncio stays predictable.Background work — if work outlives the handler, use
child = c.span.step(...),child.start()beforeapp.create_task, andchild.end()in the task body; see Testing — pattern 3.Tracers — pick at process start (CLI
--tracer,Server, or a customTracerfactory). Handlers only seeSpanhandles.Bundled sinks — TTY (interactive), JSON (NDJSON), SQLite (local queries); Bundled tracers.
Stario treats telemetry as part of the same feedback loop as tests: record what happened, how long it took, and what went wrong, next to the code that did the work.
Handlers use Span, a thin handle (id + Tracer). Spans start stopped; start() / with / end() make timing explicit. The backend owns batching and flush; swapping tracers at startup does not change handler code.
Example: request span, child steps, attributes
The Context you receive in each handler exposes c.span (the request span). Use attr / attrs (or assignment span["key"] = value) for durable metadata, event for point-in-time notes with optional body, then step for nested timed work such as database access or outbound HTTP.
Child spans start stopped. The usual pattern is a synchronous with block (see Span as a context manager): entering the block calls start(), exiting calls end(), so the span’s lifetime matches the synchronous work inside the with.
When work continues after the handler returns (for example a coroutine scheduled with app.create_task), a plain with c.span.step(...) is wrong—the context manager would end() the span before your background task finishes. Instead, create the child with c.span.step("name"), call child.start() before scheduling work, and call child.end() (typically in a finally inside the background task) when that work completes. See Testing — pattern 3.
import asyncio from stario import Context, Writer async def report(c: Context, w: Writer) -> None: c.span.attrs( { "request.id": c.req.headers.get("x-request-id", ""), "user.id": c.state.get("user_id", ""), } ) with c.span.step("db.load_profile", {"table": "profiles"}) as db_span: await asyncio.sleep(0) # stand-in for await pool.fetch(...) db_span.attr("rows", 1) with c.span.step("http.billing") as api_span: await asyncio.sleep(0) # stand-in for await client.get(...) api_span.attrs({"billing.status": 200, "billing.latency_ms": 12.5}) api_span.event("billing.payload", {"bytes": 2048}) w.respond(b"ok", b"text/plain")Span as a context manager
Span implements a synchronous context manager (with span: or with parent.step("name") as child:—not async with):
On enter,
__enter__callsstart(), so the tracer records the span’s start time.On exit,
__exit__always callsend(), which finalizes the span in the backend.
If the block finishes without an exception, nothing else runs automatically—you only get a normal completed span (unless you called fail yourself inside the block).
If the block exits because of an exception, __exit__ runs before the exception propagates: it calls span.exception(exc), which adds an exception event (including structured fields such as exc.type and exc.message, and the traceback in the event body when the tracer serializes it), then tracer.fail(self.id, str(exc)), then end(). That marks the span as failed for sinks that track error status and preserves a point-in-time record you can search in tests with client.tracer.get_event(r.span_id, "exception") (Testing).
You can still call start() and end() manually if you do not use with; if you skip start() before end(), the tracer raises at end().
Detached roots, step, and links
step(name, attrs?) is tracer.create(..., parent_id=current.id). For a new trace root not hung under the request—background job, bootstrap work—use c.span.create("task.cleanup") (or span.tracer.create from any span you already hold): that records a span with no parent in the same tracer. Use link(other_span_or_uuid, attrs?) when you need a reference between spans without a parent/child edge (for example correlating a worker to the HTTP span that enqueued it).
Request-span events and bootstrap
You can also call c.span.event("name", {...}) on the request span alone when a full child span is unnecessary. Bootstrap telemetry uses the same shapes: the Span passed into bootstrap(app, span) supports attr, step, and so on like c.span.
Failing spans
Uncaught exceptions inside a with span: block are described in Span as a context manager: the span gets an exception event and is marked failed automatically.
Span.fail(message) is for when no exception escapes—you handled the problem but want the trace to show failure (status: error in JSON-style exports, red styling in the TTY tracer). Call fail before end() (or before leaving the with block, which calls end() for you). For an exception event on the span without flipping the failed bit, call span.exception(exc) alone (rare).
Handled outcomes without raise:
import asyncio import stario.responses as responses from stario import Context, Writer async def checkout(c: Context, w: Writer) -> None: with c.span.step("cache.lookup") as s: await asyncio.sleep(0) # stand-in for cache I/O hit = False if not hit: s.fail("miss") # telemetry only; handler still continues else: s.attr("cache.hit", True) with c.span.step("payments.charge") as s: await asyncio.sleep(0) # stand-in for payment API declined = False if declined: s.fail("card declined") responses.text(w, "Payment failed", status=402) return allowed = True # your authz check if not allowed: c.span.fail("forbidden: missing role") responses.text(w, "Not allowed", status=403) return w.respond(b"ok", b"text/plain")fail does not stop request handling; it only updates telemetry. You still send the response via Writer.
Bundled tracers
All three implement the Tracer protocol. Import them from stario.telemetry (TTYTracer, JsonTracer, SqliteTracer) and pass the right instance into Server or the CLI tracer flag. They differ only in where finished spans go and how often they flush.
TTYTracer
TTYTracer() targets an interactive terminal. A background thread repaints a live span tree on the TTY (default refresh about every 125 ms) once the tracer is running—entering with tracer: is the normal way to start and stop that loop cleanly, but the display can also start when spans are first created. Exiting with tracer: stops the live display. This is for local development and demos, not a log format for ingestion pipelines.
JsonTracer
JsonTracer(output=..., flush_each=True, flush_interval=0.125, ...) serializes each finished span to one NDJSON line. Inside with tracer:, a background thread batches completed spans and wakes on a timer or when enough spans are pending; each batch is written to output (stdout by default). When the writer thread is not running (tracer constructed but not used as a context manager), end() on a span writes that span synchronously. Exiting with tracer: stops the thread and flushes remaining queued spans. Tune flush_interval, max_batch_spans, and max_pending_spans for latency versus back-pressure; if the pending queue fills, finished spans can be discarded until there is room. Serialization failures increment serialize_errors on the tracer—useful when event bodies are not JSON-safe.
SqliteTracer
SqliteTracer(path=..., flush_interval=0.125, ...) persists finished spans into a SQLite file with a similar background batching model to JsonTracer: periodic wake, batch insert, flush hooks. Use it when you want to query traces locally (SQL, ad-hoc analysis) rather than stream NDJSON. It exposes counters such as dropped_spans when the in-memory queue overflows, and surfaces writer failures for visibility.
Custom tracers
To plug in another sink—Datadog APM, an OpenTelemetry exporter, Honeycomb, a vendor agent, your own warehouse—implement the Tracer protocol (stario.telemetry.Tracer; full signatures are in the apidoc below).
Your tracer must:
Support the context manager:
__enter__/__exit__for any process-wide startup and shutdown (flush queues, close HTTP clients, etc.).Implement
create(name, attributes?, *, parent_id=None) -> Span, returning aSpan(id=..., tracer=self)while storing whatever structure you need keyed by thatUUIDuntilend.Implement
start(span_id),set_attribute,set_attributes,add_event,add_link,set_name,fail, andend(span_id)on the tracer to mutate that state. Handler code usesSpan(attr,event,step, …), which forwards those calls to the tracer;set_nameis part of theTracerprotocol, not a method onSpan(seestario.telemetry.core).
Typical integration work:
Map Stario’s flat attributes, events, and parent links to the vendor’s span model and ingest API (HTTP, gRPC, agent socket).
Batch and flush on a timer or queue threshold; mirror what
JsonTracer/SqliteTracerdo if you need back-pressure and drops under load.Decide policy for sampling, cardinality, and sensitive fields (PII in attribute values, secrets in event bodies).
If the vendor accepts structured logs or traces as JSON, wrapping or forking
JsonTracerand repointingwritecan be the fastest path; otherwise translate_serialize_state-style records inend.
Handlers stay unchanged: they only hold Span handles and never import your tracer type.
API
class Span(id, tracer)
Handle for one logical unit of work: attributes, events, child spans, links, fail/end—all forwarded to tracer.
Fields
- id(UUID):—
- tracer(Tracer):—
As a context manager: starts on enter, records exceptions and ends on exit.
Span.attr(name, value)
Set one span attribute.
Span.attrs(attributes)
Set many span attributes.
Span.create(name, attributes=None, /)
Create a detached root span in a stopped state.
Span.end()
End a started span.
Span.event(name, attributes=None, /, *, body=None)
Record an event.
Span.exception(exc, attributes=None, /, *, body=None)
Record a structured exception event.
Span.fail(message)
Mark span as failed.
Span.link(span_or_id, attributes=None, /)
Link this span to another span ID.
Span.start()
Start span in tracer.
Span.step(name, attributes=None, /)
Create a child span in a stopped state.
class Tracer(*args, **kwargs)
Backend that owns span records; Span is a thin handle keyed by id (explicit start/end timing).