HTTP API
Base URL: http://<host>:8080. All bodies are JSON unless stated otherwise.
Times in responses are RFC 3339 strings with up to nanosecond precision.
Conventions
Errors
Every error has the same shape:
{ "error": { "code": "invalid_query", "message": "Expected expression after 'and'", "position": 19 } }
| Status | code |
When |
|---|---|---|
| 400 | invalid_request, invalid_json, invalid_payload, invalid_event, invalid_encoding, invalid_query |
malformed input (position for queries, index for batch events); invalid_encoding for a gzip body that cannot be decoded; invalid_payload with "limit": "maxDepth" for ingested values nested too deeply (details) |
| 401 | unauthorized |
missing/invalid API key or session |
| 403 | forbidden |
an ingest-only key used on a read/admin endpoint |
| 404 | not_found |
unknown resource or endpoint |
| 408 | request_timeout |
the request body did not arrive in full within server.body_timeout of its head; the connection closes (details) |
| 413 | payload_too_large |
ingest body over ingest.max_request_size after gzip decoding, a batch with more events than ingest.queue_capacity, or one that would decode into too much memory; limit names which (maxRequestSize, maxEvents, maxDecodedBytes) (details, decoding limits) |
| 415 | unsupported_media_type |
content type or Content-Encoding not accepted |
| 422 | query_too_expensive |
the query would read more than query.max_scan_bytes (limitBytes gives the limit) |
| 422 | query_result_limit |
the result would exceed a result budget: more spans in one trace than query.max_trace_spans, or more than 10,000 points per metric series (limit gives the limit) (details) |
| 429 | rate_limited |
every ingest slot busy (ingest.max_concurrent) or the ingest queue full — retry after Retry-After seconds (details) |
| 503 | unavailable |
storage unavailable, or every query slot (query.max_concurrent) stayed busy for query.queue_timeout — retry after Retry-After seconds |
| 503 | storage_write_failed |
storage could not write the request (reason: noSpace, permissionDenied, readOnly, shortWrite, ioError or other). outcome is notStored, or unknown if it may reappear after a power loss (details) |
| 504 | query_timeout |
the query exceeded query.timeout (limitMs gives the limit) |
| 500 | internal, storage_error |
details are logged server-side, never returned |
Query limit errors never come with partial results. A query whose client disconnects is cancelled at its next checkpoint and nothing is sent. See Execution limits and cancellation.
Connections
Vyrtel serves HTTP/1.1 only, one request at a time per connection
(pipelined requests are answered in order). HTTP/2, h2c upgrades and
WebSockets are not served. The limits below apply to every endpoint before
routing, authentication or ingest admission; they are configured under
[server].
| What happens | The client sees |
|---|---|
All server.max_connections slots are taken |
The connection is accepted and waits up to 2 s for a slot (at most 64 wait). If none frees, or 64 already wait, Vyrtel writes 503 server_busy with Retry-After: 1 and Connection: close if the socket takes it at once, then closes. Clients may see only a closed or reset connection: treat it like the 503. |
No complete request head within server.header_timeout (also: idle keep-alive) |
The connection closes without a response. |
Request head larger than server.connection_buffer, or more than 100 headers |
431 Request Header Fields Too Large, then the connection closes. |
Body not complete within server.body_timeout of its head |
408 request_timeout, then the connection closes. Endpoints that take no body are unaffected. |
A write stays blocked for server.write_timeout (the client stopped reading) |
The connection closes; the rest of the response is not sent. |
| Malformed request line or headers, or an HTTP/2 preface | 400 Bad Request or a close. |
The refusal is written before any of the request is read:
HTTP/1.1 503 Service Unavailable
content-type: application/json
retry-after: 1
connection: close
{"error":{"code":"server_busy","message":"too many open connections; retry shortly"}}
Clients that keep connections alive (the OpenTelemetry SDKs and Collector,
browsers, reqwest, HttpClient) reuse a few connections and are not
affected unless many of them share one server. Retry a server_busy 503, a
refused or reset connection, and a 408 with backoff; nothing was stored.
Time inputs
from / to accept RFC 3339 ("2026-10-06T10:00:00Z"), Unix timestamps
(seconds, milliseconds, microseconds or nanoseconds, as numbers or strings),
"now", and offsets from now such as "-15m" or "now-1h". Ranges are
half-open: from <= t < to. Omitted bounds are unbounded (aggregations
default to the last hour).
Authentication
Always on for the UI and API. Ingestion follows the API keys:
- Ingestion (
/api/v1/events,/v1/*) is open while no ingest-scoped API key exists, so producers can send without setup. Creating the first ingest key protects it at once: from then on it requires a valid API key (Authorization: Bearer <key>orX-Api-Key: <key>; ingest or admin scope). Revoking the last ingest key opens it again. Admin keys never turn protection on or off.GET /api/v1/api-keysandGET /api/v1/system/inforeport the current state asingestKeyRequired. - Everything else under
/api/v1requires either the admin session cookie (from/api/v1/auth/login) or an API key with scopeadmin. /health,/ready,/api/v1/auth/*and the web UI assets are public.
POST /api/v1/auth/login {"username": "admin", "password": "..."} → sets HttpOnly cookie
POST /api/v1/auth/logout
GET /api/v1/auth/me → {"authEnabled": true, "user": "admin" | null} (authEnabled is always true since 0.1.2)
API keys
GET /api/v1/api-keys
POST /api/v1/api-keys {"name": "checkout", "scope": "ingest" | "admin"}
DELETE /api/v1/api-keys/{id}
The create response contains the plaintext key — it is shown once; only a
SHA-256 hash is stored.
Native event ingestion
POST /api/v1/events
Content-Type: application/json | application/x-ndjson
Content-Encoding: gzip (optional)
Body: a single event object, an array of events, {"events": [...]}, or
NDJSON (one object per line). Response: {"accepted": <n>}.
{
"timestamp": "2026-10-06T10:20:13.485Z",
"level": "Error",
"service": "payments",
"environment": "production",
"message": "Payment failed",
"messageTemplate": "Payment failed for {customerId}",
"traceId": "abc123",
"spanId": "00f067aa0ba902b7",
"properties": { "customerId": 481, "paymentProvider": "stripe", "amount": 71.50 },
"resource": { "host.name": "web-1" },
"exception": { "type": "TimeoutException", "message": "Payment provider timed out", "stackTrace": "..." }
}
| Field | Aliases | Notes |
|---|---|---|
timestamp |
@t, time, ts |
RFC 3339 or Unix number; default: arrival time |
level |
@l, severity, severityText |
default Information; unknown spellings are kept in severityText |
message |
@m, msg |
rendered from the template if absent |
messageTemplate |
@mt, template |
{Name} holes are filled from properties |
service |
serviceName, service.name |
falls back to resource["service.name"] |
environment |
env |
falls back to resource["deployment.environment[.name]"] |
traceId, spanId |
@tr, @sp, trace_id, span_id |
any string; hex is lowercased |
properties |
attributes |
object |
exception |
@x, error |
object or a stack-trace string |
| anything else | becomes a property (Serilog CLEF style) |
Property values may be any JSON, nested up to 32 levels deep. The same
property can change type between events; nothing is rejected for that.
Batches are all-or-nothing: if one event is invalid the request fails with
invalid_event and its index, and a request rejected for any other
reason (size, encoding, malformed JSON, a decoding limit)
stores nothing either. Size and encoding rules are under
request size and encoding.
The response is sent only after the batch is in the write-ahead log (and
fsynced with durability = "strict") and visible to queries.
OTLP/HTTP
POST /v1/logs
POST /v1/traces
POST /v1/metrics
Content-Type: application/x-protobuf | application/json
Content-Encoding: gzip (optional)
Point any OpenTelemetry SDK or Collector otlphttp exporter at
http://<host>:8080. Responses use the request's encoding; records that
cannot be stored (e.g. spans without ids) are dropped and reported via
partialSuccess, and the rest of the request is stored. That is the only
partial outcome: a request rejected with a 4xx stores nothing, including
one over a decoding limit, which is refused as a whole
rather than partly accepted. Error responses (4xx/5xx) are the JSON error
object above for both encodings.
Mapping:
- Resource attributes are kept as
resource;service.nameanddeployment.environment(.name)also populateservice/environment. - Logs: severity number (or text) →
level; string body →message(structured bodies are kept in thebodyproperty);exception.*attributes →exception;{OriginalFormat}→messageTemplate; instrumentation scope name →otel.scope.nameproperty. - Spans: start time →
timestamp;durationMs, kind, status, events and links are preserved. - Metrics: every data point is one event. Gauges and sums store
value; histograms store count/sum/min/max/buckets; exponential histograms and summaries keep count/sum (bucket detail dropped).
gRPC is not supported in v0.1.
Ingest limits and retries
These apply to POST /api/v1/events and POST /v1/{logs,traces,metrics}
alike.
Admission. At most ingest.max_concurrent ingest requests are in
progress at once (default: derived from the memory budget, 3 at the default
512MB / 1MB settings; see
ingest memory). The limit is the same
for native and OTLP endpoints, which share one pool. A request holds its slot from
before its body is read, through decompression and parsing, until its
write is acknowledged and the response is ready. Requests that finish, fail
(any 4xx/5xx) or are abandoned by the client (connection closed at any
point) free their slot; one abandoned mid-parse frees it when the parse
ends, because the parse still holds memory until then.
Rejection. When every slot is busy the request is rejected at once, before any of its body is read:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{"error":{"code":"rate_limited","message":"too many concurrent ingest requests; retry shortly"}}
The server then reads and discards the unread body to its end (for at
most 10 s, never buffering it) so that clients still uploading receive this
response instead of a reset connection or broken pipe. At most 32 bodies
are read at a time and at most 256 are kept waiting; beyond that, or past
the time bound, the server drops the body and closes the connection. Treat a connection reset like a 429. discarding in
GET /api/v1/system/info shows how many rejected bodies are being kept.
Request size and encoding
ingest.max_request_size (default 1 MB; 16 MB before 0.1.2) bounds every ingest body, both
the bytes after gzip decoding and the bytes on the wire. A body of exactly
the limit is accepted; one byte more is rejected.
- Send bodies uncompressed (no
Content-Encoding, oridentity) or gzip-compressed (Content-Encoding: gzip;x-gzipand any letter case work too). Any other encoding (br,deflate,zstd, or several stacked such asgzip, br) gets415 unsupported_media_typebefore the body is read. - gzip is decoded as it arrives. Decoding stops as soon as the decoded size
passes the limit and the request gets
413 payload_too_large, so a small compressed "bomb" never makes the server inflate more than the limit (plus one 64 KB read). - An uncompressed body whose
Content-Lengthis over the limit gets the 413 from its headers, before any of it is read. - Concatenated gzip members (
cat a.gz b.gz) form one stream and every member is decoded. A truncated stream, a CRC mismatch, anything other than gzip after the last member, or an empty body labelled gzip gets400 invalid_encodingwith the decoder's reason. - After a 413 or
invalid_encoding, the unread rest of the upload, however large, is read and discarded within the same bounds as after a 429 (above), so a client still sending receives the error rather than a reset connection or broken pipe. These responses carryConnection: close; send the next request on a new connection (HTTP clients do this automatically). - A rejected request stores nothing. The whole body is read and decoded
before any of it is parsed, and native batches are all-or-nothing. OTLP
partialSuccess(see OTLP/HTTP) is the only partial outcome.
HTTP/1.1 413 Payload Too Large
Content-Type: application/json
{"error":{"code":"payload_too_large","message":"request body exceeds the 1 MB limit (measured after gzip decoding); split the batch into smaller requests","limit":"maxRequestSize","limitBytes":1048576}}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":{"code":"invalid_encoding","message":"gzip body could not be decoded (unexpected end of file); send complete gzip data: one or more members and nothing after the last"}}
A 413 is not retryable as is: split the batch and send the parts. With NDJSON, splitting on line boundaries keeps every event whole:
split -n l/4 -d big.ndjson part- # four files, whole lines each
for f in part-*; do
gzip -c "$f" | curl -s -X POST http://localhost:8080/api/v1/events \
-H 'Content-Type: application/x-ndjson' -H 'Content-Encoding: gzip' \
--data-binary @-
done
Decoding limits
A body within max_request_size is then decoded within three limits,
checked while it is parsed, before the batch is built
(why and how):
Limit (limit) |
Value | Response |
|---|---|---|
maxEvents |
ingest.queue_capacity (default 10,000) |
413 payload_too_large, with maxEvents |
maxDecodedBytes |
17 × max_request_size (default 17 MB) |
413 payload_too_large, with limitBytes |
maxDepth |
32 levels of nesting | 400 invalid_payload, with maxDepth |
Events are native events, or OTLP log records, spans and metric data
points. Decoded bytes are the server's estimate of the memory the parsed
batch takes, which ordinary batches keep far below the limit: it is
reached by bodies of very many tiny or empty events, OTLP records of a few
bytes each, or many small nested values. Nesting counts objects and arrays
within one native event, or OTLP arrayValue / kvlistValue levels
within one attribute or body (key/value lists may stop at about 30, where
the JSON and protobuf parsers' own limits are, with the same error).
HTTP/1.1 413 Payload Too Large
Content-Type: application/json
{"error":{"code":"payload_too_large","message":"batch has more than 10000 events (ingest.queue_capacity); split it into requests of at most 10000 events","limit":"maxEvents","maxEvents":10000}}
HTTP/1.1 413 Payload Too Large
Content-Type: application/json
{"error":{"code":"payload_too_large","message":"batch would take more than 17 MB of memory to decode: it has too many events or values for its size. Split it into smaller requests, or send fewer, larger events","limit":"maxDecodedBytes","limitBytes":17825792}}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":{"code":"invalid_payload","message":"values are nested more than 32 levels deep; flatten them","limit":"maxDepth","maxDepth":32}}
Nothing of a refused request is stored, native or OTLP: limits never cause
an OTLP partialSuccess. None of these is retryable as is. For
maxEvents, send at most maxEvents events per request; for
maxDecodedBytes, split the batch into smaller requests (half the events
per request halves the decoded size; the split NDJSON example above works
for both); for maxDepth, flatten the values. OpenTelemetry exporters do
not retry a 413 and drop the batch, so keep their batch size below the
limits: for the Collector, send_batch_max_size at most queue_capacity
and batches within max_request_size. The SDKs' default batches (512
records) and the Collector's (8,192) are within the default limits.
Status codes and retries
| Status | code |
Stage | Retry? |
|---|---|---|---|
| 415 | unsupported_media_type |
content type or Content-Encoding, checked before admission |
no |
| 429 | rate_limited |
admission: all max_concurrent slots busy |
yes, after Retry-After |
| 413 | payload_too_large |
body over max_request_size, decoded or on the wire |
no — split the batch |
| 400 | invalid_encoding |
gzip body truncated, corrupt or followed by other data | no — resend a complete gzip body |
| 400 | invalid_request / invalid_payload / invalid_event |
body unreadable (client went away) or malformed | no — fix the payload |
| 413 | payload_too_large |
decoding: more events than queue_capacity (maxEvents) or too much decoded data (maxDecodedBytes) |
no — split the batch |
| 400 | invalid_payload |
decoding: values nested more than 32 levels (maxDepth) |
no — flatten the values |
| 429 | rate_limited |
submission: the signal's queue is full | yes, after Retry-After |
| 503 | unavailable |
no acknowledgement within ack_timeout, or storage stopping |
yes, after Retry-After; the batch may already be stored |
| 503 | shutting_down |
the server is stopping; checked before admission, the body is not read and the connection closes | yes, after Retry-After, against the restarted or another instance; nothing was stored |
| 503 | storage_write_failed |
the WAL write or its fsync failed, or sealing is failing (full disk, read-only or inaccessible data directory) | yes, with backoff. outcome: "notStored": nothing was stored. outcome: "unknown": it may reappear after a power loss, so a retry can duplicate it. Fix storage (what to do) |
Batches are all-or-nothing, so retrying a request rejected with 429 cannot
duplicate events. The OpenTelemetry SDKs and Collector already retry 429
and 503 with backoff. For your own clients, retry with capped exponential
backoff that waits at least Retry-After:
for attempt in 1 2 3 4 5; do
status=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/api/v1/events -H 'Content-Type: application/x-ndjson' --data-binary @tests/fixtures/logs.ndjson)
[ "$status" != 429 ] && [ "$status" != 503 ] && break
sleep $((attempt * attempt)) # Retry-After is 1 s; back off further on repeats
done
echo "$status"
GET /api/v1/system/info reports the effective limit, how it was chosen,
current use and rejections since start under ingest:
{
"maxConcurrent": 3,
"concurrencySource": "derived",
"maxRequestBytes": 1048576,
"budgetBytes": 80530635,
"estimatedPeakBytes": 76283904,
"inFlight": 1,
"rejected": 0,
"discarding": 0
}
concurrencySource is derived (ingest.max_concurrent = 0) or
explicit. budgetBytes is the ingest share of storage.max_memory and
estimatedPeakBytes the estimate for maxConcurrent maximum-size requests
plus rejected-body drains; with an explicit limit it can exceed the budget
(see explicit limits). If 429s are
frequent while memory is plentiful, raise storage.max_memory or lower
ingest.max_request_size rather than sending larger batches.
Querying logs
POST /api/v1/query/logs
{
"query": "level = \"Error\" and service = \"payments\"",
"from": "-1h",
"to": null,
"limit": 200,
"direction": "backward",
"continuationToken": null
}
Response:
{
"events": [ { "id": "000000000000002a", "signal": "log", "timestamp": "...", "level": "Error", "message": "...", "properties": {}, "resource": {} } ],
"diagnostics": {
"elapsedMs": 41.2, "segmentsConsidered": 23, "segmentsSkipped": 19,
"segmentsSkippedByTime": 12, "segmentsSkippedByIndex": 7, "segmentsNotNeeded": 0,
"blocksConsidered": 400, "blocksSkipped": 383, "blocksRead": 17,
"eventsExamined": 4812, "eventsMatched": 200, "bytesRead": 1843210,
"unsealedEventsScanned": 950,
"indexes": [ { "field": "timestamp", "kind": "time" }, { "field": "level", "kind": "bitmap" }, { "field": "customerId", "kind": "none" } ]
},
"continuationToken": "v1.18bd3c…"
}
direction:backward(newest first, default) orforward.limit: 1–query.max_results(default max 1000).- Pass
continuationTokenback to get the next page. Pagination is cursor-based ((timestamp, id)), never offset-based.
Aggregations (all take query, from, to):
POST /api/v1/query/{signal}/count → {"count": n, "diagnostics": {...}}
POST /api/v1/query/{signal}/histogram {"buckets": 60} → {"stepMs", "total", "buckets": [{"start", "count", "levels"}]}
POST /api/v1/query/{signal}/facets {"sample": 2000, "fields": ["level"]} → {"sampled", "fields": [{"field", "count", "values": [{"value", "count"}]}]}
POST /api/v1/query/{signal}/events same body/response as /query/logs
{signal} is logs, traces (spans) or metrics (data points).
levels in histogram buckets counts [none, Trace, Debug, Information, Warning, Error, Fatal]. Facets are computed over the most recent sample
matching events. fields (optional, at most 64) counts only the fields
named; without it every well-known field and the most common properties are
counted. Naming only well-known fields (level, service, environment,
name, status, exception.type) skips the walk over event properties.
Live tail
GET /api/v1/live/logs?query=level%20%3D%20Error (Server-Sent Events)
Events: ready once subscribed, then event (data: one event as JSON) for
each new matching event. dropped (data: count) when more than 200 events
matched in one batch, lagged (data: skipped batches) when the client fell
behind — the server never buffers unboundedly for a slow client. Use
?signal=traces for spans.
When the server shuts down, each stream sends the events it has already
matched, then shutdown (data: {}) with retry: 2000, and ends the
response normally. EventSource reconnects after two seconds; until the
server is back, reconnects fail and should be retried. A new stream opened
while the server is stopping gets 503 shutting_down.
Traces
POST /api/v1/query/traces {"query": "status = Error", "from": "-1h", "limit": 50}
GET /api/v1/traces?query=...&from=...&limit=...
Returns recent traces with at least one span matching the query:
{ "traces": [ { "traceId": "4bf9…", "rootName": "POST /checkout", "rootService": "checkout",
"start": "...", "durationMs": 800.0, "spanCount": 3,
"services": ["checkout", "payments"], "errorCount": 2, "truncated": false } ],
"diagnostics": {} }
A summary keeps at most 10,000 spans of a trace. With more, truncated is
true and spanCount, services, errorCount and durationMs cover the
kept spans only.
GET /api/v1/traces/{traceId}[?from=...&to=...]
→ {"traceId", "summary": {...}, "spans": [...], "diagnostics": {...}}
Spans are ordered by start time. Correlated logs: query logs with
traceId = "<id>". A trace with more than query.max_trace_spans spans
(default 50,000) in the range gets 422 query_result_limit rather than an
incomplete waterfall; narrow it with from/to.
Metrics
GET /api/v1/metrics → {"metrics": [{"name": "http.server.requests"}, ...], "truncated": false}
POST /api/v1/query/metrics
{
"name": "http.server.requests",
"query": "route = \"/checkout\"",
"from": "-1h",
"agg": "sum",
"stepMs": 60000,
"groupBy": "route"
}
GET /api/v1/metrics lists distinct names in name order, at most
query.max_metric_names (default 10,000) of them: with more, it returns
the first ones and truncated: true. It is a query like the others (a
query slot, query.timeout, cancelled on disconnect).
agg: sum, count, min, max, avg (default), last. For histogram
points, sum/count/avg use the histogram's sum and count. Response:
{"name", "agg", "stepMs", "unit", "kind", "series": [{"group", "points": [{"ts", "value"}]}]}
(at most 20 groups; the rest are merged into (other)). A series has at
most 10,000 points: a range and stepMs needing more get 422 query_result_limit before anything is read.
Dashboards
GET /api/v1/dashboards
POST /api/v1/dashboards {"name": "Payments", "description": null}
GET /api/v1/dashboards/{id}
PUT /api/v1/dashboards/{id} {"name": "Payments (prod)"}
DELETE /api/v1/dashboards/{id}
POST /api/v1/dashboards/{id}/panels {"title", "kind", "config", "width", "position"}
PUT /api/v1/dashboards/{id}/panels/{panelId}
DELETE /api/v1/dashboards/{id}/panels/{panelId}
Panel kinds and their config:
kind |
config |
|---|---|
log_count |
{"query": "level = Error"} — count over time |
metric_line |
{"metric": "...", "agg": "avg", "groupBy"?: "...", "query"?: "..."} |
single_stat |
{"query": "..."} (log count) or {"metric": "...", "agg": "..."} (latest value) |
saved_query |
{"query": "..."} — latest matching logs |
width is in grid columns (1–12). Dashboards take the time range from the
UI; panels have no hidden state.
Saved queries
GET /api/v1/saved-queries
POST /api/v1/saved-queries {"name": "Payment errors", "signal": "logs", "query": "..."}
DELETE /api/v1/saved-queries/{id}
Alerts
GET /api/v1/alerts
POST /api/v1/alerts
GET /api/v1/alerts/{id} (includes recent "history")
PUT /api/v1/alerts/{id}
DELETE /api/v1/alerts/{id}
POST /api/v1/alerts/{id}/evaluate evaluate immediately
{
"name": "Payment error spike",
"query": "level = \"Error\" and service = \"payments\"",
"op": ">",
"threshold": 100,
"windowSecs": 300,
"intervalSecs": 60,
"webhookUrl": "https://example.com/hooks/vyrtel",
"enabled": true
}
v0.1 supports log-count threshold alerts: count matching logs over the last
windowSecs and compare with op threshold (>, >=, <, <=, =,
!=). Status is OK, FIRING, or ERROR (query failed). Each status
change is recorded and POSTed to the webhook (3 attempts, 10 s timeout):
{
"source": "vyrtel",
"alert": { "id": 1, "name": "Payment error spike", "query": "...", "condition": "count > 100", "windowSeconds": 300 },
"status": "FIRING",
"previousStatus": "OK",
"value": 150,
"threshold": 100,
"error": null,
"evaluatedAt": "2026-10-06T10:21:00Z"
}
System
GET /health → 200 {"status": "ok"} while the process runs
GET /ready → 200 {"status": "ready", "storageWritable": true}, 503 {"status": "degraded", ...} or, while shutting down, 503 {"status": "draining"}
GET /api/v1/system/info version, uptime, event counts, queue depths, connection, ingest and query admission, active segments, memory budgets, recovery status
GET /api/v1/system/storage raw/stored bytes, compression ratio, index overhead, recovery status, per-signal detail
GET /api/v1/system/query-stats per-field query statistics (input for future adaptive indexing)
GET /api/v1/system/config effective configuration (secrets omitted)
/api/v1/system/info includes the connection limits and what happened to
connections since the process started:
"connections": { "maxConnections": 256, "active": 3, "pending": 0, "maxPending": 64,
"connectionBufferBytes": 65536, "estimatedPeakBytes": 53608448,
"admitted": 1250, "queued": 4, "refused": 0, "headerTimeouts": 1190,
"bodyTimeouts": 0, "writeTimeouts": 0, "protocolErrors": 2, "acceptErrors": 0 }
active connections hold a slot (this request's own included); pending
ones wait for one. refused counts connections closed for lack of a slot,
at once or after waiting. headerTimeouts includes idle keep-alive
connections closed normally, so it grows on a healthy server.
estimatedPeakBytes is the
transport estimate at
these limits. acceptErrors above zero usually means the process ran out of
file descriptors.
It also includes query admission and how queries that did not complete ended, counted since the process started:
"query": { "maxConcurrent": 4, "inFlight": 1, "rejected": 0, "cancelled": 2,
"timedOut": 1, "tooExpensive": 0, "resultLimited": 0 }
inFlight counts queries holding a slot, including cancelled ones that
have not reached their next checkpoint yet. rejected counts 503s for busy
slots; the others count the matching errors.
/ready is unauthenticated, so it says which signals are affected but
never why. It is 503 when a 4 KiB write-and-fsync probe in the data directory's tmp/ fails (storageWritable: false,
every signal listed), or while a signal's WAL or sealing is failing. The probe runs at most once per second; other
requests reuse its result:
{ "status": "degraded", "storageWritable": true, "signals": ["logs"] }
/api/v1/system/storage:
{
"rawBytes": 13743895347, "storedBytes": 2576980378, "segmentBytes": 2280000000,
"indexBytes": 210000000, "walBytes": 40000000, "metadataBytes": 98304,
"eventCount": 18400000, "segmentCount": 241, "compressionRatio": 5.3, "indexOverhead": 0.092,
"signals": { "logs": { ... }, "traces": { ... }, "metrics": { ... } },
"indexCache": { "budgetBytes": 80530636, "usedBytes": 1234567, "entries": 12, "hits": 900, "misses": 12 }
}
rawBytes is the uncompressed size of events in Vyrtel's binary encoding
(sealed segments plus WAL); compressionRatio is raw ÷ (data + index) for
sealed segments.
Each signals.<signal> object includes recovery: what startup crash
recovery did for that signal in this process's lifetime, and what is in its
quarantine directory now.
"recovery": {
"status": "damaged",
"segmentsLoaded": 12, "walFilesReplayed": 1, "walRecordsReplayed": 340, "walEventsReplayed": 5100,
"segmentsSealed": 0, "staleWalsRemoved": 1, "replacedSegmentsRemoved": 0,
"walBytesDiscarded": 61,
"segmentsQuarantined": 0, "walFilesQuarantined": 0, "walTailsQuarantined": 1,
"quarantinedBytes": 61,
"quarantineDir": { "files": 1, "bytes": 61, "error": null },
"quarantined": [
{ "signal": "logs", "kind": "walTail", "source": "000000000014.wal",
"file": "quarantine/logs/000000000014.wal.tail-48213.1791622413000",
"bytes": 61, "reason": "incomplete record at offset 48213 (torn write)" }
]
}
kind is segment, wal or walTail; file is relative to the data
directory and reason never contains host paths. See
Quarantine for what each kind means and what
was lost. An empty quarantined list means this start found no damage.
status is damaged when this start quarantined something or discarded
WAL bytes. Otherwise it is unknown when quarantine/<signal>/ could not
be read, quarantineRetained when it still holds files (from an earlier
start, or a recovery that was interrupted after setting a file aside), and
clean when it is empty or missing. Replaying the WAL is normal and leaves
it clean. The *Quarantined counts and quarantinedBytes cover this
start; quarantineDir counts the files on disk now, whichever start put
them there. When the directory or anything in it cannot be listed or
examined, files and bytes are null and error says why (message is
the OS error text, without a path):
"quarantineDir": { "files": null, "bytes": null,
"error": { "reason": "permissionDenied", "message": "Permission denied (os error 13)" } }
quarantineBytes at the top level is the sum of the readable
quarantine/<signal>/ directories.
The response, and /api/v1/system/info, also carry a summary: the worst
status of the three signals (damaged, unknown, quarantineRetained,
clean, in that order) and the signals that are not clean.
"recovery": { "status": "damaged", "signals": ["logs"] }
None of this is in /health or /ready; quarantine does not make an
instance unready. See Recovery health.
Storage write failures are reported per signal under faults (at most one
each for wal, segment and maintenance), along with healthy (no
faults) and ingestHealthy (no wal or segment fault). The top-level
writeProbe is the readiness write probe:
"writeProbe": { "writable": true, "fault": null },
"signals": { "logs": {
"healthy": false, "ingestHealthy": false,
"faults": [
{ "area": "wal", "operation": "walAppend", "reason": "noSpace",
"path": "wal/logs/000000000007.wal",
"message": "No space left on device (os error 28)",
"firstSeen": "2026-10-11T03:12:09.112Z", "lastSeen": "2026-10-11T03:12:41.870Z",
"occurrences": 37 }
], ... } }
operation is walAppend, walSync, walCreate, segmentWrite,
maintenance or probe. path is relative to the data directory and
message is the operating system's error text, so neither contains host
paths. A fault disappears once that part of the write path works again (see
Storage write failures).