Sentrinel β€” Production Architecture

Designed for SaaS from day one: multi-tenant, two-store, sampling-aware. Both storage engines are Apache-2.0 licensed β€” nothing here restricts offering Sentrinel as a paid hosted product.

                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   customer app      β”‚  @sentrinel/plugin       β”‚  in-process aggregation
   (Elysia.js)  ───▢ β”‚  Β· exact 1-min counters β”‚  + head sampling of raw logs
                     β”‚  Β· sampled request logs β”‚  (errors & slow always kept)
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 β”‚ HTTPS batches (flushInterval)
                                 β–Ό
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚  Sentrinel API (Bun)     β”‚  /api/ingest/*
                     β”‚  LogStore abstraction   β”‚  /api/requests|traffic|…
                     β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
              writes/reads β”‚              β”‚ writes/reads
                           β–Ό              β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ PostgreSQL     β”‚   β”‚ ClickHouse           β”‚
              β”‚ CONTROL PLANE  β”‚   β”‚ TELEMETRY STORE      β”‚
              β”‚ orgs, apps,    β”‚   β”‚ request_logs, spans, β”‚
              β”‚ endpoints,     β”‚   β”‚ app_logs             β”‚
              β”‚ consumers,     β”‚   β”‚ Β· day partitions     β”‚
              β”‚ alerts, uptime,β”‚   β”‚ Β· ZSTD codecs        β”‚
              β”‚ 1-min metrics  β”‚   β”‚ Β· TTL retention      β”‚
              β”‚ (+ optional    β”‚   β”‚ Β· 1-min MV rollup    β”‚
              β”‚  read replica) β”‚   β”‚   kept 2 years       β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β–²
                           β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚ Next.js dashboardβ”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why two stores

The measured data shape (see docs/storage sizing): metrics tables grow at ~130 MB/day even with 500 endpoints; request_logs grows at ~13 GB/day at a mere 100 req/s. One table is 99% of the volume and it's append-only, never-updated event data β€” the exact shape column stores crush (10–30Γ— compression, sub-second scans over billions of rows). Meanwhile apps, alerts and orgs need foreign keys, updates and transactions β€” the exact shape Postgres is for. SigNoz and Uptrace ship this same split.

The LogStore abstraction (packages/api/src/lib/logstore/)

All firehose reads/writes go through one interface with two implementations:

Routes never touch a database directly for logs, so switching stores is an env var, not a migration.

Multi-tenancy

organizations (Postgres) is the tenant root; every app carries org_id, and every ClickHouse row carries org_id as the FIRST column of the sort key (ORDER BY (org_id, app_id, timestamp, …)) so tenant isolation is a sort-key prefix scan, not a filter. This was baked in now because changing a ClickHouse sort key later means rewriting the table. monthlyRequestQuota on organizations is the hook for plan enforcement at the ingest API.

Sampling (the biggest cost lever)

requestLogging.sampleRate in the plugin keeps 1-in-N successful requests; errors and slow requests (β‰₯ slowRequestThresholdMs) are always kept. Counters are aggregated in-process before sampling, so dashboard metrics stay exact at any rate. Every stored row records its effective sample_rate, and the ClickHouse rollup extrapolates (sum(1/sample_rate)), so even log-derived counts stay honest. Verified: a 0.1-rate row aggregates as 10 requests.

Retention

Read/write separation

Ingest writes always hit the Postgres primary. Dashboard queries use dbRead, which binds to DATABASE_REPLICA_URL when set (managed Postgres providers hand these out; self-hosters can attach a streaming replica) and falls back to the primary otherwise. ClickHouse handles its own read parallelism; add replicas + Distributed tables only at much larger scale.

Scaling ladder

  1. Now β€” single box, TELEMETRY_STORE=postgres. Fine to ~10 req/s.
  2. First customers β€” docker-compose.prod.yml: Postgres + ClickHouse, TELEMETRY_STORE=clickhouse, sampling 0.1–1.0 per plan. Fine to ~1000 req/s on one decent host.
  3. Growth β€” managed Postgres + replica URL; ClickHouse on its own host (or ClickHouse Cloud); API scales horizontally (it's stateless).
  4. Big β€” ClickHouse cluster w/ replication, Kafka/Redpanda in front of ingest for burst absorption, per-org rate limits. The LogStore interface is where a Kafka producer would slot in.

Operations