Architecture
Vyrtel is one process with one data directory. Crates are code boundaries, not network boundaries.
flowchart LR
subgraph clients[Producers & users]
SDK[OTel SDK / Collector]
App[App logging JSON / NDJSON]
UI[Browser UI]
end
subgraph vyrtel[vyrtel process]
HTTP[server: bounded HTTP/1.1 transport\naxum · auth · routing · SSE]
ING[ingest: parse & map\nnative JSON · OTLP]
Q[query: parser · planner · executor]
ST[storage: WAL · segments · indexes\nper-signal streams]
MD[metadata: SQLite]
BG[background jobs\nseal · compact · retention\nalerts · stats flush]
WEB[embedded web UI]
end
subgraph disk[data/]
MAN[(vyrtel.json)]
WAL[(wal/)]
SEG[(segments/)]
DB[(metadata/metadata.db)]
end
SDK -- OTLP/HTTP --> HTTP
App -- POST /api/v1/events --> HTTP
UI -- REST + SSE --> HTTP
HTTP --> ING --> ST
HTTP --> Q --> ST
HTTP --> MD
HTTP --> WEB
BG --> ST
BG --> MD
ST --> WAL & SEG
MD --> DB| Crate | Responsibility | Depends on |
|---|---|---|
telemetry |
Common event model (TelemetryEvent, Fields, Value), JSON view |
– |
storage |
Event codec, WAL, segment format, indexes, recovery, sealing, compaction, retention, index cache | telemetry |
query |
Lexer, parser, AST, evaluator, index pruning, executor, aggregations, traces, query stats | telemetry, storage |
ingest |
Native JSON/NDJSON and OTLP (protobuf + JSON) → TelemetryEvent |
telemetry |
metadata |
SQLite: schema and layout identity, settings, users/sessions, API keys, dashboards, saved queries, alerts, query stats | – |
server |
Config, HTTP API, auth, live tail, alert evaluator, embedded UI, binary | all |
tools/loadgen |
Load generator | – |
Ingestion path
sequenceDiagram
participant C as Client
participant H as HTTP handler
participant P as Parser (blocking pool)
participant Q as Bounded queue (per signal)
participant W as WAL writer thread
participant A as Active buffer
participant L as Live subscribers
C->>H: POST /api/v1/events (or /v1/logs …)
H->>H: take an ingest slot (no wait)
alt every slot busy
H-->>C: 429 + Retry-After (body discarded unread)
end
H->>H: read body, decoding gzip as it arrives
alt over max_request_size (decoded or on the wire), or bad gzip
H-->>C: 413 / 400 invalid_encoding (rest of body discarded)
end
H->>P: parse + map
P-->>H: Vec<TelemetryEvent>
H->>Q: try reserve N events
alt queue full
Q-->>H: QueueFull
H-->>C: 429 + Retry-After
else accepted
Q->>W: batch
W->>W: assign ids, encode, append WAL record
W->>W: fsync (strict mode, group commit)
W->>A: publish batch (queryable)
W->>L: broadcast (bounded ring)
W-->>H: ack
H-->>C: 200 {"accepted": N}
end- Every queue is bounded: requests in progress (
ingest.max_concurrentslots), events per signal (queue_capacity), the writer channel, and the live-tail ring. - An ingest slot is held from before the body is read until the response is
ready, so it bounds bodies, decoded events and requests awaiting their
acknowledgement together. It is released however the request ends,
including client disconnects; a request abandoned mid-parse keeps it until
the parse (and its memory) is gone. Derived from the memory budget, the
number of slots assumes each request holds 20×
max_request_size(the measured parse amplification plus a margin); see ingest memory. - The writer drains whatever is queued into one group commit, so
strictdurability costs one fsync per group, not per request. - A request is acknowledged only after its WAL record is written (and fsynced in strict mode) and visible to queries.
Storage path
flowchart TB
W[WAL writer] -->|append| AW[(active WAL N)]
W -->|publish| ACT[active buffer]
ACT -->|size / age / count| ROT{rotate}
ROT -->|freeze + new WAL N+1| FRZ[frozen buffer]
FRZ --> SEAL[maintenance thread: sort, write tmp/, fsync]
SEAL -->|rename = commit| SEG[(segment N)]
SEAL -->|then delete| AW
SEG --> CMP[compaction: merge small segments]
SEG --> RET[retention: delete expired segments]Each signal (logs, traces, metrics) is an independent stream with its own writer thread, maintenance thread, WAL files, segments and retention. See storage-format.md for file layouts, the commit procedure and recovery.
A write that fails (full disk, read-only volume, permission denied) is never
acknowledged: the writer answers it with storage_write_failed and records
a per-signal fault. /ready and /api/v1/system/storage report the fault
until that part of the write path works again (see
Storage write failures).
Query path
flowchart LR
T[query text] --> LX[lexer] --> PS[parser] --> AST[AST]
AST --> CMP[compile: resolve fields, pre-process literals]
CMP --> SNAP[storage snapshot: segments + unsealed batches]
SNAP --> PLAN[per segment: time range → bitmap / Bloom / zone-map candidate blocks]
PLAN --> READ[decode only needed columns]
READ --> EVAL[evaluate predicate]
EVAL --> TOP[top-K by time, cursor, early stop]
TOP --> RES[events + diagnostics]- Parsing is independent of execution (
query::parserknows nothing about storage). - A snapshot holds
Arcs to segments and unsealed batches, so sealing, compaction and retention never disturb a running query. - Segments are visited newest-first; once the page is full, any segment or
block whose newest event is older than the oldest kept result is skipped
(
segmentsNotNeeded). - Queries run on the blocking thread pool under a concurrency limit
(
query.max_concurrentslots,query.queue_timeoutto get one). Each request gets onequery::Budget(time, scan bytes, a cancellation token), shared by all of its scans and checked at the start, per segment, around each block read and every 256 rows, and during the in-memory work after a scan (facet counting, metric-name discovery). Dropping the HTTP handler (client disconnect) cancels the token; the query stops at its next checkpoint and only then frees its slot. A failed query returns only its error (details). - Every query records per-field statistics (fields used, index kind, bytes read, segments skipped). They are flushed to SQLite every minute — the input for adaptive indexing in a later version.
Metadata
SQLite (bundled, WAL mode, foreign keys on, one schema per layout created in one transaction, prepared statements) stores product metadata only: settings, API key hashes, users and sessions, dashboards and panels, saved queries, alerts with state and history, aggregated query statistics. Telemetry never goes to SQLite.
Background jobs
| Job | Where | Interval |
|---|---|---|
WAL fsync (normal durability) |
writer thread | fsync_interval (1 s) |
| Segment sealing | maintenance thread per signal | on rotation |
| Retention, compaction | maintenance thread per signal | 30 s |
| Alert evaluation | tokio task | every 5 s, each alert at its own interval |
| Query-stats flush | tokio task | 60 s and at shutdown |
| Session purge | tokio task | hourly |
Frontend / backend relationship
The React + TypeScript UI (web/) is built by Vite into static files that
are embedded into the binary at compile time (rust-embed; see
crates/server/build.rs). In production the server serves the UI at /
and falls back to index.html for client-side routes; the UI talks to the
same origin's REST API and SSE live tail. During development npm run dev
serves the UI with hot reload and proxies /api and /v1 to a running
server.
Process boundaries, startup & shutdown
There is exactly one OS process. At startup (App::build) the server first
checks the data directory without changing it (crates/server/src/datadir.rs):
the layout identity (telemetry::LAYOUT) of the vyrtel.json manifest, the
metadata database and every WAL and segment file, then their format
versions; that a used directory has a metadata database with a schema and
every stream directory; and that every file in the inventory of the last
clean shutdown is there with its size. A directory that fails is refused
with exit status 1 and left untouched, so a partial backup, or data of
another layout (any earlier pre-1.0 release), is never started half-empty
or misread (--accept-incomplete-restore starts a partial one on purpose,
never one of another layout). Then it takes the data-directory lock,
checks again under it, removes the inventory, creates the stream
directories, opens the metadata store (creating its schema and identity in
one transaction if it has none), records the manifest and opens storage
with that lock (recovery). Nothing in the directory changes before the lock
is held, and the manifest is recorded before any telemetry file is written.
vyrtel check-data-dir runs the same check without changing anything.
See backup and restore and
upgrading between pre-1.0 releases.
Connections pass through a bounded transport (crates/server/src/transport.rs,
REL-18) before axum sees a request: a fixed number of connection slots,
each taken before the connection's task is spawned; a small capped queue of
connections waiting for one; capped read and write buffers; and deadlines
for request heads, bodies and blocked writes. Ingest, query and live-tail
admission apply on top. See
connections and transport limits.
On a stop signal (SIGINT/SIGTERM; on
Windows Ctrl+C, Ctrl+Break, console close or system shutdown) the server
stops accepting connections, answers new ingest, query and live-tail
requests with 503 shutting_down, reports /ready as draining, ends
live-tail streams with a shutdown event and drains in-flight requests to
their acknowledgement. Then each storage stream writes out its queue and
fsyncs its WAL (Storage::stop_writers, which returns any fsync failure),
background jobs stop, query statistics are saved, and the maintenance
threads finish any seal in progress and stop. Unsealed data
is not sealed at shutdown — WAL replay restores it on the next start, which
keeps shutdown fast. The whole drain shares one deadline,
server.shutdown_timeout; past it, on a second stop signal in any of these
steps, or if the final fsync fails, the process logs what was unfinished
and exits with status 1 without waiting further
(App::serve_until in crates/server/src/app.rs). The last step records
the WAL and segment files and their sizes in vyrtel.json, for the next
start to check; it shares the deadline, and a failure is an incomplete
shutdown too. See
stopping and upgrading. A data-directory lock
(data/.lock) prevents two processes from using the same directory.
Vyrtel's own diagnostics go to stderr via tracing (text or JSON). They
are deliberately not ingested into Vyrtel.