ingest token. The relay daemon calls two of them: it posts batches of
records tailed from the spool directory and sends a heartbeat per (service, instance_id), then
writes the capture policy each heartbeat returns to a file the SDK reads. It never calls
PUT /v1/policy. The command that does is vexlo policy set, which an operator runs. Any client
holding an ingest token can call all three routes, and this page does so with curl.
Every request and response on this page was captured against a server under bearer
authentication on 127.0.0.1:18200. The batch posted is the spec’s conformance trace, the 49
records of 17-full-worked-trace.json, held in $FIXTURE. $FIXTURE_INGEST_TOKEN is an ingest
token for team fixture pinned to environment prod; $SERVICE_PINNED_INGEST_TOKEN is one for
the same team pinned to -service other-service.
POST /v1/records
Ingests a batch of records. The body is a bare JSON array; the fixture file’s{description, trace_id, records} envelope is not the wire shape, so the examples post its
records array.
A record is one of five kinds, each an object with a
kind field:
A
step-open carries what a marked function knows on entry and nothing that depends on how the
call ends: the completion record’s duration_us, status, error_class, continues_retry and
slots are absent from it, and a step-open carrying any of the five is rejected. Version 2 of
the record format is what added the kind and changed no field of the other four, so a version-1
trace carries none, and a step known only from a step-open reads as an in_flight attempt on
the read API (conventions).
The exact field rules are the execution graph spec’s. An uncompressed body is capped at 4 MiB;
a gzip body is capped at 32 MiB on the wire and at 4 MiB once decompressed. Past either cap the
answer is 413 body_too_large.
Responses
Every record accepted is204 with no body. This is the response to the fixture post that
seeded the Vexlo server:
X-Ratelimit-Limit-Records and
X-Ratelimit-Limit-Bytes are the sustained rates, the two Remaining headers count down from
the bursts, and one X-Ratelimit-Reset covers both. These headers appear once a batch has been
decoded; the 400, 413, and environment 422 responses below carry none.
When some records are rejected and the rest accepted, the answer is 200 with accepted_count
and one rejected[] entry per rejected record, sorted by the record’s index in the batch. Here
the batch is the fixture’s trace-close record followed by an object of unknown kind:
A rejected record never blocks the rest of the batch.
trace_shed and trace_team_mismatch were
not captured; the fixture is one trace on one team, and the server was idle.
Two other answers are whole-request. The body must be a decodable JSON array:
429 rate_limited from this route means one of two things, and the message says which:
ingest rate limit exceeded is the token bucket, and the writer's queue is full with
Retry-After: 1 means the server’s writer could not take the batch and nothing was written.
Both are safe to retry with the same body.
Pins
An ingest token carries two optional pins. An environment pin requiresX-Vexlo-Environment to
carry that exact value; the header is compared as sent, so a comma-separated list is refused
before the pin is consulted:
-environment <that value>.
A service pin is checked against every trace-open in the batch. The fixture declares
delivery-service, and this token is pinned to other-service:
POST /v1/heartbeat checks neither pin.
Duplicates and order
A step’s identity is itspath, so ingest is an upsert: a record delivered twice, or a child
arriving before its parent, changes nothing about the trace’s shape. The same 49 records posted
a second time, gzip-compressed this time, are accepted again:
last_seen_at advanced to the second post, because the upsert keeps the
later of the two arrival instants, and GET /v1/traces sorts on last_seen_at descending: the
resent trace comes back as the newest row in the list. first_seen_at stays where it was.
The function index does not move. Each of its two counters is bumped by a delta the writer
computes against the state the record is about to overwrite, never by a fixed 1, so a record that
reports nothing the index has not already counted bumps it by 0. That is what makes both counts
idempotent under the relay daemon’s at-least-once delivery. The three functions below read the
same counts after the resend as before it:
Idempotency-Key header. Correctness rests on the upsert for the trace and
on those computed deltas for the index, so a spool file the relay daemon delivers twice inflates
neither (functions).
POST /v1/heartbeat
Reports one instrumented process’s delivery counters and returns the capture policy that process’s service resolves to. The relay daemon sends one per live(service, instance_id) every
30 seconds, the default of its -heartbeat-interval flag.
The body is capped at 16 KiB. The route shares the read surface’s rate limit (20 requests per
second, burst 40) and carries its headers. The response is always
200 with the resolved
capture policy, never 204. A team that has never set one resolves to the system default:
enabled and
capture_logs under AND, value_sample_rate under min, max_value_bytes at the SDK’s own 16 KiB
ceiling), so it changes nothing in a process regardless of its init() settings. updated_at is
stamped fresh on every response: it is a liveness signal, not the time the capture policy last
changed.
A body that is not JSON is 400 malformed_request, and a body that decodes but fails validation
is 422 invalid_heartbeat:
PUT /v1/policy
Sets the capture policy a heartbeat resolves to. A heartbeat resolves the service’s own row first, then the team-wide row, then the system default above.
The body is capped at 4 KiB, the route takes no
?environment=, and the team comes from the
token. A successful write echoes the body back with 200:
delivery-service resolves to it:
422 invalid_policy, and details.field names it:
422 service_scope_mismatch:
400 malformed_request, the same answer the heartbeat route gives.
The SDK applies a resolved capture policy downward only, and the daemon writes it to
<spool_dir>/policy/<service>.json; capture policy covers that side.