Hex.pm Docs

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

  • 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

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.