Current section
Files
Jump to
Current section
Files
README.md
# Kepler
[](https://hex.pm/packages/kepler)
[](https://hexdocs.pm/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.
```elixir
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
```elixir
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.
```elixir
# 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](guides/getting_started.md) 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*.
```json
{
"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](guides/performance.md)
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_once` is declarable now,
warns at boot, and behaves as best-effort until the durable buffer lands.
- **No tracing.** Highest risk, least necessary. `:recon_trace` already 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/sinks_and_payloads.md#using-your-own-http-client).
## Guides
- [Getting started](guides/getting_started.md) — install, configure, verify.
- [Crash attribution](guides/crash_attribution.md) — the headline source, and
what it can honestly tell you.
- [Writing watches](guides/writing_watches.md) — the full DSL.
- [Sinks and payloads](guides/sinks_and_payloads.md) — the wire format,
signature verification, custom sinks.
- [Performance](guides/performance.md) — what it costs and how that is measured.
- [Testing](guides/testing.md) — testing your watches.
## Development
```sh
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](https://github.com/houllette/kepler/blob/main/LICENSE).