1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
17
18use std::collections::HashMap;
19use std::sync::{Arc, RwLock};
20
21use axum::Json;
22use axum::extract::{Path, State};
23use axum::http::StatusCode;
24use axum::response::IntoResponse;
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Serialize, Default)]
33pub struct A11yPosture {
34 pub lang_set: bool,
36 pub skip_link_present: bool,
38 pub landmark_regions_present: bool,
41}
42
43impl A11yPosture {
44 #[must_use]
46 pub const fn is_compliant(&self) -> bool {
47 self.lang_set && self.skip_link_present && self.landmark_regions_present
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum MetricKind {
59 Counter,
61 Gauge,
63}
64
65impl MetricKind {
66 const fn as_str(&self) -> &'static str {
67 match self {
68 Self::Counter => "counter",
69 Self::Gauge => "gauge",
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
79pub struct MetricSample {
80 pub labels: Vec<(String, String)>,
82 pub value: f64,
84}
85
86#[derive(Debug, Clone)]
91pub struct MetricFamily {
92 pub name: String,
97 pub help: String,
99 pub kind: MetricKind,
105 pub samples: Vec<MetricSample>,
107}
108
109pub trait MetricsSource: Send + Sync + 'static {
131 fn collect(&self) -> Vec<MetricFamily>;
133}
134
135#[derive(Clone, Default)]
141pub struct MetricsSourceRegistry {
142 inner: Arc<RwLock<MetricsSourceRegistryInner>>,
143}
144
145#[derive(Default)]
146struct MetricsSourceRegistryInner {
147 sources: Vec<(String, Arc<dyn MetricsSource>)>,
149 error_counts: HashMap<String, u64>,
151}
152
153impl MetricsSourceRegistry {
154 #[must_use]
156 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn register(
169 &self,
170 name: impl Into<String>,
171 source: Arc<dyn MetricsSource>,
172 ) -> Result<(), String> {
173 let name = name.into();
174 {
175 let mut inner = self
176 .inner
177 .write()
178 .unwrap_or_else(std::sync::PoisonError::into_inner);
179 if inner.sources.iter().any(|(n, _)| n == &name) {
180 return Err(format!(
181 "MetricsSource '{name}' is already registered; skipping duplicate"
182 ));
183 }
184 inner.sources.push((name, source));
185 }
186 Ok(())
187 }
188
189 pub fn collect_all(&self) -> Vec<(String, Vec<MetricFamily>)> {
194 let sources: Vec<(String, Arc<dyn MetricsSource>)> = self
195 .inner
196 .read()
197 .unwrap_or_else(std::sync::PoisonError::into_inner)
198 .sources
199 .clone();
200
201 let mut results = Vec::with_capacity(sources.len());
202 let mut panicked = Vec::new();
203
204 for (name, source) in &sources {
205 let result =
206 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| source.collect()));
207 if let Ok(families) = result {
208 results.push((name.clone(), families));
209 } else {
210 tracing::error!(source_name = %name, "MetricsSource panicked during collection");
211 panicked.push(name.clone());
212 results.push((name.clone(), vec![]));
213 }
214 }
215
216 if !panicked.is_empty() {
217 let mut inner = self
218 .inner
219 .write()
220 .unwrap_or_else(std::sync::PoisonError::into_inner);
221 for name in panicked {
222 *inner.error_counts.entry(name).or_insert(0) += 1;
223 }
224 }
225
226 results
227 }
228
229 #[must_use]
231 pub fn error_counts(&self) -> HashMap<String, u64> {
232 self.inner
233 .read()
234 .unwrap_or_else(std::sync::PoisonError::into_inner)
235 .error_counts
236 .clone()
237 }
238
239 #[must_use]
241 pub fn source_names(&self) -> Vec<String> {
242 self.inner
243 .read()
244 .unwrap_or_else(std::sync::PoisonError::into_inner)
245 .sources
246 .iter()
247 .map(|(n, _)| n.clone())
248 .collect()
249 }
250
251 #[must_use]
253 pub fn is_empty(&self) -> bool {
254 self.inner
255 .read()
256 .unwrap_or_else(std::sync::PoisonError::into_inner)
257 .sources
258 .is_empty()
259 }
260
261 #[must_use]
268 pub fn contains(&self, name: &str) -> bool {
269 self.inner
270 .read()
271 .unwrap_or_else(std::sync::PoisonError::into_inner)
272 .sources
273 .iter()
274 .any(|(registered, _)| registered == name)
275 }
276}
277
278pub trait ProvideActuatorState {
284 fn metrics(&self) -> &crate::middleware::MetricsCollector;
287
288 fn log_levels(&self) -> &LogLevels;
291
292 fn task_registry(&self) -> &TaskRegistry;
295
296 fn job_registry(&self) -> &JobRegistry;
299
300 fn config_props(&self) -> &ConfigProperties;
303
304 fn profile(&self) -> &str;
307
308 fn uptime_display(&self) -> String;
311
312 #[cfg(feature = "ws")]
315 fn channels(&self) -> &crate::channels::Channels;
316
317 #[cfg(feature = "ws")]
319 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
320
321 #[cfg(feature = "db")]
324 fn pool(
325 &self,
326 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>;
327
328 #[cfg(feature = "db")]
335 fn probe_state(&self) -> Option<&crate::probe::ProbeState> {
336 None
337 }
338
339 #[cfg(feature = "db")]
342 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
343 None
344 }
345
346 #[cfg(feature = "db")]
349 fn replica_pool(
350 &self,
351 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
352 {
353 None
354 }
355
356 fn a11y_posture(&self) -> A11yPosture {
362 A11yPosture::default()
363 }
364
365 fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
371 None
372 }
373
374 fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
380 None
381 }
382
383 fn health_detailed(&self) -> bool {
388 true
389 }
390
391 fn deploy_version(&self) -> String {
399 crate::canary::STABLE.to_owned()
400 }
401
402 #[cfg(feature = "http-client")]
403 fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
405 None
406 }
407
408 fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
414 None
415 }
416
417 fn architecture_graph_json(&self) -> Option<bytes::Bytes> {
423 None
424 }
425
426 fn shadow(&self) -> Option<crate::shadow::ShadowHandle> {
434 None
435 }
436
437 fn data_capsules(
448 &self,
449 ) -> Result<crate::gdpr::portability::CapsuleService, crate::gdpr::portability::DataCapsuleError>
450 {
451 Err(crate::gdpr::portability::DataCapsuleError::NotConfigured(
452 "this state has no data-capsule service".to_owned(),
453 ))
454 }
455
456 fn cost_signal(&self) -> Option<crate::cost::CostSignal> {
458 None
459 }
460
461 fn cost_accountant(&self) -> Option<crate::cost::CostAccountant> {
464 None
465 }
466}
467
468#[derive(Clone)]
475pub struct LogLevels {
476 inner: Arc<RwLock<LogLevelsInner>>,
477}
478
479struct LogLevelsInner {
480 current_level: String,
482 logger_overrides: HashMap<String, String>,
484 reload_handle: Option<crate::telemetry::FilterReloadHandle>,
492}
493
494impl LogLevelsInner {
495 fn build_directive(&self) -> String {
499 let mut parts = Vec::new();
500 if !self.current_level.is_empty() {
501 parts.push(self.current_level.clone());
502 }
503 let mut targets: Vec<String> = self
504 .logger_overrides
505 .iter()
506 .filter(|(name, _)| name.as_str() != "root" && !name.is_empty())
507 .map(|(name, level)| format!("{name}={level}"))
508 .collect();
509 targets.sort();
510 parts.extend(targets);
511 parts.join(",")
512 }
513}
514
515fn parse_initial_directive(directive: &str) -> (String, HashMap<String, String>) {
534 let mut global = String::new();
535 let mut overrides = HashMap::new();
536 for segment in directive.split(',') {
537 let segment = segment.trim();
538 if segment.is_empty() {
539 continue;
540 }
541 if let Some((target, level)) = segment.rsplit_once('=') {
542 let target = target.trim();
543 let level = level.trim();
544 if target.is_empty() || target == "root" {
546 global = level.to_string();
547 } else {
548 overrides.insert(target.to_string(), level.to_string());
549 }
550 } else if is_tracing_level(segment) {
551 global = segment.to_string();
553 } else {
554 overrides.insert(segment.to_string(), "trace".to_string());
556 }
557 }
558 (global, overrides)
559}
560
561fn is_tracing_level(segment: &str) -> bool {
564 matches!(
565 segment.to_ascii_lowercase().as_str(),
566 "trace" | "debug" | "info" | "warn" | "error" | "off"
567 )
568}
569
570impl LogLevels {
571 #[must_use]
573 pub fn new(initial_level: &str) -> Self {
574 let (current_level, logger_overrides) = parse_initial_directive(initial_level);
575 Self {
576 inner: Arc::new(RwLock::new(LogLevelsInner {
577 current_level,
578 logger_overrides,
579 reload_handle: None,
580 })),
581 }
582 }
583
584 pub fn attach_reload_handle(&self, handle: crate::telemetry::FilterReloadHandle) {
590 if let Ok(mut guard) = self.inner.write() {
591 guard.reload_handle = Some(handle);
592 }
593 }
594
595 #[must_use]
599 pub fn reload_available(&self) -> bool {
600 self.inner
601 .read()
602 .is_ok_and(|guard| guard.reload_handle.is_some())
603 }
604
605 #[must_use]
607 pub fn current_level(&self) -> String {
608 self.inner
609 .read()
610 .map_or_else(|_| "info".to_string(), |guard| guard.current_level.clone())
611 }
612
613 #[must_use]
615 pub fn logger_overrides(&self) -> HashMap<String, String> {
616 self.inner
617 .read()
618 .map(|guard| guard.logger_overrides.clone())
619 .unwrap_or_default()
620 }
621
622 #[cfg(test)]
625 fn rebuilt_directive_for_test(&self) -> String {
626 self.inner
627 .read()
628 .map(|guard| guard.build_directive())
629 .unwrap_or_default()
630 }
631
632 pub fn set_logger_level(&self, name: &str, level: &str) -> LogLevelChange {
654 let Ok(mut guard) = self.inner.write() else {
655 return LogLevelChange::Rejected {
656 reason: "log level state lock poisoned".to_string(),
657 };
658 };
659 if guard.logger_overrides.len() >= 1000 && !guard.logger_overrides.contains_key(name) {
661 return LogLevelChange::Rejected {
662 reason: "too many logger overrides".to_string(),
663 };
664 }
665
666 let is_root = name == "root" || name.is_empty();
667 let previous_override = guard.logger_overrides.get(name).cloned();
668 let previous_current = guard.current_level.clone();
669
670 guard
671 .logger_overrides
672 .insert(name.to_string(), level.to_string());
673 let returned = if is_root {
675 guard.current_level = level.to_string();
676 Some(previous_current.clone())
677 } else {
678 previous_override.clone()
679 };
680
681 let directive = guard.build_directive();
682 let Some(handle) = guard.reload_handle.clone() else {
685 return LogLevelChange::Recorded { previous: returned };
686 };
687
688 match handle.apply_directive(&directive) {
689 Ok(()) => LogLevelChange::Applied { previous: returned },
690 Err(error) => {
691 match previous_override {
694 Some(prev) => {
695 guard.logger_overrides.insert(name.to_string(), prev);
696 }
697 None => {
698 guard.logger_overrides.remove(name);
699 }
700 }
701 if is_root {
702 guard.current_level = previous_current;
703 }
704 LogLevelChange::Rejected { reason: error }
705 }
706 }
707 }
708}
709
710#[derive(Debug, Clone, PartialEq, Eq)]
712#[must_use]
713pub enum LogLevelChange {
714 Applied {
717 previous: Option<String>,
719 },
720 Recorded {
723 previous: Option<String>,
725 },
726 Rejected {
729 reason: String,
731 },
732}
733
734impl LogLevelChange {
735 #[must_use]
737 pub fn previous(&self) -> Option<&str> {
738 match self {
739 Self::Applied { previous } | Self::Recorded { previous } => previous.as_deref(),
740 Self::Rejected { .. } => None,
741 }
742 }
743}
744
745impl std::fmt::Debug for LogLevels {
746 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
747 f.debug_struct("LogLevels")
748 .field("current_level", &self.current_level())
749 .finish()
750 }
751}
752
753#[derive(Debug, Clone, Serialize)]
755pub struct TaskStatus {
756 pub schedule: String,
758 pub coordination: crate::task::TaskCoordination,
760 pub scheduler_backend: String,
762 pub replica_id: String,
764 #[serde(skip_serializing_if = "Option::is_none")]
766 pub current_leader: Option<String>,
767 #[serde(skip_serializing_if = "Option::is_none")]
769 pub last_tick: Option<String>,
770 #[serde(skip_serializing_if = "Option::is_none")]
772 pub last_fired_at: Option<String>,
773 #[serde(skip_serializing_if = "Option::is_none")]
775 pub next_run_at: Option<String>,
776 pub status: String,
778 #[serde(skip_serializing_if = "Option::is_none")]
780 pub last_run: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub last_duration_ms: Option<u64>,
784 #[serde(skip_serializing_if = "Option::is_none")]
786 pub last_result: Option<String>,
787 #[serde(skip_serializing_if = "Option::is_none")]
789 pub last_error: Option<String>,
790 pub total_runs: u64,
792 pub total_failures: u64,
794}
795
796#[derive(Clone)]
798pub struct TaskRegistry {
799 inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
800}
801
802#[derive(Debug, Clone, Serialize)]
804pub struct JobStatus {
805 pub queued: u64,
807 pub in_flight: u64,
809 pub blocked_on_concurrency: u64,
811 pub total_successes: u64,
813 pub total_failures: u64,
815 pub dead_letters: u64,
817 pub total_deduplicated: u64,
819 #[serde(skip_serializing_if = "Option::is_none")]
821 pub last_error: Option<String>,
822}
823
824impl JobStatus {
825 const fn empty() -> Self {
826 Self {
827 queued: 0,
828 in_flight: 0,
829 blocked_on_concurrency: 0,
830 total_successes: 0,
831 total_failures: 0,
832 dead_letters: 0,
833 total_deduplicated: 0,
834 last_error: None,
835 }
836 }
837}
838
839#[derive(Debug, Clone, Serialize)]
849pub struct QueueStatus {
850 pub depth: u64,
852 pub oldest_waiting_age_ms: u64,
855}
856
857#[derive(Default)]
859struct QueueGaugeState {
860 name_to_queue: HashMap<String, String>,
862 waiting: HashMap<String, std::collections::VecDeque<u64>>,
870 surveyed: Option<HashMap<String, (u64, Option<u64>)>>,
886 pg_marks_by_job_id: indexmap::IndexMap<String, PgMark>,
905 pg_marks_next_seq: u64,
907 pg_marks_seq_order: std::collections::BTreeMap<u64, String>,
920}
921
922impl QueueGaugeState {
923 fn queue_of(&self, name: &str) -> &str {
926 self.name_to_queue
927 .get(name)
928 .map_or("default", String::as_str)
929 }
930
931 fn waiting_for_mut(&mut self, name: &str) -> Option<&mut std::collections::VecDeque<u64>> {
933 let queue = self
936 .name_to_queue
937 .get(name)
938 .map_or("default", String::as_str);
939 self.waiting.get_mut(queue)
940 }
941}
942
943const PG_MARKS_BY_JOB_ID_CAP: usize = 10_000;
949
950#[derive(Clone, Copy, Debug, PartialEq, Eq)]
970pub(crate) enum PgMarkTimeline {
971 Real,
976 Registry,
979}
980
981#[derive(Clone, Copy, Debug)]
985struct PgMark {
986 ms: u64,
987 timeline: PgMarkTimeline,
988 seq: u64,
994}
995
996#[must_use]
1005pub const fn ready_at_from_age(registry_now_ms: u64, age_ms: u64) -> u64 {
1006 registry_now_ms.saturating_sub(age_ms)
1007}
1008
1009#[cfg(test)]
1017fn now_epoch_ms() -> u64 {
1018 std::time::SystemTime::now()
1019 .duration_since(std::time::UNIX_EPOCH)
1020 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
1021}
1022
1023#[derive(Clone)]
1025pub struct JobRegistry {
1026 inner: Arc<RwLock<HashMap<String, JobStatus>>>,
1027 queues: Arc<RwLock<QueueGaugeState>>,
1028 clock: Arc<RwLock<Arc<dyn crate::time::ClockSource>>>,
1047 dead_letter_trimmed: Arc<std::sync::atomic::AtomicU64>,
1050}
1051
1052impl JobRegistry {
1053 #[must_use]
1057 pub fn new() -> Self {
1058 Self {
1059 inner: Arc::new(RwLock::new(HashMap::new())),
1060 queues: Arc::new(RwLock::new(QueueGaugeState::default())),
1061 clock: Arc::new(RwLock::new(
1062 Arc::new(crate::time::SystemClock) as Arc<dyn crate::time::ClockSource>
1063 )),
1064 dead_letter_trimmed: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1065 }
1066 }
1067
1068 #[must_use]
1075 pub fn with_clock(self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
1076 self.install_clock(clock);
1077 self
1078 }
1079
1080 pub(crate) fn install_clock(&self, clock: Arc<dyn crate::time::ClockSource>) {
1087 let mut slot = self
1088 .clock
1089 .write()
1090 .unwrap_or_else(std::sync::PoisonError::into_inner);
1091 *slot = clock;
1092 }
1093
1094 fn now_ms(&self) -> u64 {
1103 let clock = self
1108 .clock
1109 .read()
1110 .unwrap_or_else(std::sync::PoisonError::into_inner);
1111 u64::try_from(clock.now().timestamp_millis()).unwrap_or(0)
1112 }
1113
1114 #[must_use]
1121 pub fn is_initialized(&self) -> bool {
1122 !self
1123 .inner
1124 .read()
1125 .unwrap_or_else(std::sync::PoisonError::into_inner)
1126 .is_empty()
1127 }
1128
1129 pub fn register(&self, name: &str) {
1131 if let Ok(mut guard) = self.inner.write() {
1132 guard.entry(name.to_string()).or_insert(JobStatus::empty());
1133 }
1134 }
1135
1136 pub fn register_on_queue(&self, name: &str, queue: &str) {
1139 self.register(name);
1140 if let Ok(mut guard) = self.queues.write() {
1141 guard
1142 .name_to_queue
1143 .insert(name.to_string(), queue.to_string());
1144 guard.waiting.entry(queue.to_string()).or_default();
1145 }
1146 }
1147
1148 #[must_use]
1156 pub fn queue_snapshot(&self) -> HashMap<String, QueueStatus> {
1157 let now = self.now_ms();
1158 self.queues
1159 .read()
1160 .map(|g| {
1161 if let Some(surveyed) = &g.surveyed {
1162 return g
1167 .waiting
1168 .keys()
1169 .chain(surveyed.keys())
1170 .cloned()
1171 .collect::<std::collections::HashSet<String>>()
1172 .into_iter()
1173 .map(|queue| {
1174 let (depth, oldest_ready_at) =
1175 surveyed.get(&queue).copied().unwrap_or((0, None));
1176 let oldest_waiting_age_ms =
1177 oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1178 (
1179 queue,
1180 QueueStatus {
1181 depth,
1182 oldest_waiting_age_ms,
1183 },
1184 )
1185 })
1186 .collect();
1187 }
1188 g.waiting
1189 .iter()
1190 .map(|(queue, waiting)| {
1191 let mut depth = 0u64;
1195 let mut oldest_ready_at: Option<u64> = None;
1196 for ready_at in waiting {
1197 if *ready_at <= now {
1198 depth += 1;
1199 oldest_ready_at =
1200 Some(oldest_ready_at.map_or(*ready_at, |o| o.min(*ready_at)));
1201 }
1202 }
1203 let oldest_waiting_age_ms =
1204 oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
1205 (
1206 queue.clone(),
1207 QueueStatus {
1208 depth,
1209 oldest_waiting_age_ms,
1210 },
1211 )
1212 })
1213 .collect()
1214 })
1215 .unwrap_or_default()
1216 }
1217
1218 pub fn record_enqueue(&self, name: &str) {
1220 self.record_enqueue_at(name, self.now_ms());
1221 }
1222
1223 pub fn record_enqueue_scheduled(&self, name: &str, ready_at_ms: u64) {
1229 self.record_enqueue_at(name, ready_at_ms);
1230 }
1231
1232 pub(crate) fn record_pg_enqueue(
1252 &self,
1253 name: &str,
1254 id: &str,
1255 ready_at_ms: Option<u64>,
1256 timeline: PgMarkTimeline,
1257 ) {
1258 let (ready_at_ms, timeline) = ready_at_ms.map_or_else(
1259 || (self.now_ms(), PgMarkTimeline::Registry),
1260 |ms| (ms, timeline),
1261 );
1262 self.record_enqueue_at(name, ready_at_ms);
1263 self.note_pg_job_mark(id, ready_at_ms, timeline);
1264 }
1265
1266 fn record_enqueue_at(&self, name: &str, ready_at_ms: u64) {
1269 if let Ok(mut guard) = self.inner.write() {
1272 #[allow(clippy::option_if_let_else)] if let Some(status) = guard.get_mut(name) {
1274 status.queued = status.queued.saturating_add(1);
1275 } else {
1276 guard.insert(
1277 name.to_owned(),
1278 JobStatus {
1279 queued: 1,
1280 ..JobStatus::empty()
1281 },
1282 );
1283 }
1284 }
1285 if let Ok(mut guard) = self.queues.write() {
1286 #[allow(clippy::option_if_let_else)] if let Some(waiting) = guard.waiting_for_mut(name) {
1288 waiting.push_back(ready_at_ms);
1289 } else {
1290 let queue = guard.queue_of(name).to_owned();
1291 guard
1292 .waiting
1293 .entry(queue)
1294 .or_default()
1295 .push_back(ready_at_ms);
1296 }
1297 }
1298 }
1299
1300 pub fn record_deduplicated(&self, name: &str, had_enqueue_mark: bool, was_scheduled: bool) {
1321 if let Ok(mut guard) = self.inner.write()
1322 && let Some(status) = guard.get_mut(name)
1323 {
1324 status.queued = status.queued.saturating_sub(1);
1325 status.total_deduplicated = status.total_deduplicated.saturating_add(1);
1326 }
1327 if !had_enqueue_mark {
1328 return;
1329 }
1330 self.pop_waiting(name, !was_scheduled);
1334 }
1335
1336 pub fn record_concurrency_blocked(&self, name: &str) {
1338 if let Ok(mut guard) = self.inner.write()
1339 && let Some(status) = guard.get_mut(name)
1340 {
1341 status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_add(1);
1342 }
1343 }
1344
1345 pub fn record_concurrency_unblocked(&self, name: &str) {
1347 if let Ok(mut guard) = self.inner.write()
1348 && let Some(status) = guard.get_mut(name)
1349 {
1350 status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_sub(1);
1351 }
1352 }
1353
1354 pub fn set_concurrency_blocked_counts(&self, counts: &HashMap<String, u64>) {
1360 if let Ok(mut guard) = self.inner.write() {
1361 for (name, status) in guard.iter_mut() {
1362 status.blocked_on_concurrency = counts.get(name).copied().unwrap_or(0);
1363 }
1364 }
1365 }
1366
1367 pub fn set_queue_depth_gauges(&self, per_queue: &HashMap<String, (u64, Option<u64>)>) {
1380 if let Ok(mut guard) = self.queues.write() {
1381 guard.surveyed = Some(per_queue.clone());
1382 }
1383 }
1384
1385 pub fn set_queued_counts(&self, counts: &HashMap<String, u64>) {
1394 if let Ok(mut guard) = self.inner.write() {
1395 for (name, status) in guard.iter_mut() {
1396 status.queued = counts.get(name).copied().unwrap_or(0);
1397 }
1398 }
1399 }
1400
1401 pub fn record_start(&self, name: &str) {
1403 if let Ok(mut guard) = self.inner.write()
1404 && let Some(status) = guard.get_mut(name)
1405 {
1406 status.queued = status.queued.saturating_sub(1);
1407 status.in_flight = status.in_flight.saturating_add(1);
1408 }
1409 self.pop_waiting(name, true);
1410 }
1411
1412 #[cfg_attr(not(feature = "postgres"), allow(dead_code))]
1432 pub(crate) fn record_pg_start(&self, name: &str, id: &str) {
1433 if let Ok(mut guard) = self.inner.write()
1434 && let Some(status) = guard.get_mut(name)
1435 {
1436 status.queued = status.queued.saturating_sub(1);
1437 status.in_flight = status.in_flight.saturating_add(1);
1438 }
1439 let exact_ms = self
1440 .queues
1441 .write()
1442 .ok()
1443 .and_then(|mut guard| {
1444 let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1445 guard.pg_marks_seq_order.remove(&removed.seq);
1446 Some(removed)
1447 })
1448 .map(|mark| mark.ms);
1449 let removed_exact = exact_ms.is_some_and(|exact_ms| self.pop_waiting_exact(name, exact_ms));
1450 if !removed_exact {
1451 self.pop_waiting(name, true);
1452 }
1453 }
1454
1455 pub(crate) fn forget_pg_job_mark(&self, id: &str) {
1466 if let Ok(mut guard) = self.queues.write()
1467 && let Some(removed) = guard.pg_marks_by_job_id.swap_remove(id)
1468 {
1469 guard.pg_marks_seq_order.remove(&removed.seq);
1470 }
1471 }
1472
1473 pub fn record_cancel(&self, name: &str) {
1475 if let Ok(mut guard) = self.inner.write()
1476 && let Some(status) = guard.get_mut(name)
1477 {
1478 status.queued = status.queued.saturating_sub(1);
1479 }
1480 self.pop_waiting(name, true);
1481 }
1482
1483 pub fn record_cancel_scheduled(&self, name: &str) {
1489 if let Ok(mut guard) = self.inner.write()
1490 && let Some(status) = guard.get_mut(name)
1491 {
1492 status.queued = status.queued.saturating_sub(1);
1493 }
1494 self.pop_waiting(name, false);
1495 }
1496
1497 pub(crate) fn note_pg_job_mark(&self, id: &str, ready_at_ms: u64, timeline: PgMarkTimeline) {
1540 if let Ok(mut guard) = self.queues.write() {
1541 if guard.pg_marks_by_job_id.len() >= PG_MARKS_BY_JOB_ID_CAP
1542 && !guard.pg_marks_by_job_id.contains_key(id)
1543 {
1544 let registry_now = self.now_ms();
1545 #[allow(
1547 clippy::disallowed_methods,
1548 reason = "Real marks are measured by Postgres clock_timestamp(), a real clock"
1549 )]
1550 let real_now =
1551 u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(u64::MAX);
1552 let evict_id = guard
1559 .pg_marks_seq_order
1560 .iter()
1561 .find_map(|(_, candidate_id)| {
1562 let mark = guard.pg_marks_by_job_id.get(candidate_id)?;
1563 let due = match mark.timeline {
1564 PgMarkTimeline::Real => mark.ms <= real_now,
1565 PgMarkTimeline::Registry => mark.ms <= registry_now,
1566 };
1567 due.then(|| candidate_id.clone())
1568 })
1569 .or_else(|| guard.pg_marks_seq_order.values().next().cloned());
1570 if let Some(evict_id) = evict_id
1571 && let Some(evicted) = guard.pg_marks_by_job_id.swap_remove(&evict_id)
1572 {
1573 guard.pg_marks_seq_order.remove(&evicted.seq);
1574 }
1575 }
1576 let seq = guard.pg_marks_next_seq;
1577 guard.pg_marks_next_seq = guard.pg_marks_next_seq.wrapping_add(1);
1578 guard.pg_marks_seq_order.insert(seq, id.to_string());
1579 guard.pg_marks_by_job_id.insert(
1580 id.to_string(),
1581 PgMark {
1582 ms: ready_at_ms,
1583 timeline,
1584 seq,
1585 },
1586 );
1587 }
1588 }
1589
1590 pub fn record_cancel_at_backend_offset(
1637 &self,
1638 name: &str,
1639 id: &str,
1640 absolute_ms: Option<u64>,
1641 real_reference_ms: u64,
1642 offset_ms: i64,
1643 ) {
1644 if let Ok(mut guard) = self.inner.write()
1645 && let Some(status) = guard.get_mut(name)
1646 {
1647 status.queued = status.queued.saturating_sub(1);
1648 }
1649 let exact_ms = self
1650 .queues
1651 .write()
1652 .ok()
1653 .and_then(|mut guard| {
1654 let removed = guard.pg_marks_by_job_id.swap_remove(id)?;
1655 guard.pg_marks_seq_order.remove(&removed.seq);
1656 Some(removed)
1657 })
1658 .map(|mark| mark.ms);
1659 if let Some(exact_ms) = exact_ms
1660 && self.pop_waiting_exact(name, exact_ms)
1661 {
1662 return;
1663 }
1664 let mut candidates: Vec<u64> = Vec::with_capacity(3);
1665 candidates.extend(absolute_ms);
1666 candidates.push(real_reference_ms.saturating_add_signed(offset_ms));
1667 candidates.push(self.now_ms().saturating_add_signed(offset_ms));
1668 self.pop_waiting_nearest(name, &candidates);
1669 }
1670
1671 fn pop_waiting(&self, name: &str, prefer_ready: bool) {
1679 let now = self.now_ms();
1680 if let Ok(mut guard) = self.queues.write()
1683 && let Some(waiting) = guard.waiting_for_mut(name)
1684 {
1685 let idx = waiting
1686 .iter()
1687 .position(|ready_at| (*ready_at <= now) == prefer_ready)
1688 .or(if waiting.is_empty() { None } else { Some(0) });
1689 if let Some(idx) = idx {
1690 waiting.remove(idx);
1691 }
1692 }
1693 }
1694
1695 fn pop_waiting_nearest(&self, name: &str, candidates: &[u64]) {
1699 if let Ok(mut guard) = self.queues.write()
1700 && let Some(waiting) = guard.waiting_for_mut(name)
1701 {
1702 let idx = waiting
1703 .iter()
1704 .enumerate()
1705 .min_by_key(|(_, ready_at)| {
1706 candidates
1707 .iter()
1708 .map(|target| ready_at.abs_diff(*target))
1709 .min()
1710 })
1711 .map(|(idx, _)| idx);
1712 if let Some(idx) = idx {
1713 waiting.remove(idx);
1714 }
1715 }
1716 }
1717
1718 fn pop_waiting_exact(&self, name: &str, exact_ms: u64) -> bool {
1724 if let Ok(mut guard) = self.queues.write()
1725 && let Some(waiting) = guard.waiting_for_mut(name)
1726 && let Some(idx) = waiting.iter().position(|mark| *mark == exact_ms)
1727 {
1728 waiting.remove(idx);
1729 return true;
1730 }
1731 false
1732 }
1733
1734 #[cfg(test)]
1741 pub(crate) fn waiting_marks_for_test(&self, name: &str) -> Vec<u64> {
1742 self.queues
1743 .read()
1744 .ok()
1745 .and_then(|g| {
1746 g.waiting
1747 .get(g.queue_of(name))
1748 .map(|w| w.iter().copied().collect())
1749 })
1750 .unwrap_or_default()
1751 }
1752
1753 #[cfg(test)]
1757 pub(crate) fn pg_marks_len_for_test(&self) -> usize {
1758 self.queues.read().map_or(0, |g| g.pg_marks_by_job_id.len())
1759 }
1760
1761 #[cfg(test)]
1765 pub(crate) fn pg_marks_seq_order_is_consistent_for_test(&self) -> bool {
1766 self.queues.read().is_ok_and(|g| {
1767 g.pg_marks_seq_order.len() == g.pg_marks_by_job_id.len()
1768 && g.pg_marks_seq_order
1769 .values()
1770 .all(|id| g.pg_marks_by_job_id.contains_key(id))
1771 })
1772 }
1773
1774 #[cfg(test)]
1777 pub(crate) fn pg_mark_contains_id_for_test(&self, id: &str) -> bool {
1778 self.queues
1779 .read()
1780 .is_ok_and(|g| g.pg_marks_by_job_id.contains_key(id))
1781 }
1782
1783 #[cfg(test)]
1787 #[allow(dead_code, reason = "used only by feature-gated job tests")]
1788 pub(crate) const fn pg_marks_cap_for_test() -> usize {
1789 PG_MARKS_BY_JOB_ID_CAP
1790 }
1791
1792 pub fn record_success(&self, name: &str) {
1794 if let Ok(mut guard) = self.inner.write()
1795 && let Some(status) = guard.get_mut(name)
1796 {
1797 status.in_flight = status.in_flight.saturating_sub(1);
1798 status.total_successes = status.total_successes.saturating_add(1);
1799 status.last_error = None;
1800 }
1801 }
1802
1803 pub fn record_retry(&self, name: &str, error: &str, _attempt: u32) {
1805 if let Ok(mut guard) = self.inner.write()
1806 && let Some(status) = guard.get_mut(name)
1807 {
1808 status.in_flight = status.in_flight.saturating_sub(1);
1809 status.last_error = Some(error.to_string());
1810 }
1811 }
1812
1813 pub fn record_failure(&self, name: &str, error: String, dead_lettered: bool) {
1815 self.record_failure_inner(name, error, dead_lettered, true);
1816 }
1817
1818 #[cfg(any(feature = "db", feature = "redis"))]
1821 pub(crate) fn restore_in_flight(&self, name: &str) {
1822 if let Ok(mut guard) = self.inner.write()
1823 && let Some(status) = guard.get_mut(name)
1824 {
1825 status.in_flight = status.in_flight.saturating_add(1);
1826 }
1827 }
1828
1829 #[cfg(any(feature = "db", feature = "redis"))]
1832 pub(crate) fn record_failure_not_started(
1833 &self,
1834 name: &str,
1835 error: String,
1836 dead_lettered: bool,
1837 ) {
1838 self.record_failure_inner(name, error, dead_lettered, false);
1839 }
1840
1841 fn record_failure_inner(&self, name: &str, error: String, dead_lettered: bool, started: bool) {
1842 if let Ok(mut guard) = self.inner.write()
1843 && let Some(status) = guard.get_mut(name)
1844 {
1845 if started {
1846 status.in_flight = status.in_flight.saturating_sub(1);
1847 }
1848 status.total_failures = status.total_failures.saturating_add(1);
1849 status.last_error = Some(error);
1850 if dead_lettered {
1851 status.dead_letters = status.dead_letters.saturating_add(1);
1852 }
1853 }
1854 }
1855
1856 #[must_use]
1858 pub fn snapshot(&self) -> HashMap<String, JobStatus> {
1859 self.inner.read().map(|g| g.clone()).unwrap_or_default()
1860 }
1861
1862 pub fn record_dead_letter_trimmed(&self, count: u64) {
1864 self.dead_letter_trimmed
1865 .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
1866 }
1867
1868 #[must_use]
1870 pub fn dead_letter_trimmed_total(&self) -> u64 {
1871 self.dead_letter_trimmed
1872 .load(std::sync::atomic::Ordering::Relaxed)
1873 }
1874}
1875
1876impl Default for JobRegistry {
1877 fn default() -> Self {
1878 Self::new()
1879 }
1880}
1881
1882impl TaskRegistry {
1883 #[must_use]
1885 pub fn new() -> Self {
1886 Self {
1887 inner: Arc::new(RwLock::new(HashMap::new())),
1888 }
1889 }
1890
1891 pub fn register(&self, name: &str, schedule: &str) {
1893 self.register_scheduled(
1894 name,
1895 schedule,
1896 crate::task::TaskCoordination::Fleet,
1897 "in_process",
1898 "unknown",
1899 );
1900 }
1901
1902 pub fn register_scheduled(
1904 &self,
1905 name: &str,
1906 schedule: &str,
1907 coordination: crate::task::TaskCoordination,
1908 scheduler_backend: &str,
1909 replica_id: &str,
1910 ) {
1911 let Ok(mut guard) = self.inner.write() else {
1912 return;
1913 };
1914 guard.insert(
1915 name.to_string(),
1916 TaskStatus {
1917 schedule: schedule.to_string(),
1918 coordination,
1919 scheduler_backend: scheduler_backend.to_string(),
1920 replica_id: replica_id.to_string(),
1921 current_leader: None,
1922 last_tick: None,
1923 last_fired_at: None,
1924 next_run_at: None,
1925 status: "idle".to_string(),
1926 last_run: None,
1927 last_duration_ms: None,
1928 last_result: None,
1929 last_error: None,
1930 total_runs: 0,
1931 total_failures: 0,
1932 },
1933 );
1934 }
1935
1936 pub fn record_leader(&self, name: &str, leader_id: &str, tick_key: &str) {
1938 let Ok(mut guard) = self.inner.write() else {
1939 return;
1940 };
1941 let Some(task) = guard.get_mut(name) else {
1942 return;
1943 };
1944 task.current_leader = Some(leader_id.to_string());
1945 task.last_tick = Some(tick_key.to_string());
1946 }
1947
1948 pub fn record_start(&self, name: &str) {
1950 let Ok(mut guard) = self.inner.write() else {
1951 return;
1952 };
1953 let Some(task) = guard.get_mut(name) else {
1954 return;
1955 };
1956 task.status = "running".to_string();
1957 task.next_run_at = None;
1958 }
1959
1960 pub fn record_next_run_at(&self, name: &str, next_run_at: &str) {
1962 let Ok(mut guard) = self.inner.write() else {
1963 return;
1964 };
1965 let Some(task) = guard.get_mut(name) else {
1966 return;
1967 };
1968 task.next_run_at = Some(next_run_at.to_string());
1969 }
1970
1971 pub fn record_success(&self, name: &str, duration_ms: u64) {
1973 let Ok(mut guard) = self.inner.write() else {
1974 return;
1975 };
1976 let Some(task) = guard.get_mut(name) else {
1977 return;
1978 };
1979 task.status = "idle".to_string();
1980 let now = crate::time::ambient_now().to_rfc3339();
1981 task.last_run = Some(now.clone());
1982 task.last_fired_at = Some(now);
1983 task.last_duration_ms = Some(duration_ms);
1984 task.last_result = Some("ok".to_string());
1985 task.last_error = None;
1986 task.total_runs += 1;
1987 }
1988
1989 pub fn record_failure(&self, name: &str, duration_ms: u64, error: &str) {
1991 let Ok(mut guard) = self.inner.write() else {
1992 return;
1993 };
1994 let Some(task) = guard.get_mut(name) else {
1995 return;
1996 };
1997 task.status = "idle".to_string();
1998 let now = crate::time::ambient_now().to_rfc3339();
1999 task.last_run = Some(now.clone());
2000 task.last_fired_at = Some(now);
2001 task.last_duration_ms = Some(duration_ms);
2002 task.last_result = Some("failed".to_string());
2003 task.last_error = Some(error.to_string());
2004 task.total_runs += 1;
2005 task.total_failures += 1;
2006 }
2007
2008 #[must_use]
2010 pub fn snapshot(&self) -> HashMap<String, TaskStatus> {
2011 self.inner
2012 .read()
2013 .map(|guard| guard.clone())
2014 .unwrap_or_default()
2015 }
2016}
2017
2018impl Default for TaskRegistry {
2019 fn default() -> Self {
2020 Self::new()
2021 }
2022}
2023
2024impl std::fmt::Debug for TaskRegistry {
2025 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2026 f.debug_struct("TaskRegistry")
2027 .field("count", &self.snapshot().len())
2028 .finish()
2029 }
2030}
2031
2032#[derive(Debug, Clone, Serialize, Deserialize)]
2034pub struct ConfigProperty {
2035 pub value: serde_json::Value,
2037 pub source: String,
2039}
2040
2041#[derive(Debug, Clone, Default)]
2043pub struct ConfigProperties {
2044 inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
2045}
2046
2047impl ConfigProperties {
2048 #[must_use]
2050 #[allow(clippy::too_many_lines)]
2051 pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
2052 let profile = config.profile.as_deref().unwrap_or("default");
2053 let defaults = crate::config::AutumnConfig::default();
2054
2055 let mut props = HashMap::with_capacity(32);
2057 let profile_str = profile.to_string();
2058
2059 Self::track_server_props(&mut props, config, &defaults, &profile_str);
2060 Self::track_db_props(&mut props, config, &defaults, &profile_str);
2061 Self::track_log_props(&mut props, config, &defaults, &profile_str);
2062 Self::track_telemetry_props(&mut props, config, &defaults, &profile_str);
2063 Self::track_health_props(&mut props, config, &defaults, &profile_str);
2064 Self::track_actuator_props(&mut props, config, &defaults, &profile_str);
2065 Self::track_metrics_props(&mut props, config, &defaults, &profile_str);
2066 Self::track_session_props(&mut props, config, &defaults, &profile_str);
2067 Self::track_channels_props(&mut props, config, &defaults, &profile_str);
2068
2069 Self {
2070 inner: Arc::new(RwLock::new(props)),
2071 }
2072 }
2073
2074 fn track_server_props(
2075 props: &mut HashMap<String, ConfigProperty>,
2076 config: &crate::config::AutumnConfig,
2077 defaults: &crate::config::AutumnConfig,
2078 profile_str: &str,
2079 ) {
2080 Self::track_property(
2081 props,
2082 "server.host",
2083 &config.server.host,
2084 &defaults.server.host,
2085 profile_str,
2086 );
2087 Self::track_property(
2088 props,
2089 "server.port",
2090 &config.server.port.to_string(),
2091 &defaults.server.port.to_string(),
2092 profile_str,
2093 );
2094 Self::track_property(
2095 props,
2096 "server.shutdown_timeout_secs",
2097 &config.server.shutdown_timeout_secs.to_string(),
2098 &defaults.server.shutdown_timeout_secs.to_string(),
2099 profile_str,
2100 );
2101 }
2102
2103 fn track_db_props(
2104 props: &mut HashMap<String, ConfigProperty>,
2105 config: &crate::config::AutumnConfig,
2106 defaults: &crate::config::AutumnConfig,
2107 profile_str: &str,
2108 ) {
2109 let db_url = config.database.url.as_deref().unwrap_or("").to_string();
2110 let primary_url = config
2111 .database
2112 .primary_url
2113 .as_deref()
2114 .unwrap_or("")
2115 .to_string();
2116 let replica_url = config
2117 .database
2118 .replica_url
2119 .as_deref()
2120 .unwrap_or("")
2121 .to_string();
2122 Self::track_property(props, "database.url", &db_url, "", profile_str);
2123 Self::track_property(props, "database.primary_url", &primary_url, "", profile_str);
2124 Self::track_property(props, "database.replica_url", &replica_url, "", profile_str);
2125 Self::track_property(
2126 props,
2127 "database.pool_size",
2128 &config.database.pool_size.to_string(),
2129 &defaults.database.pool_size.to_string(),
2130 profile_str,
2131 );
2132 Self::track_property(
2133 props,
2134 "database.primary_pool_size",
2135 &config.database.effective_primary_pool_size().to_string(),
2136 &defaults.database.effective_primary_pool_size().to_string(),
2137 profile_str,
2138 );
2139 Self::track_property(
2140 props,
2141 "database.replica_pool_size",
2142 &config.database.effective_replica_pool_size().to_string(),
2143 &defaults.database.effective_replica_pool_size().to_string(),
2144 profile_str,
2145 );
2146 Self::track_property(
2147 props,
2148 "database.replica_fallback",
2149 &format!("{:?}", config.database.replica_fallback),
2150 &format!("{:?}", defaults.database.replica_fallback),
2151 profile_str,
2152 );
2153 Self::track_property(
2154 props,
2155 "database.replica_max_lag_ms",
2156 &format!("{:?}", config.database.replica_max_lag_ms),
2157 &format!("{:?}", defaults.database.replica_max_lag_ms),
2158 profile_str,
2159 );
2160 }
2161
2162 fn track_log_props(
2163 props: &mut HashMap<String, ConfigProperty>,
2164 config: &crate::config::AutumnConfig,
2165 defaults: &crate::config::AutumnConfig,
2166 profile_str: &str,
2167 ) {
2168 Self::track_property(
2169 props,
2170 "log.level",
2171 &config.log.level,
2172 &defaults.log.level,
2173 profile_str,
2174 );
2175 Self::track_property(
2176 props,
2177 "log.format",
2178 &format!("{:?}", config.log.format),
2179 &format!("{:?}", defaults.log.format),
2180 profile_str,
2181 );
2182 Self::track_property(
2183 props,
2184 "log.capture.enabled",
2185 &config.log.capture.enabled.to_string(),
2186 &defaults.log.capture.enabled.to_string(),
2187 profile_str,
2188 );
2189 Self::track_property(
2190 props,
2191 "log.capture.capacity",
2192 &config.log.capture.capacity.to_string(),
2193 &defaults.log.capture.capacity.to_string(),
2194 profile_str,
2195 );
2196 }
2197
2198 fn track_telemetry_props(
2199 props: &mut HashMap<String, ConfigProperty>,
2200 config: &crate::config::AutumnConfig,
2201 defaults: &crate::config::AutumnConfig,
2202 profile_str: &str,
2203 ) {
2204 Self::track_property(
2205 props,
2206 "telemetry.enabled",
2207 &config.telemetry.enabled.to_string(),
2208 &defaults.telemetry.enabled.to_string(),
2209 profile_str,
2210 );
2211 Self::track_property(
2212 props,
2213 "telemetry.service_name",
2214 &config.telemetry.service_name,
2215 &defaults.telemetry.service_name,
2216 profile_str,
2217 );
2218 Self::track_property(
2219 props,
2220 "telemetry.service_namespace",
2221 config.telemetry.service_namespace.as_deref().unwrap_or(""),
2222 defaults
2223 .telemetry
2224 .service_namespace
2225 .as_deref()
2226 .unwrap_or(""),
2227 profile_str,
2228 );
2229 Self::track_property(
2230 props,
2231 "telemetry.service_version",
2232 &config.telemetry.service_version,
2233 &defaults.telemetry.service_version,
2234 profile_str,
2235 );
2236 Self::track_property(
2237 props,
2238 "telemetry.environment",
2239 &config.telemetry.environment,
2240 &defaults.telemetry.environment,
2241 profile_str,
2242 );
2243 Self::track_property(
2244 props,
2245 "telemetry.otlp_endpoint",
2246 config.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2247 defaults.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
2248 profile_str,
2249 );
2250 Self::track_property(
2251 props,
2252 "telemetry.protocol",
2253 &format!("{:?}", config.telemetry.protocol),
2254 &format!("{:?}", defaults.telemetry.protocol),
2255 profile_str,
2256 );
2257 Self::track_property(
2258 props,
2259 "telemetry.strict",
2260 &config.telemetry.strict.to_string(),
2261 &defaults.telemetry.strict.to_string(),
2262 profile_str,
2263 );
2264 }
2265
2266 fn track_health_props(
2267 props: &mut HashMap<String, ConfigProperty>,
2268 config: &crate::config::AutumnConfig,
2269 defaults: &crate::config::AutumnConfig,
2270 profile_str: &str,
2271 ) {
2272 Self::track_property(
2273 props,
2274 "health.path",
2275 &config.health.path,
2276 &defaults.health.path,
2277 profile_str,
2278 );
2279 Self::track_property(
2280 props,
2281 "health.live_path",
2282 &config.health.live_path,
2283 &defaults.health.live_path,
2284 profile_str,
2285 );
2286 Self::track_property(
2287 props,
2288 "health.ready_path",
2289 &config.health.ready_path,
2290 &defaults.health.ready_path,
2291 profile_str,
2292 );
2293 Self::track_property(
2294 props,
2295 "health.startup_path",
2296 &config.health.startup_path,
2297 &defaults.health.startup_path,
2298 profile_str,
2299 );
2300 Self::track_property(
2301 props,
2302 "health.detailed",
2303 &config.health.detailed.to_string(),
2304 &defaults.health.detailed.to_string(),
2305 profile_str,
2306 );
2307 }
2308
2309 fn track_actuator_props(
2310 props: &mut HashMap<String, ConfigProperty>,
2311 config: &crate::config::AutumnConfig,
2312 defaults: &crate::config::AutumnConfig,
2313 profile_str: &str,
2314 ) {
2315 Self::track_property(
2316 props,
2317 "actuator.prefix",
2318 &config.actuator.prefix,
2319 &defaults.actuator.prefix,
2320 profile_str,
2321 );
2322 Self::track_property(
2323 props,
2324 "actuator.sensitive",
2325 &config.actuator.sensitive.to_string(),
2326 &defaults.actuator.sensitive.to_string(),
2327 profile_str,
2328 );
2329 Self::track_property(
2330 props,
2331 "actuator.prometheus",
2332 &config.actuator.prometheus.to_string(),
2333 &defaults.actuator.prometheus.to_string(),
2334 profile_str,
2335 );
2336 }
2337
2338 fn track_metrics_props(
2339 props: &mut HashMap<String, ConfigProperty>,
2340 config: &crate::config::AutumnConfig,
2341 defaults: &crate::config::AutumnConfig,
2342 profile_str: &str,
2343 ) {
2344 Self::track_property(
2345 props,
2346 "metrics.max_series_per_metric",
2347 &config.metrics.max_series_per_metric.to_string(),
2348 &defaults.metrics.max_series_per_metric.to_string(),
2349 profile_str,
2350 );
2351 Self::track_property(
2352 props,
2353 "metrics.max_instruments",
2354 &config.metrics.max_instruments.to_string(),
2355 &defaults.metrics.max_instruments.to_string(),
2356 profile_str,
2357 );
2358 Self::track_property(
2359 props,
2360 "metrics.max_labels_per_series",
2361 &config.metrics.max_labels_per_series.to_string(),
2362 &defaults.metrics.max_labels_per_series.to_string(),
2363 profile_str,
2364 );
2365 }
2366
2367 fn track_session_props(
2368 props: &mut HashMap<String, ConfigProperty>,
2369 config: &crate::config::AutumnConfig,
2370 defaults: &crate::config::AutumnConfig,
2371 profile_str: &str,
2372 ) {
2373 Self::track_property(
2374 props,
2375 "session.backend",
2376 &format!("{:?}", config.session.backend),
2377 &format!("{:?}", defaults.session.backend),
2378 profile_str,
2379 );
2380 Self::track_property(
2381 props,
2382 "session.cookie_name",
2383 &config.session.cookie_name,
2384 &defaults.session.cookie_name,
2385 profile_str,
2386 );
2387 Self::track_property(
2388 props,
2389 "session.max_age_secs",
2390 &config.session.max_age_secs.to_string(),
2391 &defaults.session.max_age_secs.to_string(),
2392 profile_str,
2393 );
2394 Self::track_property(
2395 props,
2396 "session.secure",
2397 &config.session.secure.to_string(),
2398 &defaults.session.secure.to_string(),
2399 profile_str,
2400 );
2401 Self::track_property(
2402 props,
2403 "session.same_site",
2404 &config.session.same_site,
2405 &defaults.session.same_site,
2406 profile_str,
2407 );
2408 Self::track_property(
2409 props,
2410 "session.http_only",
2411 &config.session.http_only.to_string(),
2412 &defaults.session.http_only.to_string(),
2413 profile_str,
2414 );
2415 Self::track_property(
2416 props,
2417 "session.path",
2418 &config.session.path,
2419 &defaults.session.path,
2420 profile_str,
2421 );
2422 Self::track_property(
2423 props,
2424 "session.allow_memory_in_production",
2425 &config.session.allow_memory_in_production.to_string(),
2426 &defaults.session.allow_memory_in_production.to_string(),
2427 profile_str,
2428 );
2429 Self::track_property(
2430 props,
2431 "session.redis.url",
2432 config.session.redis.url.as_deref().unwrap_or(""),
2433 defaults.session.redis.url.as_deref().unwrap_or(""),
2434 profile_str,
2435 );
2436 Self::track_property(
2437 props,
2438 "session.redis.key_prefix",
2439 &config.session.redis.key_prefix,
2440 &defaults.session.redis.key_prefix,
2441 profile_str,
2442 );
2443 }
2444
2445 fn track_channels_props(
2446 props: &mut HashMap<String, ConfigProperty>,
2447 config: &crate::config::AutumnConfig,
2448 defaults: &crate::config::AutumnConfig,
2449 profile_str: &str,
2450 ) {
2451 Self::track_property(
2452 props,
2453 "channels.backend",
2454 &format!("{:?}", config.channels.backend),
2455 &format!("{:?}", defaults.channels.backend),
2456 profile_str,
2457 );
2458 Self::track_property(
2459 props,
2460 "channels.capacity",
2461 &config.channels.capacity.to_string(),
2462 &defaults.channels.capacity.to_string(),
2463 profile_str,
2464 );
2465 Self::track_property(
2466 props,
2467 "channels.redis.url",
2468 config.channels.redis.url.as_deref().unwrap_or(""),
2469 defaults.channels.redis.url.as_deref().unwrap_or(""),
2470 profile_str,
2471 );
2472 Self::track_property(
2473 props,
2474 "channels.redis.key_prefix",
2475 &config.channels.redis.key_prefix,
2476 &defaults.channels.redis.key_prefix,
2477 profile_str,
2478 );
2479 }
2480
2481 fn track_property(
2482 props: &mut HashMap<String, ConfigProperty>,
2483 key: &str,
2484 value: &str,
2485 default_value: &str,
2486 profile: &str,
2487 ) {
2488 let env_key = format!("AUTUMN_{}", key.replace('.', "__").to_uppercase());
2490 let source = if std::env::var(&env_key).is_ok() {
2491 env_key
2492 } else if value != default_value && (profile == "dev" || profile == "prod") {
2493 format!("profile_default:{profile}")
2494 } else if value != default_value {
2495 "autumn.toml".to_string()
2496 } else {
2497 "default".to_string()
2498 };
2499
2500 let display_value = if should_redact(key) {
2501 serde_json::Value::String("****".into())
2502 } else {
2503 serde_json::Value::String(value.to_string())
2504 };
2505
2506 props.insert(
2507 key.to_string(),
2508 ConfigProperty {
2509 value: display_value,
2510 source,
2511 },
2512 );
2513 }
2514
2515 #[must_use]
2517 pub fn snapshot(&self) -> HashMap<String, ConfigProperty> {
2518 self.inner
2519 .read()
2520 .map(|guard| guard.clone())
2521 .unwrap_or_default()
2522 }
2523}
2524
2525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2532pub enum HealthStatus {
2533 #[serde(rename = "UP")]
2535 Up,
2536 #[serde(rename = "DOWN")]
2538 Down,
2539 #[serde(rename = "OUT_OF_SERVICE")]
2541 OutOfService,
2542 #[serde(rename = "UNKNOWN")]
2544 Unknown,
2545}
2546
2547impl HealthStatus {
2548 #[must_use]
2550 pub const fn as_str(self) -> &'static str {
2551 match self {
2552 Self::Up => "UP",
2553 Self::Down => "DOWN",
2554 Self::OutOfService => "OUT_OF_SERVICE",
2555 Self::Unknown => "UNKNOWN",
2556 }
2557 }
2558
2559 #[must_use]
2562 pub const fn is_healthy(self) -> bool {
2563 matches!(self, Self::Up | Self::Unknown)
2564 }
2565}
2566
2567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2572pub enum IndicatorGroup {
2573 Readiness,
2575 HealthOnly,
2577}
2578
2579#[derive(Debug, Clone)]
2581pub struct HealthCheckOutput {
2582 pub status: HealthStatus,
2584 pub details: HashMap<String, serde_json::Value>,
2586}
2587
2588impl HealthCheckOutput {
2589 #[must_use]
2591 pub fn up() -> Self {
2592 Self {
2593 status: HealthStatus::Up,
2594 details: HashMap::new(),
2595 }
2596 }
2597
2598 #[must_use]
2600 pub fn down() -> Self {
2601 Self {
2602 status: HealthStatus::Down,
2603 details: HashMap::new(),
2604 }
2605 }
2606
2607 #[must_use]
2609 pub fn with_details(mut self, details: HashMap<String, serde_json::Value>) -> Self {
2610 self.details = details;
2611 self
2612 }
2613}
2614
2615pub trait HealthIndicator: Send + Sync + 'static {
2638 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput>;
2644
2645 fn timeout_ms(&self) -> u64 {
2647 2000
2648 }
2649
2650 fn group(&self) -> IndicatorGroup {
2652 IndicatorGroup::Readiness
2653 }
2654}
2655
2656#[derive(Debug, Clone)]
2659pub struct HealthRunResult {
2660 pub name: String,
2662 pub group: IndicatorGroup,
2664 pub output: HealthCheckOutput,
2666}
2667
2668#[derive(Clone)]
2670struct IndicatorEntry {
2671 name: String,
2672 group: IndicatorGroup,
2673 indicator: Arc<dyn HealthIndicator>,
2674 cache: Arc<crate::health_cache::SingleFlightCache<HealthCheckOutput>>,
2675}
2676
2677impl IndicatorEntry {
2678 async fn run(self) -> HealthRunResult {
2680 let indicator = Arc::clone(&self.indicator);
2681 let output = self
2682 .cache
2683 .get_or_refresh(
2684 move || async move { run_with_timeout(indicator.as_ref()).await },
2685 || {
2686 let mut details = HashMap::new();
2687 details.insert("panicked".to_owned(), serde_json::Value::Bool(true));
2688 HealthCheckOutput::down().with_details(details)
2689 },
2690 )
2691 .await;
2692 HealthRunResult {
2693 name: self.name,
2694 group: self.group,
2695 output,
2696 }
2697 }
2698}
2699
2700#[derive(Default)]
2701struct IndicatorList {
2702 entries: Vec<IndicatorEntry>,
2703 cache_ttl: std::time::Duration,
2704}
2705
2706#[derive(Clone, Default)]
2713pub struct HealthIndicatorRegistry {
2714 inner: Arc<RwLock<IndicatorList>>,
2715}
2716
2717impl HealthIndicatorRegistry {
2718 #[must_use]
2720 pub fn new() -> Self {
2721 Self::default()
2722 }
2723
2724 pub fn register(
2732 &self,
2733 name: impl Into<String>,
2734 group: IndicatorGroup,
2735 indicator: Arc<dyn HealthIndicator>,
2736 ) -> Result<(), String> {
2737 let name = name.into();
2738 let mut inner = self
2739 .inner
2740 .write()
2741 .unwrap_or_else(std::sync::PoisonError::into_inner);
2742 if inner.entries.iter().any(|entry| entry.name == name) {
2743 return Err(format!(
2744 "HealthIndicator '{name}' is already registered; skipping duplicate"
2745 ));
2746 }
2747 let cache = Arc::new(crate::health_cache::SingleFlightCache::new(inner.cache_ttl));
2748 inner.entries.push(IndicatorEntry {
2749 name,
2750 group,
2751 indicator,
2752 cache,
2753 });
2754 drop(inner);
2755 Ok(())
2756 }
2757
2758 pub fn set_cache_ttl(&self, ttl: std::time::Duration) {
2766 let mut inner = self
2767 .inner
2768 .write()
2769 .unwrap_or_else(std::sync::PoisonError::into_inner);
2770 inner.cache_ttl = ttl;
2771 for entry in &inner.entries {
2772 entry.cache.set_ttl(ttl);
2773 }
2774 }
2775
2776 #[must_use]
2778 pub fn is_empty(&self) -> bool {
2779 self.inner
2780 .read()
2781 .unwrap_or_else(std::sync::PoisonError::into_inner)
2782 .entries
2783 .is_empty()
2784 }
2785
2786 #[must_use]
2794 pub fn contains(&self, name: &str) -> bool {
2795 self.inner
2796 .read()
2797 .unwrap_or_else(std::sync::PoisonError::into_inner)
2798 .entries
2799 .iter()
2800 .any(|entry| entry.name == name)
2801 }
2802
2803 fn entries(&self) -> Vec<IndicatorEntry> {
2805 self.inner
2806 .read()
2807 .unwrap_or_else(std::sync::PoisonError::into_inner)
2808 .entries
2809 .clone()
2810 }
2811
2812 pub async fn run_all(&self) -> Vec<HealthRunResult> {
2817 let mut results =
2818 futures::future::join_all(self.entries().into_iter().map(IndicatorEntry::run)).await;
2819
2820 for breaker in crate::circuit_breaker::global_registry().all_breakers() {
2821 let state = breaker.state();
2822 let status = match state {
2823 crate::circuit_breaker::CircuitState::Open
2824 | crate::circuit_breaker::CircuitState::HalfOpen => HealthStatus::Down,
2825 crate::circuit_breaker::CircuitState::Closed => HealthStatus::Up,
2826 };
2827
2828 let mut details = HashMap::new();
2829 details.insert(
2830 "state".to_string(),
2831 serde_json::Value::String(state.as_str().to_string()),
2832 );
2833 for (key, ratio) in [
2834 ("failure_ratio", breaker.failure_ratio()),
2835 ("slow_call_ratio", breaker.slow_call_ratio()),
2836 ] {
2837 if let Some(ratio_num) = serde_json::Number::from_f64(ratio) {
2838 details.insert(key.to_string(), serde_json::Value::Number(ratio_num));
2839 }
2840 }
2841
2842 results.push(HealthRunResult {
2843 name: format!("circuit_breaker.{}", breaker.name()),
2844 group: IndicatorGroup::HealthOnly,
2845 output: HealthCheckOutput { status, details },
2846 });
2847 }
2848
2849 results
2850 }
2851
2852 pub async fn run_readiness(&self) -> Vec<HealthRunResult> {
2857 futures::future::join_all(
2858 self.entries()
2859 .into_iter()
2860 .filter(|entry| entry.group == IndicatorGroup::Readiness)
2861 .map(IndicatorEntry::run),
2862 )
2863 .await
2864 }
2865
2866 #[must_use]
2871 pub fn aggregate_status(statuses: &[HealthStatus]) -> HealthStatus {
2872 let mut overall = HealthStatus::Up;
2873 for &s in statuses {
2874 overall = match (overall, s) {
2875 (_, HealthStatus::Down) | (HealthStatus::Down, _) => HealthStatus::Down,
2876 (_, HealthStatus::OutOfService) | (HealthStatus::OutOfService, _) => {
2877 HealthStatus::OutOfService
2878 }
2879 (_, HealthStatus::Unknown) | (HealthStatus::Unknown, _) => HealthStatus::Unknown,
2880 _ => HealthStatus::Up,
2881 };
2882 }
2883 overall
2884 }
2885}
2886
2887async fn run_with_timeout(indicator: &dyn HealthIndicator) -> HealthCheckOutput {
2890 let duration = tokio::time::Duration::from_millis(indicator.timeout_ms());
2891 match tokio::time::timeout(duration, indicator.check()).await {
2892 Ok(output) => output,
2893 Err(_elapsed) => {
2894 let mut details = HashMap::new();
2895 details.insert("timed_out".to_string(), serde_json::Value::Bool(true));
2896 HealthCheckOutput {
2897 status: HealthStatus::Unknown,
2898 details,
2899 }
2900 }
2901 }
2902}
2903
2904#[derive(Serialize)]
2908struct ActuatorHealth {
2909 status: &'static str,
2911 version: &'static str,
2912 profile: String,
2913 uptime: String,
2914 #[cfg(feature = "db")]
2915 autumn_after_commit_failures_total: u64,
2916 #[cfg(feature = "db")]
2918 autumn_tx_retries_total: u64,
2919 #[cfg(feature = "db")]
2921 autumn_tx_retry_exhausted_total: u64,
2922 #[serde(skip_serializing_if = "HashMap::is_empty")]
2924 components: HashMap<String, ComponentHealth>,
2925 #[serde(skip_serializing_if = "Option::is_none")]
2927 checks: Option<HealthChecks>,
2928}
2929
2930#[derive(Serialize, Clone)]
2931struct ComponentHealth {
2932 status: &'static str,
2933 #[serde(skip_serializing_if = "Option::is_none")]
2934 details: Option<serde_json::Value>,
2935}
2936
2937#[derive(Serialize)]
2938struct HealthChecks {
2939 #[serde(skip_serializing_if = "Option::is_none")]
2940 database: Option<DatabaseCheck>,
2941}
2942
2943#[derive(Serialize)]
2944struct DatabaseCheck {
2945 status: &'static str,
2946 pool_size: u64,
2947 active_connections: u64,
2948 idle_connections: u64,
2949 #[serde(skip_serializing_if = "Option::is_none")]
2950 error: Option<String>,
2951}
2952
2953fn build_health_components(
2954 db_status: Option<HealthStatus>,
2955 db_check: Option<&DatabaseCheck>,
2956 indicator_results: &[HealthRunResult],
2957 detailed: bool,
2958) -> HashMap<String, ComponentHealth> {
2959 let mut components: HashMap<String, ComponentHealth> = HashMap::new();
2960 for result in indicator_results {
2963 if !detailed
2964 && result.name.starts_with("circuit_breaker.")
2965 && result.output.status.is_healthy()
2966 {
2967 continue;
2968 }
2969 let details = (detailed && !result.output.details.is_empty())
2970 .then(|| serde_json::to_value(&result.output.details).unwrap_or_default());
2971 components.insert(
2972 result.name.clone(),
2973 ComponentHealth {
2974 status: result.output.status.as_str(),
2975 details,
2976 },
2977 );
2978 }
2979 if let Some(s) = db_status {
2980 let details = detailed
2981 .then(|| {
2982 db_check.map(|d| {
2983 let mut details = serde_json::json!({
2984 "status": d.status,
2985 "pool_size": d.pool_size,
2986 "active_connections": d.active_connections,
2987 "idle_connections": d.idle_connections,
2988 });
2989 if let Some(error) = &d.error {
2990 details["error"] = serde_json::json!(error);
2991 }
2992 details
2993 })
2994 })
2995 .flatten();
2996 components.insert(
2997 "db".to_string(),
2998 ComponentHealth {
2999 status: s.as_str(),
3000 details,
3001 },
3002 );
3003 }
3004 components
3005}
3006
3007pub async fn health<S: ProvideActuatorState + Send + Sync + 'static>(
3009 State(state): State<S>,
3010) -> impl IntoResponse {
3011 let detailed = state.health_detailed();
3012
3013 let db_component = async {
3015 #[cfg(feature = "db")]
3016 {
3017 if let Some(pool) = state.pool() {
3018 let primary = match state.probe_state() {
3021 Some(probes) => probes.check_primary_db(pool).await,
3022 None => crate::probe::ProbeState::check_primary_db_uncached(pool).await,
3023 };
3024 let status = pool.status();
3025 let size = status.max_size as u64;
3026 let idle = status.available as u64;
3027 let db_status = if primary.up {
3028 HealthStatus::Up
3029 } else {
3030 HealthStatus::Down
3031 };
3032 let db_check = Some(DatabaseCheck {
3033 status: if primary.up { "ok" } else { "down" },
3034 pool_size: size,
3035 active_connections: size.saturating_sub(idle),
3036 idle_connections: idle,
3037 error: primary.error.filter(|_| detailed),
3040 });
3041 (Some(db_status), db_check)
3042 } else {
3043 (None, None)
3044 }
3045 }
3046 #[cfg(not(feature = "db"))]
3047 {
3048 (None::<HealthStatus>, None::<DatabaseCheck>)
3049 }
3050 };
3051
3052 let indicators = async {
3054 if let Some(registry) = state.health_indicator_registry() {
3055 registry.run_all().await
3056 } else {
3057 Vec::new()
3058 }
3059 };
3060
3061 let ((db_component_status, db_check), indicator_results) =
3063 tokio::join!(db_component, indicators);
3064
3065 let mut all_statuses: Vec<HealthStatus> =
3067 indicator_results.iter().map(|r| r.output.status).collect();
3068 if let Some(s) = db_component_status {
3069 all_statuses.push(s);
3070 }
3071 let overall = HealthIndicatorRegistry::aggregate_status(&all_statuses);
3072
3073 let components = build_health_components(
3075 db_component_status,
3076 db_check.as_ref(),
3077 &indicator_results,
3078 detailed,
3079 );
3080
3081 let checks = db_check.map(|db| HealthChecks { database: Some(db) });
3082
3083 let body = ActuatorHealth {
3084 status: overall.as_str(),
3085 version: env!("CARGO_PKG_VERSION"),
3086 profile: state.profile().to_owned(),
3087 uptime: state.uptime_display(),
3088 #[cfg(feature = "db")]
3089 autumn_after_commit_failures_total: crate::db::AFTER_COMMIT_FAILURES_TOTAL
3090 .load(std::sync::atomic::Ordering::Relaxed),
3091 #[cfg(feature = "db")]
3092 autumn_tx_retries_total: crate::db::TX_RETRIES_TOTAL
3093 .load(std::sync::atomic::Ordering::Relaxed),
3094 #[cfg(feature = "db")]
3095 autumn_tx_retry_exhausted_total: crate::db::TX_RETRY_EXHAUSTED_TOTAL
3096 .load(std::sync::atomic::Ordering::Relaxed),
3097 components,
3098 checks,
3099 };
3100
3101 let code = if overall.is_healthy() {
3102 StatusCode::OK
3103 } else {
3104 StatusCode::SERVICE_UNAVAILABLE
3105 };
3106 (code, Json(body))
3107}
3108
3109#[derive(Serialize)]
3113pub(crate) struct ActuatorInfo {
3114 app: AppInfo,
3115 autumn: FrameworkInfo,
3116 runtime: RuntimeInfo,
3117 build: crate::build_info::BuildProvenance,
3119}
3120
3121#[derive(Serialize)]
3122struct AppInfo {
3123 name: String,
3124 version: String,
3125}
3126
3127#[derive(Serialize)]
3128struct FrameworkInfo {
3129 version: &'static str,
3130 profile: String,
3131}
3132
3133#[derive(Serialize)]
3134struct RuntimeInfo {
3135 uptime: String,
3136}
3137
3138pub(crate) async fn info<S: ProvideActuatorState + Send + Sync + 'static>(
3140 State(state): State<S>,
3141) -> Json<ActuatorInfo> {
3142 Json(ActuatorInfo {
3143 app: AppInfo {
3144 name: crate::build_info::app_name(),
3148 version: crate::build_info::app_version(),
3149 },
3150 autumn: FrameworkInfo {
3151 version: env!("CARGO_PKG_VERSION"),
3152 profile: state.profile().to_owned(),
3153 },
3154 runtime: RuntimeInfo {
3155 uptime: state.uptime_display(),
3156 },
3157 build: crate::build_info::build_provenance(),
3158 })
3159}
3160
3161#[derive(Serialize)]
3165pub(crate) struct ActuatorEnv {
3166 active_profile: String,
3167 properties: std::collections::HashMap<String, serde_json::Value>,
3168}
3169
3170const REDACT_PATTERNS: &[&str] = &[
3172 "password",
3173 "secret",
3174 "key",
3175 "token",
3176 "credential",
3177 "auth",
3178 "url",
3179];
3180
3181fn should_redact(key: &str) -> bool {
3182 let lower = key.to_lowercase();
3183 REDACT_PATTERNS.iter().any(|p| lower.contains(p))
3184}
3185
3186pub(crate) async fn env_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3188 State(state): State<S>,
3189) -> Json<ActuatorEnv> {
3190 let properties = state
3191 .config_props()
3192 .snapshot()
3193 .into_iter()
3194 .map(|(key, prop)| (key, prop.value))
3195 .collect();
3196
3197 Json(ActuatorEnv {
3198 active_profile: state.profile().to_owned(),
3199 properties,
3200 })
3201}
3202
3203fn cache_metrics_json() -> serde_json::Value {
3209 let mut cache =
3210 serde_json::to_value(crate::cache::read_through_metrics().snapshot()).unwrap_or_default();
3211 if let serde_json::Value::Object(ref mut map) = cache {
3212 map.insert(
3213 "stale_if_error_serves".to_string(),
3214 crate::cache::read_through_metrics()
3215 .stale_if_error_serves()
3216 .into(),
3217 );
3218 map.insert(
3219 "invalidation_failures".to_string(),
3220 crate::cache::invalidation_failures_total().into(),
3221 );
3222 }
3223 cache
3224}
3225
3226pub(crate) async fn metrics_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3227 State(state): State<S>,
3228) -> Json<serde_json::Value> {
3229 let snapshot = state.metrics().snapshot();
3230 let mut result = serde_json::to_value(&snapshot).unwrap_or_default();
3231
3232 if let serde_json::Value::Object(ref mut map) = result {
3235 map.insert("cache".to_string(), cache_metrics_json());
3236 }
3237
3238 #[cfg(feature = "db")]
3240 if let Some(pool) = state.pool() {
3241 let status = pool.status();
3242 let db_stats = serde_json::json!({
3243 "pool_size": status.max_size,
3244 "active_connections": (status.size as u64).saturating_sub(status.available as u64),
3245 "idle_connections": status.available,
3246 });
3247 if let serde_json::Value::Object(ref mut map) = result {
3248 map.insert("database".to_string(), db_stats);
3249 }
3250 }
3251
3252 #[cfg(feature = "db")]
3254 if let Some(shards) = state.shards() {
3255 let mut shard_stats = serde_json::Map::new();
3256 for shard in shards.iter() {
3257 let status = shard.primary_pool().status();
3258 let mut entry = serde_json::json!({
3259 "pool_size": status.max_size,
3260 "active_connections":
3261 (status.size as u64).saturating_sub(status.available as u64),
3262 "idle_connections": status.available,
3263 "slots": shard.slots().len(),
3264 });
3265 if let Some(replica) = shard.replica_pool() {
3266 let replica_status = replica.status();
3267 if let serde_json::Value::Object(ref mut entry_map) = entry {
3268 entry_map.insert(
3269 "replica".to_string(),
3270 serde_json::json!({
3271 "pool_size": replica_status.max_size,
3272 "active_connections": (replica_status.size as u64)
3273 .saturating_sub(replica_status.available as u64),
3274 "idle_connections": replica_status.available,
3275 }),
3276 );
3277 }
3278 }
3279 shard_stats.insert(shard.name().to_owned(), entry);
3280 }
3281 if let serde_json::Value::Object(ref mut map) = result {
3282 map.insert(
3283 "database_shards".to_string(),
3284 serde_json::Value::Object(shard_stats),
3285 );
3286 }
3287 }
3288
3289 insert_app_metrics(&mut result);
3290
3291 if let Some(registry) = state.metrics_source_registry() {
3293 let all = registry.collect_all();
3294 let mut sources = serde_json::Map::new();
3295 for (source_name, families) in all {
3296 let families_json: Vec<serde_json::Value> = families
3297 .iter()
3298 .map(|f| {
3299 serde_json::json!({
3300 "name": f.name,
3301 "help": f.help,
3302 "kind": f.kind.as_str(),
3303 "samples": f.samples.iter().map(|s| {
3304 let labels: serde_json::Map<String, serde_json::Value> = s.labels
3305 .iter()
3306 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
3307 .collect();
3308 serde_json::json!({
3309 "labels": labels,
3310 "value": s.value,
3311 })
3312 }).collect::<Vec<_>>(),
3313 })
3314 })
3315 .collect();
3316 sources.insert(source_name, serde_json::Value::Array(families_json));
3317 }
3318 if let serde_json::Value::Object(ref mut map) = result {
3319 map.insert("sources".to_string(), serde_json::Value::Object(sources));
3320 }
3321 }
3322
3323 Json(result)
3324}
3325
3326#[derive(Serialize)]
3327pub(crate) struct CircuitBreakerActuatorResponse {
3328 pub name: String,
3329 pub state: &'static str,
3330 pub failure_ratio: f64,
3331 pub slow_call_ratio: f64,
3332 #[serde(skip_serializing_if = "Option::is_none")]
3333 pub failure_ratio_threshold: Option<f64>,
3334 #[serde(skip_serializing_if = "Option::is_none")]
3335 pub sample_window_secs: Option<u64>,
3336 #[serde(skip_serializing_if = "Option::is_none")]
3337 pub minimum_sample_count: Option<u64>,
3338 #[serde(skip_serializing_if = "Option::is_none")]
3339 pub open_duration_secs: Option<u64>,
3340 #[serde(skip_serializing_if = "Option::is_none")]
3341 pub half_open_trial_count: Option<u64>,
3342 #[serde(skip_serializing_if = "Option::is_none")]
3344 pub slow_call_duration_threshold_ms: Option<u64>,
3345 #[serde(skip_serializing_if = "Option::is_none")]
3346 pub slow_call_rate_threshold: Option<f64>,
3347 #[serde(skip_serializing_if = "Option::is_none")]
3348 pub cancelled_call_outcome: Option<&'static str>,
3349}
3350
3351pub(crate) async fn circuitbreakers_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3353 State(state): State<S>,
3354) -> Json<Vec<CircuitBreakerActuatorResponse>> {
3355 let detailed = state.health_detailed();
3356 let mut responses = Vec::new();
3357
3358 for breaker in crate::circuit_breaker::global_registry().all_breakers() {
3359 let policy = breaker.config();
3360 responses.push(CircuitBreakerActuatorResponse {
3361 name: breaker.name().to_string(),
3362 state: breaker.state().as_str(),
3363 failure_ratio: breaker.failure_ratio(),
3364 slow_call_ratio: breaker.slow_call_ratio(),
3365 failure_ratio_threshold: detailed.then_some(policy.failure_ratio_threshold),
3366 sample_window_secs: detailed.then_some(policy.sample_window.as_secs()),
3367 minimum_sample_count: detailed.then_some(policy.minimum_sample_count),
3368 open_duration_secs: detailed.then_some(policy.open_duration.as_secs()),
3369 half_open_trial_count: detailed.then_some(policy.half_open_trial_count),
3370 slow_call_duration_threshold_ms: policy
3371 .slow_call_duration_threshold
3372 .filter(|_| detailed)
3373 .map(|t| u64::try_from(t.as_millis()).unwrap_or(u64::MAX)),
3374 slow_call_rate_threshold: detailed.then_some(policy.slow_call_rate_threshold),
3375 cancelled_call_outcome: detailed.then_some(policy.cancelled_call_outcome.as_str()),
3376 });
3377 }
3378
3379 Json(responses)
3380}
3381
3382fn insert_app_metrics(result: &mut serde_json::Value) {
3389 let app_metrics = crate::metrics::snapshot();
3390 if app_metrics.is_empty() {
3391 return;
3392 }
3393 let serde_json::Value::Object(map) = result else {
3394 return;
3395 };
3396 match serde_json::to_value(&app_metrics) {
3400 Ok(value) => {
3401 map.insert("app".to_string(), value);
3402 }
3403 Err(error) => tracing::warn!(
3404 %error,
3405 "failed to serialize app metrics; omitting the `app` key"
3406 ),
3407 }
3408}
3409
3410fn render_labels(labels: &[(String, String)]) -> String {
3416 if labels.is_empty() {
3417 return String::new();
3418 }
3419 let mut out = String::with_capacity(64);
3420 out.push('{');
3421 for (i, (k, v)) in labels.iter().enumerate() {
3422 if i > 0 {
3423 out.push(',');
3424 }
3425 out.push_str(k);
3426 out.push_str("=\"");
3427 for c in v.chars() {
3428 match c {
3429 '\\' => out.push_str("\\\\"),
3430 '\n' => out.push_str("\\n"),
3431 '"' => out.push_str("\\\""),
3432 other => out.push(other),
3433 }
3434 }
3435 out.push('"');
3436 }
3437 out.push('}');
3438 out
3439}
3440
3441pub(crate) const SERIES_DROPPED_FAMILY: &str = "autumn_metrics_series_dropped_total";
3444
3445pub(crate) const DEAD_LETTER_TRIMMED_FAMILY: &str = "autumn_jobs_dead_letter_trimmed_total";
3447
3448pub(crate) const DB_POOL_WAIT_FAMILY: &str = "autumn_db_pool_wait_seconds";
3450
3451pub(crate) const HTTP_DURATION_FAMILY: &str = "autumn_http_request_duration_seconds";
3453
3454pub(crate) const HTTP_DURATION_QUANTILES_FAMILY: &str =
3457 "autumn_http_request_duration_quantiles_seconds";
3458
3459pub(crate) const BREAKER_SLOW_CALLS_FAMILY: &str = "autumn_circuit_breaker_slow_calls_total";
3461
3462pub(crate) const BREAKER_SLOW_CALL_RATIO_FAMILY: &str = "autumn_circuit_breaker_slow_call_ratio";
3464
3465pub(crate) const BUILTIN_METRIC_FAMILY_NAMES: [&str; 46] = [
3472 "autumn_http_requests_total",
3473 "autumn_http_requests_active",
3474 "autumn_http_responses_total",
3475 HTTP_DURATION_FAMILY,
3481 "autumn_http_request_duration_seconds_bucket",
3482 "autumn_http_request_duration_seconds_sum",
3483 "autumn_http_request_duration_seconds_count",
3484 HTTP_DURATION_QUANTILES_FAMILY,
3485 "autumn_http_request_duration_quantiles_seconds_sum",
3486 "autumn_http_request_duration_quantiles_seconds_count",
3487 "autumn_shutdown_aborted_requests_total",
3488 "autumn_request_timeouts_total",
3489 "autumn_read_your_writes_pins_total",
3490 "autumn_requests_shed_total",
3491 "autumn_admission_limit",
3492 "autumn_admission_shed_total",
3493 "autumn_tenant_bulkhead_rejections_total",
3494 "autumn_http_route_requests_total",
3495 "autumn_metrics_source_errors_total",
3496 SERIES_DROPPED_FAMILY,
3497 "autumn_cache_read_through_hits_total",
3498 "autumn_cache_read_through_misses_total",
3499 "autumn_cache_read_through_coalesced_waits_total",
3500 "autumn_cache_read_through_fills_total",
3501 "autumn_cache_read_through_fill_failures_total",
3502 "autumn_cache_read_through_stale_serves_total",
3503 "autumn_cache_fill_lock_acquires_total",
3504 "autumn_cache_fill_lock_contended_total",
3505 "autumn_cache_read_through_stale_if_error_serves_total",
3506 "autumn_cache_invalidation_failures_total",
3507 DEAD_LETTER_TRIMMED_FAMILY,
3508 "autumn_jobs_queue_depth",
3509 "autumn_jobs_oldest_age_seconds",
3510 "autumn_jobs_dead_letter_total",
3511 "autumn_db_pool_max_size",
3512 "autumn_db_pool_size",
3513 "autumn_db_pool_available",
3514 "autumn_db_pool_waiting",
3515 DB_POOL_WAIT_FAMILY,
3516 "autumn_db_pool_wait_seconds_bucket",
3517 "autumn_db_pool_wait_seconds_sum",
3518 "autumn_db_pool_wait_seconds_count",
3519 BREAKER_SLOW_CALLS_FAMILY,
3520 BREAKER_SLOW_CALL_RATIO_FAMILY,
3521 crate::shadow::COMPARISONS_METRIC,
3522 crate::shadow::DIVERGENCES_METRIC,
3523];
3524
3525pub(crate) fn is_valid_metric_name(s: &str) -> bool {
3527 let mut it = s.chars();
3528 matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':')
3529 && it.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
3530}
3531
3532pub(crate) fn is_valid_label_name(s: &str) -> bool {
3534 let mut it = s.chars();
3535 matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
3536 && it.all(|c| c.is_ascii_alphanumeric() || c == '_')
3537}
3538
3539fn escape_prometheus_label_value(value: &str) -> String {
3541 let mut out = String::with_capacity(value.len());
3542 for c in value.chars() {
3543 match c {
3544 '\\' => out.push_str("\\\\"),
3545 '\n' => out.push_str("\\n"),
3546 '"' => out.push_str("\\\""),
3547 other => out.push(other),
3548 }
3549 }
3550 out
3551}
3552
3553fn escape_help_text(text: &str) -> String {
3555 let mut out = String::with_capacity(text.len());
3556 for c in text.chars() {
3557 match c {
3558 '\\' => out.push_str("\\\\"),
3559 '\n' => out.push_str("\\n"),
3560 other => out.push(other),
3561 }
3562 }
3563 out
3564}
3565
3566fn format_sample_value(v: f64) -> String {
3568 if v == f64::INFINITY {
3569 "+Inf".to_string()
3570 } else if v == f64::NEG_INFINITY {
3571 "-Inf".to_string()
3572 } else if v.is_nan() {
3573 "NaN".to_string()
3574 } else {
3575 v.to_string()
3576 }
3577}
3578
3579fn render_plugin_sources(
3585 registry: &MetricsSourceRegistry,
3586 out: &mut String,
3587 emitted_families: &mut std::collections::HashSet<String>,
3588) {
3589 use std::fmt::Write;
3590
3591 let all_sources = registry.collect_all();
3592 for (_source_name, families) in &all_sources {
3593 for family in families {
3594 if !is_valid_metric_name(&family.name) {
3595 tracing::warn!(name = %family.name, "MetricsSource returned invalid metric name; skipping family");
3596 continue;
3597 }
3598 if !emitted_families.insert(family.name.clone()) {
3599 tracing::warn!(name = %family.name, "MetricsSource returned duplicate metric family name; skipping family");
3600 continue;
3601 }
3602 let _ = writeln!(
3603 out,
3604 "# HELP {} {}",
3605 family.name,
3606 escape_help_text(&family.help)
3607 );
3608 let _ = writeln!(out, "# TYPE {} {}", family.name, family.kind.as_str());
3609 let mut emitted_series: std::collections::HashSet<String> =
3610 std::collections::HashSet::new();
3611 for sample in &family.samples {
3612 let mut bad_key = false;
3613 let mut seen_keys = std::collections::HashSet::new();
3614 let mut valid_labels: Vec<(String, String)> = Vec::new();
3615 for (k, v) in &sample.labels {
3616 if !is_valid_label_name(k) {
3617 tracing::warn!(
3618 label_name = %k,
3619 metric = %family.name,
3620 "MetricsSource returned invalid label name; skipping sample"
3621 );
3622 bad_key = true;
3623 break;
3624 }
3625 if !seen_keys.insert(k.as_str()) {
3626 tracing::warn!(label_name = %k, "MetricsSource returned duplicate label name; dropping duplicate");
3627 continue;
3628 }
3629 valid_labels.push((k.clone(), v.clone()));
3630 }
3631 if bad_key {
3632 continue;
3633 }
3634 valid_labels.sort_by(|(a, _), (b, _)| a.cmp(b));
3637 let labels = render_labels(&valid_labels);
3638 if !emitted_series.insert(labels.clone()) {
3639 tracing::warn!(
3640 metric = %family.name,
3641 labels = %labels,
3642 "MetricsSource returned duplicate series; skipping sample"
3643 );
3644 continue;
3645 }
3646 let _ = writeln!(
3647 out,
3648 "{}{} {}",
3649 family.name,
3650 labels,
3651 format_sample_value(sample.value)
3652 );
3653 }
3654 }
3655 }
3656
3657 let error_counts = registry.error_counts();
3658 if !error_counts.is_empty() {
3659 out.push_str(
3660 "# HELP autumn_metrics_source_errors_total \
3661 Number of scrape errors (panics) per plugin metrics source\n",
3662 );
3663 out.push_str("# TYPE autumn_metrics_source_errors_total counter\n");
3664 let mut names: Vec<&String> = error_counts.keys().collect();
3665 names.sort();
3666 for name in names {
3667 let label = render_labels(&[("source".to_string(), name.clone())]);
3668 let _ = writeln!(
3669 out,
3670 "autumn_metrics_source_errors_total{} {}",
3671 label, error_counts[name]
3672 );
3673 }
3674 }
3675}
3676
3677fn write_admission_metrics(
3680 out: &mut String,
3681 version: &str,
3682 http: &crate::middleware::metrics::HttpMetrics,
3683) {
3684 use std::fmt::Write as _;
3685
3686 out.push_str(
3688 "# HELP autumn_requests_shed_total \
3689 HTTP requests rejected by admission control because the in-flight count was at its limit\n",
3690 );
3691 out.push_str("# TYPE autumn_requests_shed_total counter\n");
3692 let _ = writeln!(
3693 out,
3694 "autumn_requests_shed_total{{version=\"{version}\"}} {}",
3695 http.requests_shed_total
3696 );
3697
3698 let admission = &http.admission;
3699 out.push_str(
3700 "# HELP autumn_admission_limit Current admission-control concurrency limit (0 = no limit)\n",
3701 );
3702 out.push_str("# TYPE autumn_admission_limit gauge\n");
3703 let _ = writeln!(
3704 out,
3705 "autumn_admission_limit{{version=\"{version}\"}} {}",
3706 admission.limit
3707 );
3708 out.push_str(
3709 "# HELP autumn_admission_shed_total HTTP requests rejected by admission control, by criticality\n",
3710 );
3711 out.push_str("# TYPE autumn_admission_shed_total counter\n");
3712 for (criticality, count) in [
3713 ("critical", admission.shed_critical),
3714 ("default", admission.shed_default),
3715 ("sheddable", admission.shed_sheddable),
3716 ] {
3717 let _ = writeln!(
3718 out,
3719 "autumn_admission_shed_total{{version=\"{version}\",criticality=\"{criticality}\"}} {count}"
3720 );
3721 }
3722 out.push_str(
3723 "# HELP autumn_tenant_bulkhead_rejections_total \
3724 Work rejected by a per-tenant bulkhead, by kind\n",
3725 );
3726 out.push_str("# TYPE autumn_tenant_bulkhead_rejections_total counter\n");
3727 for (kind, count) in [
3728 ("request", admission.tenant_request_rejections),
3729 ("db", admission.tenant_db_rejections),
3730 ] {
3731 let _ = writeln!(
3732 out,
3733 "autumn_tenant_bulkhead_rejections_total{{version=\"{version}\",kind=\"{kind}\"}} {count}"
3734 );
3735 }
3736}
3737
3738fn write_builtin_http_metrics(
3742 out: &mut String,
3743 version: &str,
3744 snapshot: &crate::middleware::metrics::MetricsSnapshot,
3745 duration_series: &[crate::middleware::metrics::DurationSeries],
3746) {
3747 use std::fmt::Write;
3748
3749 out.push_str("# HELP autumn_http_requests_total Total number of HTTP requests\n");
3751 out.push_str("# TYPE autumn_http_requests_total counter\n");
3752 let _ = writeln!(
3753 out,
3754 "autumn_http_requests_total{{version=\"{version}\"}} {}",
3755 snapshot.http.requests_total
3756 );
3757
3758 out.push_str("# HELP autumn_http_requests_active Currently active HTTP requests\n");
3760 out.push_str("# TYPE autumn_http_requests_active gauge\n");
3761 let _ = writeln!(
3762 out,
3763 "autumn_http_requests_active{{version=\"{version}\"}} {}",
3764 snapshot.http.requests_active
3765 );
3766
3767 out.push_str("# HELP autumn_http_responses_total HTTP responses by status code\n");
3769 out.push_str("# TYPE autumn_http_responses_total counter\n");
3770 for (status, count) in [
3771 ("2xx", snapshot.http.by_status.s2xx),
3772 ("3xx", snapshot.http.by_status.s3xx),
3773 ("4xx", snapshot.http.by_status.s4xx),
3774 ("5xx", snapshot.http.by_status.s5xx),
3775 ] {
3776 let _ = writeln!(
3777 out,
3778 "autumn_http_responses_total{{version=\"{version}\",status=\"{status}\"}} {count}"
3779 );
3780 }
3781
3782 write_http_duration_histogram(out, version, duration_series);
3783
3784 write_http_duration_quantiles(out, version, snapshot);
3785
3786 out.push_str(
3788 "# HELP autumn_shutdown_aborted_requests_total \
3789 HTTP requests forcibly dropped when the graceful-shutdown drain deadline expired\n",
3790 );
3791 out.push_str("# TYPE autumn_shutdown_aborted_requests_total counter\n");
3792 let _ = writeln!(
3793 out,
3794 "autumn_shutdown_aborted_requests_total{{version=\"{version}\"}} {}",
3795 snapshot.http.shutdown_aborted_requests_total
3796 );
3797
3798 out.push_str(
3800 "# HELP autumn_request_timeouts_total \
3801 HTTP requests that exceeded the configured per-request timeout\n",
3802 );
3803 out.push_str("# TYPE autumn_request_timeouts_total counter\n");
3804 let _ = writeln!(
3805 out,
3806 "autumn_request_timeouts_total{{version=\"{version}\"}} {}",
3807 snapshot.http.request_timeouts_total
3808 );
3809
3810 out.push_str(
3812 "# HELP autumn_read_your_writes_pins_total \
3813 Replica reads redirected to the primary by the read-your-own-writes pin\n",
3814 );
3815 out.push_str("# TYPE autumn_read_your_writes_pins_total counter\n");
3816 let _ = writeln!(
3817 out,
3818 "autumn_read_your_writes_pins_total{{version=\"{version}\"}} {}",
3819 snapshot.read_your_writes_pins_total
3820 );
3821
3822 write_admission_metrics(out, version, &snapshot.http);
3823 if !snapshot.http.by_route.is_empty() {
3825 out.push_str("# HELP autumn_http_route_requests_total HTTP requests by route and method\n");
3826 out.push_str("# TYPE autumn_http_route_requests_total counter\n");
3827 let mut route_keys: Vec<&String> = snapshot.http.by_route.keys().collect();
3828 route_keys.sort();
3829 for route_key in route_keys {
3830 let metrics = &snapshot.http.by_route[route_key];
3831 if let Some((method, path)) = route_key.split_once(' ') {
3833 let _ = writeln!(
3834 out,
3835 "autumn_http_route_requests_total{{version=\"{version}\",method=\"{method}\",route=\"{path}\"}} {}",
3836 metrics.count
3837 );
3838 }
3839 }
3840 }
3841}
3842
3843fn write_http_duration_quantiles(
3846 out: &mut String,
3847 version: &str,
3848 snapshot: &crate::middleware::metrics::MetricsSnapshot,
3849) {
3850 use std::fmt::Write;
3851
3852 let name = HTTP_DURATION_QUANTILES_FAMILY;
3853 let _ = writeln!(
3854 out,
3855 "# HELP {name} Deprecated: HTTP request latency quantiles in seconds. \
3856 Use autumn_http_request_duration_seconds"
3857 );
3858 let _ = writeln!(out, "# TYPE {name} summary");
3859 for (quantile, millis) in [
3860 ("0.5", snapshot.http.latency_ms.p50),
3861 ("0.95", snapshot.http.latency_ms.p95),
3862 ("0.99", snapshot.http.latency_ms.p99),
3863 ] {
3864 #[allow(clippy::cast_precision_loss)]
3865 let seconds = millis as f64 / 1000.0;
3866 let _ = writeln!(
3867 out,
3868 "{name}{{version=\"{version}\",quantile=\"{quantile}\"}} {seconds}"
3869 );
3870 }
3871}
3872
3873fn write_http_duration_histogram(
3879 out: &mut String,
3880 version: &str,
3881 series: &[crate::middleware::metrics::DurationSeries],
3882) {
3883 use std::fmt::Write;
3884
3885 let name = HTTP_DURATION_FAMILY;
3886 let _ = writeln!(out, "# HELP {name} HTTP request latency in seconds");
3887 let _ = writeln!(out, "# TYPE {name} histogram");
3888 for entry in series {
3889 let labels = format!(
3890 "version=\"{version}\",method=\"{}\",route=\"{}\",status_class=\"{}\"",
3891 escape_prometheus_label_value(&entry.method),
3892 escape_prometheus_label_value(&entry.route),
3893 entry.status_class,
3894 );
3895 write_histogram_samples(out, name, &labels, &entry.histogram);
3896 }
3897}
3898
3899fn write_histogram_samples(
3902 out: &mut String,
3903 name: &str,
3904 labels: &str,
3905 histogram: &crate::middleware::metrics::HistogramSnapshot,
3906) {
3907 use std::fmt::Write;
3908
3909 let separator = if labels.is_empty() { "" } else { "," };
3910 for (le, count) in crate::middleware::metrics::BUCKET_LABELS
3911 .iter()
3912 .zip(histogram.cumulative)
3913 {
3914 let _ = writeln!(
3915 out,
3916 "{name}_bucket{{{labels}{separator}le=\"{le}\"}} {count}"
3917 );
3918 }
3919 let _ = writeln!(
3920 out,
3921 "{name}_sum{{{labels}}} {}",
3922 format_sample_value(histogram.sum_seconds)
3923 );
3924 let _ = writeln!(out, "{name}_count{{{labels}}} {}", histogram.count());
3925}
3926
3927fn write_builtin_cache_metrics(
3932 out: &mut String,
3933 version: &str,
3934 snapshot: &crate::cache::ReadThroughMetricsSnapshot,
3935) {
3936 use std::fmt::Write;
3937
3938 for (name, help, value) in [
3939 (
3940 "autumn_cache_read_through_hits_total",
3941 "Read-through cache reads served from a fresh cached value",
3942 snapshot.hits,
3943 ),
3944 (
3945 "autumn_cache_read_through_misses_total",
3946 "Read-through cache reads that found no fresh cached value",
3947 snapshot.misses,
3948 ),
3949 (
3950 "autumn_cache_read_through_coalesced_waits_total",
3951 "Read-through callers that awaited a concurrent in-process fill instead of \
3952 computing their own (single-flight coalescing)",
3953 snapshot.coalesced_waits,
3954 ),
3955 (
3956 "autumn_cache_read_through_fills_total",
3957 "Read-through fill closures that completed successfully",
3958 snapshot.fills,
3959 ),
3960 (
3961 "autumn_cache_read_through_fill_failures_total",
3962 "Read-through fill closures that returned an error",
3963 snapshot.fill_failures,
3964 ),
3965 (
3966 "autumn_cache_read_through_stale_serves_total",
3967 "Stale-while-revalidate reads that served a stale value while a background \
3968 refresh ran",
3969 snapshot.stale_serves,
3970 ),
3971 (
3972 "autumn_cache_fill_lock_acquires_total",
3973 "Distributed cache fill locks acquired by this process",
3974 snapshot.fill_lock_acquires,
3975 ),
3976 (
3977 "autumn_cache_fill_lock_contended_total",
3978 "Distributed cache fill lock attempts that found the lock held by another replica",
3979 snapshot.fill_lock_contended,
3980 ),
3981 (
3982 "autumn_cache_read_through_stale_if_error_serves_total",
3983 "Read-through fills that failed and served the last value instead (stale-if-error)",
3984 crate::cache::read_through_metrics().stale_if_error_serves(),
3985 ),
3986 (
3987 "autumn_cache_invalidation_failures_total",
3988 "Cache invalidations that failed after all retries; stale data can be served",
3989 crate::cache::invalidation_failures_total(),
3990 ),
3991 ] {
3992 let _ = writeln!(out, "# HELP {name} {help}");
3993 let _ = writeln!(out, "# TYPE {name} counter");
3994 let _ = writeln!(out, "{name}{{version=\"{version}\"}} {value}");
3995 }
3996}
3997
3998fn write_builtin_job_metrics(out: &mut String, version: &str, registry: &JobRegistry) {
4004 use std::fmt::Write;
4005
4006 let name = DEAD_LETTER_TRIMMED_FAMILY;
4007 let _ = writeln!(
4008 out,
4009 "# HELP {name} Redis dead letters removed to obey jobs.redis.dead_letter_limit"
4010 );
4011 let _ = writeln!(out, "# TYPE {name} counter");
4012 let _ = writeln!(
4013 out,
4014 "{name}{{version=\"{version}\"}} {}",
4015 registry.dead_letter_trimmed_total()
4016 );
4017
4018 let mut queues: Vec<(String, QueueStatus)> = registry.queue_snapshot().into_iter().collect();
4019 queues.sort_by(|a, b| a.0.cmp(&b.0));
4020 let _ = writeln!(
4021 out,
4022 "# HELP autumn_jobs_queue_depth Jobs ready to run and waiting on each queue"
4023 );
4024 let _ = writeln!(out, "# TYPE autumn_jobs_queue_depth gauge");
4025 for (queue, status) in &queues {
4026 let queue = escape_prometheus_label_value(queue);
4027 let _ = writeln!(
4028 out,
4029 "autumn_jobs_queue_depth{{version=\"{version}\",queue=\"{queue}\"}} {}",
4030 status.depth
4031 );
4032 }
4033 let _ = writeln!(
4034 out,
4035 "# HELP autumn_jobs_oldest_age_seconds Age of the oldest ready job on each queue"
4036 );
4037 let _ = writeln!(out, "# TYPE autumn_jobs_oldest_age_seconds gauge");
4038 for (queue, status) in &queues {
4039 let queue = escape_prometheus_label_value(queue);
4040 #[allow(clippy::cast_precision_loss, reason = "Prometheus samples are f64")]
4041 let seconds = status.oldest_waiting_age_ms as f64 / 1000.0;
4042 let _ = writeln!(
4043 out,
4044 "autumn_jobs_oldest_age_seconds{{version=\"{version}\",queue=\"{queue}\"}} {}",
4045 format_sample_value(seconds)
4046 );
4047 }
4048
4049 let mut jobs: Vec<(String, JobStatus)> = registry.snapshot().into_iter().collect();
4050 jobs.sort_by(|a, b| a.0.cmp(&b.0));
4051 let _ = writeln!(
4052 out,
4053 "# HELP autumn_jobs_dead_letter_total Job executions moved to the dead-letter queue"
4054 );
4055 let _ = writeln!(out, "# TYPE autumn_jobs_dead_letter_total counter");
4056 for (job, status) in &jobs {
4057 let job = escape_prometheus_label_value(job);
4058 let _ = writeln!(
4059 out,
4060 "autumn_jobs_dead_letter_total{{version=\"{version}\",job=\"{job}\"}} {}",
4061 status.dead_letters
4062 );
4063 }
4064}
4065
4066#[cfg(feature = "db")]
4068type PoolGauge = (&'static str, &'static str, fn(&deadpool::Status) -> usize);
4069
4070fn write_builtin_db_pool_metrics<S: ProvideActuatorState>(
4076 out: &mut String,
4077 version: &str,
4078 state: &S,
4079) {
4080 use std::fmt::Write;
4081
4082 let name = DB_POOL_WAIT_FAMILY;
4083 let _ = writeln!(
4084 out,
4085 "# HELP {name} Time a Db checkout took to get a pool connection, connect time included"
4086 );
4087 let _ = writeln!(out, "# TYPE {name} histogram");
4088 write_histogram_samples(
4089 out,
4090 name,
4091 &format!("version=\"{version}\""),
4092 &state.metrics().db_pool_wait(),
4093 );
4094
4095 #[cfg(feature = "db")]
4096 {
4097 let mut pools: Vec<(String, deadpool::Status)> = Vec::new();
4098 if let Some(pool) = state.pool() {
4099 pools.push(("primary".to_owned(), pool.status()));
4100 }
4101 if let Some(pool) = state.replica_pool() {
4102 pools.push(("replica".to_owned(), pool.status()));
4103 }
4104 if let Some(shards) = state.shards() {
4105 for shard in shards.iter() {
4106 pools.push((
4107 format!("shard:{}:primary", shard.name()),
4108 shard.primary_pool().status(),
4109 ));
4110 if let Some(replica) = shard.replica_pool() {
4111 pools.push((format!("shard:{}:replica", shard.name()), replica.status()));
4112 }
4113 }
4114 }
4115 if pools.is_empty() {
4116 return;
4117 }
4118 let gauges: [PoolGauge; 4] = [
4119 (
4120 "autumn_db_pool_max_size",
4121 "Maximum connections in the pool",
4122 |s| s.max_size,
4123 ),
4124 ("autumn_db_pool_size", "Open connections in the pool", |s| {
4125 s.size
4126 }),
4127 (
4128 "autumn_db_pool_available",
4129 "Idle connections in the pool",
4130 |s| s.available,
4131 ),
4132 (
4133 "autumn_db_pool_waiting",
4134 "Tasks waiting for a pool connection",
4135 |s| s.waiting,
4136 ),
4137 ];
4138 for (gauge, help, read) in gauges {
4139 let _ = writeln!(out, "# HELP {gauge} {help}");
4140 let _ = writeln!(out, "# TYPE {gauge} gauge");
4141 for (label, status) in &pools {
4142 let label = escape_prometheus_label_value(label);
4143 let _ = writeln!(
4144 out,
4145 "{gauge}{{version=\"{version}\",pool=\"{label}\"}} {}",
4146 read(status)
4147 );
4148 }
4149 }
4150 }
4151}
4152
4153fn write_builtin_circuit_breaker_metrics(
4158 out: &mut String,
4159 version: &str,
4160 breakers: &[crate::circuit_breaker::CircuitBreaker],
4161) {
4162 use std::fmt::Write;
4163
4164 if breakers.is_empty() {
4165 return;
4166 }
4167 let mut breakers: Vec<_> = breakers.iter().collect();
4168 breakers.sort_by(|a, b| a.name().cmp(b.name()));
4169
4170 let name = BREAKER_SLOW_CALLS_FAMILY;
4171 let _ = writeln!(
4172 out,
4173 "# HELP {name} Calls each circuit breaker counted as slow, cancelled slow calls included"
4174 );
4175 let _ = writeln!(out, "# TYPE {name} counter");
4176 for breaker in &breakers {
4177 let label = escape_prometheus_label_value(breaker.name());
4178 let _ = writeln!(
4179 out,
4180 "{name}{{version=\"{version}\",name=\"{label}\"}} {}",
4181 breaker.slow_calls_total()
4182 );
4183 }
4184
4185 let name = BREAKER_SLOW_CALL_RATIO_FAMILY;
4186 let _ = writeln!(
4187 out,
4188 "# HELP {name} Share of slow calls in each circuit breaker sample window"
4189 );
4190 let _ = writeln!(out, "# TYPE {name} gauge");
4191 for breaker in &breakers {
4192 let label = escape_prometheus_label_value(breaker.name());
4193 let _ = writeln!(
4194 out,
4195 "{name}{{version=\"{version}\",name=\"{label}\"}} {}",
4196 breaker.slow_call_ratio()
4197 );
4198 }
4199}
4200
4201fn write_builtin_shadow_metrics(
4213 out: &mut String,
4214 version: &str,
4215 snapshot: &crate::shadow::ShadowSnapshot,
4216) {
4217 use std::fmt::Write;
4218
4219 for (name, help, label_name, series) in [
4220 (
4221 crate::shadow::COMPARISONS_METRIC,
4222 "Mirrored requests by route and comparison outcome",
4223 "outcome",
4224 &snapshot.comparisons_by_route,
4225 ),
4226 (
4227 crate::shadow::DIVERGENCES_METRIC,
4228 "Primary/shadow response divergences by route and kind",
4229 "kind",
4230 &snapshot.divergences_by_route,
4231 ),
4232 ] {
4233 if series.is_empty() {
4234 continue;
4235 }
4236 let _ = writeln!(out, "# HELP {name} {help}");
4237 let _ = writeln!(out, "# TYPE {name} counter");
4238 for entry in series {
4239 let route = escape_prometheus_label_value(&entry.route);
4240 let label = escape_prometheus_label_value(&entry.label);
4241 let _ = writeln!(
4242 out,
4243 "{name}{{version=\"{version}\",route=\"{route}\",{label_name}=\"{label}\"}} {}",
4244 entry.count
4245 );
4246 }
4247 }
4248}
4249
4250fn write_app_metrics(
4263 out: &mut String,
4264 snapshot: &[crate::metrics::InstrumentSnapshot],
4265 emitted_families: &mut std::collections::HashSet<String>,
4266) {
4267 let mut dropped: Vec<(&str, u64)> = Vec::new();
4270
4271 for instrument in snapshot {
4272 if !claim_app_family_names(instrument, emitted_families) {
4273 continue;
4274 }
4275 if instrument.dropped_series > 0 {
4276 dropped.push((&instrument.name, instrument.dropped_series));
4277 }
4278 write_app_instrument(out, instrument);
4279 }
4280
4281 write_app_dropped_family(out, &dropped);
4282}
4283
4284fn claim_app_family_names(
4290 instrument: &crate::metrics::InstrumentSnapshot,
4291 emitted_families: &mut std::collections::HashSet<String>,
4292) -> bool {
4293 if !is_valid_metric_name(&instrument.name) {
4294 tracing::warn!(name = %instrument.name, "app metric has an invalid metric name; skipping family");
4295 return false;
4296 }
4297 if !emitted_families.insert(instrument.name.clone()) {
4298 tracing::warn!(name = %instrument.name, "app metric collides with an already-emitted family; skipping family");
4299 return false;
4300 }
4301 if matches!(instrument.kind, crate::metrics::InstrumentKind::Histogram) {
4302 for suffix in crate::metrics::HISTOGRAM_SUFFIXES {
4303 emitted_families.insert(format!("{}{suffix}", instrument.name));
4304 }
4305 }
4306 true
4307}
4308
4309fn write_app_instrument(out: &mut String, instrument: &crate::metrics::InstrumentSnapshot) {
4311 use std::fmt::Write;
4312
4313 use crate::metrics::InstrumentKind;
4314
4315 if !instrument.help.is_empty() {
4316 let _ = writeln!(
4317 out,
4318 "# HELP {} {}",
4319 instrument.name,
4320 escape_help_text(&instrument.help)
4321 );
4322 }
4323 let kind = match instrument.kind {
4324 InstrumentKind::Counter => "counter",
4325 InstrumentKind::Gauge => "gauge",
4326 InstrumentKind::Histogram => "histogram",
4327 };
4328 let _ = writeln!(out, "# TYPE {} {kind}", instrument.name);
4329
4330 for series in &instrument.series {
4331 write_app_series(out, &instrument.name, series);
4332 }
4333}
4334
4335fn write_app_series(out: &mut String, name: &str, series: &crate::metrics::SeriesSnapshot) {
4337 use std::fmt::Write;
4338
4339 use crate::metrics::SeriesValue;
4340
4341 let labels: Vec<(String, String)> = series
4344 .labels
4345 .iter()
4346 .map(|(k, v)| (k.clone(), v.clone()))
4347 .collect();
4348 let rendered = render_labels(&labels);
4349
4350 match series.value {
4351 SeriesValue::Counter { value } => {
4352 let _ = writeln!(out, "{name}{rendered} {value}");
4353 }
4354 SeriesValue::Gauge { value } => {
4355 let _ = writeln!(out, "{name}{rendered} {}", format_sample_value(value));
4356 }
4357 SeriesValue::Histogram {
4358 count,
4359 sum,
4360 ref buckets,
4361 } => {
4362 let prefix = rendered
4369 .strip_suffix('}')
4370 .map_or_else(|| "{le=\"".to_string(), |inner| format!("{inner},le=\""));
4371 for (le, cumulative) in buckets {
4372 let _ = writeln!(out, "{name}_bucket{prefix}{le}\"}} {cumulative}");
4373 }
4374 let _ = writeln!(out, "{name}_sum{rendered} {}", format_sample_value(sum));
4375 let _ = writeln!(out, "{name}_count{rendered} {count}");
4376 }
4377 }
4378}
4379
4380fn write_app_dropped_family(out: &mut String, dropped: &[(&str, u64)]) {
4383 use std::fmt::Write;
4384
4385 if dropped.is_empty() {
4386 return;
4387 }
4388 let _ = writeln!(
4389 out,
4390 "# HELP {SERIES_DROPPED_FAMILY} \
4391 App metric samples dropped because the metric had already hit its series cardinality cap"
4392 );
4393 let _ = writeln!(out, "# TYPE {SERIES_DROPPED_FAMILY} counter");
4394 for (name, count) in dropped {
4395 let labels = render_labels(&[("metric".to_string(), (*name).to_string())]);
4396 let _ = writeln!(out, "{SERIES_DROPPED_FAMILY}{labels} {count}");
4397 }
4398}
4399
4400pub(crate) async fn prometheus_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4402 State(state): State<S>,
4403) -> impl IntoResponse {
4404 let snapshot = state.metrics().snapshot();
4405 let version = escape_prometheus_label_value(&state.deploy_version());
4409 let mut out = String::with_capacity(2048);
4410
4411 write_builtin_http_metrics(
4412 &mut out,
4413 &version,
4414 &snapshot,
4415 &state.metrics().duration_series(),
4416 );
4417 write_builtin_cache_metrics(
4418 &mut out,
4419 &version,
4420 &crate::cache::read_through_metrics().snapshot(),
4421 );
4422 write_builtin_job_metrics(&mut out, &version, state.job_registry());
4423 write_builtin_db_pool_metrics(&mut out, &version, &state);
4424 write_builtin_circuit_breaker_metrics(
4425 &mut out,
4426 &version,
4427 &crate::circuit_breaker::global_registry().all_breakers(),
4428 );
4429 if let Some(handle) = state.shadow() {
4430 write_builtin_shadow_metrics(&mut out, &version, &handle.snapshot());
4431 }
4432
4433 let mut emitted_families: std::collections::HashSet<String> = BUILTIN_METRIC_FAMILY_NAMES
4437 .iter()
4438 .map(|s| (*s).to_string())
4439 .collect();
4440 write_app_metrics(&mut out, &crate::metrics::snapshot(), &mut emitted_families);
4441 if let Some(registry) = state.metrics_source_registry() {
4442 render_plugin_sources(registry, &mut out, &mut emitted_families);
4443 }
4444
4445 (
4446 [(
4447 axum::http::header::CONTENT_TYPE,
4448 "text/plain; version=0.0.4",
4449 )],
4450 out,
4451 )
4452}
4453
4454pub(crate) async fn configprops_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4458 State(state): State<S>,
4459) -> Json<serde_json::Value> {
4460 let props = state.config_props().snapshot();
4461
4462 Json(serde_json::json!({
4463 "active_profile": state.profile(),
4464 "properties": props,
4465 }))
4466}
4467
4468const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
4472
4473#[derive(Serialize)]
4475pub(crate) struct LoggersResponse {
4476 current_level: String,
4477 available_levels: Vec<&'static str>,
4478 loggers: HashMap<String, String>,
4479}
4480
4481pub(crate) async fn loggers_get<S: ProvideActuatorState + Send + Sync + 'static>(
4483 State(state): State<S>,
4484) -> Json<LoggersResponse> {
4485 Json(LoggersResponse {
4486 current_level: state.log_levels().current_level(),
4487 available_levels: AVAILABLE_LEVELS.to_vec(),
4488 loggers: state.log_levels().logger_overrides(),
4489 })
4490}
4491
4492#[derive(Deserialize)]
4494pub(crate) struct SetLoggerRequest {
4495 level: String,
4496}
4497
4498fn is_valid_logger_name(name: &str) -> bool {
4509 if name.is_empty() || name == "root" {
4510 return true;
4511 }
4512 name.chars()
4513 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':' || ch == '.' || ch == '-')
4514}
4515
4516pub(crate) async fn loggers_put<S: ProvideActuatorState + Send + Sync + 'static>(
4518 State(state): State<S>,
4519 Path(name): Path<String>,
4520 Json(body): Json<SetLoggerRequest>,
4521) -> impl IntoResponse {
4522 let level = body.level.to_lowercase();
4523
4524 if !AVAILABLE_LEVELS.contains(&level.as_str()) {
4526 return (
4527 StatusCode::BAD_REQUEST,
4528 Json(serde_json::json!({
4529 "status": "error",
4530 "message": format!(
4531 "Invalid level '{}'. Available levels: {}",
4532 level,
4533 AVAILABLE_LEVELS.join(", ")
4534 ),
4535 })),
4536 );
4537 }
4538
4539 if !is_valid_logger_name(&name) {
4542 return (
4543 StatusCode::BAD_REQUEST,
4544 Json(serde_json::json!({
4545 "status": "error",
4546 "message": format!(
4547 "Invalid logger name '{name}'. Names may contain only \
4548 alphanumerics, '_', ':', '.' and '-' (or 'root')."
4549 ),
4550 })),
4551 );
4552 }
4553
4554 match state.log_levels().set_logger_level(&name, &level) {
4558 LogLevelChange::Applied { previous } => (
4559 StatusCode::OK,
4560 Json(serde_json::json!({
4561 "status": "ok",
4562 "message": format!("Logger '{name}' set to '{level}'"),
4563 "previous": previous,
4564 "applied": true,
4565 })),
4566 ),
4567 LogLevelChange::Recorded { previous } => (
4568 StatusCode::OK,
4569 Json(serde_json::json!({
4570 "status": "recorded",
4571 "message": format!(
4572 "Logger '{name}' recorded as '{level}' but not applied: no reload-capable subscriber is installed"
4573 ),
4574 "previous": previous,
4575 "applied": false,
4576 })),
4577 ),
4578 LogLevelChange::Rejected { reason } => (
4579 StatusCode::INTERNAL_SERVER_ERROR,
4580 Json(serde_json::json!({
4581 "status": "error",
4582 "message": format!("Logger '{name}' could not be set to '{level}': {reason}"),
4583 "applied": false,
4584 })),
4585 ),
4586 }
4587}
4588
4589#[derive(Debug, Deserialize, Default)]
4593pub(crate) struct LogfileQuery {
4594 pub level: Option<String>,
4599 pub limit: Option<usize>,
4601}
4602
4603#[derive(Debug, Serialize)]
4605pub(crate) struct LogfileResponse {
4606 pub entries: Vec<crate::log::capture::CapturedLogEntry>,
4608 pub total: usize,
4610 pub capture_enabled: bool,
4612}
4613
4614pub(crate) async fn logfile_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4626 State(state): State<S>,
4627 axum::extract::Query(query): axum::extract::Query<LogfileQuery>,
4628) -> Result<axum::Json<LogfileResponse>, (StatusCode, axum::Json<serde_json::Value>)> {
4629 let min_level = match query.level.as_deref() {
4630 None => None,
4631 Some(s) => match crate::log::capture::level_from_str(s) {
4632 Some(level) => Some(level),
4633 None => {
4634 return Err((
4635 StatusCode::BAD_REQUEST,
4636 axum::Json(serde_json::json!({
4637 "error": format!(
4638 "invalid level {:?}; valid values: TRACE, DEBUG, INFO, WARN, ERROR",
4639 s
4640 )
4641 })),
4642 ));
4643 }
4644 },
4645 };
4646
4647 Ok(match state.log_buffer() {
4648 None => axum::Json(LogfileResponse {
4649 entries: vec![],
4650 total: 0,
4651 capture_enabled: false,
4652 }),
4653 Some(buf) => {
4654 let total = buf.len();
4655 let entries = buf.snapshot(min_level, query.limit);
4656 axum::Json(LogfileResponse {
4657 entries,
4658 total,
4659 capture_enabled: true,
4660 })
4661 }
4662 })
4663}
4664
4665pub(crate) async fn tasks_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4669 State(state): State<S>,
4670) -> Json<serde_json::Value> {
4671 let tasks = state.task_registry().snapshot();
4672
4673 Json(serde_json::json!({
4674 "scheduled_tasks": tasks,
4675 }))
4676}
4677
4678pub(crate) async fn jobs_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4680 State(state): State<S>,
4681) -> Json<serde_json::Value> {
4682 let jobs = state.job_registry().snapshot();
4683 let queues = state.job_registry().queue_snapshot();
4684 Json(serde_json::json!({ "jobs": jobs, "queues": queues }))
4685}
4686
4687#[cfg(feature = "db")]
4705pub(crate) async fn derivations_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4706 State(state): State<S>,
4707) -> axum::response::Response {
4708 let mut report = Vec::new();
4713 if let Some(pool) = state.pool() {
4714 let statuses = match pool.get().await {
4719 Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4720 Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4721 error.to_string(),
4722 ))),
4723 };
4724 match statuses {
4725 Ok(statuses) => report.extend(derivation_report("control", statuses)),
4726 Err(error) if state.shards().is_some() => report.push(serde_json::json!({
4727 "target": "control",
4728 "error": "could not read derivation state",
4729 "detail": error.to_string(),
4730 })),
4731 Err(error) => {
4732 return (
4733 StatusCode::INTERNAL_SERVER_ERROR,
4734 Json(serde_json::json!({
4735 "error": "could not read derivation state",
4736 "target": "control",
4737 "detail": error.to_string(),
4738 })),
4739 )
4740 .into_response();
4741 }
4742 }
4743 } else if state.shards().is_none() {
4744 return (
4745 StatusCode::SERVICE_UNAVAILABLE,
4746 Json(serde_json::json!({
4747 "error": "no database pool is configured in this process",
4748 "hint": "derivation state lives in the `_autumn_derivations` table, so \
4749 reporting it needs a database connection",
4750 })),
4751 )
4752 .into_response();
4753 }
4754 if let Some(shards) = state.shards() {
4760 for shard in shards.iter() {
4761 let target = shard.name();
4762 let statuses = match shard.primary_pool().get().await {
4763 Ok(mut conn) => crate::derivation::derivation_status(&mut conn).await,
4764 Err(error) => Err(crate::AutumnError::from(std::io::Error::other(
4765 error.to_string(),
4766 ))),
4767 };
4768 match statuses {
4769 Ok(statuses) => report.extend(derivation_report(target, statuses)),
4770 Err(error) => report.push(serde_json::json!({
4771 "target": target,
4772 "error": "could not read derivation state",
4773 "detail": error.to_string(),
4774 })),
4775 }
4776 }
4777 }
4778 (StatusCode::OK, Json(report)).into_response()
4779}
4780
4781#[cfg(feature = "db")]
4784fn derivation_report(
4785 target: &str,
4786 statuses: Vec<crate::derivation::DerivationStatus>,
4787) -> Vec<serde_json::Value> {
4788 statuses
4789 .into_iter()
4790 .map(|status| {
4791 let mut row = serde_json::to_value(status).unwrap_or_else(
4792 |error| serde_json::json!({ "error": format!("unserialisable status: {error}") }),
4793 );
4794 if let serde_json::Value::Object(ref mut map) = row {
4795 map.insert(
4796 "target".to_owned(),
4797 serde_json::Value::String(target.to_owned()),
4798 );
4799 }
4800 row
4801 })
4802 .collect()
4803}
4804
4805pub(crate) async fn graph_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4824 State(state): State<S>,
4825) -> axum::response::Response {
4826 graph_response(state.architecture_graph_json())
4827}
4828
4829fn graph_response(graph: Option<bytes::Bytes>) -> axum::response::Response {
4834 graph.map_or_else(
4835 || {
4836 (
4837 StatusCode::SERVICE_UNAVAILABLE,
4838 Json(serde_json::json!({
4839 "error": "no architecture graph is installed for this application",
4840 "hint": "the graph is published when the application router is built; a \
4841 process that serves requests without building one (a bare \
4842 `axum::Router` in a test, say) has none to report",
4843 })),
4844 )
4845 .into_response()
4846 },
4847 |json| {
4848 (
4849 StatusCode::OK,
4850 [(axum::http::header::CONTENT_TYPE, "application/json")],
4851 json,
4852 )
4853 .into_response()
4854 },
4855 )
4856}
4857
4858pub(crate) async fn cost_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4864 State(state): State<S>,
4865) -> Json<serde_json::Value> {
4866 let signal = state.cost_signal();
4867 let accountant = state.cost_accountant();
4868 let snapshot = accountant
4869 .as_ref()
4870 .map(crate::cost::CostAccountant::snapshot)
4871 .unwrap_or_default();
4872 Json(serde_json::json!({
4873 "enabled": accountant.is_some(),
4874 "signal": signal.map(|signal| signal.snapshot()),
4875 "total": snapshot.total,
4876 "tenants": snapshot.tenants,
4877 "jobs": snapshot.jobs,
4878 "tasks": snapshot.tasks,
4879 }))
4880}
4881
4882pub(crate) async fn shadow_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4891 State(state): State<S>,
4892) -> Json<crate::shadow::ShadowSnapshot> {
4893 Json(
4894 state
4895 .shadow()
4896 .map_or_else(crate::shadow::ShadowHandle::disabled_snapshot, |handle| {
4897 handle.snapshot()
4898 }),
4899 )
4900}
4901
4902#[derive(Deserialize)]
4906pub(crate) struct CapsuleExportRequest {
4907 subject: String,
4908}
4909
4910#[derive(Deserialize)]
4912pub(crate) struct CapsuleNameRequest {
4913 capsule: String,
4914}
4915
4916fn capsule_error(error: &crate::gdpr::portability::DataCapsuleError) -> axum::response::Response {
4917 use crate::gdpr::portability::DataCapsuleError as E;
4918 let status = match error {
4919 E::NotConfigured(_) | E::MissingSigningSecret => StatusCode::NOT_IMPLEMENTED,
4920 E::InvalidName(_) | E::InvalidInput(_) => StatusCode::BAD_REQUEST,
4921 E::NotEmpty(_) | E::Conflict(_) => StatusCode::CONFLICT,
4922 E::Integrity(_)
4923 | E::UnsupportedFormat(_)
4924 | E::UnknownTable(_)
4925 | E::RelationshipCycle(_)
4926 | E::Json { .. } => StatusCode::UNPROCESSABLE_ENTITY,
4927 _ => StatusCode::INTERNAL_SERVER_ERROR,
4928 };
4929 (
4930 status,
4931 Json(serde_json::json!({"status": "error", "message": error.to_string()})),
4932 )
4933 .into_response()
4934}
4935
4936#[allow(
4938 clippy::result_large_err,
4939 reason = "the error is the response itself; it is made once per request"
4940)]
4941fn existing_capsule<S: ProvideActuatorState>(
4942 state: &S,
4943 name: &str,
4944) -> Result<(crate::gdpr::portability::CapsuleService, std::path::PathBuf), axum::response::Response>
4945{
4946 let service = state.data_capsules().map_err(|e| capsule_error(&e))?;
4947 let path = service.capsule_path(name).map_err(|e| capsule_error(&e))?;
4948 let is_real_dir = std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_dir());
4951 if !is_real_dir {
4952 return Err((
4953 StatusCode::NOT_FOUND,
4954 Json(serde_json::json!({"status": "error", "message": "capsule not found"})),
4955 )
4956 .into_response());
4957 }
4958 Ok((service, path))
4959}
4960
4961pub(crate) async fn capsules_export_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4963 State(state): State<S>,
4964 Json(body): Json<CapsuleExportRequest>,
4965) -> axum::response::Response {
4966 let service = match state.data_capsules() {
4967 Ok(service) => service,
4968 Err(e) => return capsule_error(&e),
4969 };
4970 let name = crate::gdpr::portability::CapsuleService::capsule_name();
4971 let path = match service.capsule_path(&name) {
4972 Ok(path) => path,
4973 Err(e) => return capsule_error(&e),
4974 };
4975 match service.export_to(&body.subject, &path).await {
4976 Ok(report) => (
4977 StatusCode::OK,
4978 Json(serde_json::json!({"status": "ok", "capsule": name, "report": report})),
4979 )
4980 .into_response(),
4981 Err(e) => capsule_error(&e),
4982 }
4983}
4984
4985pub(crate) async fn capsules_verify_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
4987 State(state): State<S>,
4988 Json(body): Json<CapsuleNameRequest>,
4989) -> axum::response::Response {
4990 let (service, path) = match existing_capsule(&state, &body.capsule) {
4991 Ok(found) => found,
4992 Err(response) => return response,
4993 };
4994 match service.verify(&path).await {
4995 Ok(report) => (
4996 StatusCode::OK,
4997 Json(serde_json::json!({"status": "ok", "report": report})),
4998 )
4999 .into_response(),
5000 Err(e) => capsule_error(&e),
5001 }
5002}
5003
5004pub(crate) async fn capsules_import_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5006 State(state): State<S>,
5007 Json(body): Json<CapsuleNameRequest>,
5008) -> axum::response::Response {
5009 let (service, path) = match existing_capsule(&state, &body.capsule) {
5010 Ok(found) => found,
5011 Err(response) => return response,
5012 };
5013 match service.import_from(&path).await {
5014 Ok(summary) => (
5015 StatusCode::OK,
5016 Json(serde_json::json!({"status": "ok", "summary": summary})),
5017 )
5018 .into_response(),
5019 Err(e) => capsule_error(&e),
5020 }
5021}
5022
5023#[cfg(feature = "http-client")]
5024#[derive(Deserialize)]
5026pub(crate) struct ReplayRequest {
5027 log_id: String,
5028}
5029
5030#[cfg(feature = "http-client")]
5031async fn enqueue_webhook_replay_job(log_id: &str) -> Result<(), String> {
5032 let job_payload = serde_json::json!({
5033 "log_id": log_id,
5034 "replay": true,
5035 });
5036
5037 let Some(job_client) = crate::job::global_job_client() else {
5038 return Err("Global job client is not available".to_string());
5039 };
5040
5041 job_client
5042 .enqueue("autumn_webhook_delivery", job_payload)
5043 .await
5044 .map_err(|e| format!("Failed to enqueue job: {e}"))
5045}
5046
5047#[cfg(feature = "http-client")]
5048pub(crate) async fn webhooks_dlq_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5050 State(state): State<S>,
5051) -> impl IntoResponse {
5052 let Some(manager) = state.webhook_outbound() else {
5053 return (
5054 StatusCode::NOT_IMPLEMENTED,
5055 Json(serde_json::json!({
5056 "status": "error",
5057 "message": "Outbound webhook support is not configured or enabled"
5058 })),
5059 )
5060 .into_response();
5061 };
5062
5063 match manager.store().get_dlq_logs().await {
5064 Ok(logs) => (StatusCode::OK, Json(logs)).into_response(),
5065 Err(e) => (
5066 StatusCode::INTERNAL_SERVER_ERROR,
5067 Json(serde_json::json!({
5068 "status": "error",
5069 "message": format!("Failed to fetch DLQ logs: {}", e)
5070 })),
5071 )
5072 .into_response(),
5073 }
5074}
5075
5076#[cfg(feature = "http-client")]
5077pub(crate) async fn webhooks_replay_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5079 State(state): State<S>,
5080 Json(body): Json<ReplayRequest>,
5081) -> impl IntoResponse {
5082 let Some(manager) = state.webhook_outbound() else {
5083 return (
5084 StatusCode::NOT_IMPLEMENTED,
5085 Json(serde_json::json!({
5086 "status": "error",
5087 "message": "Outbound webhook support is not configured or enabled"
5088 })),
5089 )
5090 .into_response();
5091 };
5092
5093 let log_opt = match manager.store().get_delivery_log(&body.log_id).await {
5094 Ok(log) => log,
5095 Err(e) => {
5096 return (
5097 StatusCode::INTERNAL_SERVER_ERROR,
5098 Json(serde_json::json!({
5099 "status": "error",
5100 "message": format!("Failed to retrieve log: {}", e)
5101 })),
5102 )
5103 .into_response();
5104 }
5105 };
5106
5107 let Some(log) = log_opt else {
5108 return (
5109 StatusCode::NOT_FOUND,
5110 Json(serde_json::json!({
5111 "status": "error",
5112 "message": format!("Log with ID {} not found", body.log_id)
5113 })),
5114 )
5115 .into_response();
5116 };
5117
5118 if !log.is_dlq {
5119 return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
5120 "status": "error",
5121 "message": format!("Log with ID {} is not in the Dead Letter Queue (DLQ)", body.log_id)
5122 }))).into_response();
5123 }
5124
5125 if let Some(response) = blocked_webhook_replay_response(&manager, &log, &body.log_id).await {
5126 return response;
5127 }
5128
5129 let subscription_id = log.subscription_id.clone();
5130 let original_log = log.clone();
5131 let log = reset_webhook_replay_log(log);
5132
5133 if let Err(e) = manager.store().log_delivery(log).await {
5134 return (
5135 StatusCode::INTERNAL_SERVER_ERROR,
5136 Json(serde_json::json!({
5137 "status": "error",
5138 "message": format!("Failed to update delivery log state: {}", e)
5139 })),
5140 )
5141 .into_response();
5142 }
5143
5144 if let Err(message) = enqueue_webhook_replay_job(&body.log_id).await {
5146 if let Err(rollback_error) = manager.store().replace_delivery_log(original_log).await {
5147 tracing::error!(
5148 log_id = %body.log_id,
5149 "Failed to roll back webhook replay log after enqueue failure: {}",
5150 rollback_error
5151 );
5152 return (
5153 StatusCode::INTERNAL_SERVER_ERROR,
5154 Json(serde_json::json!({
5155 "status": "error",
5156 "message": format!("{message}; failed to restore DLQ log state: {rollback_error}")
5157 })),
5158 )
5159 .into_response();
5160 }
5161
5162 return (
5163 StatusCode::INTERNAL_SERVER_ERROR,
5164 Json(serde_json::json!({
5165 "status": "error",
5166 "message": message
5167 })),
5168 )
5169 .into_response();
5170 }
5171
5172 if let Err(e) = manager
5174 .store()
5175 .reactivate_failed_subscription(&subscription_id)
5176 .await
5177 {
5178 tracing::warn!(subscription_id = %subscription_id, "Failed to reactivate subscription during replay: {}", e);
5179 }
5180
5181 (
5182 StatusCode::OK,
5183 Json(serde_json::json!({
5184 "status": "ok",
5185 "message": format!("Replay successfully enqueued for log {}", body.log_id)
5186 })),
5187 )
5188 .into_response()
5189}
5190
5191#[cfg(feature = "http-client")]
5192fn reset_webhook_replay_log(
5193 mut log: crate::webhook_outbound::WebhookDeliveryLog,
5194) -> crate::webhook_outbound::WebhookDeliveryLog {
5195 log.is_dlq = false;
5196 log.attempt = 1;
5197 log.last_error = None;
5198 log.response_status = None;
5199 log.response_body = None;
5200 log.timestamp = crate::time::ambient_now();
5201 log
5202}
5203
5204#[cfg(feature = "http-client")]
5205async fn blocked_webhook_replay_response(
5206 manager: &crate::webhook_outbound::WebhookOutboundManager,
5207 log: &crate::webhook_outbound::WebhookDeliveryLog,
5208 log_id: &str,
5209) -> Option<axum::response::Response> {
5210 let subscription = match manager.store().get_subscription(&log.subscription_id).await {
5211 Ok(subscription) => subscription,
5212 Err(e) => {
5213 return Some(
5214 (
5215 StatusCode::INTERNAL_SERVER_ERROR,
5216 Json(serde_json::json!({
5217 "status": "error",
5218 "message": format!("Failed to retrieve subscription: {}", e)
5219 })),
5220 )
5221 .into_response(),
5222 );
5223 }
5224 };
5225
5226 let Some(subscription) = subscription else {
5227 return Some(
5228 (
5229 StatusCode::NOT_FOUND,
5230 Json(serde_json::json!({
5231 "status": "error",
5232 "message": format!(
5233 "Subscription {} for replay log {} was not found",
5234 log.subscription_id, log_id
5235 )
5236 })),
5237 )
5238 .into_response(),
5239 );
5240 };
5241
5242 if subscription.status != crate::webhook_outbound::WebhookSubscriptionStatus::Disabled {
5243 return None;
5244 }
5245
5246 Some(
5247 (
5248 StatusCode::CONFLICT,
5249 Json(serde_json::json!({
5250 "status": "error",
5251 "message": format!(
5252 "Subscription {} is disabled; re-enable it before replaying log {}",
5253 log.subscription_id, log_id
5254 )
5255 })),
5256 )
5257 .into_response(),
5258 )
5259}
5260
5261pub(crate) async fn a11y_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5268 State(state): State<S>,
5269) -> Json<A11yPosture> {
5270 Json(state.a11y_posture())
5271}
5272
5273#[cfg(feature = "ws")]
5277pub(crate) async fn channels_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5278 State(state): State<S>,
5279) -> Json<serde_json::Value> {
5280 let channels = state.channels().snapshot();
5281 Json(serde_json::json!({
5282 "channels": channels,
5283 }))
5284}
5285
5286#[cfg(feature = "ws")]
5290pub(crate) async fn tasks_stream_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
5291 State(state): State<S>,
5292 tunnel: crate::http_server::KeepTunnel,
5293 ws: axum::extract::ws::WebSocketUpgrade,
5294) -> impl IntoResponse {
5295 ws.on_upgrade(move |mut socket| async move {
5296 let _tunnel = tunnel;
5298 let mut rx = state.channels().subscribe("sys:tasks");
5299 let shutdown = state.shutdown_token();
5300
5301 loop {
5302 tokio::select! {
5303 res = rx.recv() => {
5304 match res {
5305 Ok(msg) => {
5306 let ws_msg = axum::extract::ws::Message::Text(msg.into_string().into());
5307 if socket.send(ws_msg).await.is_err() {
5308 break;
5309 }
5310 }
5311 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
5312 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
5313 }
5314 }
5315 () = shutdown.cancelled() => {
5316 let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
5317 break;
5318 }
5319 else => break,
5320 }
5321 }
5322 })
5323}
5324
5325pub(crate) fn normalize_actuator_prefix(prefix: &str) -> String {
5328 let trimmed = prefix.trim();
5329 if trimmed.is_empty() || trimmed == "/" {
5330 String::new()
5331 } else {
5332 let trimmed = trimmed.trim_end_matches('/');
5333 if trimmed.starts_with('/') {
5334 trimmed.to_owned()
5335 } else {
5336 format!("/{trimmed}")
5337 }
5338 }
5339}
5340
5341pub(crate) fn actuator_route_glob(prefix: &str) -> String {
5342 let prefix = normalize_actuator_prefix(prefix);
5343 if prefix.is_empty() {
5344 "/*".to_owned()
5345 } else {
5346 format!("{prefix}/*")
5347 }
5348}
5349
5350pub(crate) fn actuator_route_path(prefix: &str, suffix: &str) -> String {
5351 let prefix = normalize_actuator_prefix(prefix);
5352 if prefix.is_empty() {
5353 suffix.to_owned()
5354 } else {
5355 format!("{prefix}{suffix}")
5356 }
5357}
5358
5359const CAPSULE_ROUTES: [&str; 3] = ["/capsules/export", "/capsules/import", "/capsules/verify"];
5361
5362pub(crate) fn actuator_endpoint_paths(
5363 prefix: &str,
5364 sensitive: bool,
5365 prometheus_enabled: bool,
5366) -> Vec<String> {
5367 let mut paths = vec![
5368 actuator_route_path(prefix, "/health"),
5369 actuator_route_path(prefix, "/info"),
5370 actuator_route_path(prefix, "/metrics"),
5371 actuator_route_path(prefix, "/a11y"),
5372 actuator_route_path(prefix, "/ui"),
5373 actuator_route_path(prefix, "/ui/metrics"),
5374 ];
5375
5376 if prometheus_enabled {
5377 paths.push(actuator_route_path(prefix, "/prometheus"));
5378 }
5379
5380 if sensitive {
5381 paths.push(actuator_route_path(prefix, "/circuitbreakers"));
5382 paths.push(actuator_route_path(prefix, "/env"));
5383 paths.push(actuator_route_path(prefix, "/configprops"));
5384 paths.push(actuator_route_path(prefix, "/loggers"));
5385 paths.push(actuator_route_path(prefix, "/logfile"));
5386 paths.push(actuator_route_path(prefix, "/tasks"));
5387 paths.push(actuator_route_path(prefix, "/jobs"));
5388 paths.push(actuator_route_path(prefix, "/ui/tasks"));
5389 paths.push(actuator_route_path(prefix, "/shadow"));
5390 paths.push(actuator_route_path(prefix, "/cost"));
5391 paths.push(actuator_route_path(prefix, "/graph"));
5392 for suffix in CAPSULE_ROUTES {
5395 paths.push(actuator_route_path(prefix, suffix));
5396 }
5397 #[cfg(feature = "db")]
5398 {
5399 paths.push(actuator_route_path(prefix, "/derivations"));
5400 }
5401 #[cfg(feature = "system-info")]
5402 {
5403 paths.push(actuator_route_path(prefix, "/system"));
5404 }
5405 #[cfg(feature = "http-client")]
5406 {
5407 paths.push(actuator_route_path(prefix, "/webhooks/dlq"));
5408 paths.push(actuator_route_path(prefix, "/webhooks/replay"));
5418 }
5419 #[cfg(feature = "ws")]
5420 {
5421 paths.push(actuator_route_path(prefix, "/channels"));
5422 paths.push(actuator_route_path(prefix, "/tasks/stream"));
5423 }
5424 }
5425
5426 paths
5427}
5428
5429pub(crate) fn actuator_mutating_routes(
5438 prefix: &str,
5439 sensitive: bool,
5440) -> Vec<(&'static str, String)> {
5441 let mut routes: Vec<(&'static str, String)> = Vec::new();
5442
5443 if sensitive {
5444 routes.push(("PUT", actuator_route_path(prefix, "/loggers/{name}")));
5445 for suffix in CAPSULE_ROUTES {
5446 routes.push(("POST", actuator_route_path(prefix, suffix)));
5447 }
5448 #[cfg(feature = "http-client")]
5449 {
5450 routes.push(("POST", actuator_route_path(prefix, "/webhooks/replay")));
5451 }
5452 }
5453
5454 routes
5455}
5456
5457pub fn actuator_router<S: ProvideActuatorState + Send + Sync + Clone + 'static>(
5466 sensitive: bool,
5467) -> axum::Router<S> {
5468 actuator_router_with_prefix("/actuator", sensitive, true)
5469}
5470
5471#[allow(clippy::too_many_lines)]
5479pub(crate) fn actuator_router_with_prefix<
5480 S: ProvideActuatorState + Send + Sync + Clone + 'static,
5481>(
5482 prefix: &str,
5483 sensitive: bool,
5484 prometheus_enabled: bool,
5485) -> axum::Router<S> {
5486 let mut router = axum::Router::new()
5487 .route(
5488 &actuator_route_path(prefix, "/health"),
5489 axum::routing::get(health::<S>),
5490 )
5491 .route(
5492 &actuator_route_path(prefix, "/info"),
5493 axum::routing::get(info::<S>),
5494 )
5495 .route(
5496 &actuator_route_path(prefix, "/metrics"),
5497 axum::routing::get(metrics_endpoint::<S>),
5498 )
5499 .route(
5500 &actuator_route_path(prefix, "/a11y"),
5501 axum::routing::get(a11y_endpoint::<S>),
5502 );
5503
5504 if prometheus_enabled {
5505 router = router.route(
5506 &actuator_route_path(prefix, "/prometheus"),
5507 axum::routing::get(prometheus_endpoint::<S>),
5508 );
5509 }
5510
5511 if sensitive {
5512 router = router
5513 .route(
5514 &actuator_route_path(prefix, "/circuitbreakers"),
5515 axum::routing::get(circuitbreakers_endpoint::<S>),
5516 )
5517 .route(
5518 &actuator_route_path(prefix, "/env"),
5519 axum::routing::get(env_endpoint::<S>),
5520 )
5521 .route(
5522 &actuator_route_path(prefix, "/configprops"),
5523 axum::routing::get(configprops_endpoint::<S>),
5524 )
5525 .route(
5526 &actuator_route_path(prefix, "/loggers"),
5527 axum::routing::get(loggers_get::<S>),
5528 )
5529 .route(
5530 &actuator_route_path(prefix, "/loggers/{name}"),
5531 axum::routing::put(loggers_put::<S>),
5532 )
5533 .route(
5534 &actuator_route_path(prefix, "/logfile"),
5535 axum::routing::get(logfile_endpoint::<S>),
5536 )
5537 .route(
5538 &actuator_route_path(prefix, "/tasks"),
5539 axum::routing::get(tasks_endpoint::<S>),
5540 )
5541 .route(
5542 &actuator_route_path(prefix, "/jobs"),
5543 axum::routing::get(jobs_endpoint::<S>),
5544 )
5545 .route(
5546 &actuator_route_path(prefix, "/ui/tasks"),
5547 axum::routing::get(ui_tasks::<S>),
5548 )
5549 .route(
5550 &actuator_route_path(prefix, "/shadow"),
5551 axum::routing::get(shadow_endpoint::<S>),
5552 )
5553 .route(
5554 &actuator_route_path(prefix, "/cost"),
5555 axum::routing::get(cost_endpoint::<S>),
5556 )
5557 .route(
5558 &actuator_route_path(prefix, "/graph"),
5559 axum::routing::get(graph_endpoint::<S>),
5560 )
5561 .route(
5562 &actuator_route_path(prefix, "/capsules/export"),
5563 axum::routing::post(capsules_export_endpoint::<S>),
5564 )
5565 .route(
5566 &actuator_route_path(prefix, "/capsules/import"),
5567 axum::routing::post(capsules_import_endpoint::<S>),
5568 )
5569 .route(
5570 &actuator_route_path(prefix, "/capsules/verify"),
5571 axum::routing::post(capsules_verify_endpoint::<S>),
5572 );
5573 #[cfg(feature = "db")]
5574 {
5575 router = router.route(
5576 &actuator_route_path(prefix, "/derivations"),
5577 axum::routing::get(derivations_endpoint::<S>),
5578 );
5579 }
5580 #[cfg(feature = "http-client")]
5581 {
5582 router = router
5583 .route(
5584 &actuator_route_path(prefix, "/webhooks/dlq"),
5585 axum::routing::get(webhooks_dlq_endpoint::<S>),
5586 )
5587 .route(
5588 &actuator_route_path(prefix, "/webhooks/replay"),
5589 axum::routing::post(webhooks_replay_endpoint::<S>),
5590 );
5591 }
5592
5593 #[cfg(feature = "system-info")]
5594 {
5595 router = router.route(
5596 &actuator_route_path(prefix, "/system"),
5597 axum::routing::get(crate::system_info::system_info_handler),
5598 );
5599 }
5600
5601 #[cfg(feature = "ws")]
5602 {
5603 router = router
5604 .route(
5605 &actuator_route_path(prefix, "/channels"),
5606 axum::routing::get(channels_endpoint::<S>),
5607 )
5608 .route(
5609 &actuator_route_path(prefix, "/tasks/stream"),
5610 axum::routing::get(tasks_stream_endpoint::<S>)
5611 .connect(tasks_stream_endpoint::<S>),
5612 );
5613 }
5614 }
5615
5616 router
5618 .route(
5619 &actuator_route_path(prefix, "/ui"),
5620 axum::routing::get(ui_dashboard),
5621 )
5622 .route(
5623 &actuator_route_path(prefix, "/ui/metrics"),
5624 axum::routing::get(ui_metrics::<S>),
5625 )
5626}
5627
5628#[cfg(test)]
5629mod tests {
5630 use super::*;
5631 use crate::config::AutumnConfig;
5632
5633 #[test]
5634 fn task_registry_flow() {
5635 let registry = TaskRegistry::new();
5636
5637 registry.register_scheduled(
5638 "my_task",
5639 "0 * * * * *",
5640 crate::task::TaskCoordination::Fleet,
5641 "mock",
5642 "node-1",
5643 );
5644 let snap1 = registry.snapshot();
5645 assert_eq!(snap1.get("my_task").unwrap().total_runs, 0);
5646
5647 registry.record_leader("my_task", "node-1", "mock_tick");
5648 let snap3 = registry.snapshot();
5649 assert_eq!(
5650 snap3.get("my_task").unwrap().current_leader.as_deref(),
5651 Some("node-1")
5652 );
5653
5654 registry.record_start("my_task");
5655 let snap4 = registry.snapshot();
5656 assert_eq!(snap4.get("my_task").unwrap().status, "running");
5657
5658 registry.record_next_run_at("my_task", "tomorrow");
5659 let snap5 = registry.snapshot();
5660 assert_eq!(
5661 snap5.get("my_task").unwrap().next_run_at.as_deref(),
5662 Some("tomorrow")
5663 );
5664
5665 registry.record_success("my_task", 100);
5666 let snap6 = registry.snapshot();
5667 assert_eq!(snap6.get("my_task").unwrap().total_runs, 1);
5668 assert_eq!(snap6.get("my_task").unwrap().last_error, None);
5669
5670 registry.record_failure("my_task", 150, "error message");
5671 let snap7 = registry.snapshot();
5672 assert_eq!(snap7.get("my_task").unwrap().total_runs, 2);
5673 assert_eq!(snap7.get("my_task").unwrap().total_failures, 1);
5674 assert_eq!(
5675 snap7.get("my_task").unwrap().last_error.as_deref(),
5676 Some("error message")
5677 );
5678
5679 let registry2 = TaskRegistry::default();
5680 assert!(registry2.snapshot().is_empty());
5681 }
5682 #[test]
5683 fn job_registry_flow() {
5684 let registry = JobRegistry::new();
5685
5686 registry.register("my_job");
5687 let snap1 = registry.snapshot();
5688 assert_eq!(snap1.get("my_job").unwrap().queued, 0);
5689
5690 registry.record_enqueue("my_job");
5691 let snap2 = registry.snapshot();
5692 assert_eq!(snap2.get("my_job").unwrap().queued, 1);
5693
5694 registry.record_start("my_job");
5695 let snap3 = registry.snapshot();
5696 assert_eq!(snap3.get("my_job").unwrap().queued, 0);
5697 assert_eq!(snap3.get("my_job").unwrap().in_flight, 1);
5698
5699 registry.record_retry("my_job", "timeout", 1);
5700 let snap4 = registry.snapshot();
5701 assert_eq!(snap4.get("my_job").unwrap().in_flight, 0);
5702 assert_eq!(
5703 snap4.get("my_job").unwrap().last_error.as_deref(),
5704 Some("timeout")
5705 );
5706
5707 registry.record_enqueue("my_job");
5708 registry.record_start("my_job");
5709 registry.record_success("my_job");
5710 let snap5 = registry.snapshot();
5711 assert_eq!(snap5.get("my_job").unwrap().in_flight, 0);
5712 assert_eq!(snap5.get("my_job").unwrap().total_successes, 1);
5713 assert_eq!(snap5.get("my_job").unwrap().last_error, None);
5714
5715 registry.record_enqueue("my_job");
5716 registry.record_cancel("my_job");
5717 let snap6 = registry.snapshot();
5718 assert_eq!(snap6.get("my_job").unwrap().queued, 0);
5719 assert_eq!(snap6.get("my_job").unwrap().in_flight, 0);
5720
5721 registry.record_enqueue("my_job");
5722 registry.record_start("my_job");
5723 registry.record_failure("my_job", "failure".to_string(), true);
5724 let snap7 = registry.snapshot();
5725 assert_eq!(snap7.get("my_job").unwrap().in_flight, 0);
5726 assert_eq!(snap7.get("my_job").unwrap().total_failures, 1);
5727 assert_eq!(snap7.get("my_job").unwrap().dead_letters, 1);
5728 assert_eq!(
5729 snap7.get("my_job").unwrap().last_error.as_deref(),
5730 Some("failure")
5731 );
5732
5733 let registry2 = JobRegistry::default();
5734 let snap8 = registry2.snapshot();
5735 assert!(snap8.is_empty());
5736 }
5737
5738 #[test]
5739 fn job_registry_tracks_per_queue_depth_and_oldest_age() {
5740 let registry = JobRegistry::new();
5741 registry.register_on_queue("reset_email", "critical");
5742 registry.register_on_queue("reindex", "bulk");
5743
5744 registry.record_enqueue("reset_email");
5746 registry.record_enqueue("reset_email");
5747 registry.record_enqueue("reindex");
5748
5749 let queues = registry.queue_snapshot();
5750 assert_eq!(queues.get("critical").unwrap().depth, 2);
5751 assert_eq!(queues.get("bulk").unwrap().depth, 1);
5752 std::thread::sleep(std::time::Duration::from_millis(5));
5755 assert!(
5756 registry
5757 .queue_snapshot()
5758 .get("critical")
5759 .unwrap()
5760 .oldest_waiting_age_ms
5761 > 0,
5762 "a job waiting for ~5ms must report a positive age"
5763 );
5764
5765 registry.record_start("reset_email");
5767 assert_eq!(registry.queue_snapshot().get("critical").unwrap().depth, 1);
5768
5769 registry.record_start("reset_email");
5771 registry.record_start("reindex");
5772 let drained = registry.queue_snapshot();
5773 assert_eq!(drained.get("critical").unwrap().depth, 0);
5774 assert_eq!(drained.get("critical").unwrap().oldest_waiting_age_ms, 0);
5775 assert_eq!(drained.get("bulk").unwrap().depth, 0);
5776 }
5777
5778 #[test]
5779 fn job_registry_unregistered_name_counts_on_the_default_queue() {
5780 let registry = JobRegistry::new();
5783 registry.record_enqueue("ghost");
5784 registry.record_enqueue("ghost");
5785 assert_eq!(registry.snapshot().get("ghost").unwrap().queued, 2);
5786 assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 2);
5787 assert_eq!(registry.waiting_marks_for_test("ghost").len(), 2);
5788 registry.record_start("ghost");
5789 assert_eq!(registry.queue_snapshot().get("default").unwrap().depth, 1);
5790 let status = registry.snapshot().get("ghost").cloned().unwrap();
5791 assert_eq!((status.queued, status.in_flight), (1, 1));
5792 registry.record_enqueue("fresh");
5794 let fresh = registry.snapshot().get("fresh").cloned().unwrap();
5795 assert_eq!(fresh.queued, 1);
5796 assert_eq!(fresh.in_flight, 0);
5797 assert_eq!(fresh.total_successes, 0);
5798 assert!(fresh.last_error.is_none());
5799 }
5800
5801 #[test]
5802 fn job_registry_registered_queue_marks_stay_on_that_queue() {
5803 let registry = JobRegistry::new();
5804 registry.register_on_queue("send_email", "mail");
5805 for _ in 0..3 {
5808 registry.record_enqueue("send_email");
5809 }
5810 let queues = registry.queue_snapshot();
5811 assert_eq!(queues.get("mail").unwrap().depth, 3);
5812 assert!(!queues.contains_key("default"));
5813 assert_eq!(registry.waiting_marks_for_test("send_email").len(), 3);
5814 registry.record_start("send_email");
5815 registry.record_start("send_email");
5816 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
5817 assert_eq!(registry.snapshot().get("send_email").unwrap().in_flight, 2);
5818 }
5819
5820 #[test]
5828 fn installing_a_clock_reaches_registry_clones() {
5829 use chrono::{TimeZone, Utc};
5830
5831 let registry = JobRegistry::new();
5833 let earlier_clone = registry.clone();
5834 registry.register_on_queue("probe", "default");
5835
5836 let epoch = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
5843 let registry = registry.with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5844
5845 let ready_at = u64::try_from(epoch.timestamp_millis()).unwrap() + 60_000;
5847 registry.record_enqueue_scheduled("probe", ready_at);
5848
5849 let through_new = registry.queue_snapshot();
5850 let through_old = earlier_clone.queue_snapshot();
5851 assert_eq!(
5852 through_new["default"].depth, 0,
5853 "the handle that installed the clock sees a scheduled job"
5854 );
5855 assert_eq!(
5856 through_old["default"].depth, through_new["default"].depth,
5857 "a clone taken before the clock was installed must judge the shared \
5858 marks identically; it read them against the real clock and saw a \
5859 ready job that cannot run"
5860 );
5861 assert_eq!(
5862 through_old["default"].oldest_waiting_age_ms,
5863 through_new["default"].oldest_waiting_age_ms,
5864 "and must report the same waiting age, not a decade of it"
5865 );
5866 }
5867
5868 #[test]
5878 fn a_surveyed_age_survives_the_rebase_onto_the_registry_clock() {
5879 use chrono::{TimeZone, Utc};
5880
5881 const DB_AGE_MS: u64 = 5_000;
5883
5884 for year in [1999, 2036] {
5887 let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
5888 let registry = JobRegistry::new()
5889 .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5890 registry.register_on_queue("probe", "default");
5891
5892 let registry_now = u64::try_from(epoch.timestamp_millis()).unwrap();
5893 let survey = HashMap::from([(
5894 "default".to_string(),
5895 (3u64, Some(ready_at_from_age(registry_now, DB_AGE_MS))),
5896 )]);
5897 registry.set_queue_depth_gauges(&survey);
5898
5899 let snapshot = registry.queue_snapshot();
5900 let status = &snapshot["default"];
5901 assert_eq!(status.depth, 3, "the survey's depth is authoritative");
5902 assert_eq!(
5903 status.oldest_waiting_age_ms, DB_AGE_MS,
5904 "the database-computed age must come back unchanged with the \
5905 registry clock at {year}; got {}ms",
5906 status.oldest_waiting_age_ms
5907 );
5908 }
5909 }
5910
5911 #[test]
5930 fn cancel_at_backend_offset_falls_back_to_the_nearest_of_its_three_timelines() {
5931 use chrono::{TimeZone, Utc};
5932
5933 const BACKEND_NOW_MS: u64 = 1_800_000_000_000;
5937 const ABSOLUTE_MARK_MS: u64 = 9_999_999_999_999;
5940
5941 for year in [1999, 2036] {
5942 let epoch = Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap();
5943 let epoch_ms = u64::try_from(epoch.timestamp_millis()).unwrap();
5944 let registry = JobRegistry::new()
5945 .with_clock(std::sync::Arc::new(crate::time::FixedClock::at(epoch)));
5946 registry.register_on_queue("immediate_job", "mail");
5947 registry.register_on_queue("relative_job", "mail");
5948 registry.register_on_queue("absolute_job", "mail");
5949
5950 registry.record_enqueue("immediate_job");
5961 registry.record_enqueue_scheduled("relative_job", BACKEND_NOW_MS + 3_600_000);
5962 registry.record_enqueue_scheduled("absolute_job", ABSOLUTE_MARK_MS);
5963 assert_eq!(
5964 registry.waiting_marks_for_test("immediate_job"),
5965 vec![epoch_ms, BACKEND_NOW_MS + 3_600_000, ABSOLUTE_MARK_MS],
5966 "all three marks pushed onto the shared queue"
5967 );
5968
5969 registry.record_cancel_at_backend_offset(
5973 "relative_job",
5974 "no-exact-mark",
5975 None,
5976 BACKEND_NOW_MS,
5977 3_600_000,
5978 );
5979 assert_eq!(
5980 registry.waiting_marks_for_test("immediate_job"),
5981 vec![epoch_ms, ABSOLUTE_MARK_MS],
5982 "canceling the relative-delay job (registry clock pinned to {year}) must \
5983 remove its own real-time mark and leave the other two intact"
5984 );
5985
5986 registry.record_cancel_at_backend_offset(
5990 "immediate_job",
5991 "no-exact-mark",
5992 None,
5993 BACKEND_NOW_MS,
5994 0,
5995 );
5996 assert_eq!(
5997 registry.waiting_marks_for_test("immediate_job"),
5998 vec![ABSOLUTE_MARK_MS],
5999 "canceling the immediate job (registry clock pinned to {year}) must remove \
6000 its own registry-clock mark and leave the absolute mark intact"
6001 );
6002
6003 registry.record_cancel_at_backend_offset(
6007 "absolute_job",
6008 "no-exact-mark",
6009 Some(ABSOLUTE_MARK_MS),
6010 BACKEND_NOW_MS,
6011 0,
6012 );
6013 assert_eq!(
6014 registry.waiting_marks_for_test("immediate_job"),
6015 Vec::<u64>::new(),
6016 "canceling the absolute job (registry clock pinned to {year}) must remove \
6017 its own mark via the exact run_at match and drain the queue to zero"
6018 );
6019 }
6020 }
6021
6022 #[test]
6033 fn cancel_at_backend_offset_prefers_the_exact_mark_over_a_coincidental_candidate_collision() {
6034 let registry = JobRegistry::new();
6035 registry.register_on_queue("relative_job", "mail");
6036 registry.register_on_queue("absolute_job", "mail");
6037
6038 registry.record_pg_enqueue(
6042 "relative_job",
6043 "job-a-id",
6044 Some(5_000),
6045 PgMarkTimeline::Real,
6046 );
6047 registry.record_pg_enqueue(
6048 "absolute_job",
6049 "job-b-id",
6050 Some(9_999),
6051 PgMarkTimeline::Real,
6052 );
6053 assert_eq!(
6054 registry.waiting_marks_for_test("relative_job"),
6055 vec![5_000, 9_999],
6056 "both marks pushed onto the shared queue"
6057 );
6058
6059 registry.record_cancel_at_backend_offset("relative_job", "job-a-id", None, 9_999, 0);
6065 assert_eq!(
6066 registry.waiting_marks_for_test("relative_job"),
6067 vec![9_999],
6068 "canceling job-a must remove its own exact mark (5_000), not job-b's mark that \
6069 its fallback candidate coincidentally collides with"
6070 );
6071 }
6072
6073 #[test]
6079 fn note_pg_job_mark_is_bounded_so_uncancelled_jobs_cannot_leak_memory_forever() {
6080 let registry = JobRegistry::new();
6081 registry.register_on_queue("never_canceled", "mail");
6082 for i in 0..(PG_MARKS_BY_JOB_ID_CAP + 5) {
6083 registry.record_pg_enqueue(
6084 "never_canceled",
6085 &format!("job-{i}"),
6086 Some(u64::try_from(i).unwrap()),
6087 PgMarkTimeline::Real,
6088 );
6089 }
6090 assert_eq!(
6091 registry.pg_marks_len_for_test(),
6092 PG_MARKS_BY_JOB_ID_CAP,
6093 "the exact-mark table must never grow past its cap, even when every job it \
6094 tracks is enqueued and never claimed at all"
6095 );
6096 }
6097
6098 #[test]
6109 fn note_pg_job_mark_eviction_protects_entries_still_in_the_future() {
6110 let registry = JobRegistry::new();
6111 registry.register_on_queue("mixed_cap", "mail");
6112
6113 let now = now_epoch_ms();
6114 let far_future = now + 3_600_000; registry.record_pg_enqueue(
6119 "mixed_cap",
6120 "still-delayed",
6121 Some(far_future),
6122 PgMarkTimeline::Real,
6123 );
6124 for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6125 registry.record_pg_enqueue(
6126 "mixed_cap",
6127 &format!("already-due-{i}"),
6128 Some(0),
6129 PgMarkTimeline::Real,
6130 );
6131 }
6132 assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6133
6134 registry.record_pg_enqueue("mixed_cap", "one-more", Some(0), PgMarkTimeline::Real);
6136
6137 assert_eq!(
6138 registry.pg_marks_len_for_test(),
6139 PG_MARKS_BY_JOB_ID_CAP,
6140 "the cap must still hold"
6141 );
6142 assert!(
6143 registry.pg_mark_contains_id_for_test("still-delayed"),
6144 "an entry still in the future must never be evicted while an already-due \
6145 entry is available to evict instead"
6146 );
6147 assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6148 }
6149
6150 #[test]
6161 fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_against_real_time_only() {
6162 use chrono::{TimeZone, Utc};
6163
6164 let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6165 crate::time::FixedClock::at(Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap()),
6166 ));
6167 registry.register_on_queue("skewed", "mail");
6168
6169 let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6170 let genuinely_delayed = real_now + 300_000;
6174
6175 registry.record_pg_enqueue(
6178 "skewed",
6179 "still-delayed",
6180 Some(genuinely_delayed),
6181 PgMarkTimeline::Real,
6182 );
6183 for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6184 registry.record_pg_enqueue(
6187 "skewed",
6188 &format!("filler-{i}"),
6189 Some(0),
6190 PgMarkTimeline::Real,
6191 );
6192 }
6193 assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6194
6195 registry.record_pg_enqueue("skewed", "one-more", Some(0), PgMarkTimeline::Real);
6197
6198 assert_eq!(
6199 registry.pg_marks_len_for_test(),
6200 PG_MARKS_BY_JOB_ID_CAP,
6201 "the cap must still hold"
6202 );
6203 assert!(
6204 registry.pg_mark_contains_id_for_test("still-delayed"),
6205 "a mark that is not yet due in real time must never be evicted just because \
6206 the registry's own (far-future) clock alone would call it due"
6207 );
6208 assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6209 }
6210
6211 #[test]
6224 fn note_pg_job_mark_eviction_judges_a_real_timeline_mark_even_when_the_registry_clock_lags() {
6225 use chrono::{TimeZone, Utc};
6226
6227 let registry = JobRegistry::new().with_clock(std::sync::Arc::new(
6228 crate::time::FixedClock::at(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap()),
6229 ));
6230 registry.register_on_queue("lagging", "mail");
6231
6232 let real_now = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap();
6233 let far_future = real_now + 3_600_000;
6236
6237 registry.record_pg_enqueue(
6238 "lagging",
6239 "still-delayed",
6240 Some(far_future),
6241 PgMarkTimeline::Real,
6242 );
6243 for i in 0..(PG_MARKS_BY_JOB_ID_CAP - 1) {
6244 registry.record_pg_enqueue(
6248 "lagging",
6249 &format!("filler-{i}"),
6250 Some(0),
6251 PgMarkTimeline::Real,
6252 );
6253 }
6254 assert_eq!(registry.pg_marks_len_for_test(), PG_MARKS_BY_JOB_ID_CAP);
6255
6256 registry.record_pg_enqueue("lagging", "one-more", Some(0), PgMarkTimeline::Real);
6258
6259 assert_eq!(
6260 registry.pg_marks_len_for_test(),
6261 PG_MARKS_BY_JOB_ID_CAP,
6262 "the cap must still hold"
6263 );
6264 assert!(
6265 registry.pg_mark_contains_id_for_test("still-delayed"),
6266 "a genuinely-future real-timeline entry must never be evicted while an \
6267 already-due real-timeline entry is available, even when the registry's own \
6268 clock lags behind real time and would call every real-timeline mark 'not \
6269 due' if it were consulted for them too"
6270 );
6271 assert!(registry.pg_marks_seq_order_is_consistent_for_test());
6272 }
6273
6274 #[test]
6286 fn record_pg_start_keeps_the_exact_mark_table_bounded_by_live_residency_not_lifetime_enqueues()
6287 {
6288 let registry = JobRegistry::new();
6289 registry.register_on_queue("churn", "mail");
6290 for i in 0..(PG_MARKS_BY_JOB_ID_CAP * 3) {
6291 let id = format!("job-{i}");
6292 registry.record_pg_enqueue(
6293 "churn",
6294 &id,
6295 Some(u64::try_from(i).unwrap()),
6296 PgMarkTimeline::Real,
6297 );
6298 registry.record_pg_start("churn", &id);
6299 }
6300 assert_eq!(
6301 registry.pg_marks_len_for_test(),
6302 0,
6303 "a job whose mark was consumed by record_pg_start must not linger in the \
6304 exact-mark table just because the cap has not been reached"
6305 );
6306 assert!(
6307 registry.pg_marks_seq_order_is_consistent_for_test(),
6308 "pg_marks_seq_order (the O(1)-eviction seq index) must mirror \
6309 pg_marks_by_job_id exactly after a long run of inserts and removals, \
6310 not just at a single snapshot"
6311 );
6312 }
6313
6314 #[test]
6325 fn record_pg_start_removes_its_own_mark_even_when_an_older_job_is_still_first_in_queue() {
6326 const OLDER_MARK: u64 = 1_000;
6327 const NEWER_MARK: u64 = 2_000;
6328
6329 let registry = JobRegistry::new();
6330 registry.register_on_queue("mixed", "work");
6331
6332 registry.record_pg_enqueue("mixed", "job-a", Some(OLDER_MARK), PgMarkTimeline::Real);
6335 registry.record_pg_enqueue("mixed", "job-b", Some(NEWER_MARK), PgMarkTimeline::Real);
6336
6337 registry.record_pg_start("mixed", "job-b");
6338
6339 assert_eq!(
6340 registry.waiting_marks_for_test("mixed"),
6341 vec![OLDER_MARK],
6342 "starting job-b must remove its own mark, leaving job-a's still-queued \
6343 mark untouched even though job-a's mark sits first in the queue"
6344 );
6345 assert_eq!(
6346 registry.pg_marks_len_for_test(),
6347 1,
6348 "job-a's exact entry must survive; only job-b's own entry is removed"
6349 );
6350 }
6351
6352 #[test]
6363 fn cancel_at_backend_offset_falls_back_when_the_exact_mark_is_stale() {
6364 const MARK_A: u64 = 1_000;
6365 const MARK_B: u64 = 2_000;
6366
6367 let registry = JobRegistry::new();
6368 registry.register_on_queue("flaky_job", "mail");
6369
6370 registry.record_pg_enqueue("flaky_job", "job-id", Some(MARK_A), PgMarkTimeline::Real);
6372
6373 registry.record_start("flaky_job");
6380 assert_eq!(
6381 registry.waiting_marks_for_test("flaky_job"),
6382 Vec::<u64>::new(),
6383 "record_start must have popped mark_a"
6384 );
6385
6386 registry.record_enqueue_scheduled("flaky_job", MARK_B);
6394
6395 registry.record_cancel_at_backend_offset("flaky_job", "job-id", None, MARK_B, 0);
6400 assert_eq!(
6401 registry.waiting_marks_for_test("flaky_job"),
6402 Vec::<u64>::new(),
6403 "the stale exact entry must not stop the cancel from removing the job's real, \
6404 current mark (mark_b) via the candidate fallback"
6405 );
6406 }
6407
6408 #[test]
6409 fn survey_setter_overwrites_queue_gauges_and_resets_absent_queues() {
6410 let registry = JobRegistry::new();
6411 registry.register_on_queue("reset_email", "critical");
6412 registry.register_on_queue("reindex", "bulk");
6413
6414 registry.record_enqueue("reset_email");
6418
6419 let now = now_epoch_ms();
6420 let mut survey = HashMap::new();
6421 survey.insert(
6423 "critical".to_string(),
6424 (4_u64, Some(now.saturating_sub(5_000))),
6425 );
6426 survey.insert("bulk".to_string(), (0_u64, None));
6428 registry.set_queue_depth_gauges(&survey);
6429
6430 let snap = registry.queue_snapshot();
6431 assert_eq!(
6432 snap.get("critical").unwrap().depth,
6433 4,
6434 "survey depth overrides the local enqueue mark"
6435 );
6436 let age = snap.get("critical").unwrap().oldest_waiting_age_ms;
6437 assert!(
6438 (5_000..=6_000).contains(&age),
6439 "age is derived from the surveyed oldest ready-at timestamp, got {age}"
6440 );
6441 assert_eq!(snap.get("bulk").unwrap().depth, 0);
6442 assert_eq!(snap.get("bulk").unwrap().oldest_waiting_age_ms, 0);
6443
6444 let mut survey2 = HashMap::new();
6447 survey2.insert("bulk".to_string(), (2_u64, Some(now)));
6448 registry.set_queue_depth_gauges(&survey2);
6449 let snap2 = registry.queue_snapshot();
6450 assert_eq!(
6451 snap2.get("critical").unwrap().depth,
6452 0,
6453 "a queue absent from the newest survey resets to 0"
6454 );
6455 assert_eq!(snap2.get("bulk").unwrap().depth, 2);
6456 }
6457
6458 #[test]
6459 fn survey_setter_overwrites_per_job_queued_and_resets_absent_names() {
6460 let registry = JobRegistry::new();
6461 registry.register("reset_email");
6462 registry.register("reindex");
6463
6464 registry.record_enqueue("reset_email");
6465 registry.record_enqueue("reset_email");
6466
6467 let mut counts = HashMap::new();
6468 counts.insert("reset_email".to_string(), 7_u64);
6469 registry.set_queued_counts(&counts);
6470 assert_eq!(
6471 registry.snapshot()["reset_email"].queued,
6472 7,
6473 "survey overwrites the local enqueue-driven queued count"
6474 );
6475 assert_eq!(
6476 registry.snapshot()["reindex"].queued,
6477 0,
6478 "a name absent from the survey resets to 0"
6479 );
6480
6481 registry.set_queued_counts(&HashMap::new());
6482 assert_eq!(registry.snapshot()["reset_email"].queued, 0);
6483 }
6484
6485 #[test]
6486 fn future_scheduled_jobs_do_not_inflate_ready_queue_depth() {
6487 let registry = JobRegistry::new();
6488 registry.register_on_queue("nightly_report", "reports");
6489 registry.register_on_queue("send_email", "reports");
6490
6491 let far_future = now_epoch_ms() + 60_000;
6496 registry.record_enqueue_scheduled("nightly_report", far_future);
6497
6498 let scheduled_only = registry.queue_snapshot();
6499 let reports = scheduled_only
6500 .get("reports")
6501 .expect("reports queue tracked");
6502 assert_eq!(
6503 reports.depth, 0,
6504 "a future-scheduled job is not ready backlog"
6505 );
6506 assert_eq!(
6507 reports.oldest_waiting_age_ms, 0,
6508 "a future-scheduled job must not age the ready queue"
6509 );
6510
6511 registry.record_enqueue("send_email");
6513 assert_eq!(
6514 registry.queue_snapshot().get("reports").unwrap().depth,
6515 1,
6516 "an immediately-runnable job still counts toward ready depth"
6517 );
6518
6519 let already_ready = now_epoch_ms().saturating_sub(5);
6522 registry.record_enqueue_scheduled("nightly_report", already_ready);
6523 let promoted = registry.queue_snapshot();
6524 assert_eq!(
6525 promoted.get("reports").unwrap().depth,
6526 2,
6527 "a scheduled job counts once its ready time has passed"
6528 );
6529 assert!(
6530 promoted.get("reports").unwrap().oldest_waiting_age_ms > 0,
6531 "a job whose ready time has passed contributes to oldest-waiting age"
6532 );
6533
6534 registry.record_start("send_email");
6537 registry.record_start("nightly_report");
6538 let drained = registry.queue_snapshot();
6539 assert_eq!(
6540 drained.get("reports").unwrap().depth,
6541 0,
6542 "with both ready jobs started only the future mark remains, counting as 0"
6543 );
6544 assert_eq!(
6545 drained.get("reports").unwrap().oldest_waiting_age_ms,
6546 0,
6547 "a lone future-scheduled mark ages nothing"
6548 );
6549 }
6550
6551 #[test]
6552 fn canceling_a_scheduled_job_preserves_a_coqueued_ready_mark() {
6553 let registry = JobRegistry::new();
6559 registry.register_on_queue("nightly_report", "reports");
6560 registry.register_on_queue("send_email", "reports");
6561
6562 registry.record_enqueue("send_email");
6565 let far_future = now_epoch_ms() + 60_000;
6566 registry.record_enqueue_scheduled("nightly_report", far_future);
6567 assert_eq!(
6568 registry.queue_snapshot().get("reports").unwrap().depth,
6569 1,
6570 "only the ready job counts toward ready depth"
6571 );
6572
6573 registry.record_cancel_scheduled("nightly_report");
6577 assert_eq!(
6578 registry.queue_snapshot().get("reports").unwrap().depth,
6579 1,
6580 "canceling the scheduled job must not steal the co-queued ready job's mark"
6581 );
6582
6583 registry.record_start("send_email");
6586 assert_eq!(
6587 registry.queue_snapshot().get("reports").unwrap().depth,
6588 0,
6589 "starting the ready job drains the queue; the scheduled mark was the one removed"
6590 );
6591 }
6592
6593 #[test]
6594 fn canceling_a_ready_job_removes_a_ready_mark() {
6595 let registry = JobRegistry::new();
6598 registry.register_on_queue("send_email", "mail");
6599
6600 registry.record_enqueue("send_email");
6601 registry.record_enqueue("send_email");
6602 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6603
6604 registry.record_cancel("send_email");
6605 assert_eq!(
6606 registry.queue_snapshot().get("mail").unwrap().depth,
6607 1,
6608 "canceling a ready job removes exactly one ready mark"
6609 );
6610
6611 registry.record_start("send_email");
6612 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6613 }
6614
6615 #[test]
6616 fn retry_dedup_without_enqueue_mark_keeps_real_duplicate_waiting() {
6617 let registry = JobRegistry::new();
6618 registry.register_on_queue("send_email", "mail");
6619
6620 registry.record_enqueue("send_email");
6622 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
6623
6624 registry.record_deduplicated("send_email", false, false);
6628 assert_eq!(
6629 registry.queue_snapshot().get("mail").unwrap().depth,
6630 1,
6631 "retry-dedup with no prior enqueue mark must not steal a waiting duplicate's mark"
6632 );
6633
6634 registry.record_enqueue("send_email");
6637 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
6638 registry.record_deduplicated("send_email", true, false);
6639 assert_eq!(
6640 registry.queue_snapshot().get("mail").unwrap().depth,
6641 1,
6642 "a normal coalesced enqueue removes exactly its own mark (no leak)"
6643 );
6644
6645 registry.record_start("send_email");
6647 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
6648 }
6649
6650 #[test]
6651 fn deduplicating_a_scheduled_duplicate_preserves_a_coqueued_ready_mark() {
6652 let registry = JobRegistry::new();
6660 registry.register_on_queue("nightly_report", "reports");
6661 registry.register_on_queue("send_email", "reports");
6662
6663 let far_future = now_epoch_ms() + 60_000;
6665 registry.record_enqueue_scheduled("nightly_report", far_future);
6666 let already_ready = now_epoch_ms().saturating_sub(5);
6669 registry.record_enqueue_scheduled("send_email", already_ready);
6670
6671 let before = registry.queue_snapshot();
6672 assert_eq!(
6673 before.get("reports").unwrap().depth,
6674 1,
6675 "only the ready job counts toward ready depth"
6676 );
6677 assert!(
6678 before.get("reports").unwrap().oldest_waiting_age_ms > 0,
6679 "the ready job ages the queue before the dedup"
6680 );
6681
6682 registry.record_deduplicated("nightly_report", true, true);
6687 let after = registry.queue_snapshot();
6688 assert_eq!(
6689 after.get("reports").unwrap().depth,
6690 1,
6691 "deduplicating the scheduled duplicate must not steal the ready job's mark"
6692 );
6693 assert!(
6694 after.get("reports").unwrap().oldest_waiting_age_ms > 0,
6695 "the ready job's mark is preserved, so it still ages the queue"
6696 );
6697
6698 registry.record_start("send_email");
6701 assert_eq!(
6702 registry.queue_snapshot().get("reports").unwrap().depth,
6703 0,
6704 "starting the ready job drains the queue; the scheduled mark was removed"
6705 );
6706 }
6707
6708 use axum::body::Body;
6709 use axum::http::Request;
6710 use tower::ServiceExt;
6711
6712 #[derive(Clone)]
6713 struct TestActuatorState {
6714 profile: String,
6715 deploy_version: String,
6716 metrics: crate::middleware::MetricsCollector,
6717 log_levels: LogLevels,
6718 task_registry: TaskRegistry,
6719 job_registry: JobRegistry,
6720 config_props: ConfigProperties,
6721 metrics_source_registry: MetricsSourceRegistry,
6722 health_indicator_registry: HealthIndicatorRegistry,
6723 health_detailed: bool,
6724 log_buffer: Option<crate::log::capture::LogBuffer>,
6725 #[cfg(feature = "http-client")]
6726 webhook_outbound: Option<crate::webhook_outbound::WebhookOutboundManager>,
6727 #[cfg(feature = "db")]
6728 pool: Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6729 #[cfg(feature = "db")]
6730 shards: Option<crate::sharding::ShardSet>,
6731 #[cfg(feature = "db")]
6732 replica_pool:
6733 Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
6734 #[cfg(feature = "ws")]
6735 channels: crate::channels::Channels,
6736 #[cfg(feature = "ws")]
6737 shutdown: tokio_util::sync::CancellationToken,
6738 data_capsules: Option<crate::gdpr::portability::CapsuleService>,
6739 }
6740
6741 impl ProvideActuatorState for TestActuatorState {
6742 fn metrics(&self) -> &crate::middleware::MetricsCollector {
6743 &self.metrics
6744 }
6745 fn log_levels(&self) -> &LogLevels {
6746 &self.log_levels
6747 }
6748 fn task_registry(&self) -> &TaskRegistry {
6749 &self.task_registry
6750 }
6751 fn job_registry(&self) -> &JobRegistry {
6752 &self.job_registry
6753 }
6754 fn config_props(&self) -> &ConfigProperties {
6755 &self.config_props
6756 }
6757 fn profile(&self) -> &str {
6758 &self.profile
6759 }
6760 fn uptime_display(&self) -> String {
6761 "test_uptime".to_string()
6762 }
6763 fn deploy_version(&self) -> String {
6764 self.deploy_version.clone()
6765 }
6766 fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
6767 Some(&self.metrics_source_registry)
6768 }
6769 #[cfg(feature = "http-client")]
6770 fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
6771 self.webhook_outbound.clone()
6772 }
6773 #[cfg(feature = "db")]
6774 fn pool(
6775 &self,
6776 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6777 {
6778 self.pool.as_ref()
6779 }
6780 #[cfg(feature = "db")]
6781 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
6782 self.shards.as_ref()
6783 }
6784 #[cfg(feature = "db")]
6785 fn replica_pool(
6786 &self,
6787 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
6788 {
6789 self.replica_pool.as_ref()
6790 }
6791 #[cfg(feature = "ws")]
6792 fn channels(&self) -> &crate::channels::Channels {
6793 &self.channels
6794 }
6795 #[cfg(feature = "ws")]
6796 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken {
6797 self.shutdown.clone()
6798 }
6799 fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
6800 Some(&self.health_indicator_registry)
6801 }
6802 fn health_detailed(&self) -> bool {
6803 self.health_detailed
6804 }
6805 fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
6806 self.log_buffer.clone()
6807 }
6808 fn data_capsules(
6809 &self,
6810 ) -> Result<
6811 crate::gdpr::portability::CapsuleService,
6812 crate::gdpr::portability::DataCapsuleError,
6813 > {
6814 self.data_capsules.clone().ok_or_else(|| {
6815 crate::gdpr::portability::DataCapsuleError::NotConfigured("test".to_owned())
6816 })
6817 }
6818 }
6819
6820 fn test_state() -> TestActuatorState {
6821 test_state_with_config(&AutumnConfig::default())
6822 }
6823
6824 fn test_state_with_config(config: &AutumnConfig) -> TestActuatorState {
6825 TestActuatorState {
6826 profile: config.profile.clone().unwrap_or_else(|| "dev".into()),
6827 deploy_version: crate::canary::STABLE.to_owned(),
6828 metrics: crate::middleware::MetricsCollector::new(),
6829 log_levels: LogLevels::new("info"),
6830 task_registry: TaskRegistry::new(),
6831 job_registry: JobRegistry::new(),
6832 config_props: ConfigProperties::from_config(config),
6833 metrics_source_registry: MetricsSourceRegistry::new(),
6834 health_indicator_registry: HealthIndicatorRegistry::new(),
6835 health_detailed: config.health.detailed,
6836 log_buffer: None,
6837 #[cfg(feature = "http-client")]
6838 webhook_outbound: None,
6839 #[cfg(feature = "db")]
6840 pool: None,
6841 #[cfg(feature = "db")]
6842 shards: None,
6843 #[cfg(feature = "db")]
6844 replica_pool: None,
6845 #[cfg(feature = "ws")]
6846 channels: crate::channels::Channels::new(32),
6847 #[cfg(feature = "ws")]
6848 shutdown: tokio_util::sync::CancellationToken::new(),
6849 data_capsules: None,
6850 }
6851 }
6852
6853 #[cfg(feature = "http-client")]
6854 fn test_state_with_webhook_outbound(
6855 manager: crate::webhook_outbound::WebhookOutboundManager,
6856 ) -> TestActuatorState {
6857 let mut state = test_state();
6858 state.webhook_outbound = Some(manager);
6859 state
6860 }
6861
6862 #[cfg(feature = "http-client")]
6863 fn replay_test_subscription() -> crate::webhook_outbound::WebhookSubscription {
6864 crate::webhook_outbound::WebhookSubscription {
6865 id: "sub-replay".to_string(),
6866 target_url: "https://example.test/webhook".to_string(),
6867 event_topics: vec!["order.created".to_string()],
6868 secret: "secret".to_string(),
6869 status: crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
6870 consecutive_failures: 50,
6871 }
6872 }
6873
6874 #[cfg(feature = "http-client")]
6875 fn replay_test_dlq_log() -> crate::webhook_outbound::WebhookDeliveryLog {
6876 crate::webhook_outbound::WebhookDeliveryLog {
6877 id: "log-replay".to_string(),
6878 subscription_id: "sub-replay".to_string(),
6879 topic: "order.created".to_string(),
6880 payload: "{\"id\":123}".to_string(),
6881 request_headers: std::collections::HashMap::new(),
6882 response_status: Some(503),
6883 response_body: Some("unavailable".to_string()),
6884 elapsed_ms: 42,
6885 attempt: 5,
6886 max_attempts: 5,
6887 is_dlq: true,
6888 last_error: Some("server returned status: 503".to_string()),
6889 timestamp: chrono::Utc::now(),
6890 }
6891 }
6892
6893 #[cfg(feature = "http-client")]
6894 #[tokio::test]
6895 async fn webhooks_replay_preserves_dlq_log_and_failures_when_enqueue_is_unavailable() {
6896 use crate::webhook_outbound::{
6897 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
6898 };
6899
6900 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
6901 crate::job::clear_global_job_client();
6902
6903 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
6904 handler
6905 .create_subscription(replay_test_subscription())
6906 .await
6907 .expect("subscription setup");
6908 let original_log = replay_test_dlq_log();
6909 handler
6910 .log_delivery(original_log.clone())
6911 .await
6912 .expect("dlq log setup");
6913 let failures_before_replay = handler
6914 .get_subscription("sub-replay")
6915 .await
6916 .expect("subscription lookup")
6917 .expect("subscription should exist")
6918 .consecutive_failures;
6919
6920 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
6921 let response = webhooks_replay_endpoint(
6922 State(state),
6923 Json(ReplayRequest {
6924 log_id: original_log.id.clone(),
6925 }),
6926 )
6927 .await
6928 .into_response();
6929
6930 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
6931
6932 let stored_log = handler
6933 .get_delivery_log(&original_log.id)
6934 .await
6935 .expect("delivery log lookup")
6936 .expect("delivery log should still exist");
6937 assert!(stored_log.is_dlq, "failed enqueue must keep log in DLQ");
6938 assert_eq!(stored_log.attempt, original_log.attempt);
6939 assert_eq!(stored_log.last_error, original_log.last_error);
6940 assert_eq!(stored_log.response_status, original_log.response_status);
6941 assert_eq!(stored_log.response_body, original_log.response_body);
6942
6943 let subscription = handler
6944 .get_subscription("sub-replay")
6945 .await
6946 .expect("subscription lookup")
6947 .expect("subscription should exist");
6948 assert_eq!(
6949 subscription.consecutive_failures, failures_before_replay,
6950 "failed enqueue must not reset subscription failure history"
6951 );
6952 assert_eq!(
6953 subscription.status,
6954 crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
6955 "failed enqueue must not reactivate an auto-failed subscription"
6956 );
6957
6958 crate::job::clear_global_job_client();
6959 }
6960
6961 #[cfg(feature = "http-client")]
6962 #[tokio::test]
6963 async fn webhooks_replay_rejects_disabled_subscription_without_removing_dlq() {
6964 use crate::webhook_outbound::{
6965 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
6966 WebhookSubscriptionStatus,
6967 };
6968
6969 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
6970 crate::job::clear_global_job_client();
6971
6972 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
6973 let mut subscription = replay_test_subscription();
6974 subscription.status = WebhookSubscriptionStatus::Disabled;
6975 subscription.consecutive_failures = 0;
6976 handler
6977 .create_subscription(subscription)
6978 .await
6979 .expect("subscription setup");
6980 let original_log = replay_test_dlq_log();
6981 handler
6982 .log_delivery(original_log.clone())
6983 .await
6984 .expect("dlq log setup");
6985
6986 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
6987 let response = webhooks_replay_endpoint(
6988 State(state),
6989 Json(ReplayRequest {
6990 log_id: original_log.id.clone(),
6991 }),
6992 )
6993 .await
6994 .into_response();
6995
6996 assert_eq!(response.status(), StatusCode::CONFLICT);
6997
6998 let stored_log = handler
6999 .get_delivery_log(&original_log.id)
7000 .await
7001 .expect("delivery log lookup")
7002 .expect("delivery log should still exist");
7003 assert!(stored_log.is_dlq);
7004 assert_eq!(stored_log.attempt, original_log.attempt);
7005 assert_eq!(stored_log.response_status, original_log.response_status);
7006 assert_eq!(stored_log.last_error, original_log.last_error);
7007
7008 let subscription = handler
7009 .get_subscription("sub-replay")
7010 .await
7011 .expect("subscription lookup")
7012 .expect("subscription should exist");
7013 assert_eq!(subscription.status, WebhookSubscriptionStatus::Disabled);
7014
7015 crate::job::clear_global_job_client();
7016 }
7017
7018 #[cfg(feature = "http-client")]
7019 #[tokio::test]
7020 async fn webhooks_replay_rejects_missing_subscription_without_removing_dlq() {
7021 use crate::webhook_outbound::{
7022 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7023 };
7024
7025 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7026 crate::job::clear_global_job_client();
7027
7028 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7029 let original_log = replay_test_dlq_log();
7030 handler
7031 .log_delivery(original_log.clone())
7032 .await
7033 .expect("dlq log setup");
7034
7035 let runtime_state = crate::AppState::for_test().with_profile("test");
7036 let shutdown = tokio_util::sync::CancellationToken::new();
7037 crate::job::start_runtime(
7038 vec![crate::job::JobInfo {
7039 version: 1,
7040 name: "autumn_webhook_delivery".to_string(),
7041 max_attempts: 1,
7042 initial_backoff_ms: 1,
7043 queue: "default".to_string(),
7044 uniqueness: None,
7045 concurrency: None,
7046 handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7047 timeout: None,
7048 }],
7049 &runtime_state,
7050 &shutdown,
7051 &crate::config::JobConfig::default(),
7052 true,
7053 )
7054 .expect("job runtime should start");
7055
7056 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7057 let response = webhooks_replay_endpoint(
7058 State(state),
7059 Json(ReplayRequest {
7060 log_id: original_log.id.clone(),
7061 }),
7062 )
7063 .await
7064 .into_response();
7065
7066 assert_eq!(response.status(), StatusCode::NOT_FOUND);
7067
7068 let stored_log = handler
7069 .get_delivery_log(&original_log.id)
7070 .await
7071 .expect("delivery log lookup")
7072 .expect("delivery log should still exist");
7073 assert!(stored_log.is_dlq);
7074 assert_eq!(stored_log.attempt, original_log.attempt);
7075 assert_eq!(stored_log.response_status, original_log.response_status);
7076 assert_eq!(stored_log.response_body, original_log.response_body);
7077 assert_eq!(stored_log.last_error, original_log.last_error);
7078
7079 assert!(
7080 handler
7081 .get_subscription("sub-replay")
7082 .await
7083 .expect("subscription lookup")
7084 .is_none(),
7085 "test setup should leave the subscription missing"
7086 );
7087
7088 shutdown.cancel();
7089 crate::job::clear_global_job_client();
7090 }
7091
7092 #[cfg(feature = "http-client")]
7093 #[tokio::test]
7094 async fn webhooks_replay_resets_log_and_failures_after_enqueue_succeeds() {
7095 use crate::webhook_outbound::{
7096 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
7097 };
7098
7099 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
7100 crate::job::clear_global_job_client();
7101
7102 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
7103 handler
7104 .create_subscription(replay_test_subscription())
7105 .await
7106 .expect("subscription setup");
7107 let original_log = replay_test_dlq_log();
7108 handler
7109 .log_delivery(original_log.clone())
7110 .await
7111 .expect("dlq log setup");
7112
7113 let runtime_state = crate::AppState::for_test().with_profile("test");
7114 let shutdown = tokio_util::sync::CancellationToken::new();
7115 crate::job::start_runtime(
7116 vec![crate::job::JobInfo {
7117 version: 1,
7118 name: "autumn_webhook_delivery".to_string(),
7119 max_attempts: 1,
7120 initial_backoff_ms: 1,
7121 queue: "default".to_string(),
7122 uniqueness: None,
7123 concurrency: None,
7124 handler: |_state, _payload| Box::pin(async move { Ok(()) }),
7125 timeout: None,
7126 }],
7127 &runtime_state,
7128 &shutdown,
7129 &crate::config::JobConfig::default(),
7130 true,
7131 )
7132 .expect("job runtime should start");
7133
7134 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
7135 let response = webhooks_replay_endpoint(
7136 State(state),
7137 Json(ReplayRequest {
7138 log_id: original_log.id.clone(),
7139 }),
7140 )
7141 .await
7142 .into_response();
7143
7144 assert_eq!(response.status(), StatusCode::OK);
7145
7146 let stored_log = handler
7147 .get_delivery_log(&original_log.id)
7148 .await
7149 .expect("delivery log lookup")
7150 .expect("delivery log should still exist");
7151 assert!(!stored_log.is_dlq);
7152 assert_eq!(stored_log.attempt, 1);
7153 assert_eq!(stored_log.last_error, None);
7154 assert_eq!(stored_log.response_status, None);
7155 assert_eq!(stored_log.response_body, None);
7156
7157 let subscription = handler
7158 .get_subscription("sub-replay")
7159 .await
7160 .expect("subscription lookup")
7161 .expect("subscription should exist");
7162 assert_eq!(subscription.consecutive_failures, 0);
7163 assert_eq!(
7164 subscription.status,
7165 crate::webhook_outbound::WebhookSubscriptionStatus::Active
7166 );
7167
7168 shutdown.cancel();
7169 crate::job::clear_global_job_client();
7170 }
7171
7172 #[cfg(all(feature = "db", not(feature = "sqlite")))]
7177 async fn refused_pool()
7178 -> diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection> {
7179 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7180 .await
7181 .expect("bind");
7182 let addr = listener.local_addr().expect("local addr");
7183 tokio::spawn(async move {
7184 while let Ok((socket, _)) = listener.accept().await {
7185 drop(socket);
7186 }
7187 });
7188 let config = crate::config::DatabaseConfig {
7189 url: Some(format!("postgres://secret_user@{addr}/autumn")),
7190 pool_size: 1,
7191 connect_timeout_secs: 1,
7192 ..Default::default()
7193 };
7194 crate::db::create_pool(&config)
7195 .expect("valid pool config")
7196 .expect("pool url is set")
7197 }
7198
7199 #[cfg(all(feature = "db", not(feature = "sqlite")))]
7200 async fn health_json(state: TestActuatorState) -> (StatusCode, serde_json::Value) {
7201 let resp = actuator_router(true)
7202 .with_state(state)
7203 .oneshot(
7204 Request::builder()
7205 .uri("/actuator/health")
7206 .body(Body::empty())
7207 .unwrap(),
7208 )
7209 .await
7210 .unwrap();
7211 let status = resp.status();
7212 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7213 .await
7214 .unwrap();
7215 (status, serde_json::from_slice(&body).unwrap())
7216 }
7217
7218 #[cfg(all(feature = "db", not(feature = "sqlite")))]
7219 #[tokio::test]
7220 async fn health_db_is_down_without_error_text_when_not_detailed() {
7221 let mut state = test_state();
7222 state.health_detailed = false;
7223 state.pool = Some(refused_pool().await);
7224
7225 let (status, json) = health_json(state).await;
7226
7227 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7228 assert_eq!(json["components"]["db"]["status"], "DOWN");
7229 assert_eq!(json["checks"]["database"]["status"], "down");
7230 assert!(json["checks"]["database"].get("error").is_none(), "{json}");
7232 assert!(!json.to_string().contains("secret_user"), "{json}");
7233 }
7234
7235 #[cfg(all(feature = "db", not(feature = "sqlite")))]
7236 #[tokio::test]
7237 async fn health_db_error_is_shown_when_detailed() {
7238 let mut state = test_state();
7239 state.health_detailed = true;
7240 state.pool = Some(refused_pool().await);
7241
7242 let (status, json) = health_json(state).await;
7243
7244 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
7245 let error = json["checks"]["database"]["error"]
7246 .as_str()
7247 .unwrap_or_default();
7248 assert!(error.starts_with("primary connection failed"), "{json}");
7249 assert_eq!(json["components"]["db"]["details"]["error"], error);
7250 }
7251
7252 #[tokio::test]
7253 async fn actuator_health_returns_ok() {
7254 let app = actuator_router(true).with_state(test_state());
7255 let resp = app
7256 .oneshot(
7257 Request::builder()
7258 .uri("/actuator/health")
7259 .body(Body::empty())
7260 .unwrap(),
7261 )
7262 .await
7263 .unwrap();
7264
7265 assert_eq!(resp.status(), StatusCode::OK);
7266 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7267 .await
7268 .unwrap();
7269 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7270 assert_eq!(json["status"], "UP");
7271 assert_eq!(json["profile"], "dev");
7272 assert!(json["uptime"].is_string());
7273 }
7274
7275 #[cfg(feature = "db")]
7276 #[tokio::test]
7277 async fn actuator_health_exposes_after_commit_failure_counter() {
7278 let app = actuator_router(true).with_state(test_state());
7279 let resp = app
7280 .oneshot(
7281 Request::builder()
7282 .uri("/actuator/health")
7283 .body(Body::empty())
7284 .unwrap(),
7285 )
7286 .await
7287 .unwrap();
7288
7289 assert_eq!(resp.status(), StatusCode::OK);
7290 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7291 .await
7292 .unwrap();
7293 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7294 assert_eq!(
7295 json["autumn_after_commit_failures_total"],
7296 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed),
7297 "/actuator/health should expose the documented after_commit counter"
7298 );
7299 }
7300
7301 #[tokio::test]
7302 #[allow(clippy::await_holding_lock)]
7303 async fn actuator_circuitbreakers_returns_breakers() {
7304 let _lock = crate::circuit_breaker::TEST_LOCK
7305 .lock()
7306 .unwrap_or_else(std::sync::PoisonError::into_inner);
7307 crate::circuit_breaker::global_registry().clear();
7308 let breaker = crate::circuit_breaker::global_registry().get_or_create(
7309 "actuator_endpoint_test_breaker",
7310 crate::circuit_breaker::CircuitBreakerPolicy {
7311 failure_ratio_threshold: 0.5,
7312 sample_window: std::time::Duration::from_secs(10),
7313 minimum_sample_count: 2,
7314 open_duration: std::time::Duration::from_secs(60),
7315 half_open_trial_count: 2,
7316 ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7317 },
7318 );
7319 assert_eq!(
7320 breaker.state(),
7321 crate::circuit_breaker::CircuitState::Closed
7322 );
7323
7324 let mut detailed_config = AutumnConfig::default();
7325 detailed_config.health.detailed = true;
7326 let state = test_state_with_config(&detailed_config);
7327 let app = actuator_router(true).with_state(state);
7328 let resp = app
7329 .clone()
7330 .oneshot(
7331 Request::builder()
7332 .uri("/actuator/circuitbreakers")
7333 .body(Body::empty())
7334 .unwrap(),
7335 )
7336 .await
7337 .unwrap();
7338
7339 assert_eq!(resp.status(), StatusCode::OK);
7340 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7341 .await
7342 .unwrap();
7343 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7344 let list = json.as_array().expect("Should be a JSON array");
7345 let item = list
7346 .iter()
7347 .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7348 .expect("Should find our breaker");
7349 assert_eq!(item["state"], "CLOSED");
7350 assert_eq!(item["failure_ratio_threshold"], 0.5);
7351 assert_eq!(item["minimum_sample_count"], 2);
7352 assert_eq!(item["slow_call_ratio"], 0.0);
7353 assert_eq!(item["slow_call_duration_threshold_ms"], 60_000);
7354 assert_eq!(item["slow_call_rate_threshold"], 1.0);
7355 assert_eq!(item["cancelled_call_outcome"], "slow");
7356
7357 let mut undetailed_config = AutumnConfig::default();
7358 undetailed_config.health.detailed = false;
7359 let undetailed_state = test_state_with_config(&undetailed_config);
7360 let app_undetailed = actuator_router(true).with_state(undetailed_state);
7361 let resp_undetailed = app_undetailed
7362 .oneshot(
7363 Request::builder()
7364 .uri("/actuator/circuitbreakers")
7365 .body(Body::empty())
7366 .unwrap(),
7367 )
7368 .await
7369 .unwrap();
7370
7371 assert_eq!(resp_undetailed.status(), StatusCode::OK);
7372 let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7373 .await
7374 .unwrap();
7375 let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7376 let list_undetailed = json_undetailed.as_array().expect("Should be a JSON array");
7377 let item_undetailed = list_undetailed
7378 .iter()
7379 .find(|i| i["name"] == "actuator_endpoint_test_breaker")
7380 .expect("Should find our breaker");
7381 assert_eq!(item_undetailed["state"], "CLOSED");
7382 assert!(item_undetailed.get("failure_ratio_threshold").is_none());
7383 assert!(item_undetailed.get("minimum_sample_count").is_none());
7384 assert_eq!(item_undetailed["slow_call_ratio"], 0.0);
7385 assert!(item_undetailed.get("slow_call_rate_threshold").is_none());
7386 assert!(
7387 item_undetailed
7388 .get("slow_call_duration_threshold_ms")
7389 .is_none()
7390 );
7391 assert!(item_undetailed.get("cancelled_call_outcome").is_none());
7392 crate::circuit_breaker::global_registry().clear();
7393 }
7394
7395 #[test]
7396 fn prometheus_renders_breaker_slow_call_families() {
7397 let mut out = String::new();
7398 write_builtin_circuit_breaker_metrics(&mut out, "stable", &[]);
7399 assert!(out.is_empty(), "no breaker: no family");
7400
7401 let policy = crate::circuit_breaker::CircuitBreakerPolicy {
7402 slow_call_duration_threshold: Some(std::time::Duration::ZERO),
7403 minimum_sample_count: 100,
7404 ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7405 };
7406 let slow = crate::circuit_breaker::CircuitBreaker::new("b\"slow", policy);
7407 crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7408 crate::circuit_breaker::CircuitBreakerGuard::new(slow.clone()).success();
7409 let idle = crate::circuit_breaker::CircuitBreaker::new(
7410 "a.idle",
7411 crate::circuit_breaker::CircuitBreakerPolicy::default(),
7412 );
7413
7414 write_builtin_circuit_breaker_metrics(&mut out, "stable", &[slow, idle]);
7415 let want = "\
7416# HELP autumn_circuit_breaker_slow_calls_total Calls each circuit breaker counted as slow, cancelled slow calls included
7417# TYPE autumn_circuit_breaker_slow_calls_total counter
7418autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"a.idle\"} 0
7419autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"b\\\"slow\"} 2
7420# HELP autumn_circuit_breaker_slow_call_ratio Share of slow calls in each circuit breaker sample window
7421# TYPE autumn_circuit_breaker_slow_call_ratio gauge
7422autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"a.idle\"} 0
7423autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"b\\\"slow\"} 1
7424";
7425 assert_eq!(out, want);
7426 }
7427
7428 #[tokio::test]
7429 #[allow(clippy::await_holding_lock)]
7430 async fn actuator_prometheus_shows_breaker_slow_call_families() {
7431 let _lock = crate::circuit_breaker::TEST_LOCK
7432 .lock()
7433 .unwrap_or_else(std::sync::PoisonError::into_inner);
7434 crate::circuit_breaker::global_registry().clear();
7435 let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7436 "prometheus_slow_breaker",
7437 crate::circuit_breaker::CircuitBreakerPolicy::default(),
7438 );
7439
7440 let app = actuator_router(true).with_state(test_state());
7441 let resp = app
7442 .oneshot(
7443 Request::builder()
7444 .uri("/actuator/prometheus")
7445 .body(Body::empty())
7446 .unwrap(),
7447 )
7448 .await
7449 .unwrap();
7450 assert_eq!(resp.status(), StatusCode::OK);
7451 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7452 .await
7453 .unwrap();
7454 let text = String::from_utf8(body.to_vec()).unwrap();
7455 assert!(text.contains(
7456 "autumn_circuit_breaker_slow_calls_total{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7457 ));
7458 assert!(text.contains(
7459 "autumn_circuit_breaker_slow_call_ratio{version=\"stable\",name=\"prometheus_slow_breaker\"} 0"
7460 ));
7461 crate::circuit_breaker::global_registry().clear();
7462 }
7463
7464 #[tokio::test]
7465 #[allow(clippy::await_holding_lock)]
7466 async fn test_health_hides_circuit_breakers_when_undetailed() {
7467 let _lock = crate::circuit_breaker::TEST_LOCK
7468 .lock()
7469 .unwrap_or_else(std::sync::PoisonError::into_inner);
7470 crate::circuit_breaker::global_registry().clear();
7471
7472 let _breaker = crate::circuit_breaker::global_registry().get_or_create(
7473 "test_health_hide_breaker",
7474 crate::circuit_breaker::CircuitBreakerPolicy {
7475 failure_ratio_threshold: 0.5,
7476 sample_window: std::time::Duration::from_secs(10),
7477 minimum_sample_count: 2,
7478 open_duration: std::time::Duration::from_secs(60),
7479 half_open_trial_count: 2,
7480 ..crate::circuit_breaker::CircuitBreakerPolicy::default()
7481 },
7482 );
7483
7484 let mut detailed_config = AutumnConfig::default();
7485 detailed_config.health.detailed = true;
7486 let state = test_state_with_config(&detailed_config);
7487 let app = actuator_router(true).with_state(state);
7488 let resp = app
7489 .oneshot(
7490 Request::builder()
7491 .uri("/actuator/health")
7492 .body(Body::empty())
7493 .unwrap(),
7494 )
7495 .await
7496 .unwrap();
7497
7498 assert_eq!(resp.status(), StatusCode::OK);
7499 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7500 .await
7501 .unwrap();
7502 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7503 assert!(json["components"]["circuit_breaker.test_health_hide_breaker"].is_object());
7504
7505 let mut undetailed_config = AutumnConfig::default();
7506 undetailed_config.health.detailed = false;
7507 let undetailed_state = test_state_with_config(&undetailed_config);
7508 let app_undetailed = actuator_router(true).with_state(undetailed_state);
7509 let resp_undetailed = app_undetailed
7510 .oneshot(
7511 Request::builder()
7512 .uri("/actuator/health")
7513 .body(Body::empty())
7514 .unwrap(),
7515 )
7516 .await
7517 .unwrap();
7518
7519 assert_eq!(resp_undetailed.status(), StatusCode::OK);
7520 let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
7521 .await
7522 .unwrap();
7523 let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
7524
7525 if let Some(components) = json_undetailed.get("components") {
7526 assert!(
7527 components
7528 .get("circuit_breaker.test_health_hide_breaker")
7529 .is_none()
7530 );
7531 }
7532
7533 crate::circuit_breaker::global_registry().clear();
7534 }
7535
7536 #[tokio::test]
7537 async fn actuator_routes_respect_custom_prefix() {
7538 let app = actuator_router_with_prefix("/ops", true, true).with_state(test_state());
7539
7540 let prefixed = app
7541 .clone()
7542 .oneshot(
7543 Request::builder()
7544 .uri("/ops/health")
7545 .body(Body::empty())
7546 .unwrap(),
7547 )
7548 .await
7549 .unwrap();
7550 assert_eq!(prefixed.status(), StatusCode::OK);
7551
7552 let legacy = app
7553 .oneshot(
7554 Request::builder()
7555 .uri("/actuator/health")
7556 .body(Body::empty())
7557 .unwrap(),
7558 )
7559 .await
7560 .unwrap();
7561 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
7562 }
7563
7564 #[test]
7565 fn actuator_route_helpers_normalize_prefixes() {
7566 assert_eq!(actuator_route_glob("ops/"), "/ops/*");
7567 assert_eq!(actuator_route_path("ops/", "/health"), "/ops/health");
7568 assert_eq!(actuator_route_glob("/"), "/*");
7569 }
7570
7571 #[tokio::test]
7572 async fn actuator_info_returns_metadata() {
7573 let app = actuator_router(true).with_state(test_state());
7574 let resp = app
7575 .oneshot(
7576 Request::builder()
7577 .uri("/actuator/info")
7578 .body(Body::empty())
7579 .unwrap(),
7580 )
7581 .await
7582 .unwrap();
7583
7584 assert_eq!(resp.status(), StatusCode::OK);
7585 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7586 .await
7587 .unwrap();
7588 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7589 assert!(json["autumn"]["version"].is_string());
7590 assert_eq!(json["autumn"]["profile"], "dev");
7591 }
7592
7593 #[tokio::test]
7594 async fn actuator_info_reports_build_and_git_provenance() {
7595 fn leak(value: String) -> &'static str {
7599 Box::leak(value.into_boxed_str())
7600 }
7601
7602 let head = std::process::Command::new("git")
7607 .args(["rev-parse", "HEAD"])
7608 .output()
7609 .ok()
7610 .filter(|out| out.status.success())
7611 .and_then(|out| String::from_utf8(out.stdout).ok())
7612 .map_or_else(
7613 || "179f1aa78077816d50af3bbed337a9328bc0609b".to_string(),
7614 |out| out.trim().to_owned(),
7615 );
7616 let short: String = head.chars().take(7).collect();
7617
7618 crate::build_info::__set_build_context(
7619 "provenance_probe_app",
7620 "9.9.9",
7621 Some(leak(head.clone())),
7622 Some(leak(short.clone())),
7623 Some("provenance-branch"),
7624 Some("false"),
7625 Some("2026-07-09T00:00:00Z"),
7626 );
7627
7628 let app = actuator_router(true).with_state(test_state());
7629 let resp = app
7630 .oneshot(
7631 Request::builder()
7632 .uri("/actuator/info")
7633 .body(Body::empty())
7634 .unwrap(),
7635 )
7636 .await
7637 .unwrap();
7638 assert_eq!(resp.status(), StatusCode::OK);
7639 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7640 .await
7641 .unwrap();
7642 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7643
7644 assert_eq!(json["app"]["name"], "provenance_probe_app");
7647 assert_eq!(json["app"]["version"], "9.9.9");
7648 assert_ne!(json["app"]["version"], "unknown");
7649
7650 assert_eq!(json["build"]["git"]["commit"], head);
7653 assert_eq!(json["build"]["git"]["commit_short"], short);
7654 assert_eq!(json["build"]["git"]["branch"], "provenance-branch");
7655 assert_eq!(json["build"]["git"]["dirty"], false);
7656 assert_eq!(json["build"]["timestamp"], "2026-07-09T00:00:00Z");
7657 assert_eq!(json["build"]["version"], "9.9.9");
7658 }
7659
7660 #[tokio::test]
7661 async fn actuator_env_available_in_sensitive_mode() {
7662 let config = AutumnConfig {
7663 profile: Some("prod".into()),
7664 server: crate::config::ServerConfig {
7665 port: 4100,
7666 ..crate::config::ServerConfig::default()
7667 },
7668 telemetry: crate::config::TelemetryConfig {
7669 enabled: true,
7670 service_name: "cloud-app".into(),
7671 ..crate::config::TelemetryConfig::default()
7672 },
7673 health: crate::config::HealthConfig {
7674 path: "/healthz".into(),
7675 ..crate::config::HealthConfig::default()
7676 },
7677 ..AutumnConfig::default()
7678 };
7679
7680 let app = actuator_router(true).with_state(test_state_with_config(&config));
7681 let resp = app
7682 .oneshot(
7683 Request::builder()
7684 .uri("/actuator/env")
7685 .body(Body::empty())
7686 .unwrap(),
7687 )
7688 .await
7689 .unwrap();
7690 assert_eq!(resp.status(), StatusCode::OK);
7691 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7692 .await
7693 .unwrap();
7694 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
7695 assert_eq!(json["active_profile"], "prod");
7696 assert_eq!(json["properties"]["server.port"], "4100");
7697 assert_eq!(json["properties"]["telemetry.enabled"], "true");
7698 assert_eq!(json["properties"]["telemetry.service_name"], "cloud-app");
7699 assert_eq!(json["properties"]["health.path"], "/healthz");
7700 }
7701
7702 #[tokio::test]
7703 async fn actuator_env_hidden_in_nonsensitive_mode() {
7704 let app = actuator_router(false).with_state(test_state());
7705 let resp = app
7706 .oneshot(
7707 Request::builder()
7708 .uri("/actuator/env")
7709 .body(Body::empty())
7710 .unwrap(),
7711 )
7712 .await
7713 .unwrap();
7714 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7715 }
7716
7717 #[tokio::test]
7718 async fn actuator_graph_hidden_in_nonsensitive_mode() {
7719 let app = actuator_router(false).with_state(test_state());
7723 let resp = app
7724 .oneshot(
7725 Request::builder()
7726 .uri("/actuator/graph")
7727 .body(Body::empty())
7728 .unwrap(),
7729 )
7730 .await
7731 .unwrap();
7732 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7733 }
7734
7735 #[test]
7736 fn actuator_graph_path_is_listed_only_in_sensitive_mode() {
7737 assert!(
7738 actuator_endpoint_paths("/actuator", true, true)
7739 .contains(&"/actuator/graph".to_owned())
7740 );
7741 assert!(
7742 !actuator_endpoint_paths("/actuator", false, true)
7743 .contains(&"/actuator/graph".to_owned()),
7744 "the listing must match the mounts, or the startup barrier seeds a path \
7745 that is not served"
7746 );
7747 }
7748
7749 #[cfg(feature = "db")]
7750 #[test]
7751 fn actuator_derivations_path_is_listed_only_in_sensitive_mode() {
7752 assert!(
7756 actuator_endpoint_paths("/actuator", true, true)
7757 .contains(&"/actuator/derivations".to_owned())
7758 );
7759 assert!(
7760 !actuator_endpoint_paths("/actuator", false, true)
7761 .contains(&"/actuator/derivations".to_owned()),
7762 "the listing must match the mounts, or the startup barrier seeds a path \
7763 that is not served"
7764 );
7765 }
7766
7767 #[cfg(feature = "db")]
7768 #[tokio::test]
7769 async fn actuator_derivations_hidden_in_nonsensitive_mode() {
7770 let app = actuator_router(false).with_state(test_state());
7771 let resp = app
7772 .oneshot(
7773 Request::builder()
7774 .uri("/actuator/derivations")
7775 .body(Body::empty())
7776 .unwrap(),
7777 )
7778 .await
7779 .unwrap();
7780 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7781 }
7782
7783 #[cfg(feature = "db")]
7784 #[test]
7785 fn actuator_derivations_200_body_is_an_array_of_status_objects() {
7786 let statuses = vec![
7790 crate::derivation::DerivationStatus {
7791 name: "posts.published_comment_count".to_owned(),
7792 definition_hash: Some("a".repeat(64)),
7793 stored_hash: Some("a".repeat(64)),
7794 backfill_state: Some(crate::derivation::BackfillState::Complete),
7795 checkpoint: Some(42),
7796 backfilled_rows: 7,
7797 updated_at: Some("2026-09-07 00:00:00+00".to_owned()),
7798 drift: Some(0),
7799 drift_error: None,
7800 },
7801 crate::derivation::DerivationStatus {
7802 name: "posts.removed".to_owned(),
7803 definition_hash: None,
7804 stored_hash: Some("b".repeat(64)),
7805 backfill_state: Some(crate::derivation::BackfillState::Unregistered),
7806 checkpoint: None,
7807 backfilled_rows: 0,
7808 updated_at: None,
7809 drift: None,
7810 drift_error: Some("column does not exist".to_owned()),
7811 },
7812 ];
7813 let body = serde_json::Value::Array(derivation_report("control", statuses));
7814 let rows = body.as_array().expect("the body is a JSON array");
7815 assert_eq!(rows.len(), 2);
7816 for row in rows {
7817 assert_eq!(row["target"], "control", "every row names its database");
7818 }
7819 for row in rows {
7820 let mut keys: Vec<&str> = row
7821 .as_object()
7822 .expect("each row is an object")
7823 .keys()
7824 .map(String::as_str)
7825 .collect();
7826 keys.sort_unstable();
7827 assert_eq!(
7828 keys,
7829 vec![
7830 "backfill_state",
7831 "backfilled_rows",
7832 "checkpoint",
7833 "definition_hash",
7834 "drift",
7835 "drift_error",
7836 "name",
7837 "stored_hash",
7838 "target",
7839 "updated_at",
7840 ],
7841 "{row}"
7842 );
7843 }
7844 assert_eq!(rows[0]["backfill_state"], serde_json::json!("complete"));
7845 assert_eq!(rows[0]["drift"], serde_json::json!(0));
7846 assert_eq!(rows[1]["backfill_state"], serde_json::json!("unregistered"));
7849 assert!(rows[1]["definition_hash"].is_null());
7850 assert!(rows[1]["drift"].is_null());
7851 }
7852
7853 #[cfg(feature = "db")]
7854 #[tokio::test]
7855 async fn actuator_derivations_reports_no_pool_as_unavailable() {
7856 let app = actuator_router(true).with_state(test_state());
7859 let resp = app
7860 .oneshot(
7861 Request::builder()
7862 .uri("/actuator/derivations")
7863 .body(Body::empty())
7864 .unwrap(),
7865 )
7866 .await
7867 .unwrap();
7868 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7869 }
7870
7871 async fn get_graph(state: crate::AppState) -> axum::response::Response {
7873 actuator_router(true)
7874 .with_state(state)
7875 .oneshot(
7876 Request::builder()
7877 .uri("/actuator/graph")
7878 .body(Body::empty())
7879 .unwrap(),
7880 )
7881 .await
7882 .unwrap()
7883 }
7884
7885 #[tokio::test]
7886 async fn actuator_graph_serves_the_installed_graph_over_http() {
7887 let state = crate::AppState::test_default();
7891 crate::graph::install(
7892 &state,
7893 crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]),
7894 );
7895
7896 let resp = get_graph(state).await;
7897 assert_eq!(resp.status(), StatusCode::OK);
7898 assert_eq!(
7899 resp.headers()
7900 .get(axum::http::header::CONTENT_TYPE)
7901 .and_then(|v| v.to_str().ok()),
7902 Some("application/json")
7903 );
7904 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7905 .await
7906 .expect("body");
7907 let decoded: crate::graph::manifest::ArchitectureGraph =
7908 serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
7909 assert_eq!(
7910 decoded.schema_version,
7911 crate::graph::manifest::MANIFEST_SCHEMA_VERSION
7912 );
7913 }
7914
7915 #[tokio::test]
7918 async fn two_apps_in_one_process_serve_their_own_graphs() {
7919 let graph_with = |jobs: usize| {
7920 let mut graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
7921 graph.completeness.jobs = jobs;
7922 graph
7923 };
7924 let first = crate::AppState::test_default();
7925 let second = crate::AppState::test_default();
7926 crate::graph::install(&first, graph_with(1));
7927 crate::graph::install(&second, graph_with(2));
7928
7929 for (state, jobs) in [(first, 1), (second, 2)] {
7930 let resp = get_graph(state).await;
7931 assert_eq!(resp.status(), StatusCode::OK);
7932 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7933 .await
7934 .expect("body");
7935 let decoded: crate::graph::manifest::ArchitectureGraph =
7936 serde_json::from_slice(&body).expect("graph document");
7937 assert_eq!(decoded.completeness.jobs, jobs);
7938 }
7939 }
7940
7941 #[tokio::test]
7944 async fn actuator_graph_is_unavailable_for_a_state_with_no_graph() {
7945 let resp = get_graph(crate::AppState::test_default()).await;
7946 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7947
7948 let resp = actuator_router(true)
7949 .with_state(test_state())
7950 .oneshot(
7951 Request::builder()
7952 .uri("/actuator/graph")
7953 .body(Body::empty())
7954 .unwrap(),
7955 )
7956 .await
7957 .unwrap();
7958 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7959 }
7960
7961 #[tokio::test]
7962 async fn actuator_graph_serves_the_installed_graph() {
7963 let graph = crate::graph::manifest::build(&[], 0, &[], &[], &[], &[]);
7964 let json = serde_json::to_vec(&graph).expect("serialize");
7965 let resp = graph_response(Some(bytes::Bytes::from(json)));
7966 assert_eq!(resp.status(), StatusCode::OK);
7967 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7968 .await
7969 .expect("body");
7970 let decoded: crate::graph::manifest::ArchitectureGraph =
7971 serde_json::from_slice(&body).expect("the endpoint must serve the graph document");
7972 assert_eq!(decoded, graph);
7973 }
7974
7975 #[tokio::test]
7976 async fn actuator_graph_explains_itself_when_none_is_installed() {
7977 let resp = graph_response(None);
7978 assert_eq!(
7979 resp.status(),
7980 StatusCode::SERVICE_UNAVAILABLE,
7981 "an app with no graph must say so rather than 404, which would read as \
7982 'this build has no such endpoint'"
7983 );
7984 }
7985
7986 #[tokio::test]
7987 async fn actuator_circuitbreakers_hidden_in_nonsensitive_mode() {
7988 let app = actuator_router(false).with_state(test_state());
7989 let resp = app
7990 .oneshot(
7991 Request::builder()
7992 .uri("/actuator/circuitbreakers")
7993 .body(Body::empty())
7994 .unwrap(),
7995 )
7996 .await
7997 .unwrap();
7998 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7999 }
8000
8001 #[test]
8002 fn redaction_patterns() {
8003 assert!(should_redact("database.url"));
8004 assert!(should_redact("api_token"));
8005 assert!(should_redact("secret_key"));
8006 assert!(!should_redact("server.port"));
8007 assert!(!should_redact("log.level"));
8008 }
8009
8010 #[tokio::test]
8013 async fn actuator_metrics_returns_http_stats() {
8014 let state = test_state();
8015 state.metrics().record("GET", "/test", 200, 10);
8016 state.metrics().record("POST", "/test", 500, 50);
8017
8018 let app = actuator_router(true).with_state(state);
8019 let resp = app
8020 .oneshot(
8021 Request::builder()
8022 .uri("/actuator/metrics")
8023 .body(Body::empty())
8024 .unwrap(),
8025 )
8026 .await
8027 .unwrap();
8028
8029 assert_eq!(resp.status(), StatusCode::OK);
8030 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8031 .await
8032 .unwrap();
8033 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8034 assert_eq!(json["http"]["requests_total"], 2);
8035 assert_eq!(json["http"]["by_status"]["2xx"], 1);
8036 assert_eq!(json["http"]["by_status"]["5xx"], 1);
8037 }
8038
8039 #[tokio::test]
8040 async fn actuator_metrics_available_in_nonsensitive_mode() {
8041 let app = actuator_router(false).with_state(test_state());
8042 let resp = app
8043 .oneshot(
8044 Request::builder()
8045 .uri("/actuator/metrics")
8046 .body(Body::empty())
8047 .unwrap(),
8048 )
8049 .await
8050 .unwrap();
8051 assert_eq!(resp.status(), StatusCode::OK);
8052 }
8053
8054 #[cfg(not(feature = "sqlite"))]
8062 #[tokio::test]
8063 #[cfg(feature = "db")]
8064 async fn actuator_metrics_returns_per_shard_stats_when_sharded() {
8065 let mut state = test_state();
8066 let config = crate::config::DatabaseConfig {
8067 shards: vec![
8068 crate::config::ShardConfig {
8069 name: "alpha".to_owned(),
8070 primary_url: "postgres://localhost/alpha".to_owned(),
8071 slots: None,
8072 replica_url: None,
8073 primary_pool_size: Some(4),
8074 replica_pool_size: None,
8075 replica_fallback: None,
8076 },
8077 crate::config::ShardConfig {
8078 name: "beta".to_owned(),
8079 primary_url: "postgres://localhost/beta".to_owned(),
8080 slots: None,
8081 replica_url: Some("postgres://localhost/beta_ro".to_owned()),
8082 primary_pool_size: None,
8083 replica_pool_size: Some(2),
8084 replica_fallback: None,
8085 },
8086 ],
8087 ..Default::default()
8088 };
8089 state.shards = crate::sharding::create_shard_set(
8090 &config,
8091 std::sync::Arc::new(crate::sharding::HashShardRouter),
8092 )
8093 .expect("lazy pools build");
8094
8095 let app = actuator_router(true).with_state(state);
8096 let resp = app
8097 .oneshot(
8098 Request::builder()
8099 .uri("/actuator/metrics")
8100 .body(Body::empty())
8101 .unwrap(),
8102 )
8103 .await
8104 .unwrap();
8105
8106 assert_eq!(resp.status(), StatusCode::OK);
8107 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8108 .await
8109 .unwrap();
8110 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8111
8112 let shards = json
8113 .get("database_shards")
8114 .expect("sharded state exposes database_shards");
8115 assert_eq!(shards["alpha"]["pool_size"], 4);
8116 assert_eq!(shards["alpha"]["slots"], 8192);
8117 assert_eq!(shards["beta"]["replica"]["pool_size"], 2);
8118 }
8119
8120 #[tokio::test]
8121 #[cfg(feature = "db")]
8122 async fn actuator_metrics_returns_db_stats_when_pool_present() {
8123 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8124 use diesel_async::pooled_connection::deadpool::Pool;
8125
8126 let mut state = test_state();
8127
8128 let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8136 crate::test_urls::primary("actuator_metrics"),
8137 );
8138 let pool = Pool::builder(manager).build().unwrap();
8139
8140 state.pool = Some(pool);
8141
8142 let app = actuator_router(true).with_state(state);
8143 let resp = app
8144 .oneshot(
8145 Request::builder()
8146 .uri("/actuator/metrics")
8147 .body(Body::empty())
8148 .unwrap(),
8149 )
8150 .await
8151 .unwrap();
8152
8153 assert_eq!(resp.status(), StatusCode::OK);
8154 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8155 .await
8156 .unwrap();
8157 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8158
8159 assert!(json.get("database").is_some());
8160 }
8161
8162 async fn scrape_prometheus(state: TestActuatorState) -> String {
8163 let app = actuator_router(true).with_state(state);
8164 let resp = app
8165 .oneshot(
8166 Request::builder()
8167 .uri("/actuator/prometheus")
8168 .body(Body::empty())
8169 .unwrap(),
8170 )
8171 .await
8172 .unwrap();
8173 assert_eq!(resp.status(), StatusCode::OK);
8174 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8175 .await
8176 .unwrap();
8177 String::from_utf8(body.to_vec()).unwrap()
8178 }
8179
8180 fn sample_value(text: &str, prefix: &str) -> f64 {
8182 let line = text
8183 .lines()
8184 .find(|l| l.starts_with(prefix))
8185 .unwrap_or_else(|| panic!("no sample `{prefix}` in:\n{text}"));
8186 line.rsplit(' ').next().unwrap().parse().unwrap()
8187 }
8188
8189 #[tokio::test]
8190 async fn prometheus_exports_job_queue_gauges_and_dead_letters() {
8191 let state = test_state();
8192 let jobs = state.job_registry.clone();
8193 jobs.register_on_queue("send_mail", "mail");
8194 jobs.register_on_queue("resize", "media");
8195 let now = jobs.now_ms();
8196 jobs.record_enqueue_scheduled("send_mail", now - 5_000);
8197 jobs.record_enqueue_scheduled("send_mail", now - 1_000);
8198 jobs.record_enqueue("resize");
8199 jobs.record_start("resize");
8200 jobs.record_failure("resize", "boom".into(), true);
8201
8202 let text = scrape_prometheus(state).await;
8203
8204 assert!(
8205 text.contains("# TYPE autumn_jobs_queue_depth gauge\n"),
8206 "{text}"
8207 );
8208 assert!(
8209 text.contains("# TYPE autumn_jobs_oldest_age_seconds gauge\n"),
8210 "{text}"
8211 );
8212 assert!(
8213 text.contains("# TYPE autumn_jobs_dead_letter_total counter\n"),
8214 "{text}"
8215 );
8216 let depth = "autumn_jobs_queue_depth{version=\"stable\",queue=\"mail\"} ";
8217 assert!((sample_value(&text, depth) - 2.0).abs() < f64::EPSILON);
8218 let empty = "autumn_jobs_queue_depth{version=\"stable\",queue=\"media\"} ";
8219 assert!(sample_value(&text, empty).abs() < f64::EPSILON);
8220 let age = "autumn_jobs_oldest_age_seconds{version=\"stable\",queue=\"mail\"} ";
8221 assert!(sample_value(&text, age) >= 4.9, "oldest job waited 5s");
8222 let dead = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"resize\"} ";
8223 assert!((sample_value(&text, dead) - 1.0).abs() < f64::EPSILON);
8224 let none = "autumn_jobs_dead_letter_total{version=\"stable\",job=\"send_mail\"} ";
8225 assert!(sample_value(&text, none).abs() < f64::EPSILON);
8226 }
8227
8228 #[tokio::test]
8229 async fn prometheus_exports_db_pool_wait_histogram() {
8230 let state = test_state();
8231 state
8232 .metrics
8233 .record_db_pool_wait(std::time::Duration::from_micros(300));
8234 state
8235 .metrics
8236 .record_db_pool_wait(std::time::Duration::from_millis(120));
8237
8238 let text = scrape_prometheus(state).await;
8239
8240 assert!(
8241 text.contains("# TYPE autumn_db_pool_wait_seconds histogram\n"),
8242 "{text}"
8243 );
8244 let first = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"0.001\"} ";
8245 assert!((sample_value(&text, first) - 1.0).abs() < f64::EPSILON);
8246 let inf = "autumn_db_pool_wait_seconds_bucket{version=\"stable\",le=\"+Inf\"} ";
8247 assert!((sample_value(&text, inf) - 2.0).abs() < f64::EPSILON);
8248 let count = "autumn_db_pool_wait_seconds_count{version=\"stable\"} ";
8249 assert!((sample_value(&text, count) - 2.0).abs() < f64::EPSILON);
8250 let sum = "autumn_db_pool_wait_seconds_sum{version=\"stable\"} ";
8251 assert!((sample_value(&text, sum) - 0.1203).abs() < 1e-9);
8252 }
8253
8254 #[tokio::test]
8255 #[cfg(feature = "db")]
8256 async fn prometheus_exports_db_pool_gauges() {
8257 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
8258 use diesel_async::pooled_connection::deadpool::Pool;
8259
8260 let mut state = test_state();
8261 let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8263 crate::test_urls::primary("actuator_prometheus_pool"),
8264 );
8265 state.pool = Some(Pool::builder(manager).max_size(7).build().unwrap());
8266 let replica = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
8267 crate::test_urls::primary("actuator_prometheus_replica"),
8268 );
8269 state.replica_pool = Some(Pool::builder(replica).max_size(3).build().unwrap());
8270
8271 let text = scrape_prometheus(state).await;
8272 let replica_max = "autumn_db_pool_max_size{version=\"stable\",pool=\"replica\"} ";
8273 assert!((sample_value(&text, replica_max) - 3.0).abs() < f64::EPSILON);
8274
8275 for (name, value) in [
8276 ("autumn_db_pool_max_size", 7.0),
8277 ("autumn_db_pool_size", 0.0),
8278 ("autumn_db_pool_available", 0.0),
8279 ("autumn_db_pool_waiting", 0.0),
8280 ] {
8281 assert!(text.contains(&format!("# TYPE {name} gauge\n")), "{text}");
8282 let prefix = format!("{name}{{version=\"stable\",pool=\"primary\"}} ");
8283 assert!((sample_value(&text, &prefix) - value).abs() < f64::EPSILON);
8284 }
8285 }
8286
8287 #[test]
8288 fn builtin_family_names_cover_queue_and_pool_metrics() {
8289 for name in [
8290 "autumn_jobs_queue_depth",
8291 "autumn_jobs_oldest_age_seconds",
8292 "autumn_jobs_dead_letter_total",
8293 "autumn_db_pool_max_size",
8294 "autumn_db_pool_size",
8295 "autumn_db_pool_available",
8296 "autumn_db_pool_waiting",
8297 "autumn_db_pool_wait_seconds",
8298 "autumn_db_pool_wait_seconds_bucket",
8299 "autumn_db_pool_wait_seconds_sum",
8300 "autumn_db_pool_wait_seconds_count",
8301 ] {
8302 assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&name), "{name}");
8303 }
8304 }
8305
8306 #[tokio::test]
8309 async fn actuator_configprops_returns_properties() {
8310 let app = actuator_router(true).with_state(test_state());
8311 let resp = app
8312 .oneshot(
8313 Request::builder()
8314 .uri("/actuator/configprops")
8315 .body(Body::empty())
8316 .unwrap(),
8317 )
8318 .await
8319 .unwrap();
8320
8321 assert_eq!(resp.status(), StatusCode::OK);
8322 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8323 .await
8324 .unwrap();
8325 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8326 assert_eq!(json["active_profile"], "dev");
8327 assert!(json["properties"].is_object());
8328 }
8329
8330 #[tokio::test]
8331 async fn actuator_configprops_hidden_in_nonsensitive_mode() {
8332 let app = actuator_router(false).with_state(test_state());
8333 let resp = app
8334 .oneshot(
8335 Request::builder()
8336 .uri("/actuator/configprops")
8337 .body(Body::empty())
8338 .unwrap(),
8339 )
8340 .await
8341 .unwrap();
8342 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8343 }
8344
8345 #[test]
8346 fn configprops_redacts_sensitive_values() {
8347 let mut props = HashMap::new();
8348 ConfigProperties::track_property(
8349 &mut props,
8350 "database.url",
8351 "postgres://user:pass@host/db",
8352 "",
8353 "dev",
8354 );
8355 assert_eq!(props["database.url"].value, "****");
8356 }
8357
8358 #[test]
8359 fn configprops_tracks_default_source() {
8360 let mut props = HashMap::new();
8361 ConfigProperties::track_property(&mut props, "server.port", "3000", "3000", "dev");
8362 assert_eq!(props["server.port"].source, "default");
8363 assert_eq!(props["server.port"].value, "3000");
8364 }
8365
8366 #[test]
8367 fn configprops_tracks_profile_source() {
8368 let mut props = HashMap::new();
8369 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
8370 assert_eq!(props["log.level"].source, "profile_default:dev");
8371 }
8372
8373 #[tokio::test]
8376 async fn actuator_loggers_get_returns_levels() {
8377 let app = actuator_router(true).with_state(test_state());
8378 let resp = app
8379 .oneshot(
8380 Request::builder()
8381 .uri("/actuator/loggers")
8382 .body(Body::empty())
8383 .unwrap(),
8384 )
8385 .await
8386 .unwrap();
8387
8388 assert_eq!(resp.status(), StatusCode::OK);
8389 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8390 .await
8391 .unwrap();
8392 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8393 assert_eq!(json["current_level"], "info");
8394 assert!(json["available_levels"].is_array());
8395 }
8396
8397 #[tokio::test]
8398 async fn actuator_loggers_put_changes_level() {
8399 let state = test_state();
8400 let app = actuator_router(true).with_state(state.clone());
8401 let resp = app
8402 .oneshot(
8403 Request::builder()
8404 .method("PUT")
8405 .uri("/actuator/loggers/autumn_web")
8406 .header("content-type", "application/json")
8407 .body(Body::from(r#"{"level": "debug"}"#))
8408 .unwrap(),
8409 )
8410 .await
8411 .unwrap();
8412
8413 assert_eq!(resp.status(), StatusCode::OK);
8414 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8415 .await
8416 .unwrap();
8417 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8418 assert_eq!(json["status"], "recorded");
8424 assert_eq!(json["applied"], false);
8425
8426 let overrides = state.log_levels().logger_overrides();
8427 assert_eq!(
8428 overrides.get("autumn_web").map(String::as_str),
8429 Some("debug")
8430 );
8431 }
8432
8433 #[tokio::test]
8434 async fn actuator_loggers_put_rejects_invalid_level() {
8435 let app = actuator_router(true).with_state(test_state());
8436 let resp = app
8437 .oneshot(
8438 Request::builder()
8439 .method("PUT")
8440 .uri("/actuator/loggers/autumn_web")
8441 .header("content-type", "application/json")
8442 .body(Body::from(r#"{"level": "banana"}"#))
8443 .unwrap(),
8444 )
8445 .await
8446 .unwrap();
8447
8448 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8449 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8450 .await
8451 .unwrap();
8452 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8453 assert_eq!(json["status"], "error");
8454 }
8455
8456 #[tokio::test]
8457 async fn actuator_loggers_put_rejects_invalid_name() {
8458 let state = test_state();
8465 for bogus in ["a=b", "a,b", "has%20space", "a::b=trace"] {
8466 let app = actuator_router(true).with_state(state.clone());
8467 let resp = app
8468 .oneshot(
8469 Request::builder()
8470 .method("PUT")
8471 .uri(format!("/actuator/loggers/{bogus}"))
8472 .header("content-type", "application/json")
8473 .body(Body::from(r#"{"level": "debug"}"#))
8474 .unwrap(),
8475 )
8476 .await
8477 .unwrap();
8478
8479 assert_eq!(
8480 resp.status(),
8481 StatusCode::BAD_REQUEST,
8482 "logger name {bogus:?} should be rejected"
8483 );
8484 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8485 .await
8486 .unwrap();
8487 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8488 assert_eq!(json["status"], "error");
8489 }
8490
8491 let app = actuator_router(true).with_state(state.clone());
8493 let resp = app
8494 .oneshot(
8495 Request::builder()
8496 .uri("/actuator/loggers")
8497 .body(Body::empty())
8498 .unwrap(),
8499 )
8500 .await
8501 .unwrap();
8502 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8503 .await
8504 .unwrap();
8505 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8506 let loggers = json["loggers"].as_object().unwrap();
8507 assert!(
8508 loggers.is_empty(),
8509 "no bogus override should be recorded, got {loggers:?}"
8510 );
8511 }
8512
8513 #[tokio::test]
8514 async fn actuator_loggers_put_accepts_dotted_and_hyphenated_names() {
8515 let state = test_state();
8522 for name in ["tower-http", "my.custom.target", "h2::proto"] {
8523 let app = actuator_router(true).with_state(state.clone());
8524 let resp = app
8525 .oneshot(
8526 Request::builder()
8527 .method("PUT")
8528 .uri(format!("/actuator/loggers/{name}"))
8529 .header("content-type", "application/json")
8530 .body(Body::from(r#"{"level": "debug"}"#))
8531 .unwrap(),
8532 )
8533 .await
8534 .unwrap();
8535
8536 assert_eq!(
8537 resp.status(),
8538 StatusCode::OK,
8539 "logger name {name:?} should be accepted, not rejected as invalid"
8540 );
8541 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8542 .await
8543 .unwrap();
8544 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8545 assert_ne!(
8549 json["status"], "error",
8550 "valid name {name:?} must not hit the invalid-name error path"
8551 );
8552 }
8553
8554 let app = actuator_router(true).with_state(state.clone());
8556 let resp = app
8557 .oneshot(
8558 Request::builder()
8559 .uri("/actuator/loggers")
8560 .body(Body::empty())
8561 .unwrap(),
8562 )
8563 .await
8564 .unwrap();
8565 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8566 .await
8567 .unwrap();
8568 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8569 let loggers = json["loggers"].as_object().unwrap();
8570 for name in ["tower-http", "my.custom.target", "h2::proto"] {
8571 assert!(
8572 loggers.contains_key(name),
8573 "accepted override {name:?} should be recorded, got {loggers:?}"
8574 );
8575 }
8576 }
8577
8578 #[tokio::test]
8579 async fn actuator_loggers_put_applied_ok_with_live_subscriber() {
8580 let state = test_state();
8585 state
8586 .log_levels()
8587 .attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8588
8589 let app = actuator_router(true).with_state(state.clone());
8590 let resp = app
8591 .oneshot(
8592 Request::builder()
8593 .method("PUT")
8594 .uri("/actuator/loggers/autumn_web")
8595 .header("content-type", "application/json")
8596 .body(Body::from(r#"{"level": "debug"}"#))
8597 .unwrap(),
8598 )
8599 .await
8600 .unwrap();
8601
8602 assert_eq!(resp.status(), StatusCode::OK);
8603 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8604 .await
8605 .unwrap();
8606 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8607 assert_eq!(json["status"], "ok");
8608 assert_eq!(json["applied"], true);
8609
8610 let overrides = state.log_levels().logger_overrides();
8611 assert_eq!(
8612 overrides.get("autumn_web").map(String::as_str),
8613 Some("debug")
8614 );
8615 }
8616
8617 #[tokio::test]
8618 async fn actuator_loggers_hidden_in_nonsensitive_mode() {
8619 let app = actuator_router(false).with_state(test_state());
8620 let resp = app
8621 .oneshot(
8622 Request::builder()
8623 .uri("/actuator/loggers")
8624 .body(Body::empty())
8625 .unwrap(),
8626 )
8627 .await
8628 .unwrap();
8629 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8630 }
8631
8632 #[test]
8633 fn log_levels_set_and_get() {
8634 let levels = LogLevels::new("info");
8635 assert_eq!(levels.current_level(), "info");
8636
8637 let _ = levels.set_logger_level("my_crate", "debug");
8638 let overrides = levels.logger_overrides();
8639 assert_eq!(overrides.get("my_crate").map(String::as_str), Some("debug"));
8640 }
8641
8642 #[test]
8643 fn log_levels_root_updates_current() {
8644 let levels = LogLevels::new("info");
8645 let change = levels.set_logger_level("root", "trace");
8646 assert_eq!(change.previous(), Some("info"));
8647 assert_eq!(levels.current_level(), "trace");
8648 }
8649
8650 #[test]
8651 fn root_level_change_preserves_startup_per_target_directives() {
8652 let levels = LogLevels::new("info,tower_http=warn,my_app=debug");
8656 levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8657
8658 let seeded = levels.logger_overrides();
8661 assert_eq!(seeded.get("tower_http").map(String::as_str), Some("warn"));
8662 assert_eq!(seeded.get("my_app").map(String::as_str), Some("debug"));
8663 assert_eq!(levels.current_level(), "info");
8664
8665 let change = levels.set_logger_level("root", "warn");
8667 assert!(matches!(change, LogLevelChange::Applied { .. }));
8668 assert_eq!(levels.current_level(), "warn");
8669
8670 let overrides = levels.logger_overrides();
8671 assert_eq!(
8672 overrides.get("tower_http").map(String::as_str),
8673 Some("warn"),
8674 "tower_http directive must survive a root-level change"
8675 );
8676 assert_eq!(
8677 overrides.get("my_app").map(String::as_str),
8678 Some("debug"),
8679 "my_app directive must survive a root-level change"
8680 );
8681
8682 let directive = levels.rebuilt_directive_for_test();
8684 assert!(
8685 directive.contains("tower_http=warn"),
8686 "rebuilt directive dropped tower_http: {directive}"
8687 );
8688 assert!(
8689 directive.contains("my_app=debug"),
8690 "rebuilt directive dropped my_app: {directive}"
8691 );
8692 }
8693
8694 #[test]
8695 fn bare_non_level_segment_is_a_trace_target_not_the_global_level() {
8696 let levels = LogLevels::new("info,my_app");
8701 assert_eq!(levels.current_level(), "info");
8702 let overrides = levels.logger_overrides();
8703 assert_eq!(
8704 overrides.get("my_app").map(String::as_str),
8705 Some("trace"),
8706 "bare non-level segment must become a trace target"
8707 );
8708
8709 assert_eq!(levels.rebuilt_directive_for_test(), "info,my_app=trace");
8711
8712 levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
8714 let change = levels.set_logger_level("root", "warn");
8715 assert!(matches!(change, LogLevelChange::Applied { .. }));
8716 assert_eq!(levels.current_level(), "warn");
8717 assert_eq!(
8718 levels.logger_overrides().get("my_app").map(String::as_str),
8719 Some("trace"),
8720 "my_app target must survive a root-level change"
8721 );
8722 }
8723
8724 #[test]
8725 fn mixed_bare_level_explicit_target_and_bare_target() {
8726 let levels = LogLevels::new("debug,tower_http=warn,my_app");
8729 assert_eq!(levels.current_level(), "debug");
8730 let overrides = levels.logger_overrides();
8731 assert_eq!(
8732 overrides.get("tower_http").map(String::as_str),
8733 Some("warn")
8734 );
8735 assert_eq!(overrides.get("my_app").map(String::as_str), Some("trace"));
8736
8737 assert_eq!(
8739 levels.rebuilt_directive_for_test(),
8740 "debug,my_app=trace,tower_http=warn"
8741 );
8742 }
8743
8744 #[test]
8745 fn purely_bare_level_config_has_no_target_overrides() {
8746 let levels = LogLevels::new("info");
8749 assert_eq!(levels.current_level(), "info");
8750 assert!(levels.logger_overrides().is_empty());
8751 assert_eq!(levels.rebuilt_directive_for_test(), "info");
8752 }
8753
8754 #[tokio::test]
8757 async fn actuator_prometheus_returns_metrics() {
8758 let state = test_state();
8759 state.metrics().record("GET", "/test", 200, 10);
8760 state.metrics().record("POST", "/test", 500, 50);
8761
8762 let app = actuator_router(true).with_state(state);
8763 let resp = app
8764 .oneshot(
8765 Request::builder()
8766 .uri("/actuator/prometheus")
8767 .body(Body::empty())
8768 .unwrap(),
8769 )
8770 .await
8771 .unwrap();
8772
8773 assert_eq!(resp.status(), StatusCode::OK);
8774 assert_eq!(
8775 resp.headers().get("content-type").unwrap(),
8776 "text/plain; version=0.0.4"
8777 );
8778
8779 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8780 .await
8781 .unwrap();
8782 let text = String::from_utf8(body.to_vec()).unwrap();
8783
8784 assert!(text.contains("# HELP autumn_http_requests_total Total number of HTTP requests"));
8785 assert!(text.contains("# TYPE autumn_http_requests_total counter"));
8786 assert!(text.contains("autumn_http_requests_total{version=\"stable\"} 2"));
8787
8788 assert!(text.contains("autumn_http_requests_active{version=\"stable\"} "));
8789 assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"2xx\"} 1"));
8790 assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"5xx\"} 1"));
8791
8792 assert!(text.contains("# TYPE autumn_http_request_duration_seconds histogram"));
8795 assert!(text.contains(
8796 "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"GET\",route=\"/test\",status_class=\"2xx\"} 1"
8797 ));
8798 assert!(text.contains(
8799 "autumn_http_request_duration_seconds_count{version=\"stable\",method=\"POST\",route=\"/test\",status_class=\"5xx\"} 1"
8800 ));
8801 assert!(text.contains("# TYPE autumn_http_request_duration_quantiles_seconds summary"));
8802 assert!(text.contains(
8803 "autumn_http_request_duration_quantiles_seconds{version=\"stable\",quantile=\"0.99\"}"
8804 ));
8805
8806 assert!(text.contains(
8807 "autumn_http_route_requests_total{version=\"stable\",method=\"GET\",route=\"/test\"} 1"
8808 ));
8809 assert!(text.contains(
8810 "autumn_http_route_requests_total{version=\"stable\",method=\"POST\",route=\"/test\"} 1"
8811 ));
8812
8813 assert!(text.contains("# HELP autumn_request_timeouts_total"));
8814 assert!(text.contains("# TYPE autumn_request_timeouts_total counter"));
8815 assert!(text.contains("autumn_request_timeouts_total{version=\"stable\"} 0"));
8816
8817 assert!(text.contains("# HELP autumn_read_your_writes_pins_total"));
8818 assert!(text.contains("# TYPE autumn_read_your_writes_pins_total counter"));
8819 assert!(text.contains("autumn_read_your_writes_pins_total{version=\"stable\"} 0"));
8820
8821 assert!(text.contains("# HELP autumn_requests_shed_total"));
8822 assert!(text.contains("# TYPE autumn_requests_shed_total counter"));
8823 assert!(text.contains("autumn_requests_shed_total{version=\"stable\"} 0"));
8824
8825 assert!(text.contains("# TYPE autumn_admission_limit gauge"));
8826 assert!(text.contains("autumn_admission_limit{version=\"stable\"} 0"));
8827 assert!(text.contains("# TYPE autumn_admission_shed_total counter"));
8828 assert!(text.contains(
8829 "autumn_admission_shed_total{version=\"stable\",criticality=\"sheddable\"} 0"
8830 ));
8831 assert!(text.contains("# TYPE autumn_tenant_bulkhead_rejections_total counter"));
8832 assert!(text.contains(
8833 "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"request\"} 0"
8834 ));
8835 assert!(
8836 text.contains(
8837 "autumn_tenant_bulkhead_rejections_total{version=\"stable\",kind=\"db\"} 0"
8838 )
8839 );
8840 }
8841
8842 #[test]
8844 fn job_registry_dead_letter_trim_counter_is_shared() {
8845 let registry = JobRegistry::new();
8846 let clone = registry.clone();
8847 assert_eq!(registry.dead_letter_trimmed_total(), 0);
8848
8849 clone.record_dead_letter_trimmed(3);
8850 registry.record_dead_letter_trimmed(0);
8851 registry.record_dead_letter_trimmed(2);
8852 assert_eq!(registry.dead_letter_trimmed_total(), 5);
8853 assert_eq!(clone.dead_letter_trimmed_total(), 5);
8854 }
8855
8856 #[tokio::test]
8858 async fn prometheus_includes_dead_letter_trimmed_counter() {
8859 const NAME: &str = "autumn_jobs_dead_letter_trimmed_total";
8860 assert!(BUILTIN_METRIC_FAMILY_NAMES.contains(&NAME));
8861
8862 let state = test_state();
8863 let scrape = |state: TestActuatorState| async move {
8864 let resp = actuator_router(true)
8865 .with_state(state)
8866 .oneshot(
8867 Request::builder()
8868 .uri("/actuator/prometheus")
8869 .body(Body::empty())
8870 .unwrap(),
8871 )
8872 .await
8873 .unwrap();
8874 assert_eq!(resp.status(), StatusCode::OK);
8875 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8876 .await
8877 .unwrap();
8878 String::from_utf8(body.to_vec()).unwrap()
8879 };
8880
8881 let text = scrape(state.clone()).await;
8883 assert!(text.contains(&format!("# TYPE {NAME} counter")), "{text}");
8884 assert!(
8885 text.contains(&format!("{NAME}{{version=\"stable\"}} 0\n")),
8886 "{text}"
8887 );
8888
8889 state.job_registry.record_dead_letter_trimmed(7);
8890 let text = scrape(state).await;
8891 assert!(
8892 text.contains(&format!("{NAME}{{version=\"stable\"}} 7\n")),
8893 "{text}"
8894 );
8895 }
8896
8897 #[cfg(feature = "cache-moka")]
8898 #[tokio::test]
8899 async fn prometheus_includes_cache_read_through_counters() {
8900 let before = crate::cache::read_through_metrics().snapshot();
8904 let cache: std::sync::Arc<dyn crate::cache::Cache> =
8905 std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
8906 let key = format!("actuator-prometheus-cache-test-{before:?}");
8907 let _: i32 =
8908 crate::cache::get_or_compute(
8909 &cache,
8910 &key,
8911 None,
8912 || async move { Ok::<i32, String>(1) },
8913 )
8914 .await
8915 .unwrap();
8916 let after = crate::cache::read_through_metrics().snapshot();
8917 assert!(after.fills > before.fills, "fills counter must increase");
8918
8919 let state = test_state();
8920 let app = actuator_router(true).with_state(state);
8921 let resp = app
8922 .oneshot(
8923 Request::builder()
8924 .uri("/actuator/prometheus")
8925 .body(Body::empty())
8926 .unwrap(),
8927 )
8928 .await
8929 .unwrap();
8930
8931 assert_eq!(resp.status(), StatusCode::OK);
8932 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8933 .await
8934 .unwrap();
8935 let text = String::from_utf8(body.to_vec()).unwrap();
8936
8937 for name in [
8938 "autumn_cache_read_through_hits_total",
8939 "autumn_cache_read_through_misses_total",
8940 "autumn_cache_read_through_coalesced_waits_total",
8941 "autumn_cache_read_through_fills_total",
8942 "autumn_cache_read_through_fill_failures_total",
8943 "autumn_cache_read_through_stale_serves_total",
8944 "autumn_cache_fill_lock_acquires_total",
8945 "autumn_cache_fill_lock_contended_total",
8946 "autumn_cache_read_through_stale_if_error_serves_total",
8947 "autumn_cache_invalidation_failures_total",
8948 ] {
8949 assert!(
8950 text.contains(&format!("# TYPE {name} counter")),
8951 "missing TYPE line for {name} in:\n{text}"
8952 );
8953 assert!(
8954 text.contains(&format!("{name}{{version=\"stable\"}}")),
8955 "missing value line for {name} in:\n{text}"
8956 );
8957 }
8958 }
8959
8960 #[cfg(feature = "cache-moka")]
8961 #[tokio::test]
8962 async fn metrics_json_includes_cache_section() {
8963 let cache: std::sync::Arc<dyn crate::cache::Cache> =
8964 std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
8965 let key = "actuator-metrics-json-cache-test";
8966 let _: i32 =
8967 crate::cache::get_or_compute(&cache, key, None, || async move { Ok::<i32, String>(1) })
8968 .await
8969 .unwrap();
8970
8971 let state = test_state();
8972 let app = actuator_router(true).with_state(state);
8973 let resp = app
8974 .oneshot(
8975 Request::builder()
8976 .uri("/actuator/metrics")
8977 .body(Body::empty())
8978 .unwrap(),
8979 )
8980 .await
8981 .unwrap();
8982
8983 assert_eq!(resp.status(), StatusCode::OK);
8984 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8985 .await
8986 .unwrap();
8987 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8988 assert!(json["cache"]["fills"].as_u64().unwrap() >= 1);
8989 assert!(json["cache"]["hits"].is_u64());
8990 assert!(json["cache"]["misses"].is_u64());
8991 assert!(json["cache"]["coalesced_waits"].is_u64());
8992 assert!(json["cache"]["fill_failures"].is_u64());
8993 assert!(json["cache"]["stale_serves"].is_u64());
8994 assert!(json["cache"]["fill_lock_acquires"].is_u64());
8995 assert!(json["cache"]["fill_lock_contended"].is_u64());
8996 assert!(json["cache"]["stale_if_error_serves"].is_u64());
8997 assert!(json["cache"]["invalidation_failures"].is_u64());
8998 }
8999
9000 #[tokio::test]
9001 async fn actuator_prometheus_labels_metrics_with_canary_version() {
9002 let mut state = test_state();
9005 state.deploy_version = crate::canary::CANARY.to_owned();
9006 state.metrics().record("GET", "/test", 200, 10);
9008 state.metrics().record("GET", "/test", 200, 20);
9009 state.metrics().record("GET", "/test", 500, 1200);
9010
9011 let app = actuator_router(true).with_state(state);
9012 let resp = app
9013 .oneshot(
9014 Request::builder()
9015 .uri("/actuator/prometheus")
9016 .body(Body::empty())
9017 .unwrap(),
9018 )
9019 .await
9020 .unwrap();
9021 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9022 .await
9023 .unwrap();
9024 let text = String::from_utf8(body.to_vec()).unwrap();
9025
9026 assert!(text.contains("autumn_http_requests_total{version=\"canary\"} 3"));
9027 assert!(text.contains("autumn_http_responses_total{version=\"canary\",status=\"5xx\"} 1"));
9028 assert!(!text.contains("version=\"stable\""));
9030
9031 let quantile = |q: &str| -> f64 {
9034 let needle = format!(
9035 "autumn_http_request_duration_quantiles_seconds{{version=\"canary\",quantile=\"{q}\"}} "
9036 );
9037 let line = text
9038 .lines()
9039 .find(|l| l.starts_with(&needle))
9040 .unwrap_or_else(|| panic!("missing duration line for quantile {q}"));
9041 line[needle.len()..].trim().parse().unwrap()
9042 };
9043 let (p50, p95, p99) = (quantile("0.5"), quantile("0.95"), quantile("0.99"));
9044 assert!(p50 <= p95, "p50 ({p50}) must be <= p95 ({p95})");
9045 assert!(p95 <= p99, "p95 ({p95}) must be <= p99 ({p99})");
9046 assert!(
9048 (p99 - 1.2).abs() < f64::EPSILON,
9049 "p99 should be 1.2s, got {p99}"
9050 );
9051 }
9052
9053 #[tokio::test]
9054 async fn actuator_prometheus_available_in_nonsensitive_mode() {
9055 let app = actuator_router(false).with_state(test_state());
9056 let resp = app
9057 .oneshot(
9058 Request::builder()
9059 .uri("/actuator/prometheus")
9060 .body(Body::empty())
9061 .unwrap(),
9062 )
9063 .await
9064 .unwrap();
9065 assert_eq!(resp.status(), StatusCode::OK);
9066 }
9067
9068 #[tokio::test]
9069 async fn actuator_prometheus_available_when_export_enabled_and_nonsensitive() {
9070 let app = actuator_router_with_prefix("/actuator", false, true).with_state(test_state());
9073 let resp = app
9074 .clone()
9075 .oneshot(
9076 Request::builder()
9077 .uri("/actuator/prometheus")
9078 .body(Body::empty())
9079 .unwrap(),
9080 )
9081 .await
9082 .unwrap();
9083 assert_eq!(resp.status(), StatusCode::OK);
9084
9085 for sensitive_path in [
9087 "/actuator/env",
9088 "/actuator/configprops",
9089 "/actuator/loggers",
9090 "/actuator/tasks",
9091 "/actuator/jobs",
9092 "/actuator/ui/tasks",
9093 ] {
9094 let resp = app
9095 .clone()
9096 .oneshot(
9097 Request::builder()
9098 .uri(sensitive_path)
9099 .body(Body::empty())
9100 .unwrap(),
9101 )
9102 .await
9103 .unwrap();
9104 assert_eq!(
9105 resp.status(),
9106 StatusCode::NOT_FOUND,
9107 "{sensitive_path} should be unavailable when actuator is non-sensitive"
9108 );
9109 }
9110 }
9111
9112 #[tokio::test]
9113 async fn actuator_prometheus_unavailable_when_export_disabled() {
9114 let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
9116 let resp = app
9117 .oneshot(
9118 Request::builder()
9119 .uri("/actuator/prometheus")
9120 .body(Body::empty())
9121 .unwrap(),
9122 )
9123 .await
9124 .unwrap();
9125 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9126 }
9127
9128 #[tokio::test]
9129 async fn actuator_prometheus_unavailable_when_export_disabled_even_if_sensitive() {
9130 let app = actuator_router_with_prefix("/actuator", true, false).with_state(test_state());
9132 let resp = app
9133 .oneshot(
9134 Request::builder()
9135 .uri("/actuator/prometheus")
9136 .body(Body::empty())
9137 .unwrap(),
9138 )
9139 .await
9140 .unwrap();
9141 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9142 }
9143
9144 #[test]
9145 fn actuator_endpoint_paths_respects_prometheus_toggle() {
9146 let enabled = actuator_endpoint_paths("/actuator", false, true);
9147 assert!(
9148 enabled.iter().any(|p| p == "/actuator/prometheus"),
9149 "prometheus path should be listed when export is enabled: {enabled:?}"
9150 );
9151
9152 let disabled = actuator_endpoint_paths("/actuator", false, false);
9153 assert!(
9154 !disabled.iter().any(|p| p == "/actuator/prometheus"),
9155 "prometheus path should be absent when export is disabled: {disabled:?}"
9156 );
9157 }
9158
9159 #[tokio::test]
9162 async fn actuator_tasks_returns_registered_tasks() {
9163 let state = test_state();
9164 state.task_registry().register("cleanup", "every 5m");
9165 state.task_registry().record_start("cleanup");
9166 state.task_registry().record_success("cleanup", 150);
9167
9168 let app = actuator_router(true).with_state(state);
9169 let resp = app
9170 .oneshot(
9171 Request::builder()
9172 .uri("/actuator/tasks")
9173 .body(Body::empty())
9174 .unwrap(),
9175 )
9176 .await
9177 .unwrap();
9178
9179 assert_eq!(resp.status(), StatusCode::OK);
9180 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9181 .await
9182 .unwrap();
9183 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9184 let task = &json["scheduled_tasks"]["cleanup"];
9185 assert_eq!(task["schedule"], "every 5m");
9186 assert_eq!(task["status"], "idle");
9187 assert_eq!(task["total_runs"], 1);
9188 assert_eq!(task["total_failures"], 0);
9189 assert_eq!(task["last_result"], "ok");
9190 assert_eq!(task["last_duration_ms"], 150);
9191 }
9192
9193 #[tokio::test]
9194 async fn actuator_jobs_returns_registered_jobs() {
9195 let state = test_state();
9196 state.job_registry().register("send_email");
9197 state.job_registry().record_enqueue("send_email");
9198 state.job_registry().record_start("send_email");
9199 state.job_registry().record_success("send_email");
9200
9201 let app = actuator_router(true).with_state(state);
9202 let resp = app
9203 .oneshot(
9204 Request::builder()
9205 .uri("/actuator/jobs")
9206 .body(Body::empty())
9207 .unwrap(),
9208 )
9209 .await
9210 .unwrap();
9211
9212 assert_eq!(resp.status(), StatusCode::OK);
9213 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9214 .await
9215 .unwrap();
9216 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9217 let job = &json["jobs"]["send_email"];
9218 assert_eq!(job["queued"], 0);
9219 assert_eq!(job["in_flight"], 0);
9220 assert_eq!(job["total_successes"], 1);
9221 assert_eq!(job["total_failures"], 0);
9222 }
9223
9224 #[cfg(feature = "ws")]
9225 #[tokio::test]
9226 async fn actuator_channels_returns_metrics() {
9227 let state = test_state();
9228 let mut rx = state.channels().subscribe("feed");
9229 state
9230 .channels()
9231 .broadcast()
9232 .publish("feed", "hello")
9233 .expect("publish should succeed");
9234 rx.try_recv().expect("subscriber should receive payload");
9235
9236 let app = actuator_router(true).with_state(state);
9237 let resp = app
9238 .oneshot(
9239 Request::builder()
9240 .uri("/actuator/channels")
9241 .body(Body::empty())
9242 .unwrap(),
9243 )
9244 .await
9245 .unwrap();
9246
9247 assert_eq!(resp.status(), StatusCode::OK);
9248 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9249 .await
9250 .unwrap();
9251 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9252 let feed = &json["channels"]["feed"];
9253 assert_eq!(feed["subscriber_count"], 1);
9254 assert_eq!(feed["lifetime_publish_count"], 1);
9255 assert_eq!(feed["dropped_count"], 0);
9256 assert_eq!(feed["lagged_count"], 0);
9257 }
9258
9259 #[tokio::test]
9260 async fn actuator_tasks_hidden_in_nonsensitive_mode() {
9261 let app = actuator_router(false).with_state(test_state());
9262 let resp = app
9263 .oneshot(
9264 Request::builder()
9265 .uri("/actuator/tasks")
9266 .body(Body::empty())
9267 .unwrap(),
9268 )
9269 .await
9270 .unwrap();
9271 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9272 }
9273
9274 #[test]
9275 fn task_registry_records_failure() {
9276 let registry = TaskRegistry::new();
9277 registry.register("my_task", "cron 0 * * * *");
9278 registry.record_start("my_task");
9279 registry.record_failure("my_task", 200, "connection refused");
9280
9281 let snapshot = registry.snapshot();
9282 let task = &snapshot["my_task"];
9283 assert_eq!(task.status, "idle");
9284 assert_eq!(task.total_runs, 1);
9285 assert_eq!(task.total_failures, 1);
9286 assert_eq!(task.last_result.as_deref(), Some("failed"));
9287 assert_eq!(task.last_error.as_deref(), Some("connection refused"));
9288 }
9289
9290 #[test]
9291 fn task_registry_empty_snapshot() {
9292 let registry = TaskRegistry::new();
9293 assert!(registry.snapshot().is_empty());
9294 }
9295 #[test]
9296 fn log_levels_rejects_new_key_at_capacity() {
9297 let levels = LogLevels::new("info");
9298 for i in 0..1000 {
9300 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9301 }
9302
9303 let result = levels.set_logger_level("logger_1000", "warn");
9305 assert!(matches!(result, LogLevelChange::Rejected { .. }));
9306 assert_eq!(levels.logger_overrides().len(), 1000);
9307 assert_eq!(levels.logger_overrides().get("logger_1000"), None);
9308 }
9309
9310 #[test]
9311 fn log_levels_accepts_existing_key_at_capacity() {
9312 let levels = LogLevels::new("info");
9313 for i in 0..1000 {
9315 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
9316 }
9317
9318 let change = levels.set_logger_level("logger_999", "warn");
9320 assert_eq!(change.previous(), Some("debug"));
9321 assert_eq!(levels.logger_overrides().len(), 1000);
9322 assert_eq!(
9323 levels
9324 .logger_overrides()
9325 .get("logger_999")
9326 .map(String::as_str),
9327 Some("warn")
9328 );
9329 }
9330
9331 #[test]
9332 fn task_registry_records_multiple_successes_and_failures() {
9333 let registry = TaskRegistry::new();
9334 registry.register("my_task", "cron * * * * *");
9335
9336 registry.record_start("my_task");
9338 registry.record_success("my_task", 100);
9339
9340 registry.record_start("my_task");
9342 registry.record_success("my_task", 110);
9343
9344 let snapshot = registry.snapshot();
9345 let task = &snapshot["my_task"];
9346 assert_eq!(task.total_runs, 2);
9347 assert_eq!(task.total_failures, 0);
9348
9349 registry.record_start("my_task");
9351 registry.record_failure("my_task", 50, "failed");
9352
9353 let snapshot2 = registry.snapshot();
9354 let task2 = &snapshot2["my_task"];
9355 assert_eq!(task2.total_runs, 3);
9356 assert_eq!(task2.total_failures, 1);
9357 }
9358
9359 #[test]
9360 fn configprops_tracks_custom_profile() {
9361 let mut props = HashMap::new();
9362 ConfigProperties::track_property(
9363 &mut props,
9364 "log.level",
9365 "debug",
9366 "info",
9367 "custom_profile",
9368 );
9369 assert_eq!(props["log.level"].source, "autumn.toml");
9370 }
9371
9372 #[test]
9373 fn configprops_tracks_dev_prod_profiles() {
9374 let mut props = HashMap::new();
9375 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
9376 assert_eq!(props["log.level"].source, "profile_default:dev");
9377
9378 ConfigProperties::track_property(&mut props, "log.format", "json", "text", "prod");
9379 assert_eq!(props["log.format"].source, "profile_default:prod");
9380 }
9381
9382 #[test]
9383 fn configprops_returns_default_when_values_match() {
9384 let mut props = HashMap::new();
9385 ConfigProperties::track_property(&mut props, "log.level", "info", "info", "dev");
9386 assert_eq!(props["log.level"].source, "default");
9387 }
9388
9389 #[tokio::test]
9390 async fn actuator_ui_dashboard_returns_html_or_unimplemented() {
9391 let app = actuator_router(true).with_state(test_state());
9392
9393 let res = app
9394 .oneshot(
9395 Request::builder()
9396 .uri("/actuator/ui")
9397 .body(Body::empty())
9398 .unwrap(),
9399 )
9400 .await
9401 .unwrap();
9402
9403 if cfg!(feature = "maud") {
9404 assert_eq!(res.status(), StatusCode::OK);
9405 assert_eq!(
9406 res.headers().get("content-type").unwrap(),
9407 "text/html; charset=utf-8"
9408 );
9409 } else {
9410 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9411 }
9412 }
9413
9414 #[tokio::test]
9415 async fn actuator_ui_metrics_returns_html_or_unimplemented() {
9416 let app = actuator_router(true).with_state(test_state());
9417
9418 let res = app
9419 .oneshot(
9420 Request::builder()
9421 .uri("/actuator/ui/metrics")
9422 .body(Body::empty())
9423 .unwrap(),
9424 )
9425 .await
9426 .unwrap();
9427
9428 if cfg!(feature = "maud") {
9429 assert_eq!(res.status(), StatusCode::OK);
9430 assert_eq!(
9431 res.headers().get("content-type").unwrap(),
9432 "text/html; charset=utf-8"
9433 );
9434 } else {
9435 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9436 }
9437 }
9438
9439 #[tokio::test]
9440 async fn actuator_ui_tasks_returns_html_or_unimplemented() {
9441 let app = actuator_router(true).with_state(test_state());
9442
9443 let res = app
9444 .oneshot(
9445 Request::builder()
9446 .uri("/actuator/ui/tasks")
9447 .body(Body::empty())
9448 .unwrap(),
9449 )
9450 .await
9451 .unwrap();
9452
9453 if cfg!(feature = "maud") {
9454 assert_eq!(res.status(), StatusCode::OK);
9455 assert_eq!(
9456 res.headers().get("content-type").unwrap(),
9457 "text/html; charset=utf-8"
9458 );
9459 } else {
9460 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
9461 }
9462 }
9463
9464 #[tokio::test]
9465 async fn test_actuator_router_calls_prefix_variant() {
9466 let app = actuator_router(false).with_state(test_state());
9469 let resp = app
9470 .oneshot(
9471 Request::builder()
9472 .uri("/actuator/health")
9473 .body(Body::empty())
9474 .unwrap(),
9475 )
9476 .await
9477 .unwrap();
9478
9479 assert_eq!(resp.status(), StatusCode::OK);
9480 }
9481
9482 #[tokio::test]
9485 async fn actuator_a11y_returns_posture_json() {
9486 let app = actuator_router(false).with_state(test_state());
9487 let resp = app
9488 .oneshot(
9489 Request::builder()
9490 .uri("/actuator/a11y")
9491 .body(Body::empty())
9492 .unwrap(),
9493 )
9494 .await
9495 .unwrap();
9496
9497 assert_eq!(resp.status(), StatusCode::OK);
9498 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9499 .await
9500 .unwrap();
9501 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9502 assert!(json["lang_set"].is_boolean(), "{json}");
9503 assert!(json["skip_link_present"].is_boolean(), "{json}");
9504 assert!(json["landmark_regions_present"].is_boolean(), "{json}");
9505 }
9506
9507 #[tokio::test]
9508 async fn actuator_a11y_available_in_nonsensitive_mode() {
9509 let app = actuator_router(false).with_state(test_state());
9510 let resp = app
9511 .oneshot(
9512 Request::builder()
9513 .uri("/actuator/a11y")
9514 .body(Body::empty())
9515 .unwrap(),
9516 )
9517 .await
9518 .unwrap();
9519 assert_eq!(resp.status(), StatusCode::OK);
9520 }
9521
9522 #[tokio::test]
9523 async fn actuator_a11y_posture_default_values() {
9524 let app = actuator_router(true).with_state(test_state());
9525 let resp = app
9526 .oneshot(
9527 Request::builder()
9528 .uri("/actuator/a11y")
9529 .body(Body::empty())
9530 .unwrap(),
9531 )
9532 .await
9533 .unwrap();
9534
9535 assert_eq!(resp.status(), StatusCode::OK);
9536 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9537 .await
9538 .unwrap();
9539 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9540 assert_eq!(json["lang_set"], false, "{json}");
9542 assert_eq!(json["skip_link_present"], false, "{json}");
9543 assert_eq!(json["landmark_regions_present"], false, "{json}");
9544 }
9545
9546 #[test]
9547 fn a11y_posture_all_passing_is_compliant() {
9548 let posture = A11yPosture {
9549 lang_set: true,
9550 skip_link_present: true,
9551 landmark_regions_present: true,
9552 };
9553 assert!(posture.is_compliant());
9554 }
9555
9556 #[test]
9557 fn a11y_posture_missing_lang_is_not_compliant() {
9558 let posture = A11yPosture {
9559 lang_set: false,
9560 skip_link_present: true,
9561 landmark_regions_present: true,
9562 };
9563 assert!(!posture.is_compliant());
9564 }
9565
9566 #[tokio::test]
9567 async fn actuator_a11y_endpoint_paths_includes_a11y() {
9568 let paths = actuator_endpoint_paths("/actuator", false, true);
9569 assert!(
9570 paths.iter().any(|p| p == "/actuator/a11y"),
9571 "a11y path not found in: {paths:?}"
9572 );
9573 }
9574
9575 #[test]
9578 fn metrics_source_registry_registers_and_collects() {
9579 struct FixedSource;
9580 impl MetricsSource for FixedSource {
9581 fn collect(&self) -> Vec<MetricFamily> {
9582 vec![MetricFamily {
9583 name: "plugin_requests_total".to_string(),
9584 help: "Plugin request count".to_string(),
9585 kind: MetricKind::Counter,
9586 samples: vec![MetricSample {
9587 labels: vec![],
9588 value: 42.0,
9589 }],
9590 }]
9591 }
9592 }
9593
9594 let registry = MetricsSourceRegistry::new();
9595 registry
9596 .register("myplugin", Arc::new(FixedSource))
9597 .unwrap();
9598
9599 let all = registry.collect_all();
9600 assert_eq!(all.len(), 1);
9601 assert_eq!(all[0].0, "myplugin");
9602 assert_eq!(all[0].1[0].name, "plugin_requests_total");
9603 assert!((all[0].1[0].samples[0].value - 42.0).abs() < f64::EPSILON);
9604 }
9605
9606 #[test]
9607 fn metrics_source_registry_rejects_duplicate_name() {
9608 struct EmptySource;
9609 impl MetricsSource for EmptySource {
9610 fn collect(&self) -> Vec<MetricFamily> {
9611 vec![]
9612 }
9613 }
9614
9615 let registry = MetricsSourceRegistry::new();
9616 registry.register("dup", Arc::new(EmptySource)).unwrap();
9617 let result = registry.register("dup", Arc::new(EmptySource));
9618 assert!(result.is_err());
9619 assert!(result.unwrap_err().contains("dup"));
9620 }
9621
9622 #[test]
9623 fn metrics_source_registry_isolates_panicking_source() {
9624 struct PanickingSource;
9625 impl MetricsSource for PanickingSource {
9626 fn collect(&self) -> Vec<MetricFamily> {
9627 panic!("source panicked!")
9628 }
9629 }
9630
9631 let registry = MetricsSourceRegistry::new();
9632 registry
9633 .register("panicker", Arc::new(PanickingSource))
9634 .unwrap();
9635
9636 let all = registry.collect_all();
9637 assert_eq!(all.len(), 1);
9638 assert_eq!(
9639 all[0].1.len(),
9640 0,
9641 "panicking source should yield no families"
9642 );
9643
9644 let errors = registry.error_counts();
9645 assert_eq!(errors.get("panicker"), Some(&1));
9646 }
9647
9648 #[tokio::test]
9649 async fn prometheus_endpoint_includes_plugin_source_families() {
9650 struct GaugeSource;
9651 impl MetricsSource for GaugeSource {
9652 fn collect(&self) -> Vec<MetricFamily> {
9653 vec![MetricFamily {
9654 name: "plugin_queue_depth".to_string(),
9655 help: "Plugin queue depth".to_string(),
9656 kind: MetricKind::Gauge,
9657 samples: vec![MetricSample {
9658 labels: vec![("shard".to_string(), "a".to_string())],
9659 value: 7.0,
9660 }],
9661 }]
9662 }
9663 }
9664
9665 let state = test_state();
9666 state
9667 .metrics_source_registry
9668 .register("gauge_plugin", Arc::new(GaugeSource))
9669 .unwrap();
9670
9671 let app = actuator_router(true).with_state(state);
9672 let resp = app
9673 .oneshot(
9674 Request::builder()
9675 .uri("/actuator/prometheus")
9676 .body(Body::empty())
9677 .unwrap(),
9678 )
9679 .await
9680 .unwrap();
9681
9682 assert_eq!(resp.status(), StatusCode::OK);
9683 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9684 .await
9685 .unwrap();
9686 let text = String::from_utf8(body.to_vec()).unwrap();
9687
9688 assert!(
9689 text.contains("# HELP plugin_queue_depth Plugin queue depth"),
9690 "missing HELP line in:\n{text}"
9691 );
9692 assert!(
9693 text.contains("# TYPE plugin_queue_depth gauge"),
9694 "missing TYPE line in:\n{text}"
9695 );
9696 assert!(
9697 text.contains("plugin_queue_depth{shard=\"a\"} 7"),
9698 "missing sample line in:\n{text}"
9699 );
9700 }
9701
9702 #[tokio::test]
9703 async fn prometheus_endpoint_emits_error_counter_for_panicking_source() {
9704 struct PanickingSource;
9705 impl MetricsSource for PanickingSource {
9706 fn collect(&self) -> Vec<MetricFamily> {
9707 panic!("oops")
9708 }
9709 }
9710
9711 let state = test_state();
9712 state
9713 .metrics_source_registry
9714 .register("panic_src", Arc::new(PanickingSource))
9715 .unwrap();
9716
9717 let app = actuator_router(true).with_state(state);
9718 let resp = app
9719 .oneshot(
9720 Request::builder()
9721 .uri("/actuator/prometheus")
9722 .body(Body::empty())
9723 .unwrap(),
9724 )
9725 .await
9726 .unwrap();
9727
9728 assert_eq!(resp.status(), StatusCode::OK);
9729 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9730 .await
9731 .unwrap();
9732 let text = String::from_utf8(body.to_vec()).unwrap();
9733
9734 assert!(
9735 text.contains("autumn_metrics_source_errors_total{source=\"panic_src\"} 1"),
9736 "missing error counter in:\n{text}"
9737 );
9738 }
9739
9740 #[tokio::test]
9741 async fn metrics_endpoint_includes_sources_section() {
9742 struct SampleSource;
9743 impl MetricsSource for SampleSource {
9744 fn collect(&self) -> Vec<MetricFamily> {
9745 vec![MetricFamily {
9746 name: "custom_counter".to_string(),
9747 help: "A custom counter".to_string(),
9748 kind: MetricKind::Counter,
9749 samples: vec![MetricSample {
9750 labels: vec![],
9751 value: 5.0,
9752 }],
9753 }]
9754 }
9755 }
9756
9757 let state = test_state();
9758 state
9759 .metrics_source_registry
9760 .register("my_source", Arc::new(SampleSource))
9761 .unwrap();
9762
9763 let app = actuator_router(true).with_state(state);
9764 let resp = app
9765 .oneshot(
9766 Request::builder()
9767 .uri("/actuator/metrics")
9768 .body(Body::empty())
9769 .unwrap(),
9770 )
9771 .await
9772 .unwrap();
9773
9774 assert_eq!(resp.status(), StatusCode::OK);
9775 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9776 .await
9777 .unwrap();
9778 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
9779
9780 assert!(
9781 json.get("sources").is_some(),
9782 "metrics JSON missing 'sources' key"
9783 );
9784 assert!(
9785 json["sources"].get("my_source").is_some(),
9786 "sources missing 'my_source' key"
9787 );
9788 }
9789
9790 #[test]
9791 fn metrics_source_registry_preserves_insertion_order() {
9792 struct NamedSource(&'static str);
9793 impl MetricsSource for NamedSource {
9794 fn collect(&self) -> Vec<MetricFamily> {
9795 vec![MetricFamily {
9796 name: self.0.to_string(),
9797 help: String::new(),
9798 kind: MetricKind::Counter,
9799 samples: vec![],
9800 }]
9801 }
9802 }
9803
9804 let registry = MetricsSourceRegistry::new();
9805 registry
9806 .register("alpha", Arc::new(NamedSource("alpha_metric")))
9807 .unwrap();
9808 registry
9809 .register("beta", Arc::new(NamedSource("beta_metric")))
9810 .unwrap();
9811 registry
9812 .register("gamma", Arc::new(NamedSource("gamma_metric")))
9813 .unwrap();
9814
9815 let all = registry.collect_all();
9816 assert_eq!(all[0].0, "alpha");
9817 assert_eq!(all[1].0, "beta");
9818 assert_eq!(all[2].0, "gamma");
9819 }
9820
9821 #[test]
9824 fn escape_help_text_escapes_backslash_and_newline() {
9825 assert_eq!(escape_help_text("a\\b\nc"), "a\\\\b\\nc");
9826 assert_eq!(escape_help_text("plain"), "plain");
9827 assert_eq!(escape_help_text(""), "");
9828 }
9829
9830 #[test]
9831 fn format_sample_value_handles_special_floats() {
9832 assert_eq!(format_sample_value(f64::INFINITY), "+Inf");
9833 assert_eq!(format_sample_value(f64::NEG_INFINITY), "-Inf");
9834 assert_eq!(format_sample_value(f64::NAN), "NaN");
9835 assert_eq!(format_sample_value(0.0), "0");
9836 assert_eq!(format_sample_value(1.5), "1.5");
9837 }
9838
9839 #[test]
9840 fn is_valid_metric_name_accepts_valid_and_rejects_invalid() {
9841 assert!(is_valid_metric_name("http_requests_total"));
9842 assert!(is_valid_metric_name("_private"));
9843 assert!(is_valid_metric_name("ns:metric"));
9844 assert!(!is_valid_metric_name(""));
9845 assert!(!is_valid_metric_name("0starts_with_digit"));
9846 assert!(!is_valid_metric_name("has-hyphen"));
9847 }
9848
9849 #[test]
9850 fn is_valid_label_name_accepts_valid_and_rejects_invalid() {
9851 assert!(is_valid_label_name("shard"));
9852 assert!(is_valid_label_name("_internal"));
9853 assert!(is_valid_label_name("a1"));
9854 assert!(!is_valid_label_name(""));
9855 assert!(!is_valid_label_name("0starts_digit"));
9856 assert!(!is_valid_label_name("has-hyphen"));
9857 assert!(!is_valid_label_name("has.dot"));
9858 }
9859
9860 #[tokio::test]
9861 async fn prometheus_endpoint_skips_family_with_invalid_metric_name() {
9862 struct BadNameSource;
9863 impl MetricsSource for BadNameSource {
9864 fn collect(&self) -> Vec<MetricFamily> {
9865 vec![
9866 MetricFamily {
9867 name: "invalid-name".to_string(),
9868 help: "should be skipped".to_string(),
9869 kind: MetricKind::Counter,
9870 samples: vec![],
9871 },
9872 MetricFamily {
9873 name: "valid_name".to_string(),
9874 help: "should appear".to_string(),
9875 kind: MetricKind::Counter,
9876 samples: vec![MetricSample {
9877 labels: vec![],
9878 value: 1.0,
9879 }],
9880 },
9881 ]
9882 }
9883 }
9884
9885 let state = test_state();
9886 state
9887 .metrics_source_registry
9888 .register("bad_name_src", Arc::new(BadNameSource))
9889 .unwrap();
9890
9891 let app = actuator_router(true).with_state(state);
9892 let resp = app
9893 .oneshot(
9894 Request::builder()
9895 .uri("/actuator/prometheus")
9896 .body(Body::empty())
9897 .unwrap(),
9898 )
9899 .await
9900 .unwrap();
9901 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9902 .await
9903 .unwrap();
9904 let text = String::from_utf8(body.to_vec()).unwrap();
9905
9906 assert!(
9907 !text.contains("invalid-name"),
9908 "invalid family must be skipped:\n{text}"
9909 );
9910 assert!(
9911 text.contains("valid_name"),
9912 "valid family must appear:\n{text}"
9913 );
9914 }
9915
9916 #[tokio::test]
9917 async fn prometheus_endpoint_skips_sample_with_invalid_label_key() {
9918 struct DirtyLabelsSource;
9922 impl MetricsSource for DirtyLabelsSource {
9923 fn collect(&self) -> Vec<MetricFamily> {
9924 vec![MetricFamily {
9925 name: "dirty_labels_metric".to_string(),
9926 help: "test".to_string(),
9927 kind: MetricKind::Counter,
9928 samples: vec![
9929 MetricSample {
9930 labels: vec![
9931 ("good".to_string(), "a".to_string()),
9932 ("bad-key".to_string(), "b".to_string()),
9933 ],
9934 value: 1.0,
9935 },
9936 MetricSample {
9937 labels: vec![("good".to_string(), "a".to_string())],
9938 value: 2.0,
9939 },
9940 ],
9941 }]
9942 }
9943 }
9944
9945 let state = test_state();
9946 state
9947 .metrics_source_registry
9948 .register("dirty", Arc::new(DirtyLabelsSource))
9949 .unwrap();
9950
9951 let app = actuator_router(true).with_state(state);
9952 let resp = app
9953 .oneshot(
9954 Request::builder()
9955 .uri("/actuator/prometheus")
9956 .body(Body::empty())
9957 .unwrap(),
9958 )
9959 .await
9960 .unwrap();
9961 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9962 .await
9963 .unwrap();
9964 let text = String::from_utf8(body.to_vec()).unwrap();
9965
9966 assert!(
9968 !text.contains("dirty_labels_metric{good=\"a\"} 1"),
9969 "sample with invalid label key must be skipped:\n{text}"
9970 );
9971 assert!(
9973 text.contains("dirty_labels_metric{good=\"a\"} 2"),
9974 "clean sample must appear:\n{text}"
9975 );
9976 }
9977
9978 #[tokio::test]
9979 async fn prometheus_endpoint_deduplicates_label_keys() {
9980 struct DupLabelSource;
9981 impl MetricsSource for DupLabelSource {
9982 fn collect(&self) -> Vec<MetricFamily> {
9983 vec![MetricFamily {
9984 name: "dup_label_metric".to_string(),
9985 help: "test".to_string(),
9986 kind: MetricKind::Counter,
9987 samples: vec![MetricSample {
9988 labels: vec![
9989 ("env".to_string(), "prod".to_string()),
9990 ("env".to_string(), "staging".to_string()),
9991 ],
9992 value: 5.0,
9993 }],
9994 }]
9995 }
9996 }
9997
9998 let state = test_state();
9999 state
10000 .metrics_source_registry
10001 .register("dup_src", Arc::new(DupLabelSource))
10002 .unwrap();
10003
10004 let app = actuator_router(true).with_state(state);
10005 let resp = app
10006 .oneshot(
10007 Request::builder()
10008 .uri("/actuator/prometheus")
10009 .body(Body::empty())
10010 .unwrap(),
10011 )
10012 .await
10013 .unwrap();
10014 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10015 .await
10016 .unwrap();
10017 let text = String::from_utf8(body.to_vec()).unwrap();
10018
10019 assert!(
10021 text.contains("dup_label_metric{env=\"prod\"} 5"),
10022 "first env value must be kept:\n{text}"
10023 );
10024 assert!(
10025 !text.contains("staging"),
10026 "duplicate env key value must be dropped:\n{text}"
10027 );
10028 }
10029
10030 #[tokio::test]
10031 async fn prometheus_endpoint_escapes_help_text_and_formats_inf() {
10032 struct SpecialSource;
10033 impl MetricsSource for SpecialSource {
10034 fn collect(&self) -> Vec<MetricFamily> {
10035 vec![MetricFamily {
10036 name: "inf_gauge".to_string(),
10037 help: "has\\backslash and\nnewline".to_string(),
10038 kind: MetricKind::Gauge,
10039 samples: vec![
10040 MetricSample {
10041 labels: vec![("dir".to_string(), "pos".to_string())],
10042 value: f64::INFINITY,
10043 },
10044 MetricSample {
10045 labels: vec![("dir".to_string(), "neg".to_string())],
10046 value: f64::NEG_INFINITY,
10047 },
10048 ],
10049 }]
10050 }
10051 }
10052
10053 let state = test_state();
10054 state
10055 .metrics_source_registry
10056 .register("special", Arc::new(SpecialSource))
10057 .unwrap();
10058
10059 let app = actuator_router(true).with_state(state);
10060 let resp = app
10061 .oneshot(
10062 Request::builder()
10063 .uri("/actuator/prometheus")
10064 .body(Body::empty())
10065 .unwrap(),
10066 )
10067 .await
10068 .unwrap();
10069 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10070 .await
10071 .unwrap();
10072 let text = String::from_utf8(body.to_vec()).unwrap();
10073
10074 assert!(
10075 text.contains("# HELP inf_gauge has\\\\backslash and\\nnewline"),
10076 "help text must be escaped in:\n{text}"
10077 );
10078 assert!(
10079 text.contains("inf_gauge{dir=\"pos\"} +Inf"),
10080 "must render +Inf in:\n{text}"
10081 );
10082 assert!(
10083 text.contains("inf_gauge{dir=\"neg\"} -Inf"),
10084 "must render -Inf in:\n{text}"
10085 );
10086 }
10087
10088 #[tokio::test]
10089 async fn prometheus_endpoint_skips_duplicate_family_name_across_sources() {
10090 struct FirstSource;
10091 impl MetricsSource for FirstSource {
10092 fn collect(&self) -> Vec<MetricFamily> {
10093 vec![MetricFamily {
10094 name: "shared_counter".to_string(),
10095 help: "from first".to_string(),
10096 kind: MetricKind::Counter,
10097 samples: vec![MetricSample {
10098 labels: vec![],
10099 value: 1.0,
10100 }],
10101 }]
10102 }
10103 }
10104 struct SecondSource;
10105 impl MetricsSource for SecondSource {
10106 fn collect(&self) -> Vec<MetricFamily> {
10107 vec![MetricFamily {
10108 name: "shared_counter".to_string(),
10109 help: "from second".to_string(),
10110 kind: MetricKind::Counter,
10111 samples: vec![MetricSample {
10112 labels: vec![],
10113 value: 2.0,
10114 }],
10115 }]
10116 }
10117 }
10118
10119 let state = test_state();
10120 state
10121 .metrics_source_registry
10122 .register("first", Arc::new(FirstSource))
10123 .unwrap();
10124 state
10125 .metrics_source_registry
10126 .register("second", Arc::new(SecondSource))
10127 .unwrap();
10128
10129 let app = actuator_router(true).with_state(state);
10130 let resp = app
10131 .oneshot(
10132 Request::builder()
10133 .uri("/actuator/prometheus")
10134 .body(Body::empty())
10135 .unwrap(),
10136 )
10137 .await
10138 .unwrap();
10139 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10140 .await
10141 .unwrap();
10142 let text = String::from_utf8(body.to_vec()).unwrap();
10143
10144 let occurrences = text.matches("# HELP shared_counter").count();
10145 assert_eq!(
10146 occurrences, 1,
10147 "must emit exactly one HELP block for shared_counter:\n{text}"
10148 );
10149 }
10150
10151 #[tokio::test]
10152 async fn prometheus_endpoint_skips_builtin_name_collision() {
10153 struct ShadowSource;
10154 impl MetricsSource for ShadowSource {
10155 fn collect(&self) -> Vec<MetricFamily> {
10156 vec![MetricFamily {
10157 name: "autumn_http_requests_total".to_string(),
10158 help: "plugin trying to shadow built-in".to_string(),
10159 kind: MetricKind::Counter,
10160 samples: vec![MetricSample {
10161 labels: vec![],
10162 value: 999.0,
10163 }],
10164 }]
10165 }
10166 }
10167
10168 let state = test_state();
10169 state
10170 .metrics_source_registry
10171 .register("shadow", Arc::new(ShadowSource))
10172 .unwrap();
10173
10174 let app = actuator_router(true).with_state(state);
10175 let resp = app
10176 .oneshot(
10177 Request::builder()
10178 .uri("/actuator/prometheus")
10179 .body(Body::empty())
10180 .unwrap(),
10181 )
10182 .await
10183 .unwrap();
10184 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10185 .await
10186 .unwrap();
10187 let text = String::from_utf8(body.to_vec()).unwrap();
10188
10189 let occurrences = text.matches("# HELP autumn_http_requests_total").count();
10190 assert_eq!(
10191 occurrences, 1,
10192 "built-in must not be shadowed by plugin:\n{text}"
10193 );
10194 assert!(
10195 !text.contains("999"),
10196 "plugin shadow value must not appear:\n{text}"
10197 );
10198 }
10199
10200 #[tokio::test]
10201 async fn prometheus_endpoint_skips_builtin_duration_family_collision() {
10202 struct ShadowLatency;
10205 impl MetricsSource for ShadowLatency {
10206 fn collect(&self) -> Vec<MetricFamily> {
10207 vec![MetricFamily {
10208 name: "autumn_http_request_duration_seconds".to_string(),
10209 help: "plugin trying to shadow built-in latency".to_string(),
10210 kind: MetricKind::Gauge,
10211 samples: vec![MetricSample {
10212 labels: vec![],
10213 value: 999.0,
10214 }],
10215 }]
10216 }
10217 }
10218
10219 let state = test_state();
10220 state
10221 .metrics_source_registry
10222 .register("shadow_latency", Arc::new(ShadowLatency))
10223 .unwrap();
10224
10225 let app = actuator_router(true).with_state(state);
10226 let resp = app
10227 .oneshot(
10228 Request::builder()
10229 .uri("/actuator/prometheus")
10230 .body(Body::empty())
10231 .unwrap(),
10232 )
10233 .await
10234 .unwrap();
10235 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10236 .await
10237 .unwrap();
10238 let text = String::from_utf8(body.to_vec()).unwrap();
10239
10240 let occurrences = text
10241 .matches("# HELP autumn_http_request_duration_seconds")
10242 .count();
10243 assert_eq!(
10244 occurrences, 1,
10245 "built-in latency family must not be shadowed by plugin:\n{text}"
10246 );
10247 assert!(
10248 !text.contains("999"),
10249 "plugin shadow value must not appear:\n{text}"
10250 );
10251 }
10252
10253 #[tokio::test]
10254 async fn prometheus_endpoint_skips_duplicate_series_within_family() {
10255 struct DupSeriesSource;
10256 impl MetricsSource for DupSeriesSource {
10257 fn collect(&self) -> Vec<MetricFamily> {
10258 vec![MetricFamily {
10259 name: "dup_series_metric".to_string(),
10260 help: "test".to_string(),
10261 kind: MetricKind::Counter,
10262 samples: vec![
10263 MetricSample {
10264 labels: vec![("region".to_string(), "us".to_string())],
10265 value: 10.0,
10266 },
10267 MetricSample {
10268 labels: vec![("region".to_string(), "us".to_string())],
10269 value: 20.0,
10270 },
10271 ],
10272 }]
10273 }
10274 }
10275
10276 let state = test_state();
10277 state
10278 .metrics_source_registry
10279 .register("dup_series", Arc::new(DupSeriesSource))
10280 .unwrap();
10281
10282 let app = actuator_router(true).with_state(state);
10283 let resp = app
10284 .oneshot(
10285 Request::builder()
10286 .uri("/actuator/prometheus")
10287 .body(Body::empty())
10288 .unwrap(),
10289 )
10290 .await
10291 .unwrap();
10292 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10293 .await
10294 .unwrap();
10295 let text = String::from_utf8(body.to_vec()).unwrap();
10296
10297 assert!(
10299 text.contains("dup_series_metric{region=\"us\"} 10"),
10300 "first sample must appear:\n{text}"
10301 );
10302 assert!(
10303 !text.contains("dup_series_metric{region=\"us\"} 20"),
10304 "duplicate series must be dropped:\n{text}"
10305 );
10306 }
10307
10308 fn make_log_buffer_with_entries() -> crate::log::capture::LogBuffer {
10311 use crate::log::capture::{CapturedLogEntry, LogBuffer};
10312 use crate::log::filter::ParameterFilter;
10313 let buf = LogBuffer::new(100, ParameterFilter::default());
10314 buf.push(CapturedLogEntry {
10315 timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10316 level: "INFO".to_owned(),
10317 target: "myapp::orders".to_owned(),
10318 message: "order created".to_owned(),
10319 fields: {
10320 let mut m = serde_json::Map::new();
10321 m.insert("order_id".to_owned(), serde_json::json!("A-1001"));
10322 m
10323 },
10324 request_id: Some("req-abc".to_owned()),
10325 });
10326 buf.push(CapturedLogEntry {
10327 timestamp: "2024-01-01T00:00:01.000Z".to_owned(),
10328 level: "WARN".to_owned(),
10329 target: "myapp::payments".to_owned(),
10330 message: "payment slow".to_owned(),
10331 fields: serde_json::Map::new(),
10332 request_id: None,
10333 });
10334 buf.push(CapturedLogEntry {
10335 timestamp: "2024-01-01T00:00:02.000Z".to_owned(),
10336 level: "ERROR".to_owned(),
10337 target: "myapp::payments".to_owned(),
10338 message: "payment failed".to_owned(),
10339 fields: serde_json::Map::new(),
10340 request_id: None,
10341 });
10342 buf
10343 }
10344
10345 #[tokio::test]
10346 async fn green_logfile_returns_empty_when_capture_disabled() {
10347 let state = test_state(); let response =
10349 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10350 .await
10351 .unwrap();
10352 let body = response.0;
10353 assert!(!body.capture_enabled);
10354 assert!(body.entries.is_empty());
10355 assert_eq!(body.total, 0);
10356 }
10357
10358 #[tokio::test]
10359 async fn green_logfile_returns_all_entries_when_no_filter() {
10360 let mut state = test_state();
10361 state.log_buffer = Some(make_log_buffer_with_entries());
10362
10363 let response =
10364 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10365 .await
10366 .unwrap();
10367 let body = response.0;
10368 assert!(body.capture_enabled);
10369 assert_eq!(body.total, 3);
10370 assert_eq!(body.entries.len(), 3);
10371 assert_eq!(body.entries[0].level, "INFO");
10373 assert_eq!(body.entries[2].level, "ERROR");
10374 }
10375
10376 #[tokio::test]
10377 async fn green_logfile_level_filter_excludes_info_when_min_warn() {
10378 let mut state = test_state();
10379 state.log_buffer = Some(make_log_buffer_with_entries());
10380
10381 let response = logfile_endpoint(
10382 State(state),
10383 axum::extract::Query(LogfileQuery {
10384 level: Some("warn".to_owned()),
10385 limit: None,
10386 }),
10387 )
10388 .await
10389 .unwrap();
10390 let body = response.0;
10391 assert_eq!(body.entries.len(), 2);
10392 assert!(body.entries.iter().all(|e| e.level != "INFO"));
10393 }
10394
10395 #[tokio::test]
10396 async fn green_logfile_limit_returns_most_recent_n() {
10397 let mut state = test_state();
10398 state.log_buffer = Some(make_log_buffer_with_entries());
10399
10400 let response = logfile_endpoint(
10401 State(state),
10402 axum::extract::Query(LogfileQuery {
10403 level: None,
10404 limit: Some(2),
10405 }),
10406 )
10407 .await
10408 .unwrap();
10409 let body = response.0;
10410 assert_eq!(body.entries.len(), 2);
10411 assert_eq!(body.entries[0].level, "WARN");
10413 assert_eq!(body.entries[1].level, "ERROR");
10414 }
10415
10416 #[tokio::test]
10417 async fn green_logfile_sensitive_fields_in_response_are_served_scrubbed() {
10418 use crate::log::capture::{CapturedLogEntry, LogBuffer};
10419 use crate::log::filter::{FILTERED_PLACEHOLDER, ParameterFilter};
10420 let buf = LogBuffer::new(10, ParameterFilter::default());
10421 buf.push(CapturedLogEntry {
10423 timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
10424 level: "INFO".to_owned(),
10425 target: "auth".to_owned(),
10426 message: "login attempt".to_owned(),
10427 fields: {
10428 let mut m = serde_json::Map::new();
10429 m.insert(
10430 "password".to_owned(),
10431 serde_json::Value::String(FILTERED_PLACEHOLDER.to_owned()),
10432 );
10433 m
10434 },
10435 request_id: None,
10436 });
10437
10438 let mut state = test_state();
10439 state.log_buffer = Some(buf);
10440
10441 let response =
10442 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10443 .await
10444 .unwrap();
10445 let body = response.0;
10446 assert_eq!(
10447 body.entries[0].fields["password"].as_str().unwrap(),
10448 FILTERED_PLACEHOLDER,
10449 "sensitive value must remain scrubbed in the response"
10450 );
10451 }
10452
10453 #[tokio::test]
10454 async fn green_logfile_invalid_level_returns_400() {
10455 let state = test_state();
10456 let result = logfile_endpoint(
10457 State(state),
10458 axum::extract::Query(LogfileQuery {
10459 level: Some("warning".to_owned()), limit: None,
10461 }),
10462 )
10463 .await;
10464 let (status, _body) = result.unwrap_err();
10465 assert_eq!(status, StatusCode::BAD_REQUEST);
10466 }
10467
10468 #[tokio::test]
10469 async fn green_logfile_endpoint_in_sensitive_router() {
10470 let state = test_state();
10472 let app = actuator_router::<TestActuatorState>(true).with_state(state);
10473 let resp = app
10474 .oneshot(
10475 Request::builder()
10476 .uri("/actuator/logfile")
10477 .body(Body::empty())
10478 .unwrap(),
10479 )
10480 .await
10481 .unwrap();
10482 assert_eq!(resp.status(), StatusCode::OK);
10483 }
10484
10485 #[tokio::test]
10486 async fn green_logfile_endpoint_not_in_non_sensitive_router() {
10487 let state = test_state();
10489 let app = actuator_router::<TestActuatorState>(false).with_state(state);
10490 let resp = app
10491 .oneshot(
10492 Request::builder()
10493 .uri("/actuator/logfile")
10494 .body(Body::empty())
10495 .unwrap(),
10496 )
10497 .await
10498 .unwrap();
10499 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10500 }
10501
10502 #[tokio::test]
10503 async fn green_logfile_structured_fields_preserved() {
10504 let mut state = test_state();
10505 state.log_buffer = Some(make_log_buffer_with_entries());
10506
10507 let response =
10508 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
10509 .await
10510 .unwrap();
10511 let body = response.0;
10512 let first = &body.entries[0];
10513 assert_eq!(first.target, "myapp::orders");
10514 assert_eq!(first.fields["order_id"].as_str().unwrap(), "A-1001");
10515 assert_eq!(first.request_id.as_deref(), Some("req-abc"));
10516 }
10517
10518 async fn actuator_body(uri: &str) -> String {
10527 let app = actuator_router(true).with_state(test_state());
10528 let resp = app
10529 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
10530 .await
10531 .unwrap();
10532 assert_eq!(resp.status(), StatusCode::OK);
10533 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10534 .await
10535 .unwrap();
10536 String::from_utf8(body.to_vec()).unwrap()
10537 }
10538
10539 #[tokio::test]
10540 async fn prometheus_endpoint_renders_facade_counter_gauge_and_histogram() {
10541 use crate::metrics::testing::unique_name;
10542
10543 let counter_name = unique_name("facade_expo_orders_total");
10544 crate::metrics::describe_counter(&counter_name, "Orders placed");
10545 crate::metrics::counter(&counter_name)
10546 .with_label("status", "paid")
10547 .with_label("region", "eu")
10548 .increment(3);
10549
10550 let gauge_name = unique_name("facade_expo_queue_depth");
10551 crate::metrics::describe_gauge(&gauge_name, "Queue depth");
10552 crate::metrics::gauge(&gauge_name).set(2.5);
10553
10554 let hist_name = unique_name("facade_expo_work_seconds");
10555 crate::metrics::describe_histogram(&hist_name, "Work duration");
10556 let hist = crate::metrics::histogram(&hist_name).with_label("route", "/x");
10557 hist.record(0.25);
10558 hist.record(0.75);
10559
10560 let text = actuator_body("/actuator/prometheus").await;
10561
10562 assert!(
10564 text.contains(&format!("# HELP {counter_name} Orders placed")),
10565 "missing counter HELP in:\n{text}"
10566 );
10567 assert!(
10568 text.contains(&format!("# TYPE {counter_name} counter")),
10569 "missing counter TYPE in:\n{text}"
10570 );
10571 assert!(
10572 text.contains(&format!(
10573 "{counter_name}{{region=\"eu\",status=\"paid\"}} 3"
10574 )),
10575 "counter must render as an integer with sorted labels in:\n{text}"
10576 );
10577
10578 assert!(
10580 text.contains(&format!("# TYPE {gauge_name} gauge")),
10581 "missing gauge TYPE in:\n{text}"
10582 );
10583 assert!(
10584 text.contains(&format!("{gauge_name} 2.5")),
10585 "missing gauge sample in:\n{text}"
10586 );
10587
10588 assert!(
10591 text.contains(&format!("# HELP {hist_name} Work duration")),
10592 "missing histogram HELP in:\n{text}"
10593 );
10594 assert!(
10595 text.contains(&format!("# TYPE {hist_name} histogram")),
10596 "missing histogram TYPE in:\n{text}"
10597 );
10598 for (le, value) in [
10599 ("0.005", 0),
10600 ("0.01", 0),
10601 ("0.025", 0),
10602 ("0.05", 0),
10603 ("0.1", 0),
10604 ("0.25", 1),
10605 ("0.5", 1),
10606 ("1", 2),
10607 ("2.5", 2),
10608 ("5", 2),
10609 ("10", 2),
10610 ("+Inf", 2),
10611 ] {
10612 let line = format!("{hist_name}_bucket{{route=\"/x\",le=\"{le}\"}} {value}");
10613 assert!(
10614 text.contains(&line),
10615 "missing bucket line `{line}` in:\n{text}"
10616 );
10617 }
10618 assert!(
10619 text.contains(&format!("{hist_name}_sum{{route=\"/x\"}} 1")),
10620 "missing histogram sum in:\n{text}"
10621 );
10622 assert!(
10623 text.contains(&format!("{hist_name}_count{{route=\"/x\"}} 2")),
10624 "missing histogram count in:\n{text}"
10625 );
10626 assert!(
10628 text.contains(&format!("{hist_name}_bucket{{route=\"/x\",le=\"+Inf\"}} 2")),
10629 "+Inf bucket must equal _count in:\n{text}"
10630 );
10631 assert_eq!(
10633 text.matches(&format!("# TYPE {counter_name} counter"))
10634 .count(),
10635 1,
10636 "duplicate TYPE line for {counter_name} in:\n{text}"
10637 );
10638 }
10639
10640 #[tokio::test]
10641 async fn prometheus_endpoint_prefers_facade_over_colliding_plugin_family() {
10642 use crate::metrics::testing::unique_name;
10643
10644 struct CollidingSource(String);
10645 impl MetricsSource for CollidingSource {
10646 fn collect(&self) -> Vec<MetricFamily> {
10647 vec![MetricFamily {
10648 name: self.0.clone(),
10649 help: "From the plugin".to_string(),
10650 kind: MetricKind::Gauge,
10651 samples: vec![MetricSample {
10652 labels: vec![],
10653 value: 99.0,
10654 }],
10655 }]
10656 }
10657 }
10658
10659 let name = unique_name("facade_expo_collision_total");
10660 crate::metrics::describe_counter(&name, "From the facade");
10661 crate::metrics::counter(&name).increment(1);
10662
10663 let state = test_state();
10664 state
10665 .metrics_source_registry
10666 .register("colliding_plugin", Arc::new(CollidingSource(name.clone())))
10667 .unwrap();
10668
10669 let app = actuator_router(true).with_state(state);
10670 let resp = app
10671 .oneshot(
10672 Request::builder()
10673 .uri("/actuator/prometheus")
10674 .body(Body::empty())
10675 .unwrap(),
10676 )
10677 .await
10678 .unwrap();
10679 assert_eq!(resp.status(), StatusCode::OK);
10680 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10681 .await
10682 .unwrap();
10683 let text = String::from_utf8(body.to_vec()).unwrap();
10684
10685 assert!(
10686 text.contains(&format!("# HELP {name} From the facade")),
10687 "the facade family must win the name in:\n{text}"
10688 );
10689 assert!(
10690 !text.contains(&format!("# HELP {name} From the plugin")),
10691 "the plugin family must be skipped in:\n{text}"
10692 );
10693 assert_eq!(
10694 text.matches(&format!("# TYPE {name} ")).count(),
10695 1,
10696 "exactly one TYPE line for {name} in:\n{text}"
10697 );
10698 assert!(
10699 !text.contains(&format!("{name} 99")),
10700 "the plugin sample must not be emitted in:\n{text}"
10701 );
10702 }
10703
10704 #[tokio::test]
10705 async fn prometheus_endpoint_skips_plugin_family_colliding_with_facade_histogram_count() {
10706 use crate::metrics::testing::unique_name;
10707
10708 struct DerivedSource(String);
10709 impl MetricsSource for DerivedSource {
10710 fn collect(&self) -> Vec<MetricFamily> {
10711 vec![MetricFamily {
10712 name: self.0.clone(),
10713 help: "Plugin shadowing a derived name".to_string(),
10714 kind: MetricKind::Counter,
10715 samples: vec![MetricSample {
10716 labels: vec![],
10717 value: 77.0,
10718 }],
10719 }]
10720 }
10721 }
10722
10723 let base = unique_name("facade_expo_derived_seconds");
10724 crate::metrics::histogram(&base).record(0.25);
10725 let derived = format!("{base}_count");
10726
10727 let state = test_state();
10728 state
10729 .metrics_source_registry
10730 .register("derived_plugin", Arc::new(DerivedSource(derived.clone())))
10731 .unwrap();
10732
10733 let app = actuator_router(true).with_state(state);
10734 let resp = app
10735 .oneshot(
10736 Request::builder()
10737 .uri("/actuator/prometheus")
10738 .body(Body::empty())
10739 .unwrap(),
10740 )
10741 .await
10742 .unwrap();
10743 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10744 .await
10745 .unwrap();
10746 let text = String::from_utf8(body.to_vec()).unwrap();
10747
10748 assert!(
10749 !text.contains("Plugin shadowing a derived name"),
10750 "a plugin family named after a facade histogram's derived family must be skipped in:\n{text}"
10751 );
10752 assert!(
10753 !text.contains(&format!("{derived} 77")),
10754 "the plugin sample must not be emitted in:\n{text}"
10755 );
10756 assert!(
10757 text.contains(&format!("{derived} 1")),
10758 "the facade's own derived count must still be present in:\n{text}"
10759 );
10760 }
10761
10762 #[tokio::test]
10763 async fn prometheus_endpoint_skips_plugin_family_colliding_with_builtin_summary_derived_name() {
10764 struct DerivedSummarySource(&'static str);
10769 impl MetricsSource for DerivedSummarySource {
10770 fn collect(&self) -> Vec<MetricFamily> {
10771 vec![MetricFamily {
10772 name: self.0.to_string(),
10773 help: "Plugin shadowing a builtin summary's derived family".to_string(),
10774 kind: MetricKind::Counter,
10775 samples: vec![MetricSample {
10776 labels: vec![],
10777 value: 4242.0,
10778 }],
10779 }]
10780 }
10781 }
10782
10783 for derived in [
10784 "autumn_http_request_duration_seconds_bucket",
10785 "autumn_http_request_duration_seconds_sum",
10786 "autumn_http_request_duration_seconds_count",
10787 "autumn_http_request_duration_quantiles_seconds_sum",
10788 "autumn_http_request_duration_quantiles_seconds_count",
10789 ] {
10790 let state = test_state();
10791 state
10792 .metrics_source_registry
10793 .register(
10794 "derived_summary_plugin",
10795 Arc::new(DerivedSummarySource(derived)),
10796 )
10797 .unwrap();
10798
10799 let app = actuator_router(true).with_state(state);
10800 let resp = app
10801 .oneshot(
10802 Request::builder()
10803 .uri("/actuator/prometheus")
10804 .body(Body::empty())
10805 .unwrap(),
10806 )
10807 .await
10808 .unwrap();
10809 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10810 .await
10811 .unwrap();
10812 let text = String::from_utf8(body.to_vec()).unwrap();
10813
10814 assert!(
10815 !text.contains(&format!("# HELP {derived} ")),
10816 "a plugin family named `{derived}` must be skipped in:\n{text}"
10817 );
10818 assert!(
10819 !text.contains("4242"),
10820 "the plugin sample must not be emitted in:\n{text}"
10821 );
10822 }
10823 }
10824
10825 #[tokio::test]
10826 async fn metrics_endpoint_includes_app_section_for_facade_metrics() {
10827 use crate::metrics::testing::unique_name;
10828
10829 let counter_name = unique_name("facade_json_orders_total");
10830 crate::metrics::describe_counter(&counter_name, "Orders placed");
10831 crate::metrics::counter(&counter_name)
10832 .with_label("status", "paid")
10833 .increment(3);
10834
10835 let hist_name = unique_name("facade_json_work_seconds");
10836 crate::metrics::histogram(&hist_name).record(0.25);
10837 crate::metrics::histogram(&hist_name).record(0.75);
10838
10839 let text = actuator_body("/actuator/metrics").await;
10840 let json: serde_json::Value = serde_json::from_str(&text).unwrap();
10841
10842 let app = json
10843 .get("app")
10844 .unwrap_or_else(|| panic!("missing top-level `app` key in:\n{text}"));
10845 let instruments = app
10846 .as_array()
10847 .unwrap_or_else(|| panic!("`app` must be an array in:\n{text}"));
10848
10849 let entry = instruments
10850 .iter()
10851 .find(|i| i["name"] == serde_json::json!(counter_name))
10852 .unwrap_or_else(|| panic!("missing {counter_name} in:\n{text}"));
10853 assert_eq!(entry["kind"], serde_json::json!("counter"));
10854 assert_eq!(entry["help"], serde_json::json!("Orders placed"));
10855 assert_eq!(entry["dropped_series"], serde_json::json!(0));
10856 assert_eq!(
10857 entry["series"][0]["labels"]["status"],
10858 serde_json::json!("paid")
10859 );
10860 assert_eq!(
10861 entry["series"][0]["value"]["value"].as_u64(),
10862 Some(3),
10863 "the JSON view must match the prometheus view"
10864 );
10865
10866 let hist = instruments
10867 .iter()
10868 .find(|i| i["name"] == serde_json::json!(hist_name))
10869 .unwrap_or_else(|| panic!("missing {hist_name} in:\n{text}"));
10870 assert_eq!(hist["kind"], serde_json::json!("histogram"));
10871 let value = &hist["series"][0]["value"];
10872 assert_eq!(value["count"].as_u64(), Some(2));
10873 assert!(
10874 (value["sum"].as_f64().unwrap() - 1.0).abs() < f64::EPSILON,
10875 "unexpected histogram sum in:\n{text}"
10876 );
10877 let buckets = value["buckets"]
10878 .as_array()
10879 .unwrap_or_else(|| panic!("missing histogram buckets in:\n{text}"));
10880 let last = buckets.last().unwrap();
10881 assert_eq!(last[0], serde_json::json!("+Inf"));
10882 assert_eq!(last[1].as_u64(), Some(2), "+Inf must equal count");
10883 }
10884
10885 #[tokio::test]
10886 async fn actuator_prometheus_disabled_hides_facade_metrics() {
10887 use crate::metrics::testing::unique_name;
10888
10889 let name = unique_name("facade_gated_total");
10892 crate::metrics::counter(&name).increment(1);
10893
10894 let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
10895 let resp = app
10896 .oneshot(
10897 Request::builder()
10898 .uri("/actuator/prometheus")
10899 .body(Body::empty())
10900 .unwrap(),
10901 )
10902 .await
10903 .unwrap();
10904 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10905 }
10906
10907 fn capsule_state(dir: &std::path::Path) -> TestActuatorState {
10910 use crate::gdpr::portability::{
10911 CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
10912 };
10913 let store = MemoryCapsuleStore::new().table(
10914 "users",
10915 vec![
10916 FieldSpec::new("id", "bigint"),
10917 FieldSpec::new("email", "text"),
10918 ],
10919 );
10920 store.insert(
10921 "users",
10922 serde_json::json!({"id": 7, "email": "ada@example.com"}),
10923 );
10924 let service = CapsuleService::new(
10925 vec![CapsuleModel::new("users", "id")],
10926 std::sync::Arc::new(store),
10927 CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
10928 )
10929 .with_dir(dir);
10930 let mut state = test_state();
10931 state.data_capsules = Some(service);
10932 state
10933 }
10934
10935 async fn post_json(
10936 state: TestActuatorState,
10937 sensitive: bool,
10938 path: &str,
10939 body: serde_json::Value,
10940 ) -> (StatusCode, serde_json::Value) {
10941 let resp = actuator_router(sensitive)
10942 .with_state(state)
10943 .oneshot(
10944 Request::builder()
10945 .method("POST")
10946 .uri(path)
10947 .header("content-type", "application/json")
10948 .body(Body::from(body.to_string()))
10949 .unwrap(),
10950 )
10951 .await
10952 .unwrap();
10953 let status = resp.status();
10954 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
10955 .await
10956 .unwrap();
10957 (
10958 status,
10959 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
10960 )
10961 }
10962
10963 #[tokio::test]
10964 async fn actuator_capsules_are_not_mounted_when_not_sensitive() {
10965 let dir = tempfile::tempdir().unwrap();
10966 let (status, _) = post_json(
10967 capsule_state(dir.path()),
10968 false,
10969 "/actuator/capsules/export",
10970 serde_json::json!({"subject": "7"}),
10971 )
10972 .await;
10973 assert_eq!(status, StatusCode::NOT_FOUND);
10974 }
10975
10976 #[tokio::test]
10977 async fn actuator_capsules_answer_501_when_not_configured() {
10978 let (status, json) = post_json(
10979 test_state(),
10980 true,
10981 "/actuator/capsules/export",
10982 serde_json::json!({"subject": "7"}),
10983 )
10984 .await;
10985 assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{json}");
10986 assert_eq!(json["status"], "error");
10987 }
10988
10989 #[tokio::test]
10990 async fn actuator_capsule_export_verify_import() {
10991 let dir = tempfile::tempdir().unwrap();
10992 let (status, json) = post_json(
10993 capsule_state(dir.path()),
10994 true,
10995 "/actuator/capsules/export",
10996 serde_json::json!({"subject": "7"}),
10997 )
10998 .await;
10999 assert_eq!(status, StatusCode::OK, "{json}");
11000 assert_eq!(json["report"]["records"], 1);
11001 let name = json["capsule"].as_str().expect("capsule name").to_owned();
11002 assert!(dir.path().join(&name).join("viewer/index.html").is_file());
11003
11004 let (status, json) = post_json(
11005 capsule_state(dir.path()),
11006 true,
11007 "/actuator/capsules/verify",
11008 serde_json::json!({"capsule": name}),
11009 )
11010 .await;
11011 assert_eq!(status, StatusCode::OK, "{json}");
11012 assert_eq!(json["report"]["subject"], "7");
11013
11014 let (status, json) = post_json(
11016 capsule_state(dir.path()),
11017 true,
11018 "/actuator/capsules/import",
11019 serde_json::json!({"capsule": name}),
11020 )
11021 .await;
11022 assert_eq!(status, StatusCode::CONFLICT, "{json}");
11023 }
11024
11025 #[tokio::test]
11026 async fn actuator_capsule_import_into_an_empty_store_succeeds() {
11027 use crate::gdpr::portability::{
11028 CapsuleModel, CapsuleService, CapsuleSigner, FieldSpec, MemoryCapsuleStore,
11029 };
11030 let dir = tempfile::tempdir().unwrap();
11031 let (_, json) = post_json(
11032 capsule_state(dir.path()),
11033 true,
11034 "/actuator/capsules/export",
11035 serde_json::json!({"subject": "7"}),
11036 )
11037 .await;
11038 let name = json["capsule"].as_str().unwrap().to_owned();
11039
11040 let empty = std::sync::Arc::new(MemoryCapsuleStore::new().table(
11041 "users",
11042 vec![
11043 FieldSpec::new("id", "bigint"),
11044 FieldSpec::new("email", "text"),
11045 ],
11046 ));
11047 let mut state = test_state();
11048 state.data_capsules = Some(
11049 CapsuleService::new(
11050 vec![CapsuleModel::new("users", "id")],
11051 empty.clone(),
11052 CapsuleSigner::new(b"actuator-capsule-secret-0123456789"),
11053 )
11054 .with_dir(dir.path()),
11055 );
11056 let (status, json) = post_json(
11057 state,
11058 true,
11059 "/actuator/capsules/import",
11060 serde_json::json!({"capsule": name}),
11061 )
11062 .await;
11063 assert_eq!(status, StatusCode::OK, "{json}");
11064 assert_eq!(json["summary"]["records"], 1);
11065 assert_eq!(json["summary"]["tables"][0]["table"], "users");
11066 assert_eq!(empty.rows("users").len(), 1);
11067 }
11068
11069 #[tokio::test]
11070 async fn actuator_capsule_export_rejects_an_empty_subject_with_400() {
11071 let dir = tempfile::tempdir().unwrap();
11072 let (status, json) = post_json(
11073 capsule_state(dir.path()),
11074 true,
11075 "/actuator/capsules/export",
11076 serde_json::json!({"subject": ""}),
11077 )
11078 .await;
11079 assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
11080 }
11081
11082 #[cfg(unix)]
11083 #[tokio::test]
11084 async fn actuator_capsule_verify_does_not_follow_a_symlinked_entry() {
11085 let outside = tempfile::tempdir().unwrap();
11087 let (_, json) = post_json(
11088 capsule_state(outside.path()),
11089 true,
11090 "/actuator/capsules/export",
11091 serde_json::json!({"subject": "7"}),
11092 )
11093 .await;
11094 let name = json["capsule"].as_str().unwrap().to_owned();
11095 let dir = tempfile::tempdir().unwrap();
11096 std::os::unix::fs::symlink(outside.path().join(&name), dir.path().join("linked")).unwrap();
11097 for endpoint in ["verify", "import"] {
11098 let (status, json) = post_json(
11099 capsule_state(dir.path()),
11100 true,
11101 &format!("/actuator/capsules/{endpoint}"),
11102 serde_json::json!({"capsule": "linked"}),
11103 )
11104 .await;
11105 assert_eq!(status, StatusCode::NOT_FOUND, "{endpoint}: {json}");
11106 }
11107 }
11108
11109 #[tokio::test]
11110 async fn actuator_capsule_import_accepts_only_a_plain_name() {
11111 let dir = tempfile::tempdir().unwrap();
11112 for name in ["../etc", "/tmp/x", "a/b"] {
11113 let (status, json) = post_json(
11114 capsule_state(dir.path()),
11115 true,
11116 "/actuator/capsules/import",
11117 serde_json::json!({"capsule": name}),
11118 )
11119 .await;
11120 assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {json}");
11121 }
11122 }
11123
11124 #[tokio::test]
11125 async fn actuator_capsule_verify_answers_404_for_an_unknown_capsule() {
11126 let dir = tempfile::tempdir().unwrap();
11127 let (status, _) = post_json(
11128 capsule_state(dir.path()),
11129 true,
11130 "/actuator/capsules/verify",
11131 serde_json::json!({"capsule": "missing"}),
11132 )
11133 .await;
11134 assert_eq!(status, StatusCode::NOT_FOUND);
11135 }
11136
11137 #[tokio::test]
11138 async fn actuator_capsule_verify_reports_tampering_as_422() {
11139 let dir = tempfile::tempdir().unwrap();
11140 let (_, json) = post_json(
11141 capsule_state(dir.path()),
11142 true,
11143 "/actuator/capsules/export",
11144 serde_json::json!({"subject": "7"}),
11145 )
11146 .await;
11147 let name = json["capsule"].as_str().unwrap().to_owned();
11148 std::fs::write(dir.path().join(&name).join("records/users.json"), "[]").unwrap();
11149 let (status, json) = post_json(
11150 capsule_state(dir.path()),
11151 true,
11152 "/actuator/capsules/verify",
11153 serde_json::json!({"capsule": name}),
11154 )
11155 .await;
11156 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{json}");
11157 }
11158
11159 #[test]
11160 fn actuator_capsule_routes_are_listed_only_in_sensitive_mode() {
11161 for suffix in ["export", "import", "verify"] {
11162 let path = format!("/actuator/capsules/{suffix}");
11163 assert!(actuator_endpoint_paths("/actuator", true, false).contains(&path));
11164 assert!(!actuator_endpoint_paths("/actuator", false, false).contains(&path));
11165 assert!(actuator_mutating_routes("/actuator", true).contains(&("POST", path.clone())));
11166 assert!(!actuator_mutating_routes("/actuator", false).contains(&("POST", path)));
11167 }
11168 }
11169}
11170
11171#[cfg(test)]
11172mod health_indicator_tests {
11173 use super::*;
11174
11175 struct AlwaysUp;
11176 impl HealthIndicator for AlwaysUp {
11177 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11178 Box::pin(async { HealthCheckOutput::up() })
11179 }
11180 }
11181
11182 struct AlwaysDown;
11183 impl HealthIndicator for AlwaysDown {
11184 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11185 Box::pin(async { HealthCheckOutput::down() })
11186 }
11187 }
11188
11189 #[test]
11190 fn health_status_as_str_values() {
11191 assert_eq!(HealthStatus::Up.as_str(), "UP");
11192 assert_eq!(HealthStatus::Down.as_str(), "DOWN");
11193 assert_eq!(HealthStatus::OutOfService.as_str(), "OUT_OF_SERVICE");
11194 assert_eq!(HealthStatus::Unknown.as_str(), "UNKNOWN");
11195 }
11196
11197 #[test]
11198 fn health_status_is_healthy() {
11199 assert!(HealthStatus::Up.is_healthy());
11200 assert!(HealthStatus::Unknown.is_healthy());
11201 assert!(!HealthStatus::Down.is_healthy());
11202 assert!(!HealthStatus::OutOfService.is_healthy());
11203 }
11204
11205 #[test]
11206 fn aggregate_status_precedence() {
11207 assert_eq!(
11208 HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up]),
11209 HealthStatus::Up
11210 );
11211 assert_eq!(
11212 HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up, HealthStatus::Unknown]),
11213 HealthStatus::Unknown
11214 );
11215 assert_eq!(
11216 HealthIndicatorRegistry::aggregate_status(&[
11217 HealthStatus::Unknown,
11218 HealthStatus::OutOfService
11219 ]),
11220 HealthStatus::OutOfService
11221 );
11222 assert_eq!(
11223 HealthIndicatorRegistry::aggregate_status(&[
11224 HealthStatus::OutOfService,
11225 HealthStatus::Down
11226 ]),
11227 HealthStatus::Down
11228 );
11229 assert_eq!(
11230 HealthIndicatorRegistry::aggregate_status(&[]),
11231 HealthStatus::Up
11232 );
11233 }
11234
11235 #[tokio::test]
11236 async fn registry_run_all_collects_results() {
11237 let registry = HealthIndicatorRegistry::new();
11238 registry
11239 .register("svc_a", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11240 .unwrap();
11241 registry
11242 .register("svc_b", IndicatorGroup::HealthOnly, Arc::new(AlwaysDown))
11243 .unwrap();
11244
11245 let results = registry.run_all().await;
11246 assert!(
11247 results
11248 .iter()
11249 .any(|r| r.name == "svc_a" && r.output.status == HealthStatus::Up)
11250 );
11251 assert!(
11252 results
11253 .iter()
11254 .any(|r| r.name == "svc_b" && r.output.status == HealthStatus::Down)
11255 );
11256 }
11257
11258 #[tokio::test]
11259 async fn registry_run_readiness_filters_health_only() {
11260 let registry = HealthIndicatorRegistry::new();
11261 registry
11262 .register("probe_check", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
11263 .unwrap();
11264 registry
11265 .register(
11266 "health_only",
11267 IndicatorGroup::HealthOnly,
11268 Arc::new(AlwaysDown),
11269 )
11270 .unwrap();
11271
11272 let results = registry.run_readiness().await;
11273 assert_eq!(results.len(), 1);
11274 assert_eq!(results[0].name, "probe_check");
11275 }
11276
11277 struct CountingIndicator {
11279 calls: std::sync::atomic::AtomicUsize,
11280 delay: std::time::Duration,
11281 }
11282
11283 impl CountingIndicator {
11284 fn new(delay: std::time::Duration) -> Arc<Self> {
11285 Arc::new(Self {
11286 calls: std::sync::atomic::AtomicUsize::new(0),
11287 delay,
11288 })
11289 }
11290
11291 fn calls(&self) -> usize {
11292 self.calls.load(std::sync::atomic::Ordering::SeqCst)
11293 }
11294 }
11295
11296 impl HealthIndicator for CountingIndicator {
11297 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11298 Box::pin(async move {
11299 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11300 tokio::time::sleep(self.delay).await;
11301 HealthCheckOutput::up()
11302 })
11303 }
11304 }
11305
11306 #[tokio::test(start_paused = true)]
11307 async fn concurrent_runs_check_each_indicator_once_per_ttl() {
11308 let registry = HealthIndicatorRegistry::new();
11309 registry.set_cache_ttl(std::time::Duration::from_secs(1));
11310 let indicator = CountingIndicator::new(std::time::Duration::from_millis(20));
11311 registry
11312 .register("svc", IndicatorGroup::Readiness, indicator.clone())
11313 .unwrap();
11314
11315 let runs: Vec<_> = (0..32)
11316 .map(|i| {
11317 let registry = registry.clone();
11318 tokio::spawn(async move {
11319 if i % 2 == 0 {
11320 registry.run_readiness().await
11321 } else {
11322 registry.run_all().await
11323 }
11324 })
11325 })
11326 .collect();
11327 for run in runs {
11328 let results = run.await.unwrap();
11329 let svc = results.iter().find(|r| r.name == "svc").unwrap();
11330 assert_eq!(svc.output.status, HealthStatus::Up);
11331 }
11332 assert_eq!(indicator.calls(), 1);
11333
11334 tokio::time::advance(std::time::Duration::from_millis(1_001)).await;
11335 registry.run_readiness().await;
11336 assert_eq!(indicator.calls(), 2);
11337 }
11338
11339 #[tokio::test(start_paused = true)]
11340 async fn new_registry_does_not_cache() {
11341 let registry = HealthIndicatorRegistry::new();
11342 let indicator = CountingIndicator::new(std::time::Duration::ZERO);
11343 registry
11344 .register("svc", IndicatorGroup::Readiness, indicator.clone())
11345 .unwrap();
11346
11347 registry.run_readiness().await;
11348 registry.run_readiness().await;
11349
11350 assert_eq!(indicator.calls(), 2);
11351 }
11352
11353 #[tokio::test(start_paused = true)]
11354 async fn timed_out_result_is_cached_too() {
11355 let registry = HealthIndicatorRegistry::new();
11356 registry.set_cache_ttl(std::time::Duration::from_secs(1));
11357 let indicator = CountingIndicator::new(std::time::Duration::from_secs(3_600));
11358 registry
11359 .register("svc", IndicatorGroup::Readiness, indicator.clone())
11360 .unwrap();
11361
11362 let first = registry.run_readiness().await;
11363 let second = registry.run_readiness().await;
11364
11365 assert_eq!(first[0].output.status, HealthStatus::Unknown);
11366 assert_eq!(second[0].output.status, HealthStatus::Unknown);
11367 assert_eq!(indicator.calls(), 1);
11368 }
11369
11370 #[tokio::test]
11371 async fn timed_out_indicator_reports_unknown_with_flag() {
11372 struct SlowIndicator;
11373 impl HealthIndicator for SlowIndicator {
11374 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
11375 Box::pin(async {
11376 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
11377 HealthCheckOutput::up()
11378 })
11379 }
11380 fn timeout_ms(&self) -> u64 {
11381 5
11382 }
11383 }
11384 let registry = HealthIndicatorRegistry::new();
11385 registry
11386 .register("slow", IndicatorGroup::Readiness, Arc::new(SlowIndicator))
11387 .unwrap();
11388 let results = registry.run_all().await;
11389 let slow_res = results
11390 .iter()
11391 .find(|r| r.name == "slow")
11392 .expect("slow indicator not found");
11393 assert_eq!(slow_res.output.status, HealthStatus::Unknown);
11394 assert_eq!(
11395 slow_res.output.details.get("timed_out"),
11396 Some(&serde_json::Value::Bool(true))
11397 );
11398 }
11399
11400 #[tokio::test]
11401 #[allow(clippy::await_holding_lock)]
11402 async fn test_circuit_breakers_in_health_indicator_registry() {
11403 let _lock = crate::circuit_breaker::TEST_LOCK
11404 .lock()
11405 .unwrap_or_else(std::sync::PoisonError::into_inner);
11406 crate::circuit_breaker::global_registry().clear();
11407 let registry = HealthIndicatorRegistry::new();
11408 let breaker = crate::circuit_breaker::global_registry().get_or_create(
11409 "actuator_test_breaker",
11410 crate::circuit_breaker::CircuitBreakerPolicy {
11411 failure_ratio_threshold: 0.5,
11412 sample_window: std::time::Duration::from_secs(10),
11413 minimum_sample_count: 2,
11414 open_duration: std::time::Duration::from_secs(60),
11415 half_open_trial_count: 2,
11416 ..crate::circuit_breaker::CircuitBreakerPolicy::default()
11417 },
11418 );
11419
11420 let results = registry.run_all().await;
11421 let found = results
11422 .iter()
11423 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11424 assert!(found.is_some(), "Should find circuit breaker in run_all");
11425 let result = found.unwrap();
11426 assert_eq!(result.group, IndicatorGroup::HealthOnly);
11427 assert_eq!(result.output.status, HealthStatus::Up);
11428 assert_eq!(result.output.details.get("state").unwrap(), "CLOSED");
11429 assert_eq!(
11430 result.output.details.get("slow_call_ratio").unwrap(),
11431 &serde_json::json!(0.0)
11432 );
11433
11434 breaker.after_call(false);
11435 breaker.after_call(false);
11436 assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
11437
11438 let results = registry.run_all().await;
11439 let found = results
11440 .iter()
11441 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11442 assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11443 assert_eq!(found.unwrap().output.details.get("state").unwrap(), "OPEN");
11444
11445 {
11447 let mut inner = breaker.inner.lock().unwrap();
11448 inner.state = crate::circuit_breaker::CircuitState::HalfOpen;
11449 inner.half_open_in_flight = 0;
11450 inner.half_open_successes = 0;
11451 inner.half_open_failures = 0;
11452 }
11453 assert_eq!(
11454 breaker.state(),
11455 crate::circuit_breaker::CircuitState::HalfOpen
11456 );
11457
11458 let results = registry.run_all().await;
11459 let found = results
11460 .iter()
11461 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11462 assert_eq!(found.unwrap().output.status, HealthStatus::Down);
11463 assert_eq!(
11464 found.unwrap().output.details.get("state").unwrap(),
11465 "HALF_OPEN"
11466 );
11467
11468 let readiness_results = registry.run_readiness().await;
11469 let found_readiness = readiness_results
11470 .iter()
11471 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
11472 assert!(
11473 found_readiness.is_none(),
11474 "Should NOT find circuit breaker in run_readiness"
11475 );
11476 crate::circuit_breaker::global_registry().clear();
11477 }
11478}
11479
11480#[cfg(test)]
11481mod havoc_proptest {
11482 use super::*;
11483 use proptest::prelude::*;
11484
11485 proptest! {
11486 #![proptest_config(ProptestConfig::with_cases(1))]
11487 #[test]
11488 fn log_levels_memory_exhaustion(names in proptest::collection::vec(".*", 5000)) {
11489 let levels = LogLevels::new("info");
11490 for name in names {
11491 let _ = levels.set_logger_level(&name, "debug");
11492 }
11493 assert!(levels.logger_overrides().len() <= 1000, "Memory leak: unbounded loggers inserted");
11494 }
11495 }
11496}
11497
11498#[cfg(all(feature = "maud", feature = "htmx"))]
11501async fn ui_dashboard() -> impl IntoResponse {
11502 let html = maud::html! {
11503 (maud::DOCTYPE)
11504 html lang="en" {
11505 head {
11506 meta charset="utf-8";
11507 meta name="viewport" content="width=device-width, initial-scale=1";
11508 title { "Autumn Actuator Dashboard" }
11509 script src="/static/js/htmx.min.js" {}
11510 style {
11511 (crate::ui::tokens::TOKENS_CSS)
11512 "body { font-family: var(--font-family); background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }"
11513 "h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }"
11514 ".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }"
11515 ".card { background: var(--surface); padding: 1.5rem; border-radius: var(--radius); box-shadow: var(--shadow); }"
11516 ".card h2 { font-size: 1.125rem; font-weight: 500; margin-top: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }"
11517 ".stat { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }"
11518 ".stat-label { color: var(--text-muted); }"
11519 ".stat-value { font-weight: 500; }"
11520 ".task-item { border: 1px solid var(--border); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 0.75rem; }"
11521 ".task-name { font-weight: 600; display: block; margin-bottom: 0.25rem; }"
11522 ".task-meta { font-size: 0.875rem; color: var(--text-muted); }"
11523 ".badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }"
11524 ".badge-green { background: #dcfce7; color: #166534; }"
11525 ".badge-gray { background: #f3f4f6; color: #374151; }"
11526 ".badge-red { background: #fee2e2; color: #991b1b; }"
11527 }
11528 }
11529 body {
11530 h1 { "🍂 Autumn Actuator Dashboard" }
11531 div class="grid" {
11532 div class="card" hx-get="ui/metrics" hx-trigger="load, every 2s" {
11533 "Loading metrics..."
11534 }
11535 div class="card" hx-get="ui/tasks" hx-trigger="load, every 2s" {
11536 "Loading tasks..."
11537 }
11538 }
11539 }
11540 }
11541 };
11542 (
11543 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11544 html.into_string(),
11545 )
11546}
11547
11548#[cfg(not(all(feature = "maud", feature = "htmx")))]
11549async fn ui_dashboard() -> impl IntoResponse {
11550 (
11551 StatusCode::NOT_IMPLEMENTED,
11552 "Maud feature is required for the UI dashboard",
11553 )
11554}
11555
11556#[cfg(all(feature = "maud", feature = "htmx"))]
11557async fn ui_metrics<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11558 let metrics = state.metrics().snapshot();
11559 let uptime = state.uptime_display();
11560
11561 let html = maud::html! {
11562 h2 { "System Metrics" }
11563 div class="stat" {
11564 span class="stat-label" { "Uptime" }
11565 span class="stat-value" { (uptime) }
11566 }
11567 div class="stat" {
11568 span class="stat-label" { "Total Requests" }
11569 span class="stat-value" { (metrics.http.requests_total) }
11570 }
11571 div class="stat" {
11572 span class="stat-label" { "Active Requests" }
11573 span class="stat-value" { (metrics.http.requests_active) }
11574 }
11575 div class="stat" {
11576 span class="stat-label" { "P95 Latency" }
11577 span class="stat-value" { (metrics.http.latency_ms.p95) " ms" }
11578 }
11579 div class="stat" {
11580 span class="stat-label" { "P99 Latency" }
11581 span class="stat-value" { (metrics.http.latency_ms.p99) " ms" }
11582 }
11583 };
11584 (
11585 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11586 html.into_string(),
11587 )
11588}
11589
11590#[cfg(not(all(feature = "maud", feature = "htmx")))]
11591async fn ui_metrics<S: ProvideActuatorState>() -> impl IntoResponse {
11592 (
11593 StatusCode::NOT_IMPLEMENTED,
11594 "Maud feature is required for the UI dashboard",
11595 )
11596}
11597
11598#[cfg(all(feature = "maud", feature = "htmx"))]
11599async fn ui_tasks<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
11600 let tasks = state.task_registry().snapshot();
11601
11602 let html = maud::html! {
11603 h2 { "Background Tasks" }
11604 @if tasks.is_empty() {
11605 p class="stat-label" { "No tasks registered." }
11606 } @else {
11607 @for (name, task) in tasks.iter() {
11608 div class="task-item" {
11609 span class="task-name" { (name) }
11610 div class="task-meta" {
11611 @if task.status == "running" {
11612 span class="badge badge-green" { "Running" }
11613 } @else {
11614 span class="badge badge-gray" { "Idle" }
11615 }
11616 " "
11617 "Runs: " (task.total_runs)
11618 @if task.total_failures > 0 {
11619 " " span class="badge badge-red" { "Failures: " (task.total_failures) }
11620 }
11621 }
11622 }
11623 }
11624 }
11625 };
11626 (
11627 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
11628 html.into_string(),
11629 )
11630}
11631
11632#[cfg(not(all(feature = "maud", feature = "htmx")))]
11633async fn ui_tasks<S: ProvideActuatorState>() -> impl IntoResponse {
11634 (
11635 StatusCode::NOT_IMPLEMENTED,
11636 "Maud feature is required for the UI dashboard",
11637 )
11638}