Skip to content

Repository files navigation

peak

Simple multi-tenant telemetry & analytics ingestion service backed by ClickHouse, with a terminal dashboard.

Terminal dashboard

See what all your projects are doing right now from a terminal. No Grafana, no browser, no heavyweight observability stack.

Use as a library

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(&registry),
    ).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.

Quick start

cp .env.example .env
cargo run -- keygen planar

keygen 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/healthz

Open the dashboard:

docker compose exec -it handler peak dashboard planar

That runs ingest on 127.0.0.1:8081 with no TLS, which is all you need locally. For a public deployment, see below.

Reverse proxy

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.

Sending events

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.py

For 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 --e2e

The E2E command uses only Python's standard library and the clickhouse-client already present in the ClickHouse container; it requires no additional test framework.

Tenants

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.

Dashboard

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 --.

Configuration

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 (default data/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 on SIGTERM (default 25000). Anything left over replays on the next start.

Schema

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;
SQL

Running without Docker

set -a && . ./.env && set +a
cargo run --release

CLICKHOUSE_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.

More

docs/architecture.md covers the read and write paths, WAL recovery, deployment topology, and scaling out.

Security

Please do not report vulnerabilities in a public issue — see SECURITY.md.

License

MIT. See LICENSE.

About

Fast multi-tenant telemetry ingestion backed by ClickHouse, with a terminal dashboard.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages