Skip to content
SS · Siddhanth Sethi, home

← Work/001 · Shipped

LogPort

Batched log ingestion through a Fastify API, queued in Redis, persisted to ClickHouse by a separate worker, and streamed live to a React dashboard.

LogPort architectureClients send logs to a Fastify API, which authenticates and rate-limits each request. For every accepted log the API does two things: it pushes the log onto a Redis list (LPUSH) and publishes it on a Redis Pub/Sub channel. A separate worker pops up to 100 logs at a time (LMPOP) and inserts them into ClickHouse in batches, retrying with exponential backoff and moving batches that still fail to a dead-letter queue. The WebSocket server subscribes to the Pub/Sub channel and pushes each log to the dashboard. The worker never publishes.LPUSHPUBLISHLMPOPINSERTCLIENTSbatched writesFASTIFY APIauth · rate limitREDIS PUB/SUBlogport:liveREDIS LISTlogport:queueWORKERflush 100 / 2sCLICKHOUSEMergeTreeDLQafter 3 retriesWEBSOCKET/ws · 3s retryDASHBOARDlive tail · pause/resume
━ each log, on the request path━ batches, off the request path┅ failed → DLQ

Problem

Services emit logs in bursts, and the people debugging them want two things: to watch logs the moment they arrive, and to search them afterwards. Writing every log straight into the database on the request path ties every service that logs to the health of that database. A slow insert slows the caller; a database outage becomes a logging outage.

LogPort puts a Redis list between ingestion and storage. The API only validates, authenticates, rate-limits and enqueues. A separate worker process turns the queue into batched ClickHouse inserts. The live view is served from Redis Pub/Sub, so it never waits for the database at all.

Constraints

  • The write path never touches ClickHouse, so the API answers at Redis speed whatever state the database is in.
  • ClickHouse wants large inserts, not one row per request: the worker sends up to 100 rows per insert.
  • Rate limits must hold across several API instances, so limiter state lives in Redis, not in process memory.
  • Two kinds of caller: existing integrations holding a static API key, and services that should get short-lived, scoped tokens (JWT, HS256, 1 hour by default).
  • Bounded input: a message is at most 64 KB, a batch at most 1,000 logs, a request body at most 5 MB, and unknown fields are rejected by the JSON schema.
  • Runs locally with Docker Compose: Redis 7.2 with the append-only file on, and ClickHouse 23.8.

Architecture

LogPort architectureClients send logs to a Fastify API, which authenticates and rate-limits each request. For every accepted log the API does two things: it pushes the log onto a Redis list (LPUSH) and publishes it on a Redis Pub/Sub channel. A separate worker pops up to 100 logs at a time (LMPOP) and inserts them into ClickHouse in batches, retrying with exponential backoff and moving batches that still fail to a dead-letter queue. The WebSocket server subscribes to the Pub/Sub channel and pushes each log to the dashboard. The worker never publishes.LPUSHPUBLISHLMPOPINSERTCLIENTSbatched writesFASTIFY APIauth · rate limitREDIS PUB/SUBlogport:liveREDIS LISTlogport:queueWORKERflush 100 / 2sCLICKHOUSEMergeTreeDLQafter 3 retriesWEBSOCKET/ws · 3s retryDASHBOARDlive tail · pause/resume
━ each log, on the request path━ batches, off the request path┅ failed → DLQ
Clients
POST /api/v1/logs (one log) or /api/v1/logs/batch (up to 1,000), with X-API-Key or Authorization: Bearer <JWT>.
Fastify API
JSON-schema validation, then auth, then a sliding-window limit per key and endpoint: 100 single or 20 batch requests per 10 seconds.
enqueueLog (in the API)
For each log: LPUSH onto logport:queue, then PUBLISH the same entry on logport:live. The API is the only publisher; the worker never publishes.
Redis list
The buffer between ingestion and storage. Logs wait here while the worker is stopped or ClickHouse is down.
Worker (separate process)
Every 500 ms, LMPOP up to 100 logs (pipelined RPOP below Redis 7); flushes to ClickHouse at 100 buffered logs or every 2 seconds.
ClickHouse
MergeTree table partitioned by month and ordered by (service, level, timestamp); service, level and host are LowCardinality.
Dead-letter queue
A Redis list capped at 10,000 entries, each holding a failed batch, its error and retry count. Inspect, replay or clear it over the API.
WebSocket server
Runs in the API process at /ws. One Redis subscriber broadcasts each message to every open socket; a 30-second ping keeps connections alive.
React dashboard
Live tail over the WebSocket (500-log buffer, pause/resume, reconnect after 3 seconds). Throughput, queue and DLQ depth come from polling GET /api/v1/metrics every 5 seconds; search goes to ClickHouse through GET /api/v1/logs.

Key decisions & trade-offs

The API and the worker are separate processes

The HTTP server never waits on a database write, the worker can be scaled on its own, and a crashed worker doesn't take ingestion down with it: logs simply queue up in Redis.

Trade-offA second process to deploy and watch. Its throughput counters live in its own memory, so the worker writes a metrics snapshot to Redis every 5 seconds (30-second TTL) for the API to serve.

A Redis list as the queue, drained with LMPOP

LPUSH on the left and LMPOP from the right give FIFO order, and LMPOP takes up to 100 items in one atomic round trip. On Redis older than 7 it falls back to a pipeline of RPOPs.

