dwara_core/synthetic/mod.rs
1//! Synthetic monitoring (DW-071, DP-10 / #238).
2//!
3//! Built-in probes per route that measure latency and uptime, feeding
4//! results into analytics and webhooks. This is the proactive/synthetic
5//! side of SLO tracking -- it pairs with DW-052 (SLO & error-budget
6//! export, M2), which derives SLO/burn-rate metrics from real traffic.
7//! Synthetic monitoring lets an SLO be tracked even on routes with
8//! little real traffic.
9//!
10//! ## Design (section 6-Traffic Intelligence)
11//!
12//! "The gateway measures the SLOs it exports." Each route can have a
13//! synthetic probe configured: a periodic HTTP request to the route's
14//! URL (or a custom URL) that records latency, status code, and
15//! success/failure. The results feed into the analytics store (as
16//! access records tagged as synthetic) and the event bus (for webhook
17//! delivery on probe failures).
18//!
19//! ## Probe lifecycle
20//!
21//! 1. At config publish time, the probe configuration is compiled
22//! into a [`ProbeSpec`] per route.
23//! 2. A background task ([`ProbeScheduler`]) runs each probe on its
24//! configured interval via the async HTTP executor ([`run_probe`]).
25//! 3. Each probe result ([`ProbeResult`]) is:
26//! - Fed into [`ProbeRunner::process_result`] for state tracking.
27//! - Emitted as an event on state transitions (alert/recovery).
28//! - Recorded in the analytics store (as a synthetic access record).
29//!
30//! ## Alerting
31//!
32//! When a probe fails, an event is emitted on the event bus. If a
33//! webhook is configured for the `probe_failed` event kind, the
34//! webhook deliverer sends a notification. The alert fires on the
35//! first failure that crosses the threshold (edge-triggered);
36//! subsequent consecutive failures do not re-fire until the probe
37//! recovers.
38//!
39//! ## Body assertions (DP-10, #238)
40//!
41//! Probes can assert on the response body:
42//! - `body_contains`: the body must contain a substring.
43//! - `body_jsonpath`: a JSONPath expression that must resolve to a
44//! non-null value (simple dot-notation, e.g. `$.status`).
45//!
46//! ## Multi-step journeys (DP-10, #238)
47//!
48//! A probe can be a multi-step journey: a sequence of requests where
49//! each step can extract values (e.g. a session token) and pass them
50//! to subsequent steps via header templates. The journey fails if any
51//! step fails.
52//!
53//! ## Probe-from header (DP-10, #238)
54//!
55//! A probe can set the `X-Dwara-Probe-From` header to identify the
56//! region/agent executing the probe, enabling multi-region synthetic
57//! monitoring where agents in different geographies probe the same
58//! route and report their origin.
59
60use std::collections::HashMap;
61use std::time::Duration;
62
63/// A synthetic probe specification for a route.
64///
65/// Created at config publish time from the route's `probe` config
66/// field. Immutable after creation.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ProbeSpec {
69 /// The route name this probe is attached to.
70 pub route_name: String,
71 /// The URL to probe. If None, the probe uses the route's own URL
72 /// (constructed from the listener + route match).
73 pub url: Option<String>,
74 /// The HTTP method to use (default: GET).
75 pub method: String,
76 /// The probe interval (how often to run the probe).
77 pub interval: Duration,
78 /// The probe timeout (how long to wait for a response).
79 pub timeout: Duration,
80 /// The expected status code. If the response status does not
81 /// match, the probe is considered failed.
82 pub expected_status: u16,
83 /// Optional headers to send with the probe request.
84 pub headers: Vec<(String, String)>,
85 /// Optional request body to send with the probe.
86 pub body: Option<String>,
87 /// The number of consecutive failures before alerting (default: 1).
88 pub failure_threshold: u32,
89 /// DP-10 (#238): optional body-contains assertion. The probe
90 /// fails if the response body does not contain this substring.
91 pub body_contains: Option<String>,
92 /// DP-10 (#238): optional JSONPath assertion (simple dot-notation
93 /// like `$.status` or `$.data.id`). The probe fails if the path
94 /// does not resolve to a non-null value in the JSON body.
95 pub body_jsonpath: Option<String>,
96 /// DP-10 (#238): optional multi-step journey. When set, the probe
97 /// executes each step sequentially; the journey fails if any step
98 /// fails. Step-extracted values (via `extract` JSONPath) are
99 /// substituted into subsequent step headers via `{{var}}` templates.
100 pub journey: Option<Vec<ProbeStep>>,
101 /// DP-10 (#238): optional probe-from identifier. When set, the
102 /// `X-Dwara-Probe-From` header is added to each probe request so
103 /// the target can identify the probing agent/region.
104 pub probe_from: Option<String>,
105}
106
107/// One step in a multi-step probe journey (DP-10, #238).
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct ProbeStep {
110 /// The URL to probe for this step.
111 pub url: String,
112 /// The HTTP method (default: GET).
113 pub method: String,
114 /// Optional headers. Values may contain `{{var}}` templates that
115 /// are substituted from values extracted by prior steps.
116 pub headers: Vec<(String, String)>,
117 /// Optional request body.
118 pub body: Option<String>,
119 /// The expected status code.
120 pub expected_status: u16,
121 /// Optional JSONPath extraction. The extracted value is stored
122 /// under the `extract_name` key for use in subsequent steps.
123 pub extract_name: Option<String>,
124 /// The JSONPath expression to extract (e.g. `$.token`).
125 pub extract_jsonpath: Option<String>,
126}
127
128/// The result of a single probe run.
129#[derive(Clone, Debug)]
130pub struct ProbeResult {
131 /// The route name this probe is for.
132 pub route_name: String,
133 /// The time the probe was initiated (Unix epoch milliseconds).
134 pub started_at_ms: u64,
135 /// The round-trip latency in milliseconds.
136 pub latency_ms: u64,
137 /// The HTTP status code received (0 if the request failed before
138 /// getting a response).
139 pub status: u16,
140 /// Whether the probe was successful (status matched expected, and
141 /// the request completed within the timeout).
142 pub success: bool,
143 /// An error message if the probe failed (None on success).
144 pub error: Option<String>,
145}
146
147/// The current state of a probe (for edge-triggered alerting).
148#[derive(Clone, Debug, Default)]
149struct ProbeState {
150 /// The number of consecutive failures since the last success.
151 consecutive_failures: u32,
152 /// Whether the probe is currently in a failed state (has crossed
153 /// the failure threshold). Used for edge-triggered alerting.
154 is_alerting: bool,
155}
156
157/// A probe runner: holds probe specs and their current states, and
158/// processes results to drive edge-triggered alerting.
159///
160/// The runner is not a background thread -- it is a coordinator that
161/// the caller (the [`ProbeScheduler`]) calls into. The scheduler is
162/// responsible for spawning async tasks and feeding results.
163pub struct ProbeRunner {
164 specs: HashMap<String, ProbeSpec>,
165 states: HashMap<String, ProbeState>,
166}
167
168/// The outcome of processing a probe result.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub enum ProbeOutcome {
171 /// The probe succeeded.
172 Success,
173 /// The probe failed but has not crossed the failure threshold.
174 Failure(u32),
175 /// The probe failed and crossed the failure threshold -- an alert
176 /// should be fired (edge-triggered).
177 AlertFired,
178 /// The probe recovered from a previous alert.
179 Recovered,
180}
181
182impl ProbeRunner {
183 /// Create a new probe runner from a list of probe specs.
184 pub fn new(specs: Vec<ProbeSpec>) -> Self {
185 let states = specs
186 .iter()
187 .map(|s| (s.route_name.clone(), ProbeState::default()))
188 .collect();
189 let specs = specs
190 .into_iter()
191 .map(|s| (s.route_name.clone(), s))
192 .collect();
193 Self { specs, states }
194 }
195
196 /// Get the probe spec for a route.
197 pub fn spec(&self, route_name: &str) -> Option<&ProbeSpec> {
198 self.specs.get(route_name)
199 }
200
201 /// Get all probe specs.
202 pub fn specs(&self) -> impl Iterator<Item = &ProbeSpec> {
203 self.specs.values()
204 }
205
206 /// Process a probe result and return the outcome.
207 ///
208 /// This updates the probe's internal state and determines whether
209 /// an alert should be fired (edge-triggered) or the probe has
210 /// recovered.
211 pub fn process_result(&mut self, result: &ProbeResult) -> ProbeOutcome {
212 let state = self.states.entry(result.route_name.clone()).or_default();
213
214 if result.success {
215 let was_alerting = state.is_alerting;
216 state.consecutive_failures = 0;
217 state.is_alerting = false;
218 if was_alerting {
219 ProbeOutcome::Recovered
220 } else {
221 ProbeOutcome::Success
222 }
223 } else {
224 state.consecutive_failures += 1;
225 let spec = self.specs.get(&result.route_name);
226 let threshold = spec.map(|s| s.failure_threshold).unwrap_or(1).max(1);
227
228 if state.consecutive_failures >= threshold && !state.is_alerting {
229 state.is_alerting = true;
230 ProbeOutcome::AlertFired
231 } else {
232 ProbeOutcome::Failure(state.consecutive_failures)
233 }
234 }
235 }
236
237 /// Whether a probe is currently in an alerting state.
238 pub fn is_alerting(&self, route_name: &str) -> bool {
239 self.states
240 .get(route_name)
241 .map(|s| s.is_alerting)
242 .unwrap_or(false)
243 }
244
245 /// The number of consecutive failures for a probe.
246 pub fn consecutive_failures(&self, route_name: &str) -> u32 {
247 self.states
248 .get(route_name)
249 .map(|s| s.consecutive_failures)
250 .unwrap_or(0)
251 }
252
253 /// The number of probes.
254 pub fn probe_count(&self) -> usize {
255 self.specs.len()
256 }
257}
258
259/// Create a probe result from a successful HTTP response.
260pub fn success_result(
261 route_name: &str,
262 started_at_ms: u64,
263 latency_ms: u64,
264 status: u16,
265) -> ProbeResult {
266 ProbeResult {
267 route_name: route_name.to_string(),
268 started_at_ms,
269 latency_ms,
270 status,
271 success: true,
272 error: None,
273 }
274}
275
276/// Create a probe result from a failed HTTP response (or error).
277pub fn failure_result(
278 route_name: &str,
279 started_at_ms: u64,
280 latency_ms: u64,
281 status: u16,
282 error: &str,
283) -> ProbeResult {
284 ProbeResult {
285 route_name: route_name.to_string(),
286 started_at_ms,
287 latency_ms,
288 status,
289 success: false,
290 error: Some(error.to_string()),
291 }
292}
293
294// --- Body assertions (DP-10, #238) ----------------------------------------
295
296/// Check a response body against the probe's body assertions.
297/// Returns `Ok(())` if all assertions pass, or `Err(message)` on
298/// the first failure.
299pub fn check_body_assertions(spec: &ProbeSpec, body: &str) -> Result<(), String> {
300 if let Some(substr) = &spec.body_contains {
301 if !body.contains(substr) {
302 return Err(format!(
303 "body does not contain expected substring {:?}",
304 substr
305 ));
306 }
307 }
308 if let Some(path) = &spec.body_jsonpath {
309 if !jsonpath_exists(body, path) {
310 return Err(format!(
311 "jsonpath {:?} did not resolve to a non-null value",
312 path
313 ));
314 }
315 }
316 Ok(())
317}
318
319/// Simple JSONPath existence check (dot-notation: `$.foo.bar`).
320/// Returns true if the path resolves to a non-null value in the JSON
321/// body. This is a minimal implementation for probe assertions; it
322/// handles the common cases (`$.field`, `$.a.b.c`, `$.arr[0]`) without
323/// a full JSONPath engine dependency.
324fn jsonpath_exists(body: &str, path: &str) -> bool {
325 let trimmed = path.strip_prefix("$.").unwrap_or(path);
326 let value = match serde_json::from_str::<serde_json::Value>(body) {
327 Ok(v) => v,
328 Err(_) => return false,
329 };
330 let mut current = &value;
331 for segment in trimmed.split('.') {
332 let segment = segment.trim();
333 if segment.is_empty() {
334 continue;
335 }
336 // Handle array index: arr[0]
337 if let Some(idx_end) = segment.find('[') {
338 let key = &segment[..idx_end];
339 let idx_str = &segment[idx_end + 1..].trim_end_matches(']');
340 if !key.is_empty() {
341 current = current.get(key).unwrap_or(&serde_json::Value::Null);
342 }
343 if let Ok(idx) = idx_str.parse::<usize>() {
344 current = current.get(idx).unwrap_or(&serde_json::Value::Null);
345 } else {
346 return false;
347 }
348 } else {
349 current = current.get(segment).unwrap_or(&serde_json::Value::Null);
350 }
351 if current.is_null() {
352 return false;
353 }
354 }
355 !current.is_null()
356}
357
358// --- Async HTTP executor (DP-10, #238) ------------------------------------
359
360/// Execute a single probe spec as an HTTP request and return the
361/// result. This is the core executor: it sends the request, measures
362/// latency, checks the status code, and runs body assertions.
363///
364/// For multi-step journeys, use [`run_journey`] instead.
365pub async fn run_probe(spec: &ProbeSpec) -> ProbeResult {
366 let started = std::time::Instant::now();
367 let started_at_ms = std::time::SystemTime::now()
368 .duration_since(std::time::UNIX_EPOCH)
369 .map(|d| d.as_millis() as u64)
370 .unwrap_or(0);
371
372 let url = match spec.url.as_deref() {
373 Some(u) => u.to_string(),
374 None => {
375 return failure_result(
376 &spec.route_name,
377 started_at_ms,
378 started.elapsed().as_millis() as u64,
379 0,
380 "probe has no url",
381 );
382 }
383 };
384
385 // Multi-step journey: delegate to the journey executor.
386 if let Some(steps) = &spec.journey {
387 return run_journey(spec, steps, &url, started, started_at_ms).await;
388 }
389
390 let result = execute_http(
391 &url,
392 &spec.method,
393 &spec.headers,
394 spec.body.as_deref(),
395 spec.timeout,
396 spec.probe_from.as_deref(),
397 )
398 .await;
399
400 match result {
401 Ok((status, body)) => {
402 let latency_ms = started.elapsed().as_millis() as u64;
403 if status != spec.expected_status {
404 return failure_result(
405 &spec.route_name,
406 started_at_ms,
407 latency_ms,
408 status,
409 &format!("expected status {} got {}", spec.expected_status, status),
410 );
411 }
412 if let Err(msg) = check_body_assertions(spec, &body) {
413 return failure_result(&spec.route_name, started_at_ms, latency_ms, status, &msg);
414 }
415 success_result(&spec.route_name, started_at_ms, latency_ms, status)
416 }
417 Err(err) => failure_result(
418 &spec.route_name,
419 started_at_ms,
420 started.elapsed().as_millis() as u64,
421 0,
422 &err,
423 ),
424 }
425}
426
427/// Execute a multi-step journey (DP-10, #238). Each step is executed
428/// sequentially; extracted values are substituted into subsequent
429/// step headers via `{{var}}` templates.
430async fn run_journey(
431 spec: &ProbeSpec,
432 steps: &[ProbeStep],
433 _base_url: &str,
434 started: std::time::Instant,
435 started_at_ms: u64,
436) -> ProbeResult {
437 let mut extracted: HashMap<String, String> = HashMap::new();
438
439 for step in steps {
440 let method = if step.method.is_empty() {
441 "GET"
442 } else {
443 step.method.as_str()
444 };
445 // Substitute {{var}} templates in headers.
446 let headers: Vec<(String, String)> = step
447 .headers
448 .iter()
449 .map(|(k, v)| {
450 let mut substituted = v.clone();
451 for (ek, ev) in &extracted {
452 substituted = substituted.replace(&format!("{{{{{}}}}}", ek), ev);
453 }
454 (k.clone(), substituted)
455 })
456 .collect();
457
458 let result = execute_http(
459 &step.url,
460 method,
461 &headers,
462 step.body.as_deref(),
463 spec.timeout,
464 spec.probe_from.as_deref(),
465 )
466 .await;
467
468 match result {
469 Ok((status, body)) => {
470 if status != step.expected_status {
471 return failure_result(
472 &spec.route_name,
473 started_at_ms,
474 started.elapsed().as_millis() as u64,
475 status,
476 &format!(
477 "journey step {:?}: expected status {} got {}",
478 step.url, step.expected_status, status
479 ),
480 );
481 }
482 // Extract values for subsequent steps.
483 if let (Some(name), Some(path)) = (&step.extract_name, &step.extract_jsonpath) {
484 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
485 if let Some(extracted_val) = jsonpath_get(&val, path) {
486 extracted.insert(name.clone(), extracted_val);
487 }
488 }
489 }
490 }
491 Err(err) => {
492 return failure_result(
493 &spec.route_name,
494 started_at_ms,
495 started.elapsed().as_millis() as u64,
496 0,
497 &format!("journey step {:?}: {}", step.url, err),
498 );
499 }
500 }
501 }
502
503 success_result(
504 &spec.route_name,
505 started_at_ms,
506 started.elapsed().as_millis() as u64,
507 200,
508 )
509}
510
511/// Extract a string value from a JSON value using a simple dot-notation
512/// path (e.g. `$.token`, `$.data.id`).
513fn jsonpath_get(value: &serde_json::Value, path: &str) -> Option<String> {
514 let trimmed = path.strip_prefix("$.").unwrap_or(path);
515 let mut current = value;
516 for segment in trimmed.split('.') {
517 let segment = segment.trim();
518 if segment.is_empty() {
519 continue;
520 }
521 if let Some(idx_end) = segment.find('[') {
522 let key = &segment[..idx_end];
523 let idx_str = &segment[idx_end + 1..].trim_end_matches(']');
524 if !key.is_empty() {
525 current = current.get(key)?;
526 }
527 if let Ok(idx) = idx_str.parse::<usize>() {
528 current = current.get(idx)?;
529 } else {
530 return None;
531 }
532 } else {
533 current = current.get(segment)?;
534 }
535 }
536 match current {
537 serde_json::Value::String(s) => Some(s.clone()),
538 serde_json::Value::Number(n) => Some(n.to_string()),
539 serde_json::Value::Bool(b) => Some(b.to_string()),
540 _ => current.as_str().map(|s| s.to_string()),
541 }
542}
543
544/// Low-level HTTP execution: send a request, return (status, body) or
545/// an error string. Uses a simple TCP+HTTP/1.1 client to avoid pulling
546/// a heavy HTTP client dependency into the synthetic module (the
547/// dataplane's upstream client is upstream-pool-specific and not
548/// suitable for arbitrary probe URLs).
549async fn execute_http(
550 url: &str,
551 method: &str,
552 headers: &[(String, String)],
553 body: Option<&str>,
554 timeout: Duration,
555 probe_from: Option<&str>,
556) -> Result<(u16, String), String> {
557 use tokio::io::{AsyncReadExt, AsyncWriteExt};
558 use tokio::net::TcpStream;
559
560 // Parse the URL: scheme://host:port/path
561 let (scheme, rest) = url
562 .split_once("://")
563 .ok_or_else(|| format!("invalid url {:?}: no scheme", url))?;
564 let is_tls = scheme == "https";
565 let (authority, path) = rest
566 .split_once('/')
567 .map(|(a, p)| (a, format!("/{}", p)))
568 .unwrap_or((rest, "/".to_string()));
569 let (host, port) = if let Some((h, p)) = authority.rsplit_once(':') {
570 (
571 h.to_string(),
572 p.parse::<u16>().map_err(|e| format!("invalid port: {e}"))?,
573 )
574 } else {
575 (authority.to_string(), if is_tls { 443 } else { 80 })
576 };
577
578 let connect_addr = format!("{}:{}", host, port);
579 let stream = tokio::time::timeout(timeout, TcpStream::connect(&connect_addr))
580 .await
581 .map_err(|_| format!("connect timeout to {}", connect_addr))?
582 .map_err(|e| format!("connect failed: {e}"))?;
583
584 // For TLS, we'd need a TLS client; synthetic probes to https URLs
585 // are supported but require the rustls connector. For now, only
586 // plaintext HTTP is supported (http:// URLs). TLS support can be
587 // added by wiring in the existing rustls client setup.
588 if is_tls {
589 return Err(
590 "https probe URLs are not yet supported; use http:// for synthetic probes".to_string(),
591 );
592 }
593
594 let mut stream = stream;
595 let _ = stream.set_nodelay(true);
596
597 // Build the HTTP/1.1 request.
598 let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host);
599 for (k, v) in headers {
600 req.push_str(&format!("{}: {}\r\n", k, v));
601 }
602 if let Some(pf) = probe_from {
603 req.push_str(&format!("X-Dwara-Probe-From: {}\r\n", pf));
604 }
605 if let Some(body) = body {
606 req.push_str(&format!("Content-Length: {}\r\n", body.len()));
607 req.push_str("\r\n");
608 req.push_str(body);
609 } else {
610 req.push_str("Connection: close\r\n\r\n");
611 }
612
613 stream
614 .write_all(req.as_bytes())
615 .await
616 .map_err(|e| format!("write failed: {e}"))?;
617
618 // Read the full response.
619 let mut response = Vec::new();
620 tokio::time::timeout(timeout, stream.read_to_end(&mut response))
621 .await
622 .map_err(|_| "response timeout".to_string())?
623 .map_err(|e| format!("read failed: {e}"))?;
624
625 let response_str = String::from_utf8_lossy(&response);
626 // Parse status line: HTTP/1.1 200 OK
627 let status: u16 = response_str
628 .lines()
629 .next()
630 .and_then(|line| line.split_whitespace().nth(1))
631 .and_then(|s| s.parse().ok())
632 .unwrap_or(0);
633
634 // Split headers and body.
635 let body = response_str
636 .split_once("\r\n\r\n")
637 .map(|(_, b)| b.to_string())
638 .unwrap_or_default();
639
640 Ok((status, body))
641}
642
643// --- Probe scheduler (DP-10, #238) ----------------------------------------
644
645/// A probe scheduler: spawns a background task per probe that runs
646/// the probe on its configured interval and feeds results into a
647/// [`ProbeRunner`]. State transitions (alert/recovery) are emitted
648/// as events.
649///
650/// The scheduler owns the [`ProbeRunner`] behind a `Mutex` so the
651/// background tasks can feed results concurrently. The caller
652/// typically holds a handle to the scheduler for graceful shutdown.
653pub struct ProbeScheduler {
654 runner: std::sync::Arc<tokio::sync::Mutex<ProbeRunner>>,
655 emitter: Option<crate::events::Emitter>,
656 handles: Vec<tokio::task::JoinHandle<()>>,
657}
658
659impl ProbeScheduler {
660 /// Create a new scheduler from probe specs. The scheduler does
661 /// not start until [`ProbeScheduler::start`] is called.
662 pub fn new(specs: Vec<ProbeSpec>, emitter: Option<crate::events::Emitter>) -> Self {
663 ProbeScheduler {
664 runner: std::sync::Arc::new(tokio::sync::Mutex::new(ProbeRunner::new(specs))),
665 emitter,
666 handles: Vec::new(),
667 }
668 }
669
670 /// Start the scheduler: spawns one background task per probe.
671 /// Each task runs the probe on its interval, feeds the result
672 /// into the runner, and emits events on state transitions.
673 pub fn start(&mut self) {
674 let runner = std::sync::Arc::clone(&self.runner);
675 let emitter = self.emitter.clone();
676 let specs: Vec<ProbeSpec> = runner
677 .try_lock()
678 .expect("runner not locked at start")
679 .specs()
680 .cloned()
681 .collect();
682 for spec in specs {
683 let runner = std::sync::Arc::clone(&runner);
684 let emitter = emitter.clone();
685 let handle = tokio::spawn(async move {
686 let mut interval = tokio::time::interval(spec.interval);
687 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
688 loop {
689 interval.tick().await;
690 let result = run_probe(&spec).await;
691 let outcome = {
692 let mut guard = runner.lock().await;
693 guard.process_result(&result)
694 };
695 // Emit events on state transitions.
696 if let Some(ref emitter) = emitter {
697 match outcome {
698 ProbeOutcome::AlertFired => {
699 emitter.emit(
700 crate::events::EventKind::ProbeFailed,
701 crate::events::EventPayload::probe(
702 &spec.route_name,
703 result.status,
704 result.latency_ms,
705 result.error.as_deref(),
706 ),
707 );
708 }
709 ProbeOutcome::Recovered => {
710 emitter.emit(
711 crate::events::EventKind::ProbeRecovered,
712 crate::events::EventPayload::probe(
713 &spec.route_name,
714 result.status,
715 result.latency_ms,
716 None,
717 ),
718 );
719 }
720 _ => {}
721 }
722 }
723 }
724 });
725 self.handles.push(handle);
726 }
727 }
728
729 /// Abort all background probe tasks.
730 pub fn shutdown(&mut self) {
731 for handle in self.handles.drain(..) {
732 handle.abort();
733 }
734 }
735
736 /// Get a snapshot of the probe runner (e.g. for admin API queries).
737 pub fn runner(&self) -> std::sync::Arc<tokio::sync::Mutex<ProbeRunner>> {
738 std::sync::Arc::clone(&self.runner)
739 }
740}
741
742impl Drop for ProbeScheduler {
743 fn drop(&mut self) {
744 self.shutdown();
745 }
746}