Grafana-lite you can deploy in 30 seconds. A single-binary log/metrics ingester with a live web dashboard. No Prometheus. No Loki. No YAML.
Status: ✅ v0.1.0 released (download) —
ingest → aggregation → Parquet → SQL all live, 107 tests green,
CI-enforced (clippy -D warnings, fmt --check, test matrix, RustSec audit). Measured on a dev machine:
100k events/s ingested with 0 drops at ~32 MB RSS; kill -9 mid-load recovers without
corruption. Full evidence in docs/BENCHMARKS.md; remaining work tracked
in docs/ROADMAP.md.
Solo devs and small teams running side projects on a $5 VPS do not want to operate a Prometheus + Grafana + Loki stack. That's three services, a reverse proxy, a pile of config, and gigabytes of RAM — to answer "is my thing up, how slow is it, and what did it just log?"
Pulse is one Rust binary that:
- accepts logs and metrics over HTTP and UDP (line protocol + JSON),
- stores them embedded (time-partitioned Parquet, queried with SQL via DataFusion),
- and serves a beautiful live dashboard (WebSocket tail, live charts, filters) straight
from the binary — the frontend is embedded via
rust-embed.
One file. One command. ~tens of MB of RAM. The Rust ecosystem loves single-binary tools; this is very shareable.
- Solo devs / small teams with side projects on a VPS who want observability, not an observability project.
- Rust shops who want a 2-line
tracingintegration (pulse-client). - Anyone who has ever typed "grafana docker compose" and closed the tab.
# install (prebuilt Linux + macOS binaries from GitHub Releases — Linux builds are
# glibc-linked, not fully static; or: cargo install --path .)
curl -fsSL https://raw.githubusercontent.com/sambai-dev/pulse/main/install.sh | sh
# run
pulse serve --port 8000 --token s3cr3t
# point anything at it
curl -s -X POST localhost:8000/ingest/logs \
-H "authorization: Bearer s3cr3t" \
-d '2026-08-22T12:00:00Z INFO api "POST /users 201" latency_ms=12 user_id=42'
# open the dashboard
# browse to: http://localhost:8000 # live tail + charts + SQL query view
# Linux desktop: xdg-open http://localhost:8000Or with Docker:
docker run -p 8000:8000 ghcr.io/sambai-dev/pulse:v0.1.0 serve --port 8000 --bind 0.0.0.0Note: the server binds to loopback (
127.0.0.1) by default, so containers must override the bind (e.g.--bind 0.0.0.0orPULSE_BIND=0.0.0.0) or published ports are unreachable.
┌─────────────────────────────┐
apps ──HTTP/UDP──────▶ │ Ingest (axum + UDP listener)│
│ line protocol + JSON │
└──────────────┬──────────────┘
│ bounded mpsc (backpressure!)
┌──────────────▼──────────────┐
│ Aggregator (tokio task) │
│ 10s windows · quantile │
│ p50/p95/p99 · tag index │
└───────┬─────────────┬───────┘
write │ │ broadcast
┌─────────────────▼──┐ ┌──────▼────────────────┐
│ Storage │ │ Live fan-out │
│ Parquet partitions │ │ tokio broadcast → WS │
│ + DataFusion SQL │ └──────┬────────────────┘
└────────────────────┘ ┌──────▼────────────────┐
│ Dashboard (uPlot, │
│ log tail, filters) │
└───────────────────────┘
Full walkthrough, instrumentation strategy, and memory budget: docs/ARCHITECTURE.md.
A repo becomes evidence when it argues its choices. Each decision below gets a full write-up in the architecture doc; the one-paragraph versions:
1. Bounded channels with drop-newest, not unbounded queueing.
Backpressure is a feature: under overload Pulse degrades to lossy instead of dead.
An unbounded queue converts a traffic spike into OOM — a restart, a lost dashboard, a lost
everything. Pulse drops newest events past the queue bound, counts every drop in
pulse_ingest_dropped_total, and surfaces that counter on its own dashboard. Tradeoff:
you lose events exactly when you're having your worst day. Mitigation: the drop counter is
itself exported, and UDP senders get fire-and-forget semantics with the same accounting.
For a $5-VPS side project, "stays up and tells you it dropped" beats "dies silently."
2. Time-partitioned Parquet + DataFusion, not redb / SQLite / Timescale.
Parquet files per time partition give columnar compression, a trivially understandable
on-disk story (data/2026-08-22/*.parquet), retention as rm -rf, and — the real prize —
a "we query with SQL" demo for free via DataFusion. Tradeoff: more moving parts than a
KV store like redb, and DataFusion is a heavy dependency. Chosen because query-ability is
the demo that makes people care, and redb can't do GROUP BY service without us writing a
query engine.
3. Single static binary with rust-embed, not a separate frontend deploy.
The dashboard is compiled into the binary. No CDN, no npm install, no version skew
between server and UI. Tradeoff: frontend iteration requires a rebuild — acceptable for a
tool whose UI is one page and changes weekly at most.
4. Line protocol + simple JSON, not OpenTelemetry. OTLP is the "right" long-term answer and the wrong v0.1 answer: protobuf schemas, a big dependency tree, and a spec that outgrows a weekend project. A curl-able line protocol makes the 30-second promise testable with zero client code. Tradeoff: no ecosystem interop yet; an OTLP ingestion path is a stretch goal, not a promise.
5. uPlot for charts, not ECharts. uPlot renders tens of thousands of points at 60fps in ~40 KB. ECharts is prettier out of the box and 10x the bundle. Live, high-frequency, canvas-fast wins for a dashboard whose job is a moving line at 1 Hz refresh. Tradeoff: we hand-roll tooltips/legend polish.
Read this before filing an issue — these are design choices, not bugs:
- Single-node only. No HA, no replication, no clustering. If the VPS dies, the data dies.
- Lossy under overload. Bounded queues drop newest (counted, visible). We choose availability over completeness.
- No alerting (yet). Dashboards only. Alert rules are a v0.2+ conversation.
- Retention is deletion, not compaction. Old partitions are dropped whole; no downsampling tiers like Prometheus.
- SQL dialect is DataFusion's, not Postgres. Most of what you want works; not everything you know transfers.
- UDP ingest is fire-and-forget. No delivery guarantees, ever. Use HTTP when you care.
Numbers or it didn't happen. Full methodology, ladder results, and honest findings live in
docs/BENCHMARKS.md. Measured so far (dev box: i9-14900KF, 64 GB,
Windows — VPS run pending):
| Metric | Result | Notes |
|---|---|---|
| Ingest throughput | 100,160 events/s accepted, 0 dropped (10 s sustained) | HTTP, batch 100, 32 client workers |
| p99 ingest latency @ that rate | 1.49 ms per batch request | client-observed on loopback |
| Steady-state RSS at 50–100k/s | 26–33 MB | goal was < 100 MB |
| SQL over 1.84M stored logs | count(*) 527 ms · GROUP BY service 723 ms |
DataFusion over Parquet partitions |
| Crash safety | kill -9 mid-load → clean restart, counts consistent |
atomic tmp→rename + .ok markers |
| Parser microbench | 2.29 µs/log line ≈ 437k lines/s/core | criterion |
Known honest wrinkle: at sustained 100k/s the single Parquet writer lags and drops
~0.25% of raw log rows (pulse_storage_dropped_total makes it visible); at ≤ 50k/s:
zero drops.
The docs under docs/ were written before implementation; reality won a few arguments:
- Quantile sketch is hand-rolled (
src/quantile.rs): log-scale bucketed sketch with an exact small-N mode, instead of thetdigestscrate. Same memory class (~2 KB/series), deterministic, property-tested against exact quantiles (≤3% error at p50/p95/p99 on uniform + skewed distributions). Avoided a dependency and made accuracy provable. - Self-metrics registry is hand-rolled instead of
metrics+metrics-exporter-prometheus: ~90 lines of atomics rendering Prometheus text format. - No
dashmap: the aggregator is single-owner by design, so plainHashMapsuffices; concurrency lives at the channel boundary. - uPlot is vendored into the binary (51 KB) — no CDN, keeping the offline single-binary promise; canvas fallback charts ship too.
- Rate limiting ships as a fixed-window per-token limiter (default 600 rpm) rather than the more elaborate per-event budget sketched in the roadmap.
pulse-client (in this repo, crates/pulse-client) is a tracing::Layer that batches
your app's events and ships them to a Pulse server — non-blocking, bounded queue, drop
counters included:
// Cargo.toml: pulse-client = "0.1" (crates.io publish pending; path/git dep for now)
let layer = pulse_client::Builder::new("http://vps:8000")
.token("s3cr3t")
.service("my-app") // default: crate target path
.build()
.await;
tracing_subscriber::registry().with(layer).init();
tracing::info!(user_id = 42, latency_ms = 12, "handled request"); // → your dashboard| Doc | What's in it |
|---|---|
docs/ARCHITECTURE.md |
Component walkthrough, design decisions in depth, tracing strategy, memory budget |
docs/ROADMAP.md |
5-week plan with demoable acceptance criteria, launch checklist |
docs/INGEST-API.md |
Line protocol + JSON schema, HTTP/UDP/WS/query endpoints |
docs/BENCHMARKS.md |
Methodology, results tables, flamegraph capture |
docs/CI-RELEASE.md |
CI from day one, release binaries, Docker, install script |
docs/UI-NOTES.md |
UI patterns studied (Datadog, Axiom, Vercel, Railway, Hookdeck) and how Pulse applies them |
