Barograph
Barograph — time-series and event analytics for Elixir, stored in SQLite. One file. No server. Full SQL.
Quick start
# mix.exs
def deps do
[
{:barograph, "~> 0.1.0"}
]
end
{:ok, db} = Barograph.open("metrics.bg")
Barograph.write(db, "engine_temp", %{forklift: "FL-07"}, 94.2)
{:ok, points} =
Barograph.query(db, "engine_temp",
labels: %{forklift: "FL-07"},
bucket: {1, :hour},
agg: :avg
)
svg = Barograph.Barogram.svg(points)
No server to run, no schema to migrate — open/2 creates and initialises the
file on first use, and the writer is supervised automatically. Raw SQL is
always available via Barograph.sql/3; see Design take below
for batching, continuous aggregates, and the rest of the design.
Ingesting from existing agents
Add {:thousand_island, "~> 1.5"} to mix.exs and pass :ingest to
open/2 to accept the Graphite plaintext line protocol — already spoken by
collectd, Telegraf, Vector, statsd, and Grafana Alloy, so this is often
"point your existing agent at Barograph" rather than writing a collector:
{:ok, db} = Barograph.open("metrics.bg", ingest: [graphite: [port: 2003]])
$ echo "engine_temp 94.2 1752931200" | nc localhost 2003
By default the whole dotted path becomes the metric name with no labels.
Pass a :template to split label values out of the path instead:
Barograph.open("metrics.bg",
ingest: [graphite: [port: 2003, template: "*.forklift.metric"]]
)
$ echo "forklift.FL-07.engine.temp 94.2 1752931200" | nc localhost 2003
# → metric: "engine.temp", labels: %{"forklift" => "FL-07"}
Graphite 1.1+ tag syntax (metric;tag=value) is also parsed natively,
independent of any template. See Barograph.Ingest.Supervisor.
One file, or many?
Barograph has no concept of "source," "device," or "tenant" — only metric name + labels within whatever file you open. How many files to use is a host-application decision, not something the library prescribes. Multiple databases may be open at once, so both of these are equally valid:
# One shared file, sources distinguished by a label
{:ok, db} = Barograph.open("fleet.bg")
Barograph.write(db, "engine_temp", %{forklift: "FL-07"}, 94.2)
Barograph.write(db, "engine_temp", %{forklift: "FL-08"}, 88.9)
# One file per source, sensors distinguished by metric name
{:ok, db} = Barograph.open("forklift_FL-07.bg")
Barograph.write(db, "engine_temp", %{}, 94.2)
Barograph.write(db, "battery_voltage", %{}, 48.1)
Shared file: cross-source queries are trivial SQL (GROUP BY labels), one
writer GenServer for everything. Per-source file: clean isolation and
trivial deletion (File.rm/1), independent retention per source, at the
cost of cross-source queries needing multiple opens. Pick based on whether
isolation (deletion, retention, export) or cross-source querying matters
more for your use case.
Design take
Notes on the technical specification in dev_docs/barograph-spec.md.
What it is
An embedded time-series/event database for Elixir backed by a single SQLite file, with TimescaleDB-shaped ergonomics: hypertables, continuous aggregates, retention policies, time-bucketed queries, and SVG chart rendering (Barogram). Apache-2.0, no feature gating. The name nods to RRDtool's ancestor — the 1844 barograph, a round-robin recorder that predates computers by a century.
Core design bets
- Single writer GenServer + WAL readers. SQLite's one-writer limitation becomes the natural BEAM shape rather than a problem to engineer around. Batched transactions (1000 samples or 100 ms, whichever first) buy roughly two orders of magnitude over per-row autocommit, because each autocommit is an fsync.
- Prometheus-style series identity. Labels are canonicalised, hashed (BLAKE2b truncated to 16 bytes) to a series ID; samples live in a
WITHOUT ROWIDtable keyed(series_id, ts)so range scans are sequential. The JSON labels column is kept redundantly on purpose — the hash is for the write path, the JSON is for humans writing SQL by hand. - Partial aggregate state, never final values. Continuous aggregates store
count/sum, neveravg. That single rule makes hierarchical rollups compose (1m → 1h → 1dwithout rescanning raw data) and keeps time-weighted averages correct at every level. Getting this wrong is the most common failure in hand-rolled rollup systems. - Raw SQL as a hard requirement. Every design decision is checked against "can
sqlite3answer this without the library?" This is exactly why columnar compression is deferred (compressed chunks would be opaque to raw SQL) and why:tablesis the default chunk backend over:files.
Sharpest trade-offs
- Chunking (§6 of the spec):
:tableskeeps everything in one file and raw-SQL queryable but needs incremental vacuum to reclaim space;:filesmakes retention a trueFile.rm/1but breaks ad-hoc SQL across chunks. Leaning:tablesas default everywhere, including Nerves — document the trade and let users choose. synchronous = NORMALunder WAL risks losing the last transaction on power loss (never corruption). The right trade for metrics; configurable toFULLfor callers who disagree.- Hyperfunctions as generated SQL over SQLite window functions — no C extension needed. The gaps-and-islands workaround for
locf(SQLite lacksIGNORE NULLS) is the kind of detail that makes this feasible rather than hand-wavy. - Late data is handled by
INSERT OR REPLACEidempotency plus dirty-bucket invalidation below the aggregate watermark — bounded, crash-safe, and honest about its horizon limit.
Roadmap shape
v0.1 is a tight vertical slice: schema, series cache, batched writer, one continuous aggregate with watermark refresh, and a basic SVG line chart — with a concrete exit criterion (10k samples/sec sustained on an RPi 5; a month of 1-minute data queried in under 100 ms). v0.2 adds ingestion — the Graphite plaintext line protocol listener above is shipped. Retention, the full hyperfunction set, edge store-and-forward, and richer Barogram output remain on the v0.2–v0.5 roadmap.
Open questions (§15)
Well-posed, and the spec's leanings look right on all of them: keep :tables the default chunk backend, make aggregate auto-routing explicit opt-in (resolve: :auto), require counters to be declared rather than guessed for rate/1, and leave per-file vs per-label tenancy to the host application.
Development
mix quality
Runs the full gate: format check, compile with warnings as errors,
hex.audit, credo --strict, the test suite, and dialyzer. All of it
must pass before a change lands.
License
Apache-2.0. Every feature — no community edition, no source-available tier.