Trade-offThere is no acknowledgement. Once LMPOP returns, those logs exist only in the worker's memory until the flush succeeds.

Batch in the worker, not in ClickHouse

ClickHouse's async_insert is turned off because the worker already batches; buffering twice would only add delay. The worker flushes at 100 logs or every 2 seconds, whichever comes first.

Trade-offAt low volume a log can take about 2.5 seconds (the 2-second flush plus a 500 ms poll) to become searchable. The live tail doesn't wait for it, because it comes from Pub/Sub.

Publish from the API, not from the worker

The live feed shows a log the moment it is accepted, whatever ClickHouse is doing.

Trade-offThe live view and the stored view can disagree: a log can appear on the dashboard and later land in the dead-letter queue instead of ClickHouse.

A sliding window on a Redis sorted set

A fixed-window counter lets a client send a full quota just before a window boundary and another just after it. The sorted set always looks back exactly 10 seconds from now. Because it lives in Redis, the limit holds across API instances. If Redis errors, the limiter fails open.

Trade-offFour commands per request (one pipelined round trip), and every request is recorded, rejected ones included. A client that keeps hammering stays locked out until it backs off.

JWT alongside the legacy API key

Existing integrations keep working with X-API-Key, while new services exchange the master key for a short-lived JWT naming the service and a scope (ingest, read or admin).

Trade-offTwo auth paths to keep consistent, and scopes are issued but not yet enforced.

The dead-letter queue lives in Redis

It has to work while ClickHouse is down, so it can't live in ClickHouse. It is capped at 10,000 entries (the oldest is dropped first) so it can't grow without bound, and POST /api/v1/dlq/replay pushes everything back onto the main queue once the database recovers.

Trade-offPast the cap, the oldest failed batches are discarded.

Failure modes handled

ClickHouse is down or an insert fails

The batch goes back to the front of the buffer and is retried three times, 500 → 1000 → 2000 ms apart. After the third failure the whole buffer moves to the dead-letter queue with the error and retry count, ready to replay. If even the DLQ push fails, the batch is dropped and logged: the one place loss is explicit.

The worker stops or crashes

The API keeps accepting logs and they wait in the Redis list (append-only file on in Compose). On SIGTERM or SIGINT the worker flushes its buffer before exiting.

Redis errors while the worker polls

The poll loop backs off 500 ms × 2ⁿ, capped at 30 seconds, and resets after the first success.

One API key floods the endpoint

That key gets 429 with Retry-After, computed from the oldest request still in its window, and X-RateLimit-* headers so well-behaved clients can slow down. Other keys are unaffected.

The dashboard's socket drops

It shows the disconnect, reconnects after 3 seconds and keeps at most 500 logs in memory. While paused, logs keep arriving but the view stays still.

The Redis subscriber reconnects

ioredis restores the subscription itself; LogPort subscribes only on the first ready event, which avoids the double-subscribe error an earlier version hit.

Malformed or oversized input

Schema validation returns 400 with details: level must be debug, info, warn or error; service names are restricted to letters, digits, dots, underscores and hyphens; unknown fields are refused. An unparseable timestamp is stored as the current time rather than failing the batch.

Unbounded growth

The DLQ is capped, rate-limit keys expire with their window, and the worker's metrics snapshot expires after 30 seconds so a dead worker can't keep reporting. The metrics endpoint's health flag turns false when the queue passes 10,000 or the DLQ is non-empty.

What I'd do differently

Acknowledge before forgetting

Up to 100 logs, or 2 seconds of traffic, live only in the worker's memory after LMPOP. Redis Streams with a consumer group (XREADGROUP + XACK), or LMOVE into a per-worker processing list, would let a restarted worker pick up what a crashed one never flushed.

Rate-limit token holders too

The limiter keys on the X-API-Key header and skips requests without one, so callers using a JWT are never limited. Keying it on the token's service name would close that gap.

Enforce scopes, and authenticate reads

Tokens carry a scope, but no route checks it, and GET /api/v1/logs, /logs/stats and the /ws feed have no auth at all.

Don't fail a request that already succeeded

enqueueLog awaits PUBLISH after LPUSH, so a failed publish returns an error for a log that is already queued, and a client retry duplicates it. Publishing best-effort, and pipelining the batch endpoint's LPUSH + PUBLISH pairs instead of awaiting 2 × N commands, would fix both the error and the round trips.

Make inserts idempotent

Every log has a UUID, but a plain MergeTree doesn't deduplicate. An insert that timed out after succeeding is retried and written twice. A ReplacingMergeTree keyed on id would collapse the copies.

Catch up after a reconnect

Logs published during the 3-second reconnect never reach that dashboard. Resuming from a stream ID, or backfilling from ClickHouse by the last timestamp seen, plus jittered backoff, would make the live tail gap-free.

Results

122
Backend test cases (Jest)
it() / test() blocks counted in backend/src/tests; CI runs them with a Redis 7.2 service container.
56
Frontend test cases (Vitest)
it() / test() blocks counted in frontend/src/tests: the WebSocket hook, search, log rows and overview.
3
CI jobs
Backend tests, frontend tests with coverage, and a production build check.

Stack

  • React
  • Node.js · Fastify
  • Redis
  • ClickHouse
  • WebSocket
  • Docker

Links