Kepler behaviour (Kepler v0.1.0)

Copy Markdown View Source

An event router that lives inside your application.

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: it observes and reports, and it never remediates.

defmodule MyApp.Watches do
  use Kepler

  watch :process_crash do
    source crash_report: :any
    enrich [:stacktrace, :process_state, :last_message, :request_context]
    severity :error
    fire immediately, cooldown: :timer.minutes(1)
  end
end

See the getting started guide to wire that up, crash attribution for the source above, and writing watches for the language in full.

The two things this sells

For any single signal, :telemetry.attach plus an HTTP post is about twenty lines. Kepler earns its place on two counts, and every feature is judged against them.

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.

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.

Not a metrics backend, and not Alertmanager

No time series, no query language, no dashboards; telemetry_metrics and Prometheus exist and are better at it. The distinction that matters:

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.

The shape of the thing

Telemetry handlers run inline in the process that emitted the event, so a slow handler directly slows your checkout path. Kepler's handler therefore does 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 the 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 evaluation pass per tick.

Signal tiers, by cost

Every source belongs to a tier, and Kepler.Watch records which:

TierSourceWhat it costs
0crash_report:, supervisor_report:, system_monitor:Nothing until something happens.
1telemetry:One atomic increment per event, on the emitting process.
2process:, vm:A few reads per tick, regardless of event volume.

Discrete tier 0 sources bypass the counter path and fire directly, but still go through the same bounded, non-blocking egress as everything else.

Three options add per-event work to a tier 1 watch, and all are opt-in for that reason: filter runs a comparison on the emitting process, and recent and enrich each write to a ring buffer. On a report source enrich is free — the report already carries the fields.

There is no tracing tier. It is the highest-risk surface and the least necessary; if you need it, :recon_trace already has the rate limiter and hard message cap that make it safe.

Deliberate limits

  • Single node. Each node observes and fires independently. There is no cross-node deduplication, because distributed coordination in the hot path is how lightweight things stop being lightweight. Events are tagged with the node that produced them.
  • Best-effort egress only. Delivery is in-process. A delivery lost to a crash is not retried after restart, and a wedged endpoint gets counted drops rather than an unbounded buffer. guarantee: :at_least_once is declarable now, warns at boot, and behaves as best-effort until the durable buffer lands.
  • No storage. Kepler holds counters and a small ring of recent events. It is not a metrics backend; telemetry_metrics and Prometheus already are one.

The public contract

Each watch name is the stable event id consumers route on. Renaming a watch is a breaking change for whoever is on the other end of the webhook.

Summary

Callbacks

Returns every watch declared in the module, in dependency order.

Functions

Declares a module as a set of Kepler watches.

Blocks until the emitter has delivered everything currently queued.

The most recent events Kepler emitted on this node, oldest first.

A snapshot of what Kepler is doing and what it is costing.

Evaluates every watch immediately instead of waiting for the next tick.

Every watch Kepler is currently running.

Callbacks

__kepler_watches__()

@callback __kepler_watches__() :: [Kepler.Watch.t()]

Returns every watch declared in the module, in dependency order.

Generated by use Kepler. The order matters: a condition that reads another watch's condition is placed after it, so one tick sees one consistent set of results.

Functions

__using__(opts)

(macro)

Declares a module as a set of Kepler watches.

Imports Kepler.DSL.watch/2 and installs the compile-time checks. The module itself does nothing at runtime until you point Kepler's :watches config at it — see the getting started guide.

drain(timeout \\ 5000)

@spec drain(timeout()) :: :ok | {:error, :timeout}

Blocks until the emitter has delivered everything currently queued.

Only useful in tests. Returns {:error, :timeout} rather than raising if the queue does not drain, since a wedged sink is a normal thing for Kepler to survive.

recent(count \\ 25)

@spec recent(pos_integer()) :: [Kepler.Event.t()]

The most recent events Kepler emitted on this node, oldest first.

Reads a small ring buffer, so it is bounded and safe to call in production. Useful for confirming a watch fires without waiting for the webhook to arrive.

status()

@spec status() :: map()

A snapshot of what Kepler is doing and what it is costing.

Includes the current tick interval, per-watch state and last value, Kepler's own share of the node's reductions, and the emitter's queue depth and drop count. This is the first thing to look at when a watch is not firing.

tick()

@spec tick() :: :ok

Evaluates every watch immediately instead of waiting for the next tick.

Intended for tests and for a REPL. Returns after the tick completes, so any event it produces has already been handed to the emitter.

watches()

@spec watches() :: [Kepler.Watch.t()]

Every watch Kepler is currently running.

Reflects what was installed at boot, which is not necessarily everything you declared — a watch whose source could not be installed is reported by status/0.