Kepler.Event (Kepler v0.1.0)

Copy Markdown View Source

The event a watch fires. Kepler's entire output.

The 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, and state are always present and always populated. A consumer can route on any of them without a nil check, forever.

context is explicitly best-effort. Everything consumer-specific lives there, and keys that do not apply are absent rather than null — a telemetry watch has no "stacktrace", a crash watch has no "measurement".

The failure mode this avoids: if the core had to satisfy a SIEM, an autoscaler, and someone debugging an incident simultaneously, it would degrade into a union of optional fields where nothing is guaranteed and every consumer writes nil checks forever.

Payload

{
  "schema": "kepler.event/1",
  "id": "01920f3c-6a1b-7c4e-9f00-3d2c1b0a9e8f",
  "node": "app@10.0.0.1",
  "watch": "checkout_latency",
  "timestamp": "2026-08-06T12:34:56.789Z",
  "severity": "critical",
  "state": "firing",
  "context": {
    "source": {"type": "telemetry", "event": "my_app.checkout.stop"},
    "tier": 1,
    "condition": "value > 2000",
    "debounce": {"sustained_ms": 30000, "cooldown_ms": 0},
    "measurement": {
      "key": "duration", "aggregate": "p99", "unit": "millisecond",
      "value": 2431, "previous": 1980, "delta": 451, "rate": 451.0,
      "window_ms": 1000
    },
    "meta": {"team": "payments"},
    "kepler": {"version": "0.1.0", "share": 0.0008}
  }
}

watch is the stable event id — route on it. Renaming a watch is a breaking change for whoever receives it. state is "firing" or "resolved".

Terms JSON cannot hold

context carries whatever your application put there, which will not always be JSON-shaped — a crash reason is a tuple, a process_state is whatever your GenServer was holding. Rather than fail to encode, which would drop the event, Kepler renders them: atoms and tuples become strings and lists, pids and refs become their inspect/1 form, keyword lists become objects, and nesting deeper than 8 levels becomes "...".

Summary

Types

Whether the condition became true or stopped being true.

t()

Functions

Builds an event for watch entering state.

The wire payload as JSON iodata, ready to hand to a transport.

The wire payload, with string keys and JSON-safe values.

Kepler's version, as reported in every payload.

Types

state()

@type state() :: :firing | :resolved

Whether the condition became true or stopped being true.

t()

@type t() :: %Kepler.Event{
  context: map(),
  id: String.t(),
  node: node(),
  severity: Kepler.Watch.severity(),
  state: state(),
  timestamp: DateTime.t(),
  watch: atom()
}

Functions

new(watch, state, reading)

@spec new(Kepler.Watch.t(), state(), map()) :: t()

Builds an event for watch entering state.

reading is whatever the source produced. A sampled watch passes :value, :prev, :delta, :rate, and :window_ms; a discrete one passes :detail. Both may pass :recent, :enriched, and :overhead. Anything absent is simply left out of the context.

to_json(event)

@spec to_json(t()) :: iodata()

The wire payload as JSON iodata, ready to hand to a transport.

to_map(event)

@spec to_map(t()) :: map()

The wire payload, with string keys and JSON-safe values.

version()

@spec version() :: String.t()

Kepler's version, as reported in every payload.