Skip to main content
A failure that prints an error is a failure you can act on. This page is about the other kind. Vexlo sits on four handoffs, and at each one a record that was never produced looks exactly like a record that was produced and thrown away: a marker that never ran, a process writing to a spool directory nobody forwards, a relay daemon whose batches the Vexlo server rejects, a read API token for another team. All four end at the same symptom, an empty trace list, and none of them raises anything in your application. Each entry below gives three things: the symptom you see, the signal that separates that cause from the ones next to it, and the command that produces that signal. Work the sections in order. Each one assumes the one above it came out clean. The setup detail lives on the pages that own it: installing the SDK, init() and its environment, running the server, minting tokens, and running the relay daemon. This page assumes those are done and something is still missing. The outputs below come from several runs against several local servers, so the address in a command is the one that produced the output under it, not the one above it. Substitute your own.

Start with what vexlo check does not check

vexlo check is the obvious first command and it answers a narrower question than its name suggests. It validates the configuration vexlo serve would resolve, reports free disk on the value store’s filesystem, and prints the retention arithmetic. It never opens the database and never opens the value store, so it reads nothing about whether records are arriving, whether a token works, or whether a relay daemon is running. Two things to know about it before you run it. It exits 1 on its own retention defaults, because the default 20 GiB value budget, 7-day shape retention and assumed 2 traces/s write rate do not fit together. The run below names the database and the value store explicitly, which is the only reason those two lines and the free-disk line read as they do; with neither flag given they resolve under ~/.vexlo/data:
That exit 1 is about the arithmetic, not about anything being broken on your host. The three flags it names are the fix, and -allow-overcommit proceeds with the arithmetic printed and proceeding: -allow-overcommit is set appended. And vexlo serve starts on the configuration that made check exit 1, logging the same retention numbers at INFO and nothing else. Only the address differs: this run passed -api-addr 127.0.0.1:18690, where the check above reported the default 127.0.0.1:8080.
So a failing check does not mean your server is down, and a passing check does not mean records are arriving. Nothing on the rest of this page depends on it. Check the configuration covers its flags.

No traces at all

Is anything in the spool directory

The SDK writes to a spool directory and never talks to a server. A record that never reaches that directory never existed, so this is the first fork, and it is a test -d away. The directory the SDK uses is the spool_dir argument to init(), then VEXLO_SPOOL_DIR, then ~/.vexlo. The relay daemon resolves -spool-dir the same way, with the same ~/.vexlo default. Two processes running as different users therefore pick two different default directories while both configurations read as correct, and the daemon finds an empty directory while your application’s backlog grows next to it. The directory is also created lazily, on the first record written, not by init(). An absent directory means capture produced nothing, and these are the causes: vexlo.diagnostics() is the one signal here, read from inside the process that runs the markers. A marked function entered with no @vexlo.trace in scope above it produces no record at all, and says so:
Against a run where the same two functions ran under a trace:
The two rows this does not separate are the middle two: an init() that never ran and an init() that ran disabled produce byte-identical counters and no directory either way.
That block is the init()-never-called run. The VEXLO_ENABLED=0 run differs only in the directory name on its third line. Separating them is a question about your own startup path, not about Vexlo: whether init() is reached before the first request, and what VEXLO_ENABLED holds in the environment the process actually got. Disabling the SDK lists every input that turns capture off. Diagnostics carries eleven counters and they are not one kind of fact. The first two are swallowed faults. steps_outside_trace and redacted_tags are the SDK doing what it was asked to. Each of the last seven counts a marked callable that ran without its step-attempt record reaching the wire, which a trace is there and a record is missing takes up below. Reading what the SDK absorbed has all eleven.
internal_errors and adapter_errors above 0 mean the SDK swallowed a fault rather than raising it into your request. They are the counters to check before anything else on this page, because a non-zero one invalidates the reasoning in every section below it.

Is a relay daemon draining the spool directory

