dwara_core/events/mod.rs
1//! In-process event bus (DW-044, feature analysis section 5 "Platform"):
2//! the notice plumbing behind alert & event webhooks.
3//!
4//! # What it is
5//!
6//! A bounded `tokio::sync::mpsc` channel plus cheap clone-able
7//! [`Emitter`] handles. Subsystems that own a state machine worth
8//! alerting on (the circuit breaker, passive/active health, the config
9//! publish pipeline) emit a small [`Event`] at every transition; the
10//! webhook deliverer ([`webhook`]) drains the channel in a background
11//! task and POSTs each event to the configured targets. std + tokio
12//! only — no dependency beyond `config` (the schema types and their
13//! bounds) and `observability` (delivery-outcome metrics).
14//!
15//! # Emission contract: never backpressure the dataplane
16//!
17//! [`Emitter::emit`] is a `try_send` onto the bounded channel. When the
18//! queue is full (or nobody is draining it) the event is DROPPED and the
19//! drop is counted ([`EventBus::dropped_total`], surfaced as the
20//! `dwara_events_dropped_total` gauge at scrape time). There is no
21//! blocking wait, no unbounded buffer growth, and no failure mode that
22//! can stall the caller — emission sites sit on request paths (the
23//! breaker `check`/`report` wire) and on the reload path, and a
24//! misbehaving webhook target must never show up as gateway latency.
25//! The drop policy is drop-NEWEST at emit time (the oldest queued
26//! events keep their place in line): a slow drain sheds the burst, not
27//! the history.
28//!
29//! # Event kinds and payloads
30//!
31//! Kinds are a closed set ([`EventKind::ALL`]) — a webhook's `events`
32//! list is validated against it, so a config cannot subscribe to an
33//! event the gateway will never emit. The payload is a flat struct of
34//! bounded config labels and numbers ([`EventPayload`]); there is no
35//! free-form field, so the serialized envelope is small by construction
36//! (the deliverer still enforces a hard byte cap). No request-derived
37//! data (paths, headers) enters a payload — identifiers are
38//! CONFIG-DECLARED labels only (`upstream`, and `consumer` for quota
39//! budgets, whose consumers are config records by construction in this
40//! edition) — an envelope is safe to POST to a third party by
41//! construction.
42//!
43//! Emission sites:
44//!
45//! - breaker state transitions (DW-015): opened (with the rule that
46//! tripped), half-open, closed (`resilience::breaker`);
47//! - outlier ejection and recovery (DW-012 passive + DW-013 active —
48//! both report through the same tracker, so both emit):
49//! `endpoint_ejected`, `endpoint_recovered`
50//! (`resilience::health`);
51//! - config published (generation + content hash + route count) and
52//! config rejected (validation issue count) (`snapshot`);
53//! - a consumer's request budget crossing the near-limit threshold
54//! (DW-033): `quota_near_limit`, edge-triggered once per (consumer,
55//! budget, window) from the dataplane's quota phase
56//! (`dataplane::proxy` — the emit lives there because the state
57//! domain must not import events).
58//!
59//! Deliberately NOT emitted (documented hook points, not gaps):
60//!
61//! - rate-limiter eviction (#132): already observable as
62//! `dwara_rate_limiter_evictions_total`; an event per eviction would
63//! be high-frequency noise, not an alert.
64//!
65//! # Failure isolation
66//!
67//! The deliverer (see `webhook`) runs as its own task, dispatches each
68//! delivery as a further task, bounds concurrent deliveries with a
69//! semaphore (over-cap deliveries are dropped and counted, never
70//! queued), and gives every delivery ONE total timeout shared by all
71//! its retry attempts. A slow, hung, or dead target can therefore
72//! consume at most `timeout_ms` of one semaphore slot — never the
73//! queue, never the emit path, never the dataplane.
74//!
75//! # Bus identity and lifecycle
76//!
77//! One bus per gateway process, owned by the `DataPlane` and attached
78//! to the `ConfigState` it was built from (so config publish events and
79//! dataplane events share one queue and one deliverer). The
80//! `gateway` field of every envelope identifies the instance:
81//! `dwara-<pid>-<boot unix ms>` — unique per process start, no
82//! hostname dependency, stable for the process lifetime.
83
84pub mod stream;
85pub mod webhook;
86
87use std::sync::atomic::{AtomicU64, Ordering};
88use std::sync::Arc;
89
90use tokio::sync::mpsc;
91
92use crate::observability::Observability;
93
94/// Capacity of the event channel (DW-044). Bound chosen for alert-rate
95/// shapes: transition events are rare per upstream (a breaker flapping
96/// once per `open_ms`, ejections once per `eject_ms`), so 256 queued
97/// events is already a gateway in serious trouble — beyond it, drops
98/// (counted, gauge-exported) are the right answer, not buffering.
99pub const EVENT_CHANNEL_CAPACITY: usize = 256;
100
101/// Every event kind the gateway emits (DW-044). Closed set: webhook
102/// `events` lists are validated against [`EventKind::from_config`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum EventKind {
106 /// A per-upstream circuit breaker moved closed -> open. Payload:
107 /// upstream, detail (the rule that tripped).
108 BreakerOpened,
109 /// An open breaker's cooling-off elapsed: the next request becomes
110 /// a half-open probe. Payload: upstream.
111 BreakerHalfOpen,
112 /// A half-open probe succeeded: the breaker closed again. Payload:
113 /// upstream.
114 BreakerClosed,
115 /// Passive or active health ejected an endpoint from rotation.
116 /// Payload: upstream, endpoint.
117 EndpointEjected,
118 /// An ejected endpoint recovered (successful probe or fail-open
119 /// success) and is back in rotation. Payload: upstream, endpoint.
120 EndpointRecovered,
121 /// A config generation was validated, compiled, and published.
122 /// Payload: generation, content_hash, route_count.
123 ConfigPublished,
124 /// A config candidate was rejected (validation or compile failure);
125 /// the previously published generation keeps serving. Payload:
126 /// issue_count, generation (the one still running).
127 ConfigRejected,
128 /// A consumer's request budget crossed the near-limit threshold
129 /// (80% of the window's cap; DW-033). Payload: consumer, detail
130 /// (the budget: "daily" or "monthly"), used, limit. Edge-triggered
131 /// once per (consumer, budget, window): the SECOND crossing inside
132 /// one window is not re-emitted, and the counter resets with the
133 /// window itself.
134 QuotaNearLimit,
135 /// A canary group was promoted (DW-091): the auto-canary
136 /// controller increased the canary weight. Payload: canary_group,
137 /// canary_action, canary_weight, canary_metric_value.
138 CanaryPromoted,
139 /// A canary group was rolled back (DW-091): the auto-canary
140 /// controller decreased the canary weight (or rolled it to 0 on
141 /// severe regression). Payload: canary_group, canary_action,
142 /// canary_weight, canary_metric_value.
143 CanaryRolledBack,
144 /// DP-10 (#238): a synthetic probe crossed the failure threshold
145 /// and entered an alerting state. Edge-triggered: subsequent
146 /// consecutive failures do not re-fire. Payload: route, status,
147 /// latency_ms, detail.
148 ProbeFailed,
149 /// DP-10 (#238): a previously-alerting synthetic probe recovered
150 /// (a successful probe after an alert). Payload: route, status,
151 /// latency_ms.
152 ProbeRecovered,
153}
154
155impl EventKind {
156 /// The closed set, in display order. The source of webhook `events`
157 /// validation and of every doc list — keep the two in lockstep.
158 pub const ALL: &'static [EventKind] = &[
159 EventKind::BreakerOpened,
160 EventKind::BreakerHalfOpen,
161 EventKind::BreakerClosed,
162 EventKind::EndpointEjected,
163 EventKind::EndpointRecovered,
164 EventKind::ConfigPublished,
165 EventKind::ConfigRejected,
166 EventKind::QuotaNearLimit,
167 EventKind::CanaryPromoted,
168 EventKind::CanaryRolledBack,
169 EventKind::ProbeFailed,
170 EventKind::ProbeRecovered,
171 ];
172
173 /// REL-14 (#249): whether this event kind is "critical" and should
174 /// be persisted to the WAL before emission. Critical events are
175 /// the ones an operator or controller needs for post-incident
176 /// investigation and fleet-state reconstruction: breaker
177 /// transitions, endpoint ejections/recoveries, config
178 /// publish/reject, quota near-limit, and canary promotions/rollbacks.
179 /// Probe events are excluded (high-volume, operational not
180 /// critical).
181 pub fn is_critical(self) -> bool {
182 matches!(
183 self,
184 EventKind::BreakerOpened
185 | EventKind::BreakerHalfOpen
186 | EventKind::BreakerClosed
187 | EventKind::EndpointEjected
188 | EventKind::EndpointRecovered
189 | EventKind::ConfigPublished
190 | EventKind::ConfigRejected
191 | EventKind::QuotaNearLimit
192 | EventKind::CanaryPromoted
193 | EventKind::CanaryRolledBack
194 )
195 }
196
197 /// Stable wire/config spelling (serde's snake_case form, spelled out
198 /// so it can be used in labels and validation messages without
199 /// serializing).
200 pub fn as_str(self) -> &'static str {
201 match self {
202 EventKind::BreakerOpened => "breaker_opened",
203 EventKind::BreakerHalfOpen => "breaker_half_open",
204 EventKind::BreakerClosed => "breaker_closed",
205 EventKind::EndpointEjected => "endpoint_ejected",
206 EventKind::EndpointRecovered => "endpoint_recovered",
207 EventKind::ConfigPublished => "config_published",
208 EventKind::ConfigRejected => "config_rejected",
209 EventKind::QuotaNearLimit => "quota_near_limit",
210 EventKind::CanaryPromoted => "canary_promoted",
211 EventKind::CanaryRolledBack => "canary_rolled_back",
212 EventKind::ProbeFailed => "probe_failed",
213 EventKind::ProbeRecovered => "probe_recovered",
214 }
215 }
216
217 /// Parse one `gateway.webhooks[].events[]` entry. Unknown spellings
218 /// are `None` (validation turns them into an issue naming the
219 /// accepted set).
220 pub fn from_config(value: &str) -> Option<EventKind> {
221 EventKind::ALL.iter().copied().find(|k| k.as_str() == value)
222 }
223}
224
225/// The payload of one event: a flat set of bounded labels and numbers.
226/// Every field is optional (each event kind sets the fields it owns);
227/// unset fields are omitted from the serialized envelope, and there is
228/// deliberately NO free-form/detail-string-from-input field — an
229/// envelope must stay small and safe to hand to a third party.
230#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
231#[serde(default)]
232pub struct EventPayload {
233 /// Upstream name (breaker and ejection events).
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub upstream: Option<String>,
236 /// Endpoint `address:port` (ejection events).
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub endpoint: Option<String>,
239 /// The rule that tripped a breaker ("consecutive_failures" or
240 /// "error_ratio") or the probe outcome that closed it — a static
241 /// string, never operator- or request-derived text.
242 #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
243 pub detail: Option<&'static str>,
244 /// Published generation number (config events).
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub generation: Option<u64>,
247 /// Per-process change-detection token of the published config
248 /// (config_published; same caveats as the snapshot content hash).
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub content_hash: Option<u64>,
251 /// Routes in the published generation (config_published).
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub route_count: Option<usize>,
254 /// Validation issues counted on a rejected config (config_rejected).
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub issue_count: Option<usize>,
257 /// Config-declared consumer name (quota_near_limit, DW-033). Budgets
258 /// attach to CONFIG consumer records only in this edition, so this
259 /// is a config label, never a request-derived or admin-entered
260 /// string (see the module docs' payload-safety contract).
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub consumer: Option<String>,
263 /// Requests counted in the budget's current window
264 /// (quota_near_limit).
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub used: Option<u64>,
267 /// The budget's configured cap (quota_near_limit).
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub limit: Option<u64>,
270 /// Config-declared canary group name (canary_promoted /
271 /// canary_rolled_back, DW-091): the service name or AI alias the
272 /// auto-canary controller adjusted. A config label, never
273 /// request-derived.
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub canary_group: Option<String>,
276 /// The canary action that triggered the event (DW-091): "promote",
277 /// "rollback", or "severe_regression". A static string.
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub canary_action: Option<String>,
280 /// The new canary weight after the adjustment (DW-091).
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub canary_weight: Option<u64>,
283 /// The metric value that triggered the adjustment (DW-091).
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub canary_metric_value: Option<f64>,
286 /// DP-10 (#238): the route name for synthetic probe events.
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub route: Option<String>,
289 /// DP-10 (#238): the HTTP status code from the probe response.
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub status: Option<u16>,
292 /// DP-10 (#238): the probe round-trip latency in milliseconds.
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub latency_ms: Option<u64>,
295 /// DP-10 (#238): dynamic error message from a failed probe (unlike
296 /// `detail`, this is a runtime string, not a static label).
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub probe_detail: Option<String>,
299}
300
301impl EventPayload {
302 /// Payload for a breaker transition of `upstream` (DW-044).
303 pub fn breaker(upstream: &str, detail: Option<&'static str>) -> Self {
304 EventPayload {
305 upstream: Some(upstream.to_string()),
306 detail,
307 ..EventPayload::default()
308 }
309 }
310
311 /// Payload for an endpoint ejection/recovery event (DW-044).
312 pub fn endpoint(upstream: &str, endpoint: &str) -> Self {
313 EventPayload {
314 upstream: Some(upstream.to_string()),
315 endpoint: Some(endpoint.to_string()),
316 ..EventPayload::default()
317 }
318 }
319
320 /// Payload for a quota near-limit crossing (DW-033): `budget` is the
321 /// static budget name carried in `detail` ("daily"/"monthly").
322 pub fn quota(consumer: &str, budget: &'static str, used: u64, limit: u64) -> Self {
323 EventPayload {
324 consumer: Some(consumer.to_string()),
325 detail: Some(budget),
326 used: Some(used),
327 limit: Some(limit),
328 ..EventPayload::default()
329 }
330 }
331
332 /// Payload for a synthetic probe event (DP-10, #238): `route` is
333 /// the probed route name, `status` the HTTP status code, and
334 /// `error` an optional dynamic error message (None on recovery).
335 pub fn probe(route: &str, status: u16, latency_ms: u64, error: Option<&str>) -> Self {
336 EventPayload {
337 route: Some(route.to_string()),
338 status: Some(status),
339 latency_ms: Some(latency_ms),
340 probe_detail: error.map(|s| s.to_string()),
341 ..EventPayload::default()
342 }
343 }
344}
345
346/// One emitted event. `id` and `timestamp_ms` are assigned by the bus at
347/// emit time (single place), `gateway` identifies the process.
348#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
349pub struct Event {
350 /// Process-unique, monotonically assigned: `evt-<hex unix ms>-<hex
351 /// counter>` (the request-id shape; a correlation handle, not a
352 /// secret).
353 pub id: String,
354 pub kind: EventKind,
355 /// Emission time, Unix epoch milliseconds.
356 pub timestamp_ms: u64,
357 /// Instance label (`dwara-<pid>-<boot ms>`; see the module docs).
358 pub gateway: String,
359 pub payload: EventPayload,
360}
361
362/// Wall-clock Unix milliseconds (the same clock reading the resilience
363/// domain uses; a monotonic clock cannot be serialized into an envelope).
364pub(crate) fn now_unix_ms() -> u64 {
365 std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .map(|d| d.as_millis() as u64)
368 .unwrap_or(0)
369}
370
371/// Process-unique instance label: `dwara-<pid>-<boot unix ms hex>`.
372/// Two gateway processes on one host never share it; it carries no
373/// hostname (a third-party envelope reader needs process identity, not
374/// topology).
375pub fn generate_instance_id() -> String {
376 format!("dwara-{}-{:x}", std::process::id(), now_unix_ms())
377}
378
379/// REL-14 (#249): the durability seam for the event bus. The events
380/// domain owns the trait; the dataplane (which already holds both the
381/// `StateStore` and the `EventBus`) provides a concrete
382/// implementation that wraps `StateStore` methods. This keeps the
383/// dependency direction correct: `events` does not import `state`.
384pub trait EventDurability: Send + Sync + std::fmt::Debug {
385 /// Persist a critical event to the WAL before emission. Called
386 /// synchronously on the emit path; a failure is logged but does
387 /// not block the emit (best-effort durability).
388 fn append(&self, event: &Event);
389
390 /// Mark an event as delivered (acked). Called by the webhook
391 /// deliverer after the event has been dispatched to matching
392 /// targets.
393 fn ack(&self, id: &str);
394
395 /// Load all un-acked events from the WAL, ordered oldest-first.
396 /// Called on startup to replay events that were not delivered
397 /// before the previous process exited.
398 fn unacked(&self) -> Vec<Event>;
399}
400
401/// The bounded event queue plus its identity and counters. Share via
402/// `Arc`; emitters hold an [`Emitter`] (a cheap clone of that Arc).
403///
404/// Construction: [`EventBus::with_receiver`] hands the single-consumer
405/// receiver to the deliverer; [`EventBus::new`] creates a bus nobody
406/// drains yet (the `DataPlane` does this before spawning its deliverer,
407/// so startup publishes are queued, not lost — and if no deliverer is
408/// ever spawned, every emit is a counted drop, never a block).
409pub struct EventBus {
410 tx: mpsc::Sender<Event>,
411 rx: std::sync::Mutex<Option<mpsc::Receiver<Event>>>,
412 instance: Arc<str>,
413 next_id: AtomicU64,
414 emitted_total: AtomicU64,
415 dropped_total: AtomicU64,
416 /// REL-14 (#249): optional durability layer. When set, critical
417 /// events are persisted to the WAL before emission and replayed on
418 /// startup. Settable after construction (the state store may open
419 /// after the bus in some wiring orders); a read lock is taken only
420 /// on critical event emits, which are infrequent.
421 durability: std::sync::RwLock<Option<Arc<dyn EventDurability>>>,
422}
423
424impl EventBus {
425 /// New bus with [`EVENT_CHANNEL_CAPACITY`] and no receiver taken.
426 pub fn new() -> Arc<Self> {
427 Self::with_capacity(EVENT_CHANNEL_CAPACITY)
428 }
429
430 /// New bus with an explicit capacity (tests).
431 pub fn with_capacity(capacity: usize) -> Arc<Self> {
432 let (tx, rx) = mpsc::channel(capacity.max(1));
433 Arc::new(EventBus {
434 tx,
435 rx: std::sync::Mutex::new(Some(rx)),
436 instance: Arc::from(generate_instance_id()),
437 next_id: AtomicU64::new(0),
438 emitted_total: AtomicU64::new(0),
439 dropped_total: AtomicU64::new(0),
440 durability: std::sync::RwLock::new(None),
441 })
442 }
443
444 /// New bus, returning the receiver alongside (the deliverer's end).
445 pub fn with_receiver(capacity: usize) -> (Arc<Self>, mpsc::Receiver<Event>) {
446 let bus = Self::with_capacity(capacity);
447 let rx = bus
448 .rx
449 .lock()
450 .expect("event receiver lock poisoned")
451 .take()
452 .expect("a fresh bus holds its receiver");
453 (bus, rx)
454 }
455
456 /// REL-14 (#249): new bus with a durability layer attached. On
457 /// construction, un-acked events from the WAL are replayed into
458 /// the channel (so they are delivered on the next deliverer
459 /// spawn). Critical events emitted after this point are persisted
460 /// to the WAL before being sent to the channel.
461 pub fn with_durability(
462 capacity: usize,
463 durability: Arc<dyn EventDurability>,
464 ) -> (Arc<Self>, mpsc::Receiver<Event>) {
465 let (tx, rx) = mpsc::channel(capacity.max(1));
466 let bus = Arc::new(EventBus {
467 tx,
468 rx: std::sync::Mutex::new(Some(rx)),
469 instance: Arc::from(generate_instance_id()),
470 next_id: AtomicU64::new(0),
471 emitted_total: AtomicU64::new(0),
472 dropped_total: AtomicU64::new(0),
473 durability: std::sync::RwLock::new(Some(durability)),
474 });
475
476 // Replay un-acked events from the WAL.
477 let replayed = bus
478 .durability
479 .read()
480 .expect("durability lock poisoned")
481 .as_ref()
482 .unwrap()
483 .unacked();
484 for event in replayed {
485 let _ = bus.tx.try_send(event);
486 }
487
488 let rx = bus
489 .rx
490 .lock()
491 .expect("event receiver lock poisoned")
492 .take()
493 .expect("a fresh bus holds its receiver");
494 (bus, rx)
495 }
496
497 /// REL-14 (#249): attach a durability layer to an existing bus.
498 /// The bus may already have emitted events (e.g. the startup
499 /// config_published); those are NOT retroactively persisted. After
500 /// this call, critical events are persisted to the WAL before
501 /// emission, and un-acked events from the WAL are replayed into
502 /// the channel for the next deliverer to drain. First attach
503 /// wins; a second call is a no-op.
504 pub fn attach_durability(&self, durability: Arc<dyn EventDurability>) {
505 let mut slot = self.durability.write().expect("durability lock poisoned");
506 if slot.is_none() {
507 // Replay un-acked events before marking the slot as set,
508 // so the deliverer sees them.
509 let replayed = durability.unacked();
510 for event in replayed {
511 let _ = self.tx.try_send(event);
512 }
513 *slot = Some(durability);
514 }
515 }
516
517 /// Take the single-consumer receiver (the first caller wins; later
518 /// callers get `None` — one deliverer per bus, by design).
519 pub fn take_receiver(&self) -> Option<mpsc::Receiver<Event>> {
520 self.rx.lock().expect("event receiver lock poisoned").take()
521 }
522
523 /// The instance label stamped on every envelope.
524 pub fn instance(&self) -> &str {
525 &self.instance
526 }
527
528 /// Events handed to the channel since process start.
529 pub fn emitted_total(&self) -> u64 {
530 self.emitted_total.load(Ordering::Relaxed)
531 }
532
533 /// Events dropped at emit time (queue full, or no deliverer
534 /// draining). Surfaced as `dwara_events_dropped_total`.
535 pub fn dropped_total(&self) -> u64 {
536 self.dropped_total.load(Ordering::Relaxed)
537 }
538
539 /// REL-14 (#249): the durability layer, if attached. The webhook
540 /// deliverer uses this to ack events after dispatch.
541 pub fn durability(&self) -> Option<Arc<dyn EventDurability>> {
542 self.durability
543 .read()
544 .expect("durability lock poisoned")
545 .clone()
546 }
547
548 /// A cheap emitter handle (clone of the bus Arc). A fresh `Arc`
549 /// bump per call site is fine: emitters are taken once at build
550 /// time, not per event.
551 pub fn emitter(self: &Arc<Self>) -> Emitter {
552 Emitter {
553 bus: Arc::clone(self),
554 }
555 }
556
557 /// Assign identity and enqueue, or count the drop. The ONE emit
558 /// path: never blocks, never allocates beyond the event itself.
559 /// REL-14 (#249): when a durability layer is attached, critical
560 /// events are persisted to the WAL before being sent to the
561 /// channel. A WAL write failure is logged but does not block the
562 /// emit (best-effort durability).
563 fn dispatch(&self, kind: EventKind, payload: EventPayload) {
564 let n = self.next_id.fetch_add(1, Ordering::Relaxed);
565 let event = Event {
566 id: format!("evt-{:x}-{:06x}", now_unix_ms(), n & 0xff_ffff),
567 kind,
568 timestamp_ms: now_unix_ms(),
569 gateway: self.instance.to_string(),
570 payload,
571 };
572 // REL-14 (#249): persist critical events to the WAL before
573 // sending. Best-effort: a WAL failure is logged but does not
574 // block the emit.
575 if kind.is_critical() {
576 if let Some(durability) = self
577 .durability
578 .read()
579 .expect("durability lock poisoned")
580 .as_ref()
581 {
582 durability.append(&event);
583 }
584 }
585 match self.tx.try_send(event) {
586 Ok(()) => {
587 self.emitted_total.fetch_add(1, Ordering::Relaxed);
588 }
589 Err(mpsc::error::TrySendError::Full(_)) => {
590 self.dropped_total.fetch_add(1, Ordering::Relaxed);
591 tracing::debug!(
592 code = "event_dropped_queue_full",
593 kind = kind.as_str(),
594 "event queue full; dropped (and counted) rather than blocking the caller"
595 );
596 }
597 Err(mpsc::error::TrySendError::Closed(_)) => {
598 self.dropped_total.fetch_add(1, Ordering::Relaxed);
599 }
600 }
601 }
602}
603
604/// A clone-able emission handle (an `Arc<EventBus>`). `Option<Emitter>`
605/// at every emission site: `None` (tests, unwired constructions) is a
606/// documented no-op, so the subsystems never need a bus to exist.
607#[derive(Clone)]
608pub struct Emitter {
609 bus: Arc<EventBus>,
610}
611
612impl Emitter {
613 /// Emit one event; drop-and-count on a full/undrained queue.
614 pub fn emit(&self, kind: EventKind, payload: EventPayload) {
615 self.bus.dispatch(kind, payload);
616 }
617
618 /// An emitter pre-bound to one upstream's label (breaker transitions
619 /// and endpoint events): keeps `upstream` consistent across every
620 /// event that upstream's state machines emit.
621 pub fn for_upstream(&self, upstream: &str) -> UpstreamEmitter {
622 UpstreamEmitter {
623 emitter: self.clone(),
624 upstream: Arc::from(upstream),
625 }
626 }
627}
628
629impl std::fmt::Debug for Emitter {
630 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
631 f.debug_struct("Emitter")
632 .field("gateway", &self.bus.instance.as_ref())
633 .finish_non_exhaustive()
634 }
635}
636
637/// [`Emitter`] pre-bound to an upstream label (DW-044): held by the
638/// per-upstream circuit breaker and balancer.
639#[derive(Clone, Debug)]
640pub struct UpstreamEmitter {
641 emitter: Emitter,
642 upstream: Arc<str>,
643}
644
645impl UpstreamEmitter {
646 /// Emit a breaker state transition of the bound upstream. `detail`
647 /// is a static string naming the rule/outcome.
648 pub fn breaker_transition(&self, kind: EventKind, detail: Option<&'static str>) {
649 self.emitter
650 .emit(kind, EventPayload::breaker(&self.upstream, detail));
651 }
652
653 /// Emit an endpoint ejection/recovery event of the bound upstream.
654 pub fn endpoint_event(&self, kind: EventKind, endpoint: &str) {
655 self.emitter
656 .emit(kind, EventPayload::endpoint(&self.upstream, endpoint));
657 }
658
659 /// Bind one endpoint label: the handle the per-endpoint health
660 /// tracker holds, so every ejection/recovery of that endpoint is
661 /// labeled consistently.
662 pub fn for_endpoint(&self, endpoint: &str) -> EndpointEvents {
663 EndpointEvents {
664 upstream: self.clone(),
665 endpoint: Arc::from(endpoint),
666 }
667 }
668}
669
670/// [`UpstreamEmitter`] further bound to one endpoint label (DW-044):
671/// held by the per-endpoint passive health tracker
672/// (`resilience::health::EndpointHealth`), which knows its state
673/// machine but not its own `address:port` (the balancer does).
674#[derive(Clone, Debug)]
675pub struct EndpointEvents {
676 upstream: UpstreamEmitter,
677 endpoint: Arc<str>,
678}
679
680impl EndpointEvents {
681 /// Emit one endpoint event (`EndpointEjected` or
682 /// `EndpointRecovered`) with both labels filled from the bindings.
683 pub fn emit(&self, kind: EventKind) {
684 self.upstream.emitter.emit(
685 kind,
686 EventPayload::endpoint(&self.upstream.upstream, &self.endpoint),
687 );
688 }
689}
690
691/// Refresh the event-bus observation gauge at scrape time (the
692/// `dwara_rate_limiter_evictions_total` model: the emit path bumps a
693/// plain atomic, and only the scrape couples it to the registry — a
694/// gauge rather than a counter so the hot path stays registry-free).
695pub fn refresh_event_gauges(bus: &EventBus, obs: &Observability) {
696 obs.set_events_dropped(bus.dropped_total() as i64);
697 obs.set_events_emitted(bus.emitted_total() as i64);
698}