1#![forbid(unsafe_code)]
2
3mod chain;
4#[cfg(feature = "object-store")]
5mod cloud;
6pub mod config;
7pub mod delivery;
8mod inflight;
9pub mod providers;
10mod reload;
11pub mod routing;
12pub mod secrets;
13pub mod signal;
14pub mod sink;
15pub mod source;
16pub mod template;
17mod throttle;
18
19const MAX_RETRY_ATTEMPTS: u32 = 3;
20const INITIAL_BACKOFF_MS: u64 = 200;
21
22pub const DEFAULT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
25
26use config::{Config, SinkKind};
27use delivery::{DeliveryResult, DeliveryStatus, Provider, ProviderOutcome};
28use inflight::InFlight;
29use providers::{
30 console::ConsoleProvider, email::EmailProvider, opsgenie::OpsgenieProvider,
31 pagerduty::PagerDutyProvider, slack::SlackProvider, teams::TeamsProvider,
32 webhook::WebhookProvider,
33};
34use routing::{DeliveryPlan, RoutingConfig};
35use signal::{Signal, SignalError, SignalInput};
36use sink::{SignalSink, SqliteSink};
37use source::{ConfigSource, LoadOptions};
38use std::collections::HashMap;
39use std::sync::{Arc, Mutex, RwLock};
40use std::time::Duration;
41use throttle::{Admission, Throttle};
42
43pub struct Pipeline {
47 pub routing: routing::RoutingConfig,
48 pub providers: HashMap<String, Arc<dyn Provider>>,
49}
50
51impl Pipeline {
52 pub fn from_config(config: &Config) -> Self {
55 let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
56 let console_templates = config
57 .providers
58 .console
59 .as_ref()
60 .and_then(|c| template::Templates::load("console", &c.template));
61 providers.insert(
62 "console".to_string(),
63 Arc::new(ConsoleProvider::new().with_templates(console_templates)),
64 );
65 if let Some(slack) = &config.providers.slack {
66 providers.insert(
67 "slack".to_string(),
68 Arc::new(
69 SlackProvider::new(slack.webhook_url.clone())
70 .with_templates(template::Templates::load("slack", &slack.template)),
71 ),
72 );
73 }
74 if let Some(webhook) = &config.providers.webhook {
75 providers.insert(
76 "webhook".to_string(),
77 Arc::new(
78 WebhookProvider::new(webhook.url.clone())
79 .with_templates(template::Templates::load("webhook", &webhook.template)),
80 ),
81 );
82 }
83 if let Some(pagerduty) = &config.providers.pagerduty {
84 providers.insert(
85 "pagerduty".to_string(),
86 Arc::new(
87 PagerDutyProvider::new(pagerduty.routing_key.clone(), pagerduty.url.clone())
88 .with_templates(template::Templates::load(
89 "pagerduty",
90 &pagerduty.template,
91 )),
92 ),
93 );
94 }
95 if let Some(teams) = &config.providers.teams {
96 providers.insert(
97 "teams".to_string(),
98 Arc::new(
99 TeamsProvider::new(teams.webhook_url.clone())
100 .with_templates(template::Templates::load("teams", &teams.template)),
101 ),
102 );
103 }
104 if let Some(opsgenie) = &config.providers.opsgenie {
105 providers.insert(
106 "opsgenie".to_string(),
107 Arc::new(
108 OpsgenieProvider::new(
109 opsgenie.api_key.clone(),
110 opsgenie.url.clone(),
111 opsgenie.tags.clone(),
112 )
113 .with_templates(template::Templates::load("opsgenie", &opsgenie.template)),
114 ),
115 );
116 }
117 if let Some(email) = &config.providers.email {
118 providers.insert(
119 "email".to_string(),
120 Arc::new(EmailProvider::new(email.clone())),
121 );
122 }
123 Pipeline {
124 routing: config.routing(),
125 providers,
126 }
127 }
128
129 pub fn preview(
134 &self,
135 signal: &Signal,
136 only: Option<&str>,
137 ) -> Result<Vec<(String, Option<serde_json::Value>)>, SignalError> {
138 let names: Vec<String> = match only {
139 Some(name) => vec![name.to_string()],
140 None => self.routing.plan(signal).providers().cloned().collect(),
141 };
142 names
143 .into_iter()
144 .map(|name| match self.providers.get(&name) {
145 Some(provider) => {
146 let payload = provider.preview(signal);
147 Ok((name, payload))
148 }
149 None => {
150 let mut known: Vec<&String> = self.providers.keys().collect();
151 known.sort();
152 let known: Vec<&str> = known.iter().map(|s| s.as_str()).collect();
153 Err(SignalError::Config(format!(
154 "provider '{name}' is not configured (configured: {})",
155 known.join(", ")
156 )))
157 }
158 })
159 .collect()
160 }
161}
162
163type SharedPipeline = Arc<RwLock<Arc<Pipeline>>>;
164
165pub struct SignalClient {
175 pub sink: Arc<dyn SignalSink>,
176 pipeline: SharedPipeline,
177 throttle: Option<Arc<Throttle>>,
180 in_flight: Arc<InFlight>,
181 pub runtime: tokio::runtime::Runtime,
182}
183
184impl SignalClient {
185 pub fn from_config(config: &Config) -> Result<Self, SignalError> {
191 Self::with_runtime(config, build_runtime()?)
192 }
193
194 pub fn from_source(source: &ConfigSource, opts: &LoadOptions) -> Result<Self, SignalError> {
199 Self::from_sources(std::slice::from_ref(source), opts)
200 }
201
202 pub fn from_sources(sources: &[ConfigSource], opts: &LoadOptions) -> Result<Self, SignalError> {
206 let runtime = build_runtime()?;
207 let loaded = runtime.block_on(source::load_initial(sources, opts))?;
208 let client = Self::with_runtime(&loaded.config, runtime)?;
209 if let Some(every) = opts.refresh {
210 reload::spawn(
211 &client.runtime,
212 reload::Reloader {
213 roots: sources.to_vec(),
214 opts: opts.clone(),
215 every,
216 last_texts: loaded.texts,
217 storage: loaded.config.storage.clone(),
218 pipeline: Arc::clone(&client.pipeline),
219 },
220 );
221 }
222 Ok(client)
223 }
224
225 pub fn from_env() -> Result<Self, SignalError> {
229 let opts = LoadOptions::from_env()?;
230 match ConfigSource::discover()? {
231 Some(sources) => Self::from_sources(&sources, &opts),
232 None => Self::from_config(&Config::default()),
233 }
234 }
235
236 fn with_runtime(
237 config: &Config,
238 runtime: tokio::runtime::Runtime,
239 ) -> Result<Self, SignalError> {
240 let sink: Arc<dyn SignalSink> = match config.storage.sink {
241 SinkKind::Sqlite => Arc::new(SqliteSink::open(&config.storage.path)?),
242 };
243 let throttle = match Throttle::open(&config.storage.path) {
244 Ok(t) => Some(Arc::new(t)),
245 Err(e) => {
246 tracing::error!("{e}; dedup and rate limits are off");
247 None
248 }
249 };
250 Ok(SignalClient {
251 sink,
252 pipeline: Arc::new(RwLock::new(Arc::new(Pipeline::from_config(config)))),
253 throttle,
254 in_flight: Arc::default(),
255 runtime,
256 })
257 }
258
259 pub fn pipeline(&self) -> Arc<Pipeline> {
261 current(&self.pipeline)
262 }
263
264 pub fn notify_async(&self, input: SignalInput) {
269 if let Err(e) = signal::validate(&input) {
270 tracing::warn!("signal validation failed, dropping: {e}");
271 return;
272 }
273 let signal = Signal::from_input(input);
274 record(self.sink.as_ref(), &signal);
277 let sink = Arc::clone(&self.sink);
278 let pipeline = self.pipeline();
279 let (plan, admission) = plan_signal(self.throttle.as_deref(), &pipeline.routing, &signal);
282 let throttle = self.throttle.clone();
283 let guard = self.in_flight.start();
284
285 self.runtime.spawn(async move {
286 let _guard = guard;
287 let tracker = Tracker::new(&signal, sink.as_ref());
288 let result = deliver(&tracker, &plan, &pipeline.providers).await;
289 settle(throttle.as_deref(), &admission, &result);
290 if let Err(e) = sink.complete(&signal, &result) {
291 tracing::error!("sink write failed: {e}");
292 }
293 });
294 }
295
296 pub fn flush(&self, timeout: Duration) -> bool {
301 self.in_flight.wait(timeout) == 0
302 }
303
304 #[must_use = "check whether the signal was delivered"]
310 pub fn notify_sync(
311 &self,
312 input: SignalInput,
313 timeout: Duration,
314 ) -> Result<DeliveryResult, SignalError> {
315 signal::validate(&input)?;
316 let signal = Signal::from_input(input);
317 record(self.sink.as_ref(), &signal);
318 let pipeline = self.pipeline();
319 let tracker = Tracker::new(&signal, self.sink.as_ref());
320 let (plan, admission) = plan_signal(self.throttle.as_deref(), &pipeline.routing, &signal);
321
322 let result = self.runtime.block_on(async {
323 match tokio::time::timeout(timeout, deliver(&tracker, &plan, &pipeline.providers)).await
324 {
325 Ok(result) => result,
326 Err(_) => summarize(
327 &plan,
328 &tracker.outcomes(),
329 Some(&format!("timed out after {timeout:?}")),
330 ),
331 }
332 });
333 settle(self.throttle.as_deref(), &admission, &result);
334
335 self.sink.complete(&signal, &result)?;
336 Ok(result)
337 }
338}
339
340impl Drop for SignalClient {
341 fn drop(&mut self) {
342 if !self.flush(DEFAULT_FLUSH_TIMEOUT) {
343 tracing::warn!(
344 "exiting with signals still being delivered; they stay recorded as pending"
345 );
346 }
347 }
348}
349
350fn record(sink: &dyn SignalSink, signal: &Signal) {
353 if let Err(e) = sink.record(signal) {
354 tracing::error!("sink write failed, delivering anyway: {e}");
355 }
356}
357
358fn plan_signal(
361 throttle: Option<&Throttle>,
362 routing: &RoutingConfig,
363 signal: &Signal,
364) -> (DeliveryPlan, Admission) {
365 let mut admission = Admission::default();
366 let now = throttle::now_ms();
367 let plan = routing.plan_with(signal, |route| match throttle {
368 Some(t) => t.admit(route, signal, now, &mut admission),
369 None => Ok(()),
370 });
371 for line in &plan.suppressed {
372 tracing::info!("signal {} suppressed on {line}", signal.id);
373 }
374 (plan, admission)
375}
376
377fn settle(throttle: Option<&Throttle>, admission: &Admission, result: &DeliveryResult) {
381 if let (Some(t), DeliveryStatus::Failed) = (throttle, result.status) {
382 t.release(admission);
383 }
384}
385
386fn build_runtime() -> Result<tokio::runtime::Runtime, SignalError> {
387 tokio::runtime::Builder::new_multi_thread()
388 .worker_threads(2)
389 .thread_name("opssignal")
390 .enable_all()
391 .build()
392 .map_err(|e| SignalError::Internal(format!("failed to start runtime: {e}")))
393}
394
395fn current(pipeline: &SharedPipeline) -> Arc<Pipeline> {
398 Arc::clone(&pipeline.read().unwrap_or_else(|e| e.into_inner()))
399}
400
401struct Tracker<'a> {
405 signal: &'a Signal,
406 sink: &'a dyn SignalSink,
407 outcomes: Mutex<Vec<ProviderOutcome>>,
408}
409
410impl<'a> Tracker<'a> {
411 fn new(signal: &'a Signal, sink: &'a dyn SignalSink) -> Self {
412 Tracker {
413 signal,
414 sink,
415 outcomes: Mutex::default(),
416 }
417 }
418
419 fn report(&self, outcome: ProviderOutcome) {
420 if let Err(e) = self.sink.provider_outcome(self.signal, &outcome) {
421 tracing::error!("sink write failed: {e}");
422 }
423 let mut outcomes = self.outcomes.lock().unwrap_or_else(|e| e.into_inner());
424 match outcomes.iter_mut().find(|o| o.provider == outcome.provider) {
425 Some(existing) => *existing = outcome,
426 None => outcomes.push(outcome),
427 }
428 }
429
430 fn outcomes(&self) -> Vec<ProviderOutcome> {
431 self.outcomes
432 .lock()
433 .unwrap_or_else(|e| e.into_inner())
434 .clone()
435 }
436}
437
438async fn deliver(
442 tracker: &Tracker<'_>,
443 plan: &DeliveryPlan,
444 providers: &HashMap<String, Arc<dyn Provider>>,
445) -> DeliveryResult {
446 futures_util::future::join_all(
447 plan.targets
448 .iter()
449 .map(|chain| deliver_chain(tracker, chain, providers)),
450 )
451 .await;
452 summarize(plan, &tracker.outcomes(), None)
453}
454
455async fn deliver_chain(
457 tracker: &Tracker<'_>,
458 chain: &[String],
459 providers: &HashMap<String, Arc<dyn Provider>>,
460) {
461 for name in chain {
462 let Some(provider) = providers.get(name) else {
463 tracing::warn!("route references unknown provider: {name}");
464 continue;
465 };
466 let outcome = |status, error, attempts| ProviderOutcome {
467 provider: name.clone(),
468 status,
469 error,
470 attempts,
471 };
472 tracker.report(outcome(DeliveryStatus::Pending, None, 0));
473 let mut last_error = None;
474 for attempt in 1..=MAX_RETRY_ATTEMPTS {
477 match provider.send(tracker.signal).await {
478 Ok(()) => {
479 tracker.report(outcome(DeliveryStatus::Delivered, None, attempt));
480 return;
481 }
482 Err(e) => {
483 last_error = Some(e.to_string());
484 if attempt < MAX_RETRY_ATTEMPTS {
485 tokio::time::sleep(Duration::from_millis(
486 INITIAL_BACKOFF_MS * 2u64.pow(attempt),
487 ))
488 .await;
489 }
490 }
491 }
492 }
493 tracker.report(outcome(
494 DeliveryStatus::Failed,
495 last_error,
496 MAX_RETRY_ATTEMPTS,
497 ));
498 }
499}
500
501fn summarize(
505 plan: &DeliveryPlan,
506 outcomes: &[ProviderOutcome],
507 cut_off: Option<&str>,
508) -> DeliveryResult {
509 let mut deliveries = Vec::new();
510 let mut delivered = Vec::new();
511 let mut errors = Vec::new();
512 let mut attempts = 0;
513 for chain in &plan.targets {
514 let mut tried: Vec<ProviderOutcome> = chain
515 .iter()
516 .filter_map(|name| outcomes.iter().find(|o| &o.provider == name).cloned())
517 .collect();
518 if let Some(msg) = cut_off {
519 for o in tried
520 .iter_mut()
521 .filter(|o| o.status == DeliveryStatus::Pending)
522 {
523 o.status = DeliveryStatus::Failed;
524 o.error = Some(msg.to_string());
525 }
526 }
527 match tried.last() {
530 Some(o) if o.status == DeliveryStatus::Delivered => {
531 delivered.push(o.provider.clone());
532 attempts = attempts.max(o.attempts);
533 }
534 Some(o) => {
535 let error = o.error.clone().unwrap_or_else(|| o.status.to_string());
536 errors.push(format!("{}: {error}", o.provider));
537 if plan.targets.len() == 1 {
538 errors = vec![error];
539 }
540 attempts = attempts.max(o.attempts);
541 }
542 None => errors.push("no provider delivered successfully".to_string()),
543 }
544 deliveries.extend(tried);
545 }
546
547 let status = if plan.targets.is_empty() {
548 errors = plan.suppressed.clone();
550 DeliveryStatus::Skipped
551 } else if delivered.len() == plan.targets.len() {
552 DeliveryStatus::Delivered
553 } else if delivered.is_empty() {
554 DeliveryStatus::Failed
555 } else {
556 DeliveryStatus::Partial
557 };
558 DeliveryResult {
559 status,
560 provider: (!delivered.is_empty()).then(|| delivered.join(",")),
561 error: (!errors.is_empty()).then(|| errors.join("; ")),
562 attempts,
563 deliveries,
564 suppressed: plan.suppressed.clone(),
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::routing::RoutingConfig;
572 use crate::signal::Severity;
573 use wiremock::matchers::{method, path};
574 use wiremock::{Mock, MockServer, ResponseTemplate};
575
576 fn temp_db() -> String {
577 std::env::temp_dir()
578 .join(format!("opssignal-test-{}.db", uuid::Uuid::new_v4()))
579 .to_string_lossy()
580 .into_owned()
581 }
582
583 fn input(severity: Severity) -> SignalInput {
584 SignalInput {
585 source: "airflow".into(),
586 event_type: "task_failed".into(),
587 title: "dbt model failed".into(),
588 severity,
589 environment: Some("prod".into()),
590 ..Default::default()
591 }
592 }
593
594 fn stored_rows(db: &str) -> Vec<(String, Option<String>)> {
595 let conn = rusqlite::Connection::open(db).expect("open db");
596 let mut stmt = conn
597 .prepare("SELECT delivery_status, provider FROM signals")
598 .expect("prepare");
599 stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
600 .expect("query")
601 .map(|r| r.expect("row"))
602 .collect()
603 }
604
605 #[test]
606 fn zero_config_client_delivers_to_console_and_records_it() {
607 let db = temp_db();
608 let yaml = format!("storage:\n path: \"{db}\"\n");
609 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
610 .expect("client");
611
612 let result = client
613 .notify_sync(input(Severity::Info), Duration::from_secs(5))
614 .expect("notify_sync");
615
616 assert_eq!(result.status, DeliveryStatus::Delivered);
617 assert_eq!(result.provider.as_deref(), Some("console"));
618 assert_eq!(
619 stored_rows(&db),
620 vec![("delivered".to_string(), Some("console".to_string()))]
621 );
622 let _ = std::fs::remove_file(&db);
623 }
624
625 #[test]
626 fn configured_route_delivers_to_webhook() {
627 let server_rt = tokio::runtime::Runtime::new().expect("server runtime");
630 let server = server_rt.block_on(async {
631 let server = MockServer::start().await;
632 Mock::given(method("POST"))
633 .respond_with(ResponseTemplate::new(200))
634 .expect(1)
635 .mount(&server)
636 .await;
637 server
638 });
639
640 let db = temp_db();
641 let yaml = format!(
642 "providers:\n webhook:\n url: \"{}/hook\"\nroutes:\n - match:\n severity: [error, critical]\n environment: prod\n providers: [webhook]\nstorage:\n path: \"{db}\"\n",
643 server.uri()
644 );
645 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
646 .expect("client");
647
648 let result = client
649 .notify_sync(input(Severity::Error), Duration::from_secs(5))
650 .expect("notify_sync");
651
652 assert_eq!(result.status, DeliveryStatus::Delivered);
653 assert_eq!(result.provider.as_deref(), Some("webhook"));
654 server_rt.block_on(server.verify());
655 let _ = std::fs::remove_file(&db);
656 }
657
658 struct FlakyProvider {
661 name: &'static str,
662 fail_times: u32,
663 calls: std::sync::atomic::AtomicU32,
664 }
665
666 impl FlakyProvider {
667 fn new(name: &'static str, fail_times: u32) -> Arc<Self> {
668 Arc::new(FlakyProvider {
669 name,
670 fail_times,
671 calls: std::sync::atomic::AtomicU32::new(0),
672 })
673 }
674
675 fn calls(&self) -> u32 {
676 self.calls.load(std::sync::atomic::Ordering::SeqCst)
677 }
678 }
679
680 #[async_trait::async_trait]
681 impl Provider for FlakyProvider {
682 fn name(&self) -> &'static str {
683 self.name
684 }
685
686 async fn send(&self, _signal: &Signal) -> Result<(), SignalError> {
687 let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
688 if call <= self.fail_times {
689 Err(SignalError::Delivery(format!(
690 "{} failure #{call}",
691 self.name
692 )))
693 } else {
694 Ok(())
695 }
696 }
697 }
698
699 struct HangingProvider;
701
702 #[async_trait::async_trait]
703 impl Provider for HangingProvider {
704 fn name(&self) -> &'static str {
705 "hanging"
706 }
707
708 async fn send(&self, _signal: &Signal) -> Result<(), SignalError> {
709 tokio::time::sleep(Duration::from_secs(3600)).await;
710 Ok(())
711 }
712 }
713
714 fn route(providers: &[&str], mode: routing::DeliveryMode) -> RoutingConfig {
715 routing::RoutingConfig {
716 routes: vec![routing::Route {
717 match_rule: routing::RouteMatch {
718 severity: vec![],
719 environment: None,
720 source: None,
721 },
722 providers: providers.iter().map(|p| p.to_string()).collect(),
723 mode,
724 dedup: None,
725 rate_limit: None,
726 }],
727 }
728 }
729
730 fn routing_to(providers: &[&str]) -> RoutingConfig {
731 route(providers, routing::DeliveryMode::Failover)
732 }
733
734 fn fanout_to(providers: &[&str]) -> RoutingConfig {
735 route(providers, routing::DeliveryMode::Fanout)
736 }
737
738 struct NoSink;
740
741 impl SignalSink for NoSink {
742 fn record(&self, _: &Signal) -> Result<(), SignalError> {
743 Ok(())
744 }
745 fn complete(&self, _: &Signal, _: &DeliveryResult) -> Result<(), SignalError> {
746 Ok(())
747 }
748 }
749
750 async fn deliver_to(
751 routing: &RoutingConfig,
752 providers: &HashMap<String, Arc<dyn Provider>>,
753 ) -> DeliveryResult {
754 let signal = signal();
755 let tracker = Tracker::new(&signal, &NoSink);
756 deliver(&tracker, &routing.plan(&signal), providers).await
757 }
758
759 fn registry(providers: Vec<Arc<dyn Provider>>) -> HashMap<String, Arc<dyn Provider>> {
760 providers
761 .into_iter()
762 .map(|p| (p.name().to_string(), p))
763 .collect()
764 }
765
766 fn signal() -> Signal {
767 Signal::from_input(input(Severity::Error))
768 }
769
770 #[tokio::test(start_paused = true)]
771 async fn deliver_succeeds_first_try_without_backoff() {
772 let ok = FlakyProvider::new("ok", 0);
773 let start = tokio::time::Instant::now();
774
775 let result = deliver_to(&routing_to(&["ok"]), ®istry(vec![ok.clone()])).await;
776
777 assert_eq!(result.status, DeliveryStatus::Delivered);
778 assert_eq!(result.provider.as_deref(), Some("ok"));
779 assert_eq!(result.attempts, 1);
780 assert_eq!(ok.calls(), 1);
781 assert_eq!(start.elapsed(), Duration::ZERO);
782 }
783
784 #[tokio::test(start_paused = true)]
785 async fn deliver_retries_with_exponential_backoff_then_succeeds() {
786 let flaky = FlakyProvider::new("flaky", 2);
787 let start = tokio::time::Instant::now();
788
789 let result = deliver_to(&routing_to(&["flaky"]), ®istry(vec![flaky.clone()])).await;
790
791 assert_eq!(result.status, DeliveryStatus::Delivered);
792 assert_eq!(result.attempts, 3);
793 assert_eq!(flaky.calls(), 3);
794 assert_eq!(start.elapsed(), Duration::from_millis(1200));
796 }
797
798 #[tokio::test(start_paused = true)]
799 async fn deliver_falls_back_to_next_provider_after_retries_are_exhausted() {
800 let broken = FlakyProvider::new("broken", u32::MAX);
801 let backup = FlakyProvider::new("backup", 0);
802
803 let result = deliver_to(
804 &routing_to(&["broken", "backup"]),
805 ®istry(vec![broken.clone(), backup.clone()]),
806 )
807 .await;
808
809 assert_eq!(result.status, DeliveryStatus::Delivered);
810 assert_eq!(result.provider.as_deref(), Some("backup"));
811 assert_eq!(result.attempts, 1);
812 assert_eq!(broken.calls(), MAX_RETRY_ATTEMPTS);
813 assert_eq!(backup.calls(), 1);
814 }
815
816 #[tokio::test(start_paused = true)]
817 async fn deliver_stops_at_first_successful_provider() {
818 let first = FlakyProvider::new("first", 0);
819 let second = FlakyProvider::new("second", 0);
820
821 let result = deliver_to(
822 &routing_to(&["first", "second"]),
823 ®istry(vec![first.clone(), second.clone()]),
824 )
825 .await;
826
827 assert_eq!(result.provider.as_deref(), Some("first"));
828 assert_eq!(second.calls(), 0);
829 }
830
831 #[tokio::test(start_paused = true)]
832 async fn deliver_reports_last_error_when_every_provider_fails() {
833 let a = FlakyProvider::new("a", u32::MAX);
834 let b = FlakyProvider::new("b", u32::MAX);
835
836 let result = deliver_to(
837 &routing_to(&["a", "b"]),
838 ®istry(vec![a.clone(), b.clone()]),
839 )
840 .await;
841
842 assert_eq!(result.status, DeliveryStatus::Failed);
843 assert_eq!(result.provider, None);
844 assert_eq!(result.attempts, MAX_RETRY_ATTEMPTS);
845 assert_eq!(
846 result.error.as_deref(),
847 Some("delivery failed: b failure #3")
848 );
849 assert_eq!(a.calls(), MAX_RETRY_ATTEMPTS);
850 assert_eq!(b.calls(), MAX_RETRY_ATTEMPTS);
851 }
852
853 #[tokio::test(start_paused = true)]
854 async fn deliver_skips_unregistered_provider_and_uses_the_next() {
855 let ok = FlakyProvider::new("ok", 0);
856
857 let result = deliver_to(&routing_to(&["missing", "ok"]), ®istry(vec![ok.clone()])).await;
858
859 assert_eq!(result.status, DeliveryStatus::Delivered);
860 assert_eq!(result.provider.as_deref(), Some("ok"));
861 }
862
863 #[tokio::test(start_paused = true)]
864 async fn deliver_with_no_registered_provider_fails_with_a_message() {
865 let result = deliver_to(&routing_to(&["missing"]), &HashMap::new()).await;
866
867 assert_eq!(result.status, DeliveryStatus::Failed);
868 assert_eq!(
869 result.error.as_deref(),
870 Some("no provider delivered successfully")
871 );
872 }
873
874 #[tokio::test(start_paused = true)]
875 async fn fanout_delivers_to_every_provider_concurrently() {
876 let a = FlakyProvider::new("a", 2);
877 let b = FlakyProvider::new("b", 2);
878 let start = tokio::time::Instant::now();
879
880 let result = deliver_to(
881 &fanout_to(&["a", "b"]),
882 ®istry(vec![a.clone(), b.clone()]),
883 )
884 .await;
885
886 assert_eq!(result.status, DeliveryStatus::Delivered);
887 assert_eq!(result.provider.as_deref(), Some("a,b"));
888 assert_eq!(result.error, None);
889 assert_eq!(result.attempts, 3);
890 assert_eq!((a.calls(), b.calls()), (3, 3));
891 assert_eq!(start.elapsed(), Duration::from_millis(1200));
894 }
895
896 #[tokio::test(start_paused = true)]
897 async fn fanout_retries_never_resend_to_a_provider_that_delivered() {
898 let ok = FlakyProvider::new("ok", 0);
899 let flaky = FlakyProvider::new("flaky", 2);
900
901 let result = deliver_to(
902 &fanout_to(&["ok", "flaky"]),
903 ®istry(vec![ok.clone(), flaky.clone()]),
904 )
905 .await;
906
907 assert_eq!(result.status, DeliveryStatus::Delivered);
908 assert_eq!(ok.calls(), 1);
909 assert_eq!(flaky.calls(), 3);
910 assert_eq!(
911 result.deliveries,
912 vec![
913 ProviderOutcome {
914 provider: "ok".into(),
915 status: DeliveryStatus::Delivered,
916 error: None,
917 attempts: 1,
918 },
919 ProviderOutcome {
920 provider: "flaky".into(),
921 status: DeliveryStatus::Delivered,
922 error: None,
923 attempts: 3,
924 },
925 ]
926 );
927 }
928
929 #[tokio::test(start_paused = true)]
930 async fn fanout_with_a_failing_provider_is_partial() {
931 let ok = FlakyProvider::new("ok", 0);
932 let broken = FlakyProvider::new("broken", u32::MAX);
933
934 let result = deliver_to(
935 &fanout_to(&["broken", "ok"]),
936 ®istry(vec![ok.clone(), broken.clone()]),
937 )
938 .await;
939
940 assert_eq!(result.status, DeliveryStatus::Partial);
941 assert_eq!(result.provider.as_deref(), Some("ok"));
942 assert_eq!(
943 result.error.as_deref(),
944 Some("broken: delivery failed: broken failure #3")
945 );
946 assert_eq!(ok.calls(), 1);
947 assert_eq!(broken.calls(), MAX_RETRY_ATTEMPTS);
948 }
949
950 #[tokio::test(start_paused = true)]
951 async fn fanout_where_every_provider_fails_names_each_error() {
952 let a = FlakyProvider::new("a", u32::MAX);
953 let b = FlakyProvider::new("b", u32::MAX);
954
955 let result = deliver_to(&fanout_to(&["a", "b"]), ®istry(vec![a, b])).await;
956
957 assert_eq!(result.status, DeliveryStatus::Failed);
958 assert_eq!(result.provider, None);
959 assert_eq!(
960 result.error.as_deref(),
961 Some("a: delivery failed: a failure #3; b: delivery failed: b failure #3")
962 );
963 }
964
965 #[tokio::test(start_paused = true)]
966 async fn failover_result_lists_every_provider_it_tried() {
967 let broken = FlakyProvider::new("broken", u32::MAX);
968 let backup = FlakyProvider::new("backup", 0);
969
970 let result = deliver_to(
971 &routing_to(&["broken", "backup"]),
972 ®istry(vec![broken, backup]),
973 )
974 .await;
975
976 let tried: Vec<_> = result
977 .deliveries
978 .iter()
979 .map(|o| (o.provider.as_str(), o.status))
980 .collect();
981 assert_eq!(
982 tried,
983 vec![
984 ("broken", DeliveryStatus::Failed),
985 ("backup", DeliveryStatus::Delivered)
986 ]
987 );
988 }
989
990 fn delivery_rows(db: &str) -> Vec<(String, String, Option<String>)> {
991 let conn = rusqlite::Connection::open(db).expect("open db");
992 let mut stmt = conn
993 .prepare("SELECT provider, status, error FROM deliveries ORDER BY provider")
994 .expect("prepare");
995 stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
996 .expect("query")
997 .map(|r| r.expect("row"))
998 .collect()
999 }
1000
1001 #[test]
1002 fn notify_sync_timeout_keeps_the_providers_that_already_delivered() {
1003 let db = temp_db();
1004 let ok = FlakyProvider::new("ok", 0);
1005 let client = client_with_routing(
1006 vec![ok.clone(), Arc::new(HangingProvider)],
1007 fanout_to(&["ok", "hanging"]),
1008 &db,
1009 );
1010
1011 let result = client
1012 .notify_sync(input(Severity::Error), Duration::from_millis(100))
1013 .expect("notify_sync");
1014
1015 assert_eq!(result.status, DeliveryStatus::Partial);
1016 assert_eq!(result.provider.as_deref(), Some("ok"));
1017 assert_eq!(
1018 result.error.as_deref(),
1019 Some("hanging: timed out after 100ms")
1020 );
1021 assert_eq!(
1022 stored_rows(&db),
1023 vec![("partial".to_string(), Some("ok".to_string()))]
1024 );
1025 assert_eq!(
1026 delivery_rows(&db),
1027 vec![
1028 (
1029 "hanging".into(),
1030 "failed".into(),
1031 Some("timed out after 100ms".into())
1032 ),
1033 ("ok".into(), "delivered".into(), None),
1034 ]
1035 );
1036 let _ = std::fs::remove_file(&db);
1037 }
1038
1039 #[test]
1040 fn provider_is_saved_as_pending_before_its_send() {
1041 let db = temp_db();
1042 let observer = Arc::new(DeliveriesObserver {
1043 db: db.clone(),
1044 seen: Default::default(),
1045 });
1046 let client = client_with(vec![observer.clone()], &["observer"], &db);
1047
1048 client
1049 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1050 .expect("notify_sync");
1051
1052 assert_eq!(
1053 *observer.seen.lock().expect("seen"),
1054 vec![("observer".to_string(), "pending".to_string(), None)]
1055 );
1056 assert_eq!(
1057 delivery_rows(&db),
1058 vec![("observer".into(), "delivered".into(), None)]
1059 );
1060 let _ = std::fs::remove_file(&db);
1061 }
1062
1063 struct DeliveriesObserver {
1065 db: String,
1066 seen: std::sync::Mutex<Vec<(String, String, Option<String>)>>,
1067 }
1068
1069 #[async_trait::async_trait]
1070 impl Provider for DeliveriesObserver {
1071 fn name(&self) -> &'static str {
1072 "observer"
1073 }
1074
1075 async fn send(&self, _signal: &Signal) -> Result<(), SignalError> {
1076 *self.seen.lock().expect("seen") = delivery_rows(&self.db);
1077 Ok(())
1078 }
1079 }
1080
1081 #[test]
1082 fn notify_async_fanout_records_each_provider() {
1083 let db = temp_db();
1084 let a = FlakyProvider::new("a", 0);
1085 let b = FlakyProvider::new("b", 0);
1086 let client = client_with_routing(vec![a.clone(), b.clone()], fanout_to(&["a", "b"]), &db);
1087
1088 client.notify_async(input(Severity::Error));
1089
1090 assert!(client.flush(Duration::from_secs(5)));
1091 assert_eq!(
1092 stored_rows(&db),
1093 vec![("delivered".to_string(), Some("a,b".to_string()))]
1094 );
1095 assert_eq!(
1096 delivery_rows(&db),
1097 vec![
1098 ("a".into(), "delivered".into(), None),
1099 ("b".into(), "delivered".into(), None),
1100 ]
1101 );
1102 let _ = std::fs::remove_file(&db);
1103 }
1104
1105 fn client_with(providers: Vec<Arc<dyn Provider>>, route: &[&str], db: &str) -> SignalClient {
1106 client_with_routing(providers, routing_to(route), db)
1107 }
1108
1109 fn client_with_routing(
1110 providers: Vec<Arc<dyn Provider>>,
1111 routing: RoutingConfig,
1112 db: &str,
1113 ) -> SignalClient {
1114 SignalClient {
1115 sink: Arc::new(SqliteSink::open(db).expect("open sink")),
1116 pipeline: Arc::new(RwLock::new(Arc::new(Pipeline {
1117 routing,
1118 providers: registry(providers),
1119 }))),
1120 throttle: Some(Arc::new(Throttle::open(db).expect("open throttle"))),
1121 in_flight: Arc::default(),
1122 runtime: tokio::runtime::Builder::new_multi_thread()
1123 .worker_threads(1)
1124 .enable_all()
1125 .build()
1126 .expect("runtime"),
1127 }
1128 }
1129
1130 #[test]
1131 fn notify_sync_times_out_and_records_the_failure() {
1132 let db = temp_db();
1133 let client = client_with(vec![Arc::new(HangingProvider)], &["hanging"], &db);
1134
1135 let result = client
1136 .notify_sync(input(Severity::Error), Duration::from_millis(50))
1137 .expect("notify_sync returns a result, not an error, on timeout");
1138
1139 assert_eq!(result.status, DeliveryStatus::Failed);
1140 assert_eq!(result.attempts, 0);
1141 assert!(result
1142 .error
1143 .as_deref()
1144 .is_some_and(|e| e.contains("timed out")));
1145 assert_eq!(stored_rows(&db), vec![("failed".to_string(), None)]);
1146 let _ = std::fs::remove_file(&db);
1147 }
1148
1149 #[test]
1150 fn notify_sync_rejects_invalid_input_without_recording_it() {
1151 let db = temp_db();
1152 let client = client_with(vec![FlakyProvider::new("ok", 0)], &["ok"], &db);
1153 let mut bad = input(Severity::Info);
1154 bad.title = " ".into();
1155
1156 let err = client
1157 .notify_sync(bad, Duration::from_secs(1))
1158 .expect_err("empty title must be rejected");
1159
1160 assert!(matches!(err, SignalError::Validation(_)));
1161 assert!(stored_rows(&db).is_empty());
1162 let _ = std::fs::remove_file(&db);
1163 }
1164
1165 #[test]
1166 fn notify_async_delivers_in_the_background_and_records_it() {
1167 let db = temp_db();
1168 let ok = FlakyProvider::new("ok", 0);
1169 let client = client_with(vec![ok.clone()], &["ok"], &db);
1170
1171 client.notify_async(input(Severity::Error));
1172
1173 assert!(client.flush(Duration::from_secs(5)));
1174 assert_eq!(
1175 stored_rows(&db),
1176 vec![("delivered".to_string(), Some("ok".to_string()))]
1177 );
1178 assert_eq!(ok.calls(), 1);
1179 let _ = std::fs::remove_file(&db);
1180 }
1181
1182 struct SlowProvider(Duration);
1184
1185 #[async_trait::async_trait]
1186 impl Provider for SlowProvider {
1187 fn name(&self) -> &'static str {
1188 "slow"
1189 }
1190
1191 async fn send(&self, _signal: &Signal) -> Result<(), SignalError> {
1192 tokio::time::sleep(self.0).await;
1193 Ok(())
1194 }
1195 }
1196
1197 struct SinkObserver {
1199 db: String,
1200 seen: std::sync::Mutex<Vec<(String, Option<String>)>>,
1201 }
1202
1203 #[async_trait::async_trait]
1204 impl Provider for SinkObserver {
1205 fn name(&self) -> &'static str {
1206 "observer"
1207 }
1208
1209 async fn send(&self, _signal: &Signal) -> Result<(), SignalError> {
1210 *self.seen.lock().expect("seen") = stored_rows(&self.db);
1211 Ok(())
1212 }
1213 }
1214
1215 #[test]
1216 fn signal_is_saved_as_pending_before_delivery() {
1217 let db = temp_db();
1218 let observer = Arc::new(SinkObserver {
1219 db: db.clone(),
1220 seen: Default::default(),
1221 });
1222 let client = client_with(vec![observer.clone()], &["observer"], &db);
1223
1224 let result = client
1225 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1226 .expect("notify_sync");
1227
1228 assert_eq!(result.status, DeliveryStatus::Delivered);
1229 assert_eq!(
1230 *observer.seen.lock().expect("seen"),
1231 vec![("pending".to_string(), None)]
1232 );
1233 assert_eq!(
1234 stored_rows(&db),
1235 vec![("delivered".to_string(), Some("observer".to_string()))]
1236 );
1237 let _ = std::fs::remove_file(&db);
1238 }
1239
1240 #[test]
1241 fn notify_async_saves_the_signal_before_returning() {
1242 let db = temp_db();
1243 let client = client_with(
1244 vec![Arc::new(SlowProvider(Duration::from_millis(200)))],
1245 &["slow"],
1246 &db,
1247 );
1248
1249 client.notify_async(input(Severity::Error));
1250
1251 assert_eq!(stored_rows(&db), vec![("pending".to_string(), None)]);
1252 drop(client);
1253 let _ = std::fs::remove_file(&db);
1254 }
1255
1256 #[test]
1257 fn flush_waits_for_background_deliveries() {
1258 let db = temp_db();
1259 let client = client_with(
1260 vec![Arc::new(SlowProvider(Duration::from_millis(100)))],
1261 &["slow"],
1262 &db,
1263 );
1264
1265 client.notify_async(input(Severity::Error));
1266 client.notify_async(input(Severity::Error));
1267
1268 assert!(client.flush(Duration::from_secs(5)));
1269 assert_eq!(
1270 stored_rows(&db),
1271 vec![("delivered".to_string(), Some("slow".to_string())); 2]
1272 );
1273 let _ = std::fs::remove_file(&db);
1274 }
1275
1276 #[test]
1277 fn flush_gives_up_after_the_timeout_and_leaves_the_signal_pending() {
1278 let db = temp_db();
1279 let client = client_with(
1280 vec![Arc::new(SlowProvider(Duration::from_millis(300)))],
1281 &["slow"],
1282 &db,
1283 );
1284
1285 client.notify_async(input(Severity::Error));
1286
1287 assert!(!client.flush(Duration::from_millis(20)));
1288 assert_eq!(stored_rows(&db), vec![("pending".to_string(), None)]);
1289 let _ = std::fs::remove_file(&db);
1290 }
1291
1292 #[test]
1293 fn dropping_the_client_finishes_background_deliveries() {
1294 let db = temp_db();
1295 let client = client_with(
1296 vec![Arc::new(SlowProvider(Duration::from_millis(100)))],
1297 &["slow"],
1298 &db,
1299 );
1300
1301 client.notify_async(input(Severity::Error));
1302 drop(client);
1303
1304 assert_eq!(
1305 stored_rows(&db),
1306 vec![("delivered".to_string(), Some("slow".to_string()))]
1307 );
1308 let _ = std::fs::remove_file(&db);
1309 }
1310
1311 #[test]
1312 fn a_sink_that_cannot_record_still_lets_the_signal_through() {
1313 struct BrokenRecord;
1314 impl SignalSink for BrokenRecord {
1315 fn record(&self, _: &Signal) -> Result<(), SignalError> {
1316 Err(SignalError::Sink("disk full".into()))
1317 }
1318 fn complete(&self, _: &Signal, _: &DeliveryResult) -> Result<(), SignalError> {
1319 Ok(())
1320 }
1321 }
1322 let ok = FlakyProvider::new("ok", 0);
1323 let mut client = client_with(vec![ok.clone()], &["ok"], &temp_db());
1324 client.sink = Arc::new(BrokenRecord);
1325
1326 let result = client
1327 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1328 .expect("notify_sync");
1329
1330 assert_eq!(result.status, DeliveryStatus::Delivered);
1331 assert_eq!(ok.calls(), 1);
1332 }
1333
1334 #[test]
1335 fn notify_async_drops_invalid_input_without_delivering() {
1336 let db = temp_db();
1337 let ok = FlakyProvider::new("ok", 0);
1338 let client = client_with(vec![ok.clone()], &["ok"], &db);
1339 let mut bad = input(Severity::Info);
1340 bad.source = String::new();
1341
1342 client.notify_async(bad);
1343 std::thread::sleep(Duration::from_millis(100));
1344
1345 assert_eq!(ok.calls(), 0);
1346 assert!(stored_rows(&db).is_empty());
1347 let _ = std::fs::remove_file(&db);
1348 }
1349
1350 #[test]
1351 fn from_config_registers_only_configured_providers() {
1352 let db = temp_db();
1353 let yaml = format!(
1354 "providers:\n slack:\n webhook_url: \"https://hooks.example.test/x\"\nstorage:\n path: \"{db}\"\n"
1355 );
1356 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
1357 .expect("client");
1358
1359 let pipeline = client.pipeline();
1360 let mut names: Vec<&str> = pipeline.providers.keys().map(String::as_str).collect();
1361 names.sort_unstable();
1362 assert_eq!(names, vec!["console", "slack"]);
1363 let _ = std::fs::remove_file(&db);
1364 }
1365
1366 #[test]
1367 fn from_config_registers_pagerduty_and_teams() {
1368 let db = temp_db();
1369 let yaml = format!(
1370 "providers:\n pagerduty:\n routing_key: k\n teams:\n webhook_url: \"https://example.test/t\"\nstorage:\n path: \"{db}\"\n"
1371 );
1372 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
1373 .expect("client");
1374
1375 let pipeline = client.pipeline();
1376 let mut names: Vec<&str> = pipeline.providers.keys().map(String::as_str).collect();
1377 names.sort_unstable();
1378 assert_eq!(names, vec!["console", "pagerduty", "teams"]);
1379 let _ = std::fs::remove_file(&db);
1380 }
1381
1382 #[test]
1383 fn failed_pagerduty_falls_back_to_teams() {
1384 let server_rt = tokio::runtime::Runtime::new().expect("server runtime");
1385 let server = server_rt.block_on(async {
1386 let server = MockServer::start().await;
1387 Mock::given(method("POST"))
1388 .and(path("/pd"))
1389 .respond_with(ResponseTemplate::new(400))
1390 .mount(&server)
1391 .await;
1392 Mock::given(method("POST"))
1393 .and(path("/teams"))
1394 .respond_with(ResponseTemplate::new(202))
1395 .expect(1)
1396 .mount(&server)
1397 .await;
1398 server
1399 });
1400
1401 let db = temp_db();
1402 let uri = server.uri();
1403 let yaml = format!(
1404 "providers:\n pagerduty:\n routing_key: k\n url: \"{uri}/pd\"\n teams:\n webhook_url: \"{uri}/teams\"\nroutes:\n - match:\n severity: [error]\n providers: [pagerduty, teams]\nstorage:\n path: \"{db}\"\n"
1405 );
1406 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
1407 .expect("client");
1408
1409 let result = client
1410 .notify_sync(input(Severity::Error), Duration::from_secs(30))
1411 .expect("notify_sync");
1412
1413 assert_eq!(result.status, DeliveryStatus::Delivered);
1414 assert_eq!(result.provider.as_deref(), Some("teams"));
1415 server_rt.block_on(server.verify());
1416 let _ = std::fs::remove_file(&db);
1417 }
1418
1419 #[test]
1420 fn fanout_route_pages_pagerduty_and_posts_to_teams() {
1421 let server_rt = tokio::runtime::Runtime::new().expect("server runtime");
1422 let server = server_rt.block_on(async {
1423 let server = MockServer::start().await;
1424 for p in ["/pd", "/teams"] {
1425 Mock::given(method("POST"))
1426 .and(path(p))
1427 .respond_with(ResponseTemplate::new(202))
1428 .expect(1)
1429 .mount(&server)
1430 .await;
1431 }
1432 server
1433 });
1434
1435 let db = temp_db();
1436 let uri = server.uri();
1437 let yaml = format!(
1438 "providers:\n pagerduty:\n routing_key: k\n url: \"{uri}/pd\"\n teams:\n webhook_url: \"{uri}/teams\"\nroutes:\n - match:\n severity: [error]\n providers: [pagerduty, teams]\n mode: fanout\nstorage:\n path: \"{db}\"\n"
1439 );
1440 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
1441 .expect("client");
1442
1443 let result = client
1444 .notify_sync(input(Severity::Error), Duration::from_secs(30))
1445 .expect("notify_sync");
1446
1447 assert_eq!(result.status, DeliveryStatus::Delivered);
1448 assert_eq!(result.provider.as_deref(), Some("pagerduty,teams"));
1449 server_rt.block_on(server.verify());
1450 let _ = std::fs::remove_file(&db);
1451 }
1452
1453 #[test]
1454 fn failed_opsgenie_falls_back_to_slack() {
1455 let server_rt = tokio::runtime::Runtime::new().expect("server runtime");
1456 let server = server_rt.block_on(async {
1457 let server = MockServer::start().await;
1458 Mock::given(method("POST"))
1459 .and(path("/og"))
1460 .respond_with(ResponseTemplate::new(422))
1461 .mount(&server)
1462 .await;
1463 Mock::given(method("POST"))
1464 .and(path("/slack"))
1465 .respond_with(ResponseTemplate::new(200))
1466 .expect(1)
1467 .mount(&server)
1468 .await;
1469 server
1470 });
1471
1472 let db = temp_db();
1473 let uri = server.uri();
1474 let yaml = format!(
1475 "providers:\n opsgenie:\n api_key: k\n url: \"{uri}/og\"\n slack:\n webhook_url: \"{uri}/slack\"\nroutes:\n - match:\n severity: [error]\n providers: [opsgenie, slack]\nstorage:\n path: \"{db}\"\n"
1476 );
1477 let client = SignalClient::from_config(&Config::from_yaml_str(&yaml).expect("config"))
1478 .expect("client");
1479
1480 let result = client
1481 .notify_sync(input(Severity::Error), Duration::from_secs(30))
1482 .expect("notify_sync");
1483
1484 assert_eq!(result.status, DeliveryStatus::Delivered);
1485 assert_eq!(result.provider.as_deref(), Some("slack"));
1486 server_rt.block_on(server.verify());
1487 let _ = std::fs::remove_file(&db);
1488 }
1489
1490 #[test]
1491 fn from_config_fails_when_the_sink_cannot_be_opened() {
1492 let yaml = "storage:\n path: \"/nonexistent-opssignal-dir/sub/signals.db\"\n";
1493 let err = SignalClient::from_config(&Config::from_yaml_str(yaml).expect("config"))
1494 .err()
1495 .expect("unopenable sink must fail");
1496 assert!(matches!(err, SignalError::Sink(_)));
1497 }
1498
1499 fn throttled(route: routing::Route, dedup: Option<&str>, rate: Option<&str>) -> routing::Route {
1500 routing::Route {
1501 dedup: dedup.map(|w| w.parse().expect("window")),
1502 rate_limit: rate.map(|r| serde_yaml::from_str(r).expect("rate_limit")),
1503 ..route
1504 }
1505 }
1506
1507 fn deduped(providers: &[&str], mode: routing::DeliveryMode) -> RoutingConfig {
1508 let mut routing = route(providers, mode);
1509 routing.routes[0] = throttled(routing.routes[0].clone(), Some("10m"), None);
1510 routing
1511 }
1512
1513 #[test]
1514 fn dedup_sends_an_incident_once_per_window() {
1515 let db = temp_db();
1516 let pd = FlakyProvider::new("pagerduty", 0);
1517 let client = client_with_routing(
1518 vec![pd.clone()],
1519 deduped(&["pagerduty"], routing::DeliveryMode::Failover),
1520 &db,
1521 );
1522
1523 let first = client
1524 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1525 .expect("first");
1526 assert_eq!(first.status, DeliveryStatus::Delivered);
1527 assert!(first.suppressed.is_empty());
1528
1529 for _ in 0..5 {
1530 let repeat = client
1531 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1532 .expect("repeat");
1533 assert_eq!(repeat.status, DeliveryStatus::Skipped);
1534 assert!(repeat.deliveries.is_empty());
1535 assert_eq!(repeat.suppressed.len(), 1);
1536 let error = repeat.error.expect("reason");
1537 assert!(
1538 error.starts_with("route [pagerduty]: duplicate of a signal sent"),
1539 "{error}"
1540 );
1541 }
1542 assert_eq!(pd.calls(), 1);
1543
1544 let mut other = input(Severity::Error);
1546 other.title = "another model failed".into();
1547 let other = client
1548 .notify_sync(other, Duration::from_secs(5))
1549 .expect("other");
1550 assert_eq!(other.status, DeliveryStatus::Delivered);
1551 assert_eq!(pd.calls(), 2);
1552
1553 let rows = stored_rows(&db);
1555 assert_eq!(rows.len(), 7);
1556 assert_eq!(
1557 rows.iter()
1558 .filter(|(status, _)| status == "skipped")
1559 .count(),
1560 5
1561 );
1562 drop(client);
1563 let _ = std::fs::remove_file(&db);
1564 }
1565
1566 #[test]
1567 fn suppressed_route_leaves_other_matching_routes_sending() {
1568 let db = temp_db();
1569 let pd = FlakyProvider::new("pagerduty", 0);
1570 let slack = FlakyProvider::new("slack", 0);
1571 let mut routing = deduped(&["pagerduty"], routing::DeliveryMode::Fanout);
1572 routing.routes.push(
1573 route(&["slack"], routing::DeliveryMode::Failover)
1574 .routes
1575 .remove(0),
1576 );
1577 let client = client_with_routing(vec![pd.clone(), slack.clone()], routing, &db);
1578
1579 for _ in 0..3 {
1580 let _ = client
1581 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1582 .expect("notify");
1583 }
1584 let last = client
1585 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1586 .expect("notify");
1587
1588 assert_eq!(last.status, DeliveryStatus::Delivered);
1589 assert_eq!(last.provider.as_deref(), Some("slack"));
1590 assert_eq!(last.suppressed.len(), 1);
1591 assert_eq!(pd.calls(), 1);
1592 assert_eq!(slack.calls(), 4);
1593 drop(client);
1594 let _ = std::fs::remove_file(&db);
1595 }
1596
1597 #[test]
1598 fn rate_limit_caps_sends_across_incidents() {
1599 let db = temp_db();
1600 let pd = FlakyProvider::new("pagerduty", 0);
1601 let mut routing = route(&["pagerduty"], routing::DeliveryMode::Failover);
1602 routing.routes[0] = throttled(routing.routes[0].clone(), None, Some("{max: 3, per: 1h}"));
1603 let client = client_with_routing(vec![pd.clone()], routing, &db);
1604
1605 let statuses: Vec<DeliveryStatus> = (0..10)
1606 .map(|i| {
1607 let mut signal = input(Severity::Error);
1608 signal.title = format!("model {i} failed");
1609 client
1610 .notify_sync(signal, Duration::from_secs(5))
1611 .expect("notify")
1612 .status
1613 })
1614 .collect();
1615
1616 assert_eq!(pd.calls(), 3);
1617 assert_eq!(
1618 statuses
1619 .iter()
1620 .filter(|s| **s == DeliveryStatus::Skipped)
1621 .count(),
1622 7
1623 );
1624 drop(client);
1625 let _ = std::fs::remove_file(&db);
1626 }
1627
1628 #[test]
1629 fn a_failed_send_does_not_start_the_dedup_window() {
1630 let db = temp_db();
1631 let pd = FlakyProvider::new("pagerduty", MAX_RETRY_ATTEMPTS);
1633 let client = client_with_routing(
1634 vec![pd.clone()],
1635 deduped(&["pagerduty"], routing::DeliveryMode::Failover),
1636 &db,
1637 );
1638
1639 let first = client
1640 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1641 .expect("first");
1642 assert_eq!(first.status, DeliveryStatus::Failed);
1643 let retry = client
1644 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1645 .expect("retry");
1646 assert_eq!(retry.status, DeliveryStatus::Delivered);
1647 let repeat = client
1648 .notify_sync(input(Severity::Error), Duration::from_secs(5))
1649 .expect("repeat");
1650 assert_eq!(repeat.status, DeliveryStatus::Skipped);
1651 drop(client);
1652 let _ = std::fs::remove_file(&db);
1653 }
1654
1655 #[test]
1656 fn notify_async_applies_dedup_too() {
1657 let db = temp_db();
1658 let pd = FlakyProvider::new("pagerduty", 0);
1659 let client = client_with_routing(
1660 vec![pd.clone()],
1661 deduped(&["pagerduty"], routing::DeliveryMode::Failover),
1662 &db,
1663 );
1664 for _ in 0..20 {
1665 client.notify_async(input(Severity::Error));
1666 }
1667 assert!(client.flush(Duration::from_secs(5)));
1668 assert_eq!(pd.calls(), 1);
1669 drop(client);
1670 let _ = std::fs::remove_file(&db);
1671 }
1672}