Run vexlo relay status -spool-dir <dir> against the spool directory your application writes to. It reads the directory and the writers’ own counter files, so it works with no daemon running and no server reachable. A backlog with no daemon behind it:
Three lines carry the answer. records sent: 0 with a non-zero backlog and an oldest unsent age that climbs between two runs of the command means nothing is draining this directory. dropped: none means nothing has been lost yet. oldest unsent age is the age of the oldest hour still holding unsent data for that writer, not of the oldest record. Records are opaque bytes to the daemon with no parsed timestamp, so the figure is a conservative proxy: the true age sits between it and that hour’s end, never above it. The 2.0GiB on the first line is this command’s own -spool-budget-bytes, which defaults to 2 GiB. relay status reads the directory and the writers’ counter files and no daemon state, so a daemon started with -spool-budget-bytes 536870912 still leaves this line reading 2.0GiB. Pass the daemon’s own value to compare the two. Enforcement is the daemon’s retention tick, so with no daemon running nothing evicts anything and the directory grows for as long as the process keeps writing. Point the same command at a directory no writer has ever touched and it says so, which is how a daemon watching the wrong directory looks:
If that is what you get against the directory you believe your application writes to, compare it against the one init() resolved. The spool directory has its layout and vexlo relay status has the rest of its output.

Is the server accepting what the daemon sends

With a daemon running there are four outcomes, and what separates them is how the daemon treats the response it got. A transport failure, a 429, and a 500, 502, 503 or 504 are retried: three attempts per send, then the batch is left on disk and the next poll loads it again, so the backlog holds and nothing is lost. Every other status, 400, 401, 403, 413, 422, 501 and anything the exchange does not recognise, is terminal on the first response. The batch is dropped, counted once per record under daemon_terminal_reject, and never retried. Those records are gone. The third row is the one worth reading twice. A daemon dropping every batch reports itself healthy, because /healthz degrades on a backlog with nothing accepted behind it, and a dropped batch leaves no backlog. This is a daemon whose -environment did not match the pin on its ingest token:
One WARN line, a healthy health response, and seven records permanently gone. dropped: daemon_terminal_reject on a writer is the signal, and records sent: 0 beside it is what separates this from a daemon that forwarded for a while and then started being rejected. Four causes produce those three outputs and differ only in the status and in one line of relay status. A -environment that does not match the token’s pin gives status=422, as above. A token the server does not hold, or has revoked, gives status=401. A read-role token in -token gives status=403, which the end of this section takes up. And a service pin the records do not match gives status=422 as well, with the writer’s own service: line naming the service the records carry:
That token was minted with -service delivery-service, and checkout-service on the writer line is the whole of what the host told you. Read the pin back with GET /v1/whoami using the ingest token and compare both fields against the writer:
The first two rows of the table are indistinguishable in relay status and in /healthz, and the daemon’s log is the only thing that separates them. An unreachable destination, three attempts and then the requeue:
A reachable server answering 500, which is the same three lines with a status in place of the transport error. That log is the multi-environment-token daemon taken up below, which is the one configuration in this set that makes a working server answer 500:
Both leave the same backlog and the same health response:
Read that reason carefully. /healthz degrades the moment a backlog exists and the daemon has never had a record accepted, which is the case for both daemons above: neither of them ever had a record accepted. Once one send has been accepted, it takes five minutes without another accepted send for the same backlog to degrade it again. The wording does not distinguish the two. Two more triggers have nothing to do with sending. The directory holding more than 80% of its budget degrades a daemon that is forwarding fine. And a spool status read that fails answers degraded with spool status unavailable as its reason, which is what a permissions or filesystem fault on the directory itself looks like. Read it with curl -s http://127.0.0.1:19690/healthz against the daemon’s own -health-addr; health and metrics has the rest of what it serves. Here is the configuration behind that 500, with nothing wrong with the server. An ingest token pinned to more than one environment fails authentication on every ingest route, and the failure surfaces as a 500:
Because 500 is retryable, the daemon retries that batch indefinitely and drops nothing, so the symptom is a backlog that never moves. The same GET /v1/whoami call is the check, and it answers 200 on the token every ingest route refuses:
More than one entry in environment_scope is this failure. Mint the token with a single -environment value. Pins covers the scoping rules and POST /v1/records covers the per-record rejections a 200 response reports, which the daemon also counts as terminal. The 403 is the one of the four where the token itself is good. POST /v1/records requires an ingest-role token and refuses a read-role one, and the daemon runs no check of its own on the token it was handed, so a read token in -token starts as cleanly as any other and the status in the log is the whole of what you are told:
POST /v1/heartbeat requires the same role, so, like an unknown token, this one’s drops never reach the server side either. That daemon heartbeated every two seconds for nine seconds, and the service coverage read two sections below came back empty:
GET /v1/whoami settles it in one field, and the field is role:
Mint the daemon’s own token with -role ingest. Mint a token has the roles and what each one reaches.

