Skip to main content
Everything the SDK can be told is an argument to vexlo.init(). Seven of those arguments also read an environment variable when the argument is not passed, so a deployment can set them without a code change. How a captured value is rendered and bounded is on what gets captured.

init()

An argument passed explicitly always wins over its environment variable. None means “not passed”; an explicit service="" is an empty name and raises, while an explicit spool_dir="" counts as absent. For VEXLO_ENABLED, VEXLO_ADAPTERS, VEXLO_CAPTURE_VALUES, and VEXLO_CAPTURE_ERROR_MESSAGE, the values 0, false, no, off, and the empty string turn the setting off, compared case-insensitively after stripping whitespace; any other value turns it on. Those seven are the only environment variables the SDK reads, apart from HOME for the default spool directory. VEXLO_ENDPOINT and VEXLO_INGEST_TOKEN do not exist in the SDK: the server address and the API token belong to the relay daemon, which is the only process on the host that sends anything.

Calling init() again

A second init() call replaces the configuration wholesale: a new instance_id is minted, a new writer is started, and the previous writer is drained (for at most 2 seconds) and then stopped. Nothing carries over from the earlier call, so a process that calls init() twice must pass every setting both times. All eleven counters are cumulative for the life of the process and a second init() does not reset them; see reading what the SDK absorbed.

Disabling the SDK

enabled=False or VEXLO_ENABLED=0 makes every marker a plain pass-through: the marked function is called and nothing else happens. No vexlo-writer thread starts, no atexit hook is registered, no spool directory is created, and tag() and flush() return without doing anything. Marked code can ship before a relay daemon or a server exists. One shape is not a complete pass-through. A marked async generator’s driver is chosen when the decorator runs, which can be before init(), and an async generator has no yield from to delegate the rest of the drive with, so a disabled one still passes each item back out through the driver’s frame at about 0.12 microseconds an item. Take the marker off a hot async generator rather than disabling the SDK to quiet it.
disabled.py

Log capture

capture_logs=True appends one logging.Handler to the root logger. It never calls basicConfig, never sets a level, and never touches another handler, so every handler the host configured keeps receiving every record. The handler buffers each record emitted while a step is active against that step’s attempt: at most 100 entries per attempt, each message cut at 4 KiB, 16 KiB per attempt in total. A record emitted inside a marked child belongs to the child, not to the call above it. A record emitted with no step active is dropped. A later init(capture_logs=False) removes that same handler. The buffered entries ship. While capture is on, every attempt carries a logs slot alongside its other slots, and the entries kept for it travel in a value record like any other value. One entry is {"level", "logged_at_us", "logger_name", "message", "truncated"}, where logged_at_us is the offset in microseconds from the start of the trace. Log text therefore leaves the process, and redaction matches names and cannot reach inside a message the host already formatted. Read what your service logs before turning this on. A logs handle differs from the handle on any other slot in three ways:
  • preview is the last entry’s message, not the first 64 code points of the shipped bytes.
  • bytes_count is the length of the blob that ships, not the size before a cut. truncated is the field that says entries were dropped.
  • An attempt that logged nothing ships state: "stored" with bytes_count: 0, no content_id, and no preview. Two further states mean the sink kept nothing for a reason: too_large when the first entry alone did not fit 16 KiB, and not_captured when capture_logs is on but the handler is no longer on the root logger, which is what a host that rebuilds the root logger’s handler list after init() leaves behind.
capture_logs.py
The root logger holds the one handler the SDK added. The find_order record, whose body logged a warning, carries three slots:
The entry behind that logs handle travels in its own value record:
The root logged nothing of its own, so its logs slot says so with no bytes to fetch:

strict

strict=True re-raises the SDK’s own internal faults instead of counting and swallowing them. It re-raises at four places: a marker’s entry, a marker’s success path, inside tag(), and inside flush(). It is for a test suite that wants to know the SDK is misbehaving. Everywhere else the fault is still swallowed, including the error path, where an exception the marked function raised is always the one that propagates and a fault while recording it is absorbed; the writer thread’s start; and the drain at process exit. strict does not govern what init() raises, and it does not govern the refusal a marker makes at decoration. Both raise whatever it is set to.

Reading what the SDK absorbed

A fault the SDK swallows is counted, and vexlo.diagnostics() is how a host reads the counts. It returns a vexlo.Diagnostics holding eleven integers, cumulative for the life of the process and never reset by a later init().
diagnostics.py
Four of the eleven are about capture itself: The other seven each count a marked callable that ran without a complete step reaching the wire, so that a missing step is never silent: Two of those seven cost the step’s outcome rather than the step itself. An attempt is reported the moment it is entered, so one that was still open when its trace closed, which is attempts_outlived_trace and drives_lost_to_trace_end, leaves a step-open in the graph with no outcome ever arriving. The other five put nothing on the wire at all. internal_errors, adapter_errors, and steps_outside_trace are 0 in a process where nothing has gone wrong, which makes those three worth asserting on in a test suite and worth putting on a health route. Four of the rest rise on code working exactly as designed, so asserting a whole Diagnostics against zeros fails: redacted_tags on every redacted key passed to tag(), markers_declined once per decorated class at import, attempts_outlived_trace for every task or thread that finishes after its trace closed, and drives_lost_to_trace_end for every async for loop that leaves a marked async generator unfinished. The eleven are read one after another, so a fault landing between two reads shows on one and not the other. Each count is correct for itself. One loss these counters do not report: a tag dropped at one of its bounds, which nothing counts. A record the outbox evicted is counted, and rides out on the trace-close record as records_lost.

