Skip to main content
Three things put a first trace on disk: one vexlo.init() call at process startup, one function marked @vexlo.trace, and one it calls marked @vexlo.step. This page runs exactly that and reads back what the SDK wrote. No server and no relay daemon are involved; the output is the spool directory.

Call init() once, at startup

vexlo.init(service=...) configures the SDK for the whole process. service is the one required argument: 1 to 128 printable ASCII characters, the name every trace from this process carries and the leading key of the function index. Call it where the process starts, never inside a request handler: every call replaces the configuration wholesale, mints a new instance_id, and restarts the background writer. The demo service does it at the top of its main:
vexlo-demo/src/delivery/cli.py
init() does two things beyond storing the configuration:
  • It starts one daemon thread named vexlo-writer. Marked functions append records to an in-process outbox; this thread drains the outbox to the spool directory, flushing and fsyncing at least once a second, or sooner once 512 records or 1 MiB are waiting.
  • It registers an atexit hook, once per process, that drains whatever is still buffered for at most 2 seconds when the interpreter exits.
Neither happens when the SDK is disabled (enabled=False or VEXLO_ENABLED=0), and init() never opens a network connection: the SDK writes files, and only the relay daemon sends them anywhere. Bad configuration raises ConfigurationError whatever else is set; catch vexlo.VexloError. Applying a marker to a classmethod, property, cached_property, or partialmethod raises too, where the decorator runs. Every other public call raises only under init(strict=True). The configuration reference lists every argument, environment variable, and error message.

Mark two functions and run

Save this as first_trace.py:
first_trace.py
@vexlo.trace on assign_dasher opens a trace when the function is entered with none active. @vexlo.step on find_order records one step inside it. The vexlo.flush() at the end is there so the script can be read back immediately; a long-running process does not need it, because the writer thread flushes on its own and the atexit hook drains at exit. Run it with the spool directory pointed at a directory of your choice:
The function returned what it always returns. Marking a function changes nothing about its arguments, return value, or exceptions.

What landed on disk

The spool directory now holds two files, every directory at mode 0700 and every file at 0600:
The layout is <spool_dir>/spool/<UTC hour>/<writer-id>.jsonl plus <spool_dir>/writers/<writer-id>.identity.json:
  • The hour directory is the UTC wall-clock hour of the write, YYYY-MM-DDTHH. The .jsonl file inside it is the segment file, the one file records are appended to. The writer opens a new segment file when the hour advances and never goes back to an earlier one.
  • The writer id is <unix-seconds>-<pid>-<8 hex chars>, minted once per process at its first write (a forked child mints its own). One process appends to one file per hour, so concurrent processes on a host never share a segment.
  • The identity file names the service and the process’s instance_id, written once and never rewritten. Here it holds {"service": "delivery-service", "instance_id": "0ee85f26d79092faaf0d9e1d3b058057"}.
Nothing is created until the first record is written: a process whose marked functions never run inside a trace leaves no spool directory behind. Without VEXLO_SPOOL_DIR or the spool_dir argument the SDK uses ~/.vexlo, the same default the relay daemon reads from.

The records

The segment file holds one JSON record per line, seven for this run. Pretty-printed here so the fields are readable; on disk each record is a single line.
Reading the seven records in order:
  1. trace-open ships when assign_dasher is entered, before its body runs. tags is empty because no tag_keys were declared.
  2. The step-attempt for find_order ships when it returns, which is before its caller does, so a child’s completion record always precedes its parent’s. Its path is 0000, the first child of the root; its parent_name is the root’s name. Both slots are pending: the trace’s sampling decision is not known until the root returns.
  3. The step-attempt for assign_dasher has path: "", the root, and no parent_name. Its slots are stored: the root returning is the moment the trace’s fate is decided.
  4. started_us is a step’s offset from the start of the trace, in microseconds: 0 for the root by construction, 51 for find_order. duration_us is the marked function’s own wall time, so the root’s covers its child’s.
  5. Three value records carry the bytes behind the handles, one per distinct content_id. find_order’s input and assign_dasher’s input are the same bytes, so they share one record.
  6. trace-close ends the trace: no records were lost and the 10,000-step cap was not hit.
Every step-attempt carries handles, not bytes; the value records carry the bytes. That split is what lets a reader fetch the shape of a trace without its values. What gets captured covers the slots and their states.

The fifth record kind

A trace that finishes quickly, like this one, uses four of the five record kinds. The fifth, step-open, reports an attempt that was still running. The SDK holds one for every attempt from the moment the marked function is entered and writes it on whichever of three triggers comes first: the attempt has been open 1 second, its trace’s trace-close is written, or the process makes its 2-second shutdown flush. A held record is discarded unwritten when that attempt’s own step-attempt is written, so a call that returns promptly produces exactly one record and this run produced no step-open at all. A step-open carries what a marked function knows on entry and nothing more: trace_id, path, name, parent_name, attempt, started_us, and declared_retryable. It has no duration_us, no status, and no slots, because each of those is a fact about how the attempt ended. One attempt produces at most one step-open, and “this attempt has not finished” is not a field on the wire: it is what a reader derives from an open record with no completion beside it. The format_version: 2 on the trace-open record above is the stamp for the five-kind format; version 1 was the four-kind format and carries no step-open at all. One case ships nothing. A step-open is never written for an attempt whose path is not yet final, and one kind of attempt has no final path on entry: an undeclared call that matches its parent’s last failed child on every retry condition but the exception it has not raised yet. Its path waits on that exception, and an open record at a path no completion ever lands on would show a reader a step that never ran there. The exclusion covers everything entered beneath such an attempt too, since a path is its parent’s path plus an ordinal. @vexlo.step(retries=True) is the way out: a declared step’s path is final on entry, so it and its descendants report normally. A hang inside an undeclared retry candidate is the one hang this record does not surface. Context across threads shows a run that produces one.

From here

The SDK’s job ends at the segment file. The relay daemon on the same host tails the spool directory, batches the records, and sends them to the Vexlo server; the SDK holds no server address and no token. To see this trace on the /v1 API, run the daemon and the server as the quickstart does, or read the relay daemon page. Next: mark functions covers the two markers in full.