Kepler

Hex.pmDocs

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:

MetricsEvents
ShapeAggregated, sampledDiscrete, contextual
CardinalityBoundedUnbounded
AttributionImpossibleThe point
ToolPrometheus + AlertmanagerKepler

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

TierSourceCost
0crash_report:, supervisor_report:, system_monitor:Nothing until something happens.
1telemetry:One atomic increment per event, on your process.
2process:, 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

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

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.