Simple multi-tenant telemetry & analytics ingestion service backed by ClickHouse, with a terminal dashboard.
See what all your projects are doing right now from a terminal. No Grafana, no browser, no heavyweight observability stack.
Peak can be embedded in another Axum application. During API stabilization, use the Git dependency rather than crates.io:
[dependencies]
peak = { git = "ssh://git@github.com/khcd/peak", tag = "v0.1.0", default-features = false, features = ["server"] }The server feature provides the authenticated ingest router without the terminal dashboard. The
default feature set (server, cli) builds the standalone peak binary. Mount the router under
your application's path:
let app = axum::Router::new()
.nest("/telemetry", peak::server::router(state, 1_048_576));For the embedded approach, your application creates the registry, authentication registry, and durable writer, then starts its own listener:
use std::{path::Path, sync::Arc, time::Duration};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let registry = Arc::new(
peak::Registry::load(Path::new("tenants")).map_err(std::io::Error::other)?,
);
let producers = Arc::new(peak::ProducerRegistry::from_pairs(
&std::env::var("INGEST_KEYS")?,
Arc::clone(®istry),
).map_err(std::io::Error::other)?);
let clickhouse = peak::config::ClickhouseConfig::new(
"http://127.0.0.1:8123",
"telemetry",
"telemetry",
Some(std::env::var("CLICKHOUSE_PASSWORD")?),
peak::config::TransportCompression::Lz4,
)
.client();
let writer = peak::batcher::BatchWriter::new(
clickhouse.clone(), "data/events.wal", 200, Duration::from_secs(5),
).map_err(std::io::Error::other)?;
let writer_task = writer.start();
let state = peak::AppState::new(
clickhouse,
writer.clone(),
producers,
peak::Limits {
max_attributes_bytes: 16_384,
max_event_age_days: 190,
max_future_skew_seconds: 300,
},
200,
false,
env!("CARGO_PKG_VERSION"),
);
let app = axum::Router::new()
.nest("/telemetry", peak::server::router(state, 1_048_576));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;
writer.drain(writer_task, Duration::from_secs(25)).await;
Ok(())
}If you want Peak to own the listener and lifecycle instead, use the standalone server entrypoint:
use std::{path::Path, sync::Arc};
#[tokio::main]
async fn main() -> Result<(), String> {
let registry = Arc::new(peak::Registry::load(Path::new("tenants"))?);
peak::server::serve(registry).await
}AppState::new accepts a ClickHouse client, durable BatchWriter, ProducerRegistry, and the
validation settings. Importing Peak does not start a process or bind a port; the host application
must start its Axum listener and Tokio runtime. The host must also start the BatchWriter before
serving requests. For a standalone process, use peak::server::serve(registry) instead; it owns
configuration, the writer, listener, and graceful shutdown. Keep the WAL on durable storage and
terminate TLS at a trusted reverse proxy.
cp .env.example .env
cargo run -- keygen planarkeygen prints a planar:<secret> pair. Put it in .env as INGEST_KEYS, and set
CLICKHOUSE_PASSWORD and TELEMETRY_DOMAIN (localhost for a local run). All three need real
values or Compose refuses to start.
Start ClickHouse and the ingest handler:
docker compose up -d --build clickhouse handler
curl http://127.0.0.1:8081/healthzOpen the dashboard:
docker compose exec -it handler peak dashboard planarThat runs ingest on 127.0.0.1:8081 with no TLS, which is all you need locally. For a public
deployment, see below.
Dropping the service names — docker compose up -d --build — also starts Caddy on ports 80 and
443. It gets a certificate for TELEMETRY_DOMAIN automatically and forwards to the handler.
Caddy is built from deploy/caddy/Dockerfile rather than pulled, because
deploy/Caddyfile uses the
rate-limit module, which the official image does not
ship. That first build compiles Caddy from source and takes a few minutes; it is cached afterwards.
The limit is 600 requests per minute per source IP, and request bodies are capped at 1MB at the
edge. Installs behind one NAT share a source address, so raise the limit in the Caddyfile if real
clients start seeing 429.
POST /v2/events takes a JSON array and a bearer token:
[
{
"event_id": "b9c176a4-badc-47a9-afbf-4a01cf1d65b9",
"event_name": "session_start",
"schema_version": 1,
"occurred_at": "2026-08-05T12:34:56.789Z",
"subject": { "kind": "install", "id": "1dd9d9c1-0bb4-4b21-b665-7f79d9f3e256" },
"session_id": "679590a4-eea6-4e91-aeb0-23c8aa90ccaa",
"resource": {
"service_name": "example-client",
"service_version": "0.1.0",
"platform": "macOS",
"platform_version": "15.0"
},
"attributes": {}
}
]The tenant comes from Authorization: Bearer <secret>. Only (tenant, event_name, schema_version)
combinations declared in a manifest are accepted. The response is 200 with
{ "accepted": N, "rejected": [...] }; accepted events are fsynced to a write-ahead log before the
response returns and reach ClickHouse a few seconds later.
Bodies may be plain JSON, Content-Encoding: gzip, or Content-Encoding: zstd. Compress your
batches.
load_test.py is a dependency-free client for exercising the endpoint:
INGEST_TOKEN='<the secret>' python3 load_test.pyFor a quick local end-to-end check, start the clickhouse and handler Compose services, then
run the small deterministic fixture. It exercises gzip decoding, authentication, WAL enqueueing,
async persistence, and verifies the resulting rows through ClickHouse:
set -a && . ./.env && set +a
INGEST_TOKEN="${INGEST_KEYS#planar:}" python3 load_test.py --e2eThe E2E command uses only Python's standard library and the clickhouse-client already present in
the ClickHouse container; it requires no additional test framework.
Each tenant is one TOML manifest in tenants/ declaring its events and their fields.
_example.toml documents every option and is ignored by the loader.
To add a tenant: copy the example to tenants/<name>.toml, run cargo run -- keygen <name>, append
the pair to INGEST_KEYS (comma-separated), and restart.
The dashboard is read-only and needs no ingest key, so it is safe to run inside the production network.
| Key | Action |
|---|---|
1 2 3 |
switch time window |
t / Shift+T |
next / previous tenant |
p |
pause polling |
r |
refresh |
q / Esc |
exit |
Windows are whole calendar days in DASHBOARD_TIMEZONE (default UTC), so the same window means
the same span wherever the dashboard runs. The live chart is a rolling 60 seconds. CONNECTED
counts installs whose liveness ping arrived recently; tenants without a liveness event show --.
Secrets and deployment settings come from .env — see .env.example for every
variable and its default. Non-secret service settings live in config.json.
The knobs you are most likely to touch:
MAX_INSERT_BATCH_EVENTS/BATCH_WAIT_MS— insert batch size and the flush timer for low-volume tenants.WAL_PATH— write-ahead log location (defaultdata/events.wal). Each instance needs its own WAL on its own durable storage; the service takes an exclusive lock and refuses to share one.SHUTDOWN_DRAIN_MS— how long to spend flushing the WAL onSIGTERM(default25000). Anything left over replays on the next start.
deploy/clickhouse/init/01-schema.sql is the whole schema,
applied automatically when the ClickHouse volume is first created. There is no migration tooling:
change the file, recreate the volume. Telemetry is disposable enough that this is usually the right
trade at this size — if it stops being true for you, put a real migration tool in front of it.
Retention is 180 days, except live_ping heartbeats, which expire after 2 days.
If the dashboard's live chart gets slow at higher volume, this skip index helps and is safe to add to a running table:
docker compose exec -T clickhouse clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" --multiquery <<'SQL'
ALTER TABLE telemetry.events ADD INDEX idx_received_at received_at TYPE minmax GRANULARITY 4;
ALTER TABLE telemetry.events MATERIALIZE INDEX idx_received_at;
SQLset -a && . ./.env && set +a
cargo run --releaseCLICKHOUSE_USER and CLICKHOUSE_PASSWORD must both be set explicitly — the ClickHouse image
disables the default account once CLICKHOUSE_USER is present, so relying on the fallback fails
with Code: 194.
docs/architecture.md covers the read and write paths, WAL recovery,
deployment topology, and scaling out.
Please do not report vulnerabilities in a public issue — see SECURITY.md.
MIT. See LICENSE.
