# Kepler

[![Hex.pm](https://img.shields.io/hexpm/v/kepler.svg)](https://hex.pm/packages/kepler)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](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).
