Skip to main content

tokio_prompt_orchestrator/webhooks/
mod.rs

1#![allow(dead_code)]
2//! # Module: Webhook Notifications
3//!
4//! ## Responsibility
5//! Delivers structured JSON event notifications to one or more configured
6//! webhook URLs when important pipeline events occur:
7//!
8//! | Event | Trigger |
9//! |-------|---------|
10//! | [`WebhookEvent::CircuitBreakerStateChange`] | Circuit breaker transitions CLOSED→OPEN, OPEN→HALF-OPEN, etc. |
11//! | [`WebhookEvent::BudgetThresholdExceeded`] | Spend crosses a configured threshold |
12//! | [`WebhookEvent::AnomalyDetected`] | Anomaly detector raises an alert |
13//! | [`WebhookEvent::ThroughputDrop`] | Pipeline throughput drops >50% compared to recent baseline |
14//!
15//! Each event is delivered with a context JSON payload via HTTP POST.
16//!
17//! ## Delivery guarantees
18//! - At-most-once: no retry on failure (logged as warning)
19//! - Non-blocking: delivery is async; the caller never blocks
20//! - Circuit-breaker aware: a per-webhook soft-disable prevents thundering
21//!   herd when a webhook URL is consistently unreachable (≥5 consecutive
22//!   failures causes a 60-second backoff)
23//!
24//! ## Usage
25//!
26//! ```ignore
27//! let dispatcher = WebhookDispatcher::new(vec![
28//!     WebhookConfig { url: "https://hooks.example.com/pipeline".into(), ..Default::default() },
29//! ])?;
30//!
31//! dispatcher.dispatch(WebhookEvent::CircuitBreakerStateChange {
32//!     worker: "openai".into(),
33//!     from: CircuitBreakerState::Closed,
34//!     to: CircuitBreakerState::Open,
35//!     failure_count: 5,
36//! }).await;
37//! ```
38
39use std::collections::HashMap;
40use std::sync::{Arc, Mutex};
41use std::time::{Duration, Instant};
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45use tracing::{debug, info, warn};
46
47// ─── Errors ──────────────────────────────────────────────────────────────────
48
49/// Errors produced by the webhook dispatcher.
50#[derive(Debug, Error)]
51pub enum WebhookError {
52    /// A webhook URL is syntactically invalid.
53    #[error("invalid webhook URL: {0}")]
54    InvalidUrl(String),
55    /// An HTTP delivery failed.
56    #[error("delivery failed to {url}: {reason}")]
57    DeliveryFailed { url: String, reason: String },
58    /// JSON serialisation of the payload failed.
59    #[error("serialisation error: {0}")]
60    Serialisation(String),
61    /// Internal mutex was poisoned.
62    #[error("internal lock poisoned")]
63    LockPoisoned,
64}
65
66// ─── Circuit breaker state ───────────────────────────────────────────────────
67
68/// State of a circuit breaker (mirrored here to avoid importing enhanced::).
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum CircuitBreakerState {
71    /// Requests flowing normally.
72    Closed,
73    /// Testing recovery with probe requests.
74    HalfOpen,
75    /// Fast-failing all requests.
76    Open,
77}
78
79impl std::fmt::Display for CircuitBreakerState {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            CircuitBreakerState::Closed => write!(f, "CLOSED"),
83            CircuitBreakerState::HalfOpen => write!(f, "HALF-OPEN"),
84            CircuitBreakerState::Open => write!(f, "OPEN"),
85        }
86    }
87}
88
89// ─── Events ───────────────────────────────────────────────────────────────────
90
91/// A pipeline event that triggers webhook delivery.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "event_type", rename_all = "snake_case")]
94pub enum WebhookEvent {
95    /// A circuit breaker changed state.
96    CircuitBreakerStateChange {
97        /// Worker or service whose circuit breaker changed.
98        worker: String,
99        /// Previous state.
100        from: CircuitBreakerState,
101        /// New state.
102        to: CircuitBreakerState,
103        /// Number of consecutive failures that triggered the transition.
104        failure_count: u32,
105    },
106    /// Spend crossed a configured budget threshold.
107    BudgetThresholdExceeded {
108        /// Budget name or identifier.
109        budget_name: String,
110        /// Threshold that was crossed in USD.
111        threshold_usd: f64,
112        /// Current spend in USD.
113        current_spend_usd: f64,
114        /// Percentage of budget consumed.
115        percent_used: f64,
116    },
117    /// An anomaly detector raised an alert.
118    AnomalyDetected {
119        /// Name of the metric where the anomaly was detected.
120        metric: String,
121        /// The anomalous value.
122        value: f64,
123        /// Z-score or CUSUM magnitude.
124        severity: f64,
125        /// Human-readable description.
126        description: String,
127    },
128    /// Pipeline throughput dropped by more than `drop_percent`%.
129    ThroughputDrop {
130        /// Requests per second over the last sampling window.
131        current_rps: f64,
132        /// Baseline requests per second (rolling average).
133        baseline_rps: f64,
134        /// Percentage drop (0–100).
135        drop_percent: f64,
136    },
137}
138
139impl WebhookEvent {
140    /// Short human-readable event name.
141    #[must_use]
142    pub fn name(&self) -> &'static str {
143        match self {
144            WebhookEvent::CircuitBreakerStateChange { .. } => "circuit_breaker_state_change",
145            WebhookEvent::BudgetThresholdExceeded { .. } => "budget_threshold_exceeded",
146            WebhookEvent::AnomalyDetected { .. } => "anomaly_detected",
147            WebhookEvent::ThroughputDrop { .. } => "throughput_drop",
148        }
149    }
150}
151
152// ─── Webhook payload ─────────────────────────────────────────────────────────
153
154/// The JSON body delivered to webhook endpoints.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct WebhookPayload {
157    /// ISO-8601 timestamp of the event.
158    pub timestamp: String,
159    /// Service name.
160    pub service: String,
161    /// The event itself (serialised as a nested object).
162    pub event: WebhookEvent,
163    /// Arbitrary context metadata.
164    pub context: HashMap<String, serde_json::Value>,
165}
166
167impl WebhookPayload {
168    /// Build a payload for the given event.
169    #[must_use]
170    pub fn new(event: WebhookEvent, service: &str) -> Self {
171        Self {
172            timestamp: chrono::Utc::now().to_rfc3339(),
173            service: service.to_string(),
174            event,
175            context: HashMap::new(),
176        }
177    }
178
179    /// Add a context key.
180    #[must_use]
181    pub fn with_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
182        self.context.insert(key.into(), value);
183        self
184    }
185}
186
187// ─── Webhook configuration ───────────────────────────────────────────────────
188
189/// Configuration for a single webhook endpoint.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct WebhookConfig {
192    /// HTTP URL to POST events to.
193    pub url: String,
194    /// Optional Bearer token added to the `Authorization` header.
195    pub bearer_token: Option<String>,
196    /// Optional additional headers.
197    pub extra_headers: HashMap<String, String>,
198    /// Delivery timeout.
199    pub timeout: Duration,
200    /// Which event types to deliver (empty = all).
201    pub filter_events: Vec<String>,
202}
203
204impl Default for WebhookConfig {
205    fn default() -> Self {
206        Self {
207            url: String::new(),
208            bearer_token: None,
209            extra_headers: HashMap::new(),
210            timeout: Duration::from_secs(5),
211            filter_events: vec![],
212        }
213    }
214}
215
216// ─── Per-webhook backoff state ────────────────────────────────────────────────
217
218#[derive(Debug)]
219struct WebhookState {
220    config: WebhookConfig,
221    consecutive_failures: u32,
222    backoff_until: Option<Instant>,
223}
224
225impl WebhookState {
226    fn new(config: WebhookConfig) -> Self {
227        Self {
228            config,
229            consecutive_failures: 0,
230            backoff_until: None,
231        }
232    }
233
234    fn is_backing_off(&self) -> bool {
235        match self.backoff_until {
236            Some(until) => Instant::now() < until,
237            None => false,
238        }
239    }
240
241    fn record_failure(&mut self) {
242        self.consecutive_failures += 1;
243        if self.consecutive_failures >= 5 {
244            let backoff = Duration::from_secs(60);
245            self.backoff_until = Some(Instant::now() + backoff);
246            warn!(
247                url = %self.config.url,
248                failures = self.consecutive_failures,
249                "webhook backing off for 60s after consecutive failures"
250            );
251        }
252    }
253
254    fn record_success(&mut self) {
255        self.consecutive_failures = 0;
256        self.backoff_until = None;
257    }
258
259    fn passes_filter(&self, event: &WebhookEvent) -> bool {
260        if self.config.filter_events.is_empty() {
261            return true;
262        }
263        self.config.filter_events.iter().any(|f| f == event.name())
264    }
265}
266
267// ─── Dispatcher ───────────────────────────────────────────────────────────────
268
269/// Sends webhook events to all configured endpoints.
270pub struct WebhookDispatcher {
271    service_name: String,
272    states: Arc<Mutex<Vec<WebhookState>>>,
273    http_client: reqwest::Client,
274}
275
276impl WebhookDispatcher {
277    /// Create a new dispatcher.
278    ///
279    /// # Errors
280    /// Returns [`WebhookError::InvalidUrl`] if any configured URL is empty.
281    pub fn new(configs: Vec<WebhookConfig>) -> Result<Arc<Self>, WebhookError> {
282        Self::with_service_name(configs, "tokio-prompt-orchestrator")
283    }
284
285    /// Create with a custom service name.
286    ///
287    /// # Errors
288    /// Returns [`WebhookError::InvalidUrl`] if any configured URL is empty.
289    pub fn with_service_name(
290        configs: Vec<WebhookConfig>,
291        service_name: &str,
292    ) -> Result<Arc<Self>, WebhookError> {
293        for cfg in &configs {
294            if cfg.url.is_empty() {
295                return Err(WebhookError::InvalidUrl("URL must not be empty".to_string()));
296            }
297        }
298
299        let http_client = reqwest::Client::builder()
300            .timeout(Duration::from_secs(10))
301            .build()
302            .map_err(|e| WebhookError::InvalidUrl(e.to_string()))?;
303
304        let states = configs
305            .into_iter()
306            .map(WebhookState::new)
307            .collect::<Vec<_>>();
308
309        Ok(Arc::new(Self {
310            service_name: service_name.to_string(),
311            states: Arc::new(Mutex::new(states)),
312            http_client,
313        }))
314    }
315
316    /// Dispatch an event to all configured (non-backed-off) webhooks.
317    ///
318    /// Delivery is attempted for each webhook sequentially. Failures are
319    /// logged but do not propagate to the caller.
320    pub async fn dispatch(&self, event: WebhookEvent) {
321        let payload = WebhookPayload::new(event, &self.service_name);
322        let body = match serde_json::to_string(&payload) {
323            Ok(b) => b,
324            Err(e) => {
325                warn!(error = %e, "failed to serialise webhook payload; skipping dispatch");
326                return;
327            }
328        };
329
330        // Collect the configs we need to deliver to (under lock, then release)
331        // (index, url, secret, headers, timeout)
332        type Target = (usize, String, Option<String>, HashMap<String, String>, Duration);
333        let targets: Vec<Target> = {
334            let Ok(states) = self.states.lock() else {
335                warn!("webhook dispatcher lock poisoned");
336                return;
337            };
338            states
339                .iter()
340                .enumerate()
341                .filter(|(_, s)| !s.is_backing_off() && s.passes_filter(&payload.event))
342                .map(|(i, s)| {
343                    (
344                        i,
345                        s.config.url.clone(),
346                        s.config.bearer_token.clone(),
347                        s.config.extra_headers.clone(),
348                        s.config.timeout,
349                    )
350                })
351                .collect()
352        };
353
354        for (idx, url, bearer, extra_headers, timeout) in targets {
355            let result = self
356                .deliver(&url, &bearer, &extra_headers, timeout, &body)
357                .await;
358            let Ok(mut states) = self.states.lock() else {
359                continue;
360            };
361            match result {
362                Ok(()) => {
363                    debug!(url = %url, event = %payload.event.name(), "webhook delivered");
364                    if let Some(s) = states.get_mut(idx) {
365                        s.record_success();
366                    }
367                }
368                Err(e) => {
369                    warn!(url = %url, error = %e, "webhook delivery failed");
370                    if let Some(s) = states.get_mut(idx) {
371                        s.record_failure();
372                    }
373                }
374            }
375        }
376
377        info!(
378            event = %payload.event.name(),
379            service = %self.service_name,
380            "webhook dispatch complete"
381        );
382    }
383
384    async fn deliver(
385        &self,
386        url: &str,
387        bearer: &Option<String>,
388        extra_headers: &HashMap<String, String>,
389        timeout: Duration,
390        body: &str,
391    ) -> Result<(), WebhookError> {
392        let mut req = self
393            .http_client
394            .post(url)
395            .timeout(timeout)
396            .header("Content-Type", "application/json")
397            .header("User-Agent", "tokio-prompt-orchestrator/1.0")
398            .body(body.to_string());
399
400        if let Some(token) = bearer {
401            req = req.header("Authorization", format!("Bearer {token}"));
402        }
403        for (k, v) in extra_headers {
404            req = req.header(k.as_str(), v.as_str());
405        }
406
407        let resp = req.send().await.map_err(|e| WebhookError::DeliveryFailed {
408            url: url.to_string(),
409            reason: e.to_string(),
410        })?;
411
412        if !resp.status().is_success() {
413            return Err(WebhookError::DeliveryFailed {
414                url: url.to_string(),
415                reason: format!("HTTP {}", resp.status()),
416            });
417        }
418        Ok(())
419    }
420
421    /// Return the number of configured webhooks currently in backoff.
422    pub fn backed_off_count(&self) -> usize {
423        let Ok(states) = self.states.lock() else {
424            return 0;
425        };
426        states.iter().filter(|s| s.is_backing_off()).count()
427    }
428
429    /// Return the total number of configured webhooks.
430    pub fn webhook_count(&self) -> usize {
431        let Ok(states) = self.states.lock() else {
432            return 0;
433        };
434        states.len()
435    }
436}
437
438// ─── Convenience constructors for common events ────────────────────────────────
439
440/// Build a [`WebhookEvent::CircuitBreakerStateChange`] event.
441#[must_use]
442pub fn circuit_breaker_event(
443    worker: impl Into<String>,
444    from: CircuitBreakerState,
445    to: CircuitBreakerState,
446    failure_count: u32,
447) -> WebhookEvent {
448    WebhookEvent::CircuitBreakerStateChange {
449        worker: worker.into(),
450        from,
451        to,
452        failure_count,
453    }
454}
455
456/// Build a [`WebhookEvent::BudgetThresholdExceeded`] event.
457#[must_use]
458pub fn budget_exceeded_event(
459    budget_name: impl Into<String>,
460    threshold_usd: f64,
461    current_spend_usd: f64,
462) -> WebhookEvent {
463    let percent_used = if threshold_usd > 0.0 {
464        (current_spend_usd / threshold_usd * 100.0).min(999.9)
465    } else {
466        0.0
467    };
468    WebhookEvent::BudgetThresholdExceeded {
469        budget_name: budget_name.into(),
470        threshold_usd,
471        current_spend_usd,
472        percent_used,
473    }
474}
475
476/// Build a [`WebhookEvent::ThroughputDrop`] event.
477///
478/// Returns `None` if the drop is less than `min_drop_percent`.
479#[must_use]
480pub fn throughput_drop_event(
481    current_rps: f64,
482    baseline_rps: f64,
483    min_drop_percent: f64,
484) -> Option<WebhookEvent> {
485    if baseline_rps <= 0.0 {
486        return None;
487    }
488    let drop_percent = ((baseline_rps - current_rps) / baseline_rps * 100.0).max(0.0);
489    if drop_percent < min_drop_percent {
490        return None;
491    }
492    Some(WebhookEvent::ThroughputDrop {
493        current_rps,
494        baseline_rps,
495        drop_percent,
496    })
497}
498
499// ─── Tests ───────────────────────────────────────────────────────────────────
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn webhook_payload_serialises() {
507        let event = circuit_breaker_event(
508            "openai",
509            CircuitBreakerState::Closed,
510            CircuitBreakerState::Open,
511            5,
512        );
513        let payload = WebhookPayload::new(event, "test-service");
514        let json = serde_json::to_string(&payload).expect("should serialise");
515        assert!(json.contains("circuit_breaker_state_change"));
516        assert!(json.contains("openai"));
517    }
518
519    #[test]
520    fn event_name_helpers() {
521        assert_eq!(
522            circuit_breaker_event(
523                "w",
524                CircuitBreakerState::Open,
525                CircuitBreakerState::HalfOpen,
526                3
527            )
528            .name(),
529            "circuit_breaker_state_change"
530        );
531        assert_eq!(
532            budget_exceeded_event("monthly", 100.0, 110.0).name(),
533            "budget_threshold_exceeded"
534        );
535    }
536
537    #[test]
538    fn throughput_drop_event_threshold() {
539        assert!(throughput_drop_event(100.0, 100.0, 50.0).is_none());
540        assert!(throughput_drop_event(40.0, 100.0, 50.0).is_some());
541        assert!(throughput_drop_event(51.0, 100.0, 50.0).is_none());
542        let evt = throughput_drop_event(20.0, 100.0, 50.0).unwrap();
543        if let WebhookEvent::ThroughputDrop { drop_percent, .. } = evt {
544            assert!((drop_percent - 80.0).abs() < 0.01);
545        } else {
546            panic!("wrong variant");
547        }
548    }
549
550    #[test]
551    fn dispatcher_invalid_url_rejected() {
552        let result = WebhookDispatcher::new(vec![WebhookConfig {
553            url: String::new(),
554            ..Default::default()
555        }]);
556        assert!(result.is_err());
557    }
558
559    #[test]
560    fn dispatcher_no_webhooks() {
561        let dispatcher = WebhookDispatcher::new(vec![]).expect("empty ok");
562        assert_eq!(dispatcher.webhook_count(), 0);
563        assert_eq!(dispatcher.backed_off_count(), 0);
564    }
565
566    #[test]
567    fn webhook_state_backoff_after_5_failures() {
568        let mut state = WebhookState::new(WebhookConfig {
569            url: "http://example.com".into(),
570            ..Default::default()
571        });
572        assert!(!state.is_backing_off());
573        for _ in 0..5 {
574            state.record_failure();
575        }
576        assert!(state.is_backing_off());
577        state.record_success();
578        assert!(!state.is_backing_off());
579    }
580
581    #[test]
582    fn webhook_filter_passes_matching() {
583        let state = WebhookState::new(WebhookConfig {
584            url: "http://example.com".into(),
585            filter_events: vec!["anomaly_detected".into()],
586            ..Default::default()
587        });
588        let anomaly_event = WebhookEvent::AnomalyDetected {
589            metric: "latency".into(),
590            value: 99.9,
591            severity: 4.5,
592            description: "high latency".into(),
593        };
594        assert!(state.passes_filter(&anomaly_event));
595
596        let cb_event = circuit_breaker_event(
597            "w",
598            CircuitBreakerState::Closed,
599            CircuitBreakerState::Open,
600            1,
601        );
602        assert!(!state.passes_filter(&cb_event));
603    }
604
605    #[test]
606    fn budget_exceeded_percent_computed() {
607        let evt = budget_exceeded_event("monthly", 100.0, 75.0);
608        if let WebhookEvent::BudgetThresholdExceeded { percent_used, .. } = evt {
609            assert!((percent_used - 75.0).abs() < 0.01);
610        } else {
611            panic!("wrong variant");
612        }
613    }
614}