Kepler
An event router that lives inside your Elixir app.
You declare the classes of occurrence you care about. When one happens, Kepler enriches it with in-VM context that is unavailable from outside the BEAM, and delivers it to an external consumer. Deterministic, passive, always-on.
defmodule MyApp.Watches do
use Kepler
watch :process_crash do
source crash_report: :any
enrich [:stacktrace, :process_state, :last_message, :request_context]
severity :error
sink :investigator
fire immediately, cooldown: :timer.minutes(1)
end
watch :ratelimit_anomaly do
source telemetry: [:my_app, :ratelimit, :reject]
enrich [:remote_ip, :user_id, :path]
measure :rate
severity :critical
sink :siem, guarantee: :at_least_once
fire when: value > 50, sustained: :timer.seconds(10)
end
end
The two things this sells
For any single signal, :telemetry.attach plus Req.post is about twenty
lines. Kepler earns its place on two counts, and every feature is judged
against them.
1. Enrichment at fire time. A crash event carrying the dying process's state, its last message, its stacktrace, and the request that caused it. Nothing outside the VM can assemble that. It is the entire reason to be in-process.
2. Safe egress from a hot BEAM. Fifteen hand-rolled webhook-firing telemetry handlers across a production app will eventually take prod down — a slow HTTP call inline on a hot path, or an event storm hammering your own SIEM during an incident. Kepler is the one correct, bounded, non-blocking way for events to leave a live VM. Unglamorous, and the part people actually get wrong.
What it is not
Not a metrics backend. No time series, no query language, no dashboards.
telemetry_metrics and Prometheus exist and are better at it.
Not an Alertmanager replacement. This distinction is load-bearing:
| Metrics | Events | |
|---|---|---|
| Shape | Aggregated, sampled | Discrete, contextual |
| Cardinality | Bounded | Unbounded |
| Attribution | Impossible | The point |
| Tool | Prometheus + Alertmanager | Kepler |
If a signal can be expressed as a threshold on a bounded-cardinality time series, it is not Kepler's job. Queue-depth-triggered autoscaling is the clearest example — KEDA already scales off Prometheus queries.
Not aware of any specific consumer. Configuration is a sink and a URL, never anything shaped like a particular downstream system.
Installation
def deps do
[{:kepler, "~> 0.1.0"}]
end
Kepler starts its own supervision tree when your application boots. There is nothing to add to your own supervisor.
# config/runtime.exs
config :kepler,
watches: MyApp.Watches,
sinks: [
investigator: {Kepler.Sink.Webhook,
url: System.fetch_env!("KEPLER_WEBHOOK_URL"),
secret: System.fetch_env!("KEPLER_WEBHOOK_SECRET")}
]
# Crash attribution sees more with SASL reports on.
config :logger, handle_sasl_reports: true
The getting started guide walks through it properly, including how to check it works before you rely on it.
The event contract
The struct is split in two, and the split is the most important decision in the schema.
The core is small and fully required — id, node, watch, timestamp,
severity, state. Always present, always populated, safe to route on without
a nil check.
context is explicitly best-effort. Everything consumer-specific lives
there, and keys that do not apply are absent rather than null.
{
"schema": "kepler.event/1",
"id": "01920f3c-6a1b-7c4e-9f00-3d2c1b0a9e8f",
"node": "app@10.0.0.1",
"watch": "process_crash",
"timestamp": "2026-08-06T12:34:56.789Z",
"severity": "error",
"state": "firing",
"context": {
"source": {"type": "crash_report", "report": "any"},
"enriched": {
"reason": "%RuntimeError{message: \"kaboom\"}",
"process_state": {"orders": [1, 2, 3]},
"last_message": ["$gen_cast", "export"],
"request_context": {"request_id": "req-42", "user_id": 7}
},
"kepler": {"version": "0.1.0", "share": 0.0008}
}
}
Without the split, the core would have to satisfy a SIEM, an autoscaler, and someone debugging an incident simultaneously — and would degrade into a union of optional fields where nothing is guaranteed and every consumer writes nil checks forever.
watch is the stable event id consumers route on. Version it.
Why it's cheap
Telemetry handlers run inline in the process that emitted the event, so a slow handler slows your checkout path directly. Kepler's handler does exactly one thing: an atomic increment of a lock-free counter. No allocation, no message send, no ETS write.
A single poller then wakes on a tick, reads counters, computes deltas, and evaluates every declared condition in one pass. Event volume and evaluation volume are decoupled: 100k events per second costs 100k atomic increments plus one pass per second. Purely discrete occurrences — a crash, a long GC — bypass the counter path and fire directly, but still go through the same bounded, non-blocking egress.
| Tier | Source | Cost |
|---|---|---|
| 0 | crash_report:, supervisor_report:, system_monitor: | Nothing until something happens. |
| 1 | telemetry: | One atomic increment per event, on your process. |
| 2 | process:, vm: | A few reads per tick, regardless of event volume. |
Measured: a counting watch adds 52 ns per event, a percentile watch 72 ns. Kepler measures its own share of the node every tick and sheds work if it exceeds a budget you set. See the performance guide for the method and the repro script.
Deliberate limits
- Single node. Each node observes and fires independently — no cross-node deduplication. Distributed coordination in the hot path is how lightweight things stop being lightweight. Events are tagged with the node.
- Best-effort egress only. A bounded buffer, drops on backpressure, and a
count of what was dropped.
guarantee: :at_least_onceis declarable now, warns at boot, and behaves as best-effort until the durable buffer lands. - No tracing. Highest risk, least necessary.
:recon_tracealready has the rate limiter and hard message cap that make it safe.
Dependencies
{:telemetry, "~> 1.0"}. That is it. The default webhook transport is OTP's own
:httpc, in its own profile, with TLS verification on — so adding Kepler does
not commit you to an HTTP client. If you already run Finch or Req,
plug it in.
Guides
- Getting started — install, configure, verify.
- Crash attribution — the headline source, and what it can honestly tell you.
- Writing watches — the full DSL.
- Sinks and payloads — the wire format, signature verification, custom sinks.
- Performance — what it costs and how that is measured.
- Testing — testing your watches.
Development
mix deps.get # install deps
mix test # run the test suite
mix precommit # everything CI checks
AGENTS.md has the full command list and project conventions.
License
MIT. See LICENSE.