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
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
@type state() :: :firing | :resolved
Whether the condition became true or stopped being true.
@type t() :: %Kepler.Event{ context: map(), id: String.t(), node: node(), severity: Kepler.Watch.severity(), state: state(), timestamp: DateTime.t(), watch: atom() }
Functions
@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.
The wire payload as JSON iodata, ready to hand to a transport.
The wire payload, with string keys and JSON-safe values.
@spec version() :: String.t()
Kepler's version, as reported in every payload.