Telemetry

At a glance

  • Per request — c.span is the root span. Use attr / attrs / event for dimensions and point-in-time notes; step() for nested timed work (DB, outbound HTTP).

  • No implicit current span — relationships are explicit (parent_id on create / step), so nested and concurrent asyncio stays predictable.

  • Background work — if work outlives the handler, use child = c.span.step(...), child.start() before app.create_task, and child.end() in the task body; see Testing — pattern 3.

  • Tracers — pick at process start (CLI --tracer, Server, or a custom Tracer factory). Handlers only see Span handles.

  • 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.

python
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__ calls start(), so the tracer records the span’s start time.

  • On exit, __exit__ always calls end(), 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().

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:

python
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 a Span(id=..., tracer=self) while storing whatever structure you need keyed by that UUID until end.

  • Implement start(span_id), set_attribute, set_attributes, add_event, add_link, set_name, fail, and end(span_id) on the tracer to mutate that state. Handler code uses Span (attr, event, step, …), which forwards those calls to the tracer; set_name is part of the Tracer protocol, not a method on Span (see stario.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 / SqliteTracer do 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 JsonTracer and repointing write can be the fastest path; otherwise translate _serialize_state-style records in end.

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.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).

Tracer.add_event(span_id, name, attributes=None, /, *, body=None)

Tracer.create(name, attributes=None, /, *, parent_id=None)

Tracer.end(span_id)

Tracer.fail(span_id, message)

Tracer.set_attribute(span_id, name, value)

Tracer.set_attributes(span_id, attributes)

Tracer.set_name(span_id, name)

Tracer.start(span_id)