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_concurrent slots), 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 strict durability 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::parser knows 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_concurrent slots, query.queue_timeout to get one). Each request gets one query::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.