What init() raises

init() raises ConfigurationError, a subclass of vexlo.VexloError, before anything is started; ConfigurationError itself is not exported, so catch vexlo.VexloError. The five conditions and their messages, from one script that triggers each in turn:
config_errors.py
init() is not the only call that raises. A marker applied to a classmethod, property, cached_property, or partialmethod object, or to something not callable, raises UnsupportedCallable where the decorator runs; see what a marker refuses to wrap. Every other public call raises only under strict. Once init() has returned, a spool directory that turns out to be unwritable is not an error the host sees: each record whose write fails is counted as lost and dropped, and the marked functions keep running.

The capture policy file

The SDK never talks to the server, so the capture policy for a service reaches it through the relay daemon, which writes <spool_dir>/policy/<service>.json:
The vexlo-writer thread re-reads the file at most once a second and caches the result, so no marker call touches the disk to find out. The file only ever lowers what init() configured. Five settings are read from it: enabled, capture_logs, capture_values, and capture_error_message are on only if both the file and init() say so, and value_sample_rate is the smaller of the two. A field the file omits, or gives the wrong type, keeps the init() value. max_value_bytes is parsed and clamped the same way, but this version of the SDK applies a fixed 16 KiB slot cap and does not lower it from the file. Two of the five are what an operator reaches for after finding credentials in a service’s exception messages: capture_values and capture_error_message turn that capture off without a deploy. The relay daemon shipping today writes enabled, value_sample_rate, and capture_logs; the other two take effect when something writes them. A file whose updated_at is more than 5 minutes old is stale, and the SDK reverts to its init() values until a fresh one appears. A missing, unreadable, or malformed file is not an error: the init() values apply. The script below calls init() with the rate given as its first argument, writes a policy file with the rate and the age given as its second and third, waits 1.5 seconds for the writer’s next read, and runs one trace.
policy.py
Reading the segment file back takes one more script, which every tab below uses and which the pages in this section reuse:
spool_records.py
Three runs of the policy script. Each tab shows the policy file the run wrote, then the two state lines of the root’s step-attempt record in the segment file.
The file lowered the rate from 1.0 to 0.0, so the root’s values were sampled out.
How the policy is set on the server, and how the daemon resolves it per service, is on the capture policy page.

flush() and shutdown

vexlo.flush() drains the outbox to the spool directory on the calling thread and returns when the outbox is empty or 2 seconds have passed, whichever comes first. It does nothing when init() was never called or the SDK is disabled. Anything still buffered at the deadline is counted as lost. A failed write, from a full or read-only disk, is counted too, and swallowed, unless init(strict=True) is set, in which case flush() re-raises it. The same drain, with the same 2-second deadline, runs from the atexit hook init() registers, so an ordinary interpreter exit writes what is buffered without any call from the host. That drain is outside strict: it swallows a failed write whatever strict is set to. The SDK installs no signal handler: a host’s own SIGTERM handling is untouched, and a process killed by a signal that bypasses atexit loses whatever the writer had not yet flushed. Between flushes the writer thread writes on its own cadence: every second, or as soon as 512 records or 1 MiB are waiting, with one fsync per batch.

In-memory bounds

Marked functions never write to disk themselves. They append to the outbox, and the writer thread drains it. Two bounds keep the outbox from growing without limit: A held step-open is never evicted to make room, and no trace is evicted on its account, but it does occupy the buffer until it drains, so it counts toward the occupancy bound above. Eviction removes what is still in the outbox, so whatever the writer thread already put on disk stays there. A trace big enough to span a drain therefore arrives partial rather than absent. The root’s completion is the last record a trace writes, which makes it the likeliest casualty, and when it is lost the root’s held step-open is what ships instead. One root calling one leaf 10,200 times, then flush(), leaves this on disk:
overflow.py
So loss does not always arrive as a whole missing trace. Two bounds acted on this one and the close record reports them separately. truncated: true is the 10,000-step cap: the run made 10,201 marked entries, so 201 of them got no path and no record. records_lost is the eviction: of the 10,000 entries that did get a path, 255 completions reached disk. No value record survived at all, and the only record at the root path is the step-open the close trigger wrote, so the root’s completion never arrived and the server derives completeness: running, then abandoned once nothing has arrived for 15 minutes. Read records_lost and truncated before reading a trace as complete. Every drop is counted, and when the loss happens before the trace’s trace-close record is admitted, the count rides out on that record as records_lost, which the server reports in the trace’s coverage block. A local write that fails (a full disk, a permissions error, a removed directory) is counted the same way and never retried.