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()
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:
previewis the last entry’s message, not the first 64 code points of the shipped bytes.bytes_countis the length of the blob that ships, not the size before a cut.truncatedis the field that says entries were dropped.- An attempt that logged nothing ships
state: "stored"withbytes_count: 0, nocontent_id, and nopreview. Two further states mean the sink kept nothing for a reason:too_largewhen the first entry alone did not fit 16 KiB, andnot_capturedwhencapture_logsis on but the handler is no longer on the root logger, which is what a host that rebuilds the root logger’s handler list afterinit()leaves behind.
capture_logs.py
find_order record, whose body logged
a warning, carries three slots:
logs handle travels in its own value record:
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, andvexlo.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
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:
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
spool_records.py
state lines of the root’s step-attempt record in the segment file.
- init 1.0, file 0.0, fresh
- init 1.0, file 0.0, 10 minutes old
- init 0.0, file 1.0, fresh
1.0 to 0.0, so the root’s values were sampled out.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
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.