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}