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}