The two drop causes that are not a rejection

A dropped: block has two other causes, and neither is a response from anywhere. Both are records the daemon gave up on before a server ever saw them. daemon_spool_expired counts records the daemon deleted unsent. Pressure retention runs on the daemon’s retention tick, a minute apart by default: while the directory is over -spool-budget-bytes, it deletes the oldest hour that is past -finality-grace, counts every still-unsent record in it under this cause, and repeats until the total is back under budget. The current hour is never a candidate, so reaching this needs a backlog held across an hour boundary, which is what a destination that stays unreachable for long enough produces. Every writer that had unsent records in a deleted hour logs one spool pressure evicted unsent records WARN carrying the writer, the hour and the count. A directory over budget with nothing old enough to delete logs spool over budget with no hour past finality_grace yet instead and deletes nothing, which is the line that comes first. Earlier still, /healthz reads degraded from 80% of the budget, while every record is still on disk. daemon_torn_record counts a segment’s trailing bytes past its last newline, once, when the hour has gone final and the writer that would have completed the line is gone. It logs torn trailing record orphaned by hour finality with the byte count. This is a partial write, not a rejection, and the records ahead of it are unaffected. Both land in the dropped: block vexlo relay status already prints and in the same records_lost_24h the heartbeat carries, so the cause name is the whole of the signal. The spool directory has the sweep’s other job.

Where a terminally rejected batch does become visible

POST /v1/heartbeat consults neither pin, where POST /v1/records checks both, so a daemon whose -environment or service pin is wrong keeps heartbeating successfully while every batch of records is refused. The heartbeat carries the daemon’s own drop counters and the server folds them into service coverage, which makes GET /v1/coverage the read that tells someone on the server side that a producer exists and is losing everything:
Seven records lost, no trace, no marked function, and an empty last_seen_at. That combination is the signal: traces_24h and last_seen_at are both computed from traces that arrived, so a service listed with records_lost_24h above 0 and no last_seen_at has a relay daemon reaching this server and no record getting past it. A service absent from the list has had neither an arrived record nor a reported drop in the last 24 hours, so absence does not rule out a daemon that is forwarding nothing and dropping nothing either. Two bounds on that read. Drops appear only on the daemon’s next heartbeat, 30 seconds apart by default, so checking within the first half-minute of starting a daemon shows nothing. And the heartbeat admits exactly the token POST /v1/records admits, so the three failures that are about the token rather than a pin, an unknown token, a read-role token and the multi-environment 500 above, stop the heartbeat as well and leave nothing here to read. records_lost_24h is a trailing 24-hour window, not a running total. Each heartbeat’s new drops are folded into the current hour’s bucket and the read sums only the buckets inside the last 24 hours, so a daemon that dropped 50,000 records yesterday and nothing since reports 0 here. Read a zero as nothing reported lost in the last day, never as nothing ever lost. The figure covers all three of the daemon’s drop causes and is not the same quantity as a trace’s own records_lost, which the SDK stamps on the trace-close record for losses inside the process. The two are counted on opposite sides of the spool directory and neither bounds the other. GET /v1/health reports the same per-service loss counters beside the store’s own figures. GET /v1/coverage has every field.

Did records arrive under a team this token cannot read

Past the daemon, both the trace list and service coverage are scoped to the reading token’s teams, and the trace list filters further. An empty list says nothing on its own about whether records arrived. Run GET /v1/coverage first, for the reason above and because it carries no filter of your own:
A service with a recent last_seen_at means records are landing and the problem is in the query. An empty list:
means nothing has arrived for any team this token holds, which is either the ingest side above or a token for the wrong team. GET /v1/whoami settles which team that is:
Compare team_ids against the team on the traces you expect. A trace carries the team of the ingest token that delivered it, so a read token for another team sees an empty list against a server holding the trace:
The same empty body comes back from a filter that excludes everything. This is the same server and the same token that returns one trace unfiltered, asked for ?environment=prod when the daemon delivered the trace with -environment dev:
The environment and service on a stored trace come from the daemon’s -environment flag and the SDK’s service, not from anything the reader sets, so a filter written against what you meant to deploy excludes traces delivered under what you actually deployed. Drop every filter, confirm the trace is there, then add them back one at a time. Team and environment filters has the rules, and GET /v1/traces has every parameter.

A trace is there and a record is missing

A trace is served from whatever has arrived, so a trace whose records are incomplete still returns, and it returns without complaint. The read is the ordinary one, GET /v1/traces with no filter or GET /v1/traces/{id} for a trace you have an id for, and three fields on the list row carry the difference, two of them by being absent.
A request still in flight, read five seconds into a ten-second call:
Both steps are counted and neither has returned. What reached the server is a trace-open and one step-open record per entered function, which is what the spool directory holds at that moment. Reading it back takes a few lines of Python, and every record block below this point on the page is its output: one line per record, each record’s kind, the fields named on the command line, and each slot’s state.
spool.py
The SDK holds a step-open 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. The held record is discarded unwritten when the attempt’s own step-attempt is written, so a call that returns inside that second produces no step-open at all. Three seconds after the call above returned, the same row carries root_status, a duration_ms and completeness: "complete". Now the same read against two traces posted with records held back: the first without the root’s own step-attempt or its trace-close, the second without its trace-close:
Read the two rows against the table. The first is missing root_function as well as root_status, and that is table row two: a record at the root, of either kind, creates the row root_function is read from, so its absence means nothing at the root arrived at all. The second trace is the one with no signal on it: a lost trace-close leaves completeness reading complete, deliberately, because counting the close record would reclassify every trace whose close was lost as partial. The cost is that a trace’s records_lost and truncated ride on that record, so when it is the record that went missing, the trace reports no loss and means nothing by it. The gap in that table is between row three and row one, and it is the one to know about. When the root’s completion record is lost inside the process, the step-open the SDK was holding for that attempt is written in its place, so the row reads exactly like a request still running: running, root_function present, no root_status. The trace list cannot separate those two, and the thing that eventually does is the clock. running is time-bounded: a trace whose root has not arrived reads running while a record for it arrived within the last fifteen minutes and abandoned after that, derived per read, so two reads of one trace can differ with nothing written in between. abandoned on a request you know returned is a lost root record. Row two needs more than a lost completion. Nothing at the root path, of either record kind, reached the server, and a completion lost on its own does not produce that, because the close trigger writes the held step-open instead. The capture above made it past the spool directory: the trace’s records were posted directly, minus the root’s own step-attempt and its trace-close. In production the losses on that side of the handoff are the ones the sections above cover, a batch the server rejected terminally among them. The four fields on every row are the rest of the arithmetic. records_lost counts records the SDK could not deliver for that trace, records_shed records ingest refused under pressure, truncated a trace that hit the 10,000-step cap, and instance_mismatch records attributed to more than one process. completeness reads complete when the root’s own completion record arrived and all four of those report nothing, and partial when the root arrived and at least one of them does not. records_lost and truncated are stamped by the SDK, so they are readable in the spool directory as well. Ask spool.py for them by name and the last of its seven lines, the trace-close, is the one that carries them:
records_shed and instance_mismatch have no counterpart there and are added by the server, which is what tells you which side of the handoff a trace lost its records on. For a step a marked function ran and no record was ever written for, the counters in vexlo.diagnostics() are the surface, not the trace: steps_after_trace_close, attempts_outlived_trace, drives_lost_to_trace_end, drives_degraded, attempts_dropped_hidden_coroutine, traces_evicted and markers_declined each count a marked callable that ran with no step-attempt record reaching the wire. They are cumulative for the process and not attributable to one trace, so they tell you a shape of loss is happening, not which trace it hit. Three of them rise during ordinary operation and are not faults: drives_lost_to_trace_end once per marked generator the host abandons before exhaustion, attempts_outlived_trace once per task or carried-context thread that finishes after its trace closed, and markers_declined once per decorated class at import. What a marker refuses to wrap and finishing work before the root returns cover the two you can change. One more case reads as missing steps when it is not. A single trace’s steps come back one page at a time, at most 500, so “this step was never reported” holds only after a full walk with next_cursor back to null. What a page leaves out depends on the sort. On the default steps_sort=path it is whatever sorts after the page by path, with no bias toward either kind of step. On steps_sort=duration_desc a step whose every attempt is still in flight has no duration and sorts after every ranked step, so the unfinished ones are the last to come back. Either way the number missing from one page is not a count of anything. Step paging and sorting has the walk.

The steps are there and the values are missing

An input or an output that is absent is reported by its slot’s state, not by the slot being gone. Two of the four reasons below have a state of their own: sampled_out for the sampling roll and expired for the byte budget. A capture policy that lowers value_sample_rate arrives as that same sampled_out, a policy with enabled: false stops capture before a slot exists, and redaction leaves the state untouched, so the state narrows the cause without settling it on its own. A trace captured with values on, read back with the spool.py above:
The same code with init(value_sample_rate=0.0), in a directory of its own:
sampled_out on the root’s slots and no value records at all. pending on pick_dasher reads the same in both runs and means something different in each. It is the state of an ok attempt below the root, whose fate is not settled until the root returns: in the first run the three value records are what settled it, and in the second nothing ever will, because the trace was excluded and an excluded pending slot is never revised. So pending is the one state on this page that is not a signal, and every ok attempt below the root carries it. A non-root ok slot also reads pending on the read API after its value record has been ingested, so the same word covers bytes that are there, bytes still coming, and bytes that are never coming. Fetch the values and branch on whether a value came back, never on the state. Handles states that rule for the read API and value sampling has what the SDK writes. The second cause is a capture policy set on the server. A policy lowers what init() asked for and never raises it, so one set on the server reduces or stops capture for every process reading that service’s policy file, with nothing logged in the application. vexlo policy show is the check:
The updated_at on that last line is not when the policy changed. vexlo policy show sends a heartbeat and prints the response, and the route stamps that field with the time it answered, as a liveness signal. Nothing the server serves says when a policy was last set. The daemon writes that same policy into the spool directory on its next heartbeat, at <spool-dir>/policy/<service>.json, and the SDK reads it from there. Its stamp is the earlier heartbeat’s, for the same policy:
Two properties of that file matter when you are reading one. Its updated_at is what decides whether it counts: a file older than five minutes by that stamp, or missing, or unparseable, is ignored and the process falls back to init()’s own values, so a policy stops applying when the daemon stops heartbeating. And the file is read on the writer thread’s own poll rather than at init(), so a newly set policy does not apply to work that starts before that thread’s first poll. The capture policy file and setting a capture policy have the fields and the precedence. Two more reasons a value is absent, neither of them loss, and the first is not a state at all. Redaction replaces the value under any str dict key that matches a redaction rule, at any depth in an input or an output, and drops a matching vexlo.tag() pair. The slot keeps its state and the handle carries redacted: true, so this is the one absence on this page the state does not report. A name rule reaches no further than a name: a secret interpolated into an exception message, as in ValueError(f"invalid token {token}"), or into a captured log message ships as written, whatever redact_keys holds. Each of those two has a switch of its own: capture_error_message for the message and capture_logs for the log text. The second reason is the clocks. Values are bounded by a byte budget while shape is bounded by time, so values go first: on the default 20 GiB budget and 7-day shape retention, the arithmetic vexlo check prints above puts values at about 104 hours against shape’s 168. A trace older than its values keeps every step and has nothing to show in them. The state for that is expired, resolved at read time, and vexlo serve answers it for a value that never arrived as well, because its store keeps no record that a value once did. Read expired as no bytes to read, not as bytes this server held and lost. The ninth state, missing, is reserved for a store that can tell those two apart and vexlo serve never answers it. Retention has the two clocks and handles has all nine states.

Failures that do announce themselves

These print a message and exit non-zero. If you are reading a message rather than guessing, the page that owns the command is the faster read.