Skip to main content
Three routes take an 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 is 204 with no body. This is the response to the fixture post that seeded the Vexlo server:
The route is rate limited on two units at once, records and bytes: 4,000 records per second with a burst of 8,000, and 4 MiB per second with a burst of 8 MiB. 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:
A 5 MiB body of whitespace inside brackets is refused before it is read as JSON:
A 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 requires X-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:
Omitting the header with a pinned token is a mismatch:
A relay daemon forwarding for a pinned token therefore runs with -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 its path, 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:
The trace’s steps, counts, and coverage block read as they did after the first post:
One thing did change. 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:
The server reads no 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:
Every field of the default is the identity value of the clamp the SDK applies (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:
The next heartbeat for delivery-service resolves to it:
An out-of-range field is 422 invalid_policy, and details.field names it:
A token pinned to a service writes only that service’s row. Writing another service’s row, or the team-wide row, is 422 service_scope_mismatch:
A body that is not JSON is 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.