1use std::collections::HashMap;
11use std::sync::{Arc, RwLock};
12
13use axum::Json;
14use axum::extract::{Path, State};
15use axum::http::StatusCode;
16use axum::response::IntoResponse;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Default)]
25pub struct A11yPosture {
26 pub lang_set: bool,
28 pub skip_link_present: bool,
30 pub landmark_regions_present: bool,
33}
34
35impl A11yPosture {
36 #[must_use]
38 pub const fn is_compliant(&self) -> bool {
39 self.lang_set && self.skip_link_present && self.landmark_regions_present
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MetricKind {
51 Counter,
53 Gauge,
55}
56
57impl MetricKind {
58 const fn as_str(&self) -> &'static str {
59 match self {
60 Self::Counter => "counter",
61 Self::Gauge => "gauge",
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
71pub struct MetricSample {
72 pub labels: Vec<(String, String)>,
74 pub value: f64,
76}
77
78#[derive(Debug, Clone)]
83pub struct MetricFamily {
84 pub name: String,
89 pub help: String,
91 pub kind: MetricKind,
93 pub samples: Vec<MetricSample>,
95}
96
97pub trait MetricsSource: Send + Sync + 'static {
119 fn collect(&self) -> Vec<MetricFamily>;
121}
122
123#[derive(Clone, Default)]
129pub struct MetricsSourceRegistry {
130 inner: Arc<RwLock<MetricsSourceRegistryInner>>,
131}
132
133#[derive(Default)]
134struct MetricsSourceRegistryInner {
135 sources: Vec<(String, Arc<dyn MetricsSource>)>,
137 error_counts: HashMap<String, u64>,
139}
140
141impl MetricsSourceRegistry {
142 #[must_use]
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn register(
157 &self,
158 name: impl Into<String>,
159 source: Arc<dyn MetricsSource>,
160 ) -> Result<(), String> {
161 let name = name.into();
162 {
163 let mut inner = self
164 .inner
165 .write()
166 .unwrap_or_else(std::sync::PoisonError::into_inner);
167 if inner.sources.iter().any(|(n, _)| n == &name) {
168 return Err(format!(
169 "MetricsSource '{name}' is already registered; skipping duplicate"
170 ));
171 }
172 inner.sources.push((name, source));
173 }
174 Ok(())
175 }
176
177 pub fn collect_all(&self) -> Vec<(String, Vec<MetricFamily>)> {
182 let sources: Vec<(String, Arc<dyn MetricsSource>)> = self
183 .inner
184 .read()
185 .unwrap_or_else(std::sync::PoisonError::into_inner)
186 .sources
187 .clone();
188
189 let mut results = Vec::with_capacity(sources.len());
190 let mut panicked = Vec::new();
191
192 for (name, source) in &sources {
193 let result =
194 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| source.collect()));
195 if let Ok(families) = result {
196 results.push((name.clone(), families));
197 } else {
198 tracing::error!(source_name = %name, "MetricsSource panicked during collection");
199 panicked.push(name.clone());
200 results.push((name.clone(), vec![]));
201 }
202 }
203
204 if !panicked.is_empty() {
205 let mut inner = self
206 .inner
207 .write()
208 .unwrap_or_else(std::sync::PoisonError::into_inner);
209 for name in panicked {
210 *inner.error_counts.entry(name).or_insert(0) += 1;
211 }
212 }
213
214 results
215 }
216
217 #[must_use]
219 pub fn error_counts(&self) -> HashMap<String, u64> {
220 self.inner
221 .read()
222 .unwrap_or_else(std::sync::PoisonError::into_inner)
223 .error_counts
224 .clone()
225 }
226
227 #[must_use]
229 pub fn source_names(&self) -> Vec<String> {
230 self.inner
231 .read()
232 .unwrap_or_else(std::sync::PoisonError::into_inner)
233 .sources
234 .iter()
235 .map(|(n, _)| n.clone())
236 .collect()
237 }
238
239 #[must_use]
241 pub fn is_empty(&self) -> bool {
242 self.inner
243 .read()
244 .unwrap_or_else(std::sync::PoisonError::into_inner)
245 .sources
246 .is_empty()
247 }
248}
249
250pub trait ProvideActuatorState {
256 fn metrics(&self) -> &crate::middleware::MetricsCollector;
259
260 fn log_levels(&self) -> &LogLevels;
263
264 fn task_registry(&self) -> &TaskRegistry;
267
268 fn job_registry(&self) -> &JobRegistry;
271
272 fn config_props(&self) -> &ConfigProperties;
275
276 fn profile(&self) -> &str;
279
280 fn uptime_display(&self) -> String;
283
284 #[cfg(feature = "ws")]
287 fn channels(&self) -> &crate::channels::Channels;
288
289 #[cfg(feature = "ws")]
291 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken;
292
293 #[cfg(feature = "db")]
296 fn pool(
297 &self,
298 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>;
299
300 #[cfg(feature = "db")]
303 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
304 None
305 }
306
307 fn a11y_posture(&self) -> A11yPosture {
313 A11yPosture::default()
314 }
315
316 fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
322 None
323 }
324
325 fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
331 None
332 }
333
334 fn health_detailed(&self) -> bool {
339 true
340 }
341
342 fn deploy_version(&self) -> String {
350 crate::canary::STABLE.to_owned()
351 }
352
353 #[cfg(feature = "http-client")]
354 fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
356 None
357 }
358
359 fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
365 None
366 }
367}
368
369#[derive(Clone)]
376pub struct LogLevels {
377 inner: Arc<RwLock<LogLevelsInner>>,
378}
379
380struct LogLevelsInner {
381 current_level: String,
383 logger_overrides: HashMap<String, String>,
385 reload_handle: Option<crate::telemetry::FilterReloadHandle>,
393}
394
395impl LogLevelsInner {
396 fn build_directive(&self) -> String {
400 let mut parts = Vec::new();
401 if !self.current_level.is_empty() {
402 parts.push(self.current_level.clone());
403 }
404 let mut targets: Vec<String> = self
405 .logger_overrides
406 .iter()
407 .filter(|(name, _)| name.as_str() != "root" && !name.is_empty())
408 .map(|(name, level)| format!("{name}={level}"))
409 .collect();
410 targets.sort();
411 parts.extend(targets);
412 parts.join(",")
413 }
414}
415
416fn parse_initial_directive(directive: &str) -> (String, HashMap<String, String>) {
435 let mut global = String::new();
436 let mut overrides = HashMap::new();
437 for segment in directive.split(',') {
438 let segment = segment.trim();
439 if segment.is_empty() {
440 continue;
441 }
442 if let Some((target, level)) = segment.rsplit_once('=') {
443 let target = target.trim();
444 let level = level.trim();
445 if target.is_empty() || target == "root" {
447 global = level.to_string();
448 } else {
449 overrides.insert(target.to_string(), level.to_string());
450 }
451 } else if is_tracing_level(segment) {
452 global = segment.to_string();
454 } else {
455 overrides.insert(segment.to_string(), "trace".to_string());
457 }
458 }
459 (global, overrides)
460}
461
462fn is_tracing_level(segment: &str) -> bool {
465 matches!(
466 segment.to_ascii_lowercase().as_str(),
467 "trace" | "debug" | "info" | "warn" | "error" | "off"
468 )
469}
470
471impl LogLevels {
472 #[must_use]
474 pub fn new(initial_level: &str) -> Self {
475 let (current_level, logger_overrides) = parse_initial_directive(initial_level);
476 Self {
477 inner: Arc::new(RwLock::new(LogLevelsInner {
478 current_level,
479 logger_overrides,
480 reload_handle: None,
481 })),
482 }
483 }
484
485 pub fn attach_reload_handle(&self, handle: crate::telemetry::FilterReloadHandle) {
491 if let Ok(mut guard) = self.inner.write() {
492 guard.reload_handle = Some(handle);
493 }
494 }
495
496 #[must_use]
500 pub fn reload_available(&self) -> bool {
501 self.inner
502 .read()
503 .is_ok_and(|guard| guard.reload_handle.is_some())
504 }
505
506 #[must_use]
508 pub fn current_level(&self) -> String {
509 self.inner
510 .read()
511 .map_or_else(|_| "info".to_string(), |guard| guard.current_level.clone())
512 }
513
514 #[must_use]
516 pub fn logger_overrides(&self) -> HashMap<String, String> {
517 self.inner
518 .read()
519 .map(|guard| guard.logger_overrides.clone())
520 .unwrap_or_default()
521 }
522
523 #[cfg(test)]
526 fn rebuilt_directive_for_test(&self) -> String {
527 self.inner
528 .read()
529 .map(|guard| guard.build_directive())
530 .unwrap_or_default()
531 }
532
533 pub fn set_logger_level(&self, name: &str, level: &str) -> LogLevelChange {
555 let Ok(mut guard) = self.inner.write() else {
556 return LogLevelChange::Rejected {
557 reason: "log level state lock poisoned".to_string(),
558 };
559 };
560 if guard.logger_overrides.len() >= 1000 && !guard.logger_overrides.contains_key(name) {
562 return LogLevelChange::Rejected {
563 reason: "too many logger overrides".to_string(),
564 };
565 }
566
567 let is_root = name == "root" || name.is_empty();
568 let previous_override = guard.logger_overrides.get(name).cloned();
569 let previous_current = guard.current_level.clone();
570
571 guard
572 .logger_overrides
573 .insert(name.to_string(), level.to_string());
574 let returned = if is_root {
576 guard.current_level = level.to_string();
577 Some(previous_current.clone())
578 } else {
579 previous_override.clone()
580 };
581
582 let directive = guard.build_directive();
583 let Some(handle) = guard.reload_handle.clone() else {
586 return LogLevelChange::Recorded { previous: returned };
587 };
588
589 match handle.apply_directive(&directive) {
590 Ok(()) => LogLevelChange::Applied { previous: returned },
591 Err(error) => {
592 match previous_override {
595 Some(prev) => {
596 guard.logger_overrides.insert(name.to_string(), prev);
597 }
598 None => {
599 guard.logger_overrides.remove(name);
600 }
601 }
602 if is_root {
603 guard.current_level = previous_current;
604 }
605 LogLevelChange::Rejected { reason: error }
606 }
607 }
608 }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
613#[must_use]
614pub enum LogLevelChange {
615 Applied {
618 previous: Option<String>,
620 },
621 Recorded {
624 previous: Option<String>,
626 },
627 Rejected {
630 reason: String,
632 },
633}
634
635impl LogLevelChange {
636 #[must_use]
638 pub fn previous(&self) -> Option<&str> {
639 match self {
640 Self::Applied { previous } | Self::Recorded { previous } => previous.as_deref(),
641 Self::Rejected { .. } => None,
642 }
643 }
644}
645
646impl std::fmt::Debug for LogLevels {
647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648 f.debug_struct("LogLevels")
649 .field("current_level", &self.current_level())
650 .finish()
651 }
652}
653
654#[derive(Debug, Clone, Serialize)]
656pub struct TaskStatus {
657 pub schedule: String,
659 pub coordination: crate::task::TaskCoordination,
661 pub scheduler_backend: String,
663 pub replica_id: String,
665 #[serde(skip_serializing_if = "Option::is_none")]
667 pub current_leader: Option<String>,
668 #[serde(skip_serializing_if = "Option::is_none")]
670 pub last_tick: Option<String>,
671 #[serde(skip_serializing_if = "Option::is_none")]
673 pub last_fired_at: Option<String>,
674 #[serde(skip_serializing_if = "Option::is_none")]
676 pub next_run_at: Option<String>,
677 pub status: String,
679 #[serde(skip_serializing_if = "Option::is_none")]
681 pub last_run: Option<String>,
682 #[serde(skip_serializing_if = "Option::is_none")]
684 pub last_duration_ms: Option<u64>,
685 #[serde(skip_serializing_if = "Option::is_none")]
687 pub last_result: Option<String>,
688 #[serde(skip_serializing_if = "Option::is_none")]
690 pub last_error: Option<String>,
691 pub total_runs: u64,
693 pub total_failures: u64,
695}
696
697#[derive(Clone)]
699pub struct TaskRegistry {
700 inner: Arc<RwLock<HashMap<String, TaskStatus>>>,
701}
702
703#[derive(Debug, Clone, Serialize)]
705pub struct JobStatus {
706 pub queued: u64,
708 pub in_flight: u64,
710 pub blocked_on_concurrency: u64,
712 pub total_successes: u64,
714 pub total_failures: u64,
716 pub dead_letters: u64,
718 pub total_deduplicated: u64,
720 #[serde(skip_serializing_if = "Option::is_none")]
722 pub last_error: Option<String>,
723}
724
725impl JobStatus {
726 const fn empty() -> Self {
727 Self {
728 queued: 0,
729 in_flight: 0,
730 blocked_on_concurrency: 0,
731 total_successes: 0,
732 total_failures: 0,
733 dead_letters: 0,
734 total_deduplicated: 0,
735 last_error: None,
736 }
737 }
738}
739
740#[derive(Debug, Clone, Serialize)]
750pub struct QueueStatus {
751 pub depth: u64,
753 pub oldest_waiting_age_ms: u64,
756}
757
758#[derive(Default)]
760struct QueueGaugeState {
761 name_to_queue: HashMap<String, String>,
763 waiting: HashMap<String, std::collections::VecDeque<u64>>,
771 surveyed: Option<HashMap<String, (u64, Option<u64>)>>,
779}
780
781fn now_epoch_ms() -> u64 {
782 std::time::SystemTime::now()
783 .duration_since(std::time::UNIX_EPOCH)
784 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
785}
786
787#[derive(Clone)]
789pub struct JobRegistry {
790 inner: Arc<RwLock<HashMap<String, JobStatus>>>,
791 queues: Arc<RwLock<QueueGaugeState>>,
792}
793
794impl JobRegistry {
795 #[must_use]
797 pub fn new() -> Self {
798 Self {
799 inner: Arc::new(RwLock::new(HashMap::new())),
800 queues: Arc::new(RwLock::new(QueueGaugeState::default())),
801 }
802 }
803
804 pub fn register(&self, name: &str) {
806 if let Ok(mut guard) = self.inner.write() {
807 guard.entry(name.to_string()).or_insert(JobStatus::empty());
808 }
809 }
810
811 pub fn register_on_queue(&self, name: &str, queue: &str) {
814 self.register(name);
815 if let Ok(mut guard) = self.queues.write() {
816 guard
817 .name_to_queue
818 .insert(name.to_string(), queue.to_string());
819 guard.waiting.entry(queue.to_string()).or_default();
820 }
821 }
822
823 fn queue_for(&self, name: &str) -> String {
825 self.queues
826 .read()
827 .ok()
828 .and_then(|g| g.name_to_queue.get(name).cloned())
829 .unwrap_or_else(|| "default".to_string())
830 }
831
832 #[must_use]
840 pub fn queue_snapshot(&self) -> HashMap<String, QueueStatus> {
841 let now = now_epoch_ms();
842 self.queues
843 .read()
844 .map(|g| {
845 if let Some(surveyed) = &g.surveyed {
846 return g
851 .waiting
852 .keys()
853 .chain(surveyed.keys())
854 .cloned()
855 .collect::<std::collections::HashSet<String>>()
856 .into_iter()
857 .map(|queue| {
858 let (depth, oldest_ready_at) =
859 surveyed.get(&queue).copied().unwrap_or((0, None));
860 let oldest_waiting_age_ms =
861 oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
862 (
863 queue,
864 QueueStatus {
865 depth,
866 oldest_waiting_age_ms,
867 },
868 )
869 })
870 .collect();
871 }
872 g.waiting
873 .iter()
874 .map(|(queue, waiting)| {
875 let mut depth = 0u64;
879 let mut oldest_ready_at: Option<u64> = None;
880 for ready_at in waiting {
881 if *ready_at <= now {
882 depth += 1;
883 oldest_ready_at =
884 Some(oldest_ready_at.map_or(*ready_at, |o| o.min(*ready_at)));
885 }
886 }
887 let oldest_waiting_age_ms =
888 oldest_ready_at.map_or(0, |ts| now.saturating_sub(ts));
889 (
890 queue.clone(),
891 QueueStatus {
892 depth,
893 oldest_waiting_age_ms,
894 },
895 )
896 })
897 .collect()
898 })
899 .unwrap_or_default()
900 }
901
902 pub fn record_enqueue(&self, name: &str) {
904 self.record_enqueue_at(name, now_epoch_ms());
905 }
906
907 pub fn record_enqueue_scheduled(&self, name: &str, ready_at_ms: u64) {
913 self.record_enqueue_at(name, ready_at_ms);
914 }
915
916 fn record_enqueue_at(&self, name: &str, ready_at_ms: u64) {
919 if let Ok(mut guard) = self.inner.write() {
920 let status = guard.entry(name.to_string()).or_insert(JobStatus::empty());
921 status.queued = status.queued.saturating_add(1);
922 }
923 let queue = self.queue_for(name);
924 if let Ok(mut guard) = self.queues.write() {
925 guard
926 .waiting
927 .entry(queue)
928 .or_default()
929 .push_back(ready_at_ms);
930 }
931 }
932
933 pub fn record_deduplicated(&self, name: &str, had_enqueue_mark: bool, was_scheduled: bool) {
954 if let Ok(mut guard) = self.inner.write()
955 && let Some(status) = guard.get_mut(name)
956 {
957 status.queued = status.queued.saturating_sub(1);
958 status.total_deduplicated = status.total_deduplicated.saturating_add(1);
959 }
960 if !had_enqueue_mark {
961 return;
962 }
963 self.pop_waiting(name, !was_scheduled);
967 }
968
969 pub fn record_concurrency_blocked(&self, name: &str) {
971 if let Ok(mut guard) = self.inner.write()
972 && let Some(status) = guard.get_mut(name)
973 {
974 status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_add(1);
975 }
976 }
977
978 pub fn record_concurrency_unblocked(&self, name: &str) {
980 if let Ok(mut guard) = self.inner.write()
981 && let Some(status) = guard.get_mut(name)
982 {
983 status.blocked_on_concurrency = status.blocked_on_concurrency.saturating_sub(1);
984 }
985 }
986
987 pub fn set_concurrency_blocked_counts(&self, counts: &HashMap<String, u64>) {
993 if let Ok(mut guard) = self.inner.write() {
994 for (name, status) in guard.iter_mut() {
995 status.blocked_on_concurrency = counts.get(name).copied().unwrap_or(0);
996 }
997 }
998 }
999
1000 pub fn set_queue_depth_gauges(&self, per_queue: &HashMap<String, (u64, Option<u64>)>) {
1013 if let Ok(mut guard) = self.queues.write() {
1014 guard.surveyed = Some(per_queue.clone());
1015 }
1016 }
1017
1018 pub fn set_queued_counts(&self, counts: &HashMap<String, u64>) {
1027 if let Ok(mut guard) = self.inner.write() {
1028 for (name, status) in guard.iter_mut() {
1029 status.queued = counts.get(name).copied().unwrap_or(0);
1030 }
1031 }
1032 }
1033
1034 pub fn record_start(&self, name: &str) {
1036 if let Ok(mut guard) = self.inner.write()
1037 && let Some(status) = guard.get_mut(name)
1038 {
1039 status.queued = status.queued.saturating_sub(1);
1040 status.in_flight = status.in_flight.saturating_add(1);
1041 }
1042 self.pop_waiting(name, true);
1043 }
1044
1045 pub fn record_cancel(&self, name: &str) {
1047 if let Ok(mut guard) = self.inner.write()
1048 && let Some(status) = guard.get_mut(name)
1049 {
1050 status.queued = status.queued.saturating_sub(1);
1051 }
1052 self.pop_waiting(name, true);
1053 }
1054
1055 pub fn record_cancel_scheduled(&self, name: &str) {
1061 if let Ok(mut guard) = self.inner.write()
1062 && let Some(status) = guard.get_mut(name)
1063 {
1064 status.queued = status.queued.saturating_sub(1);
1065 }
1066 self.pop_waiting(name, false);
1067 }
1068
1069 fn pop_waiting(&self, name: &str, prefer_ready: bool) {
1077 let queue = self.queue_for(name);
1078 let now = now_epoch_ms();
1079 if let Ok(mut guard) = self.queues.write()
1080 && let Some(waiting) = guard.waiting.get_mut(&queue)
1081 {
1082 let idx = waiting
1083 .iter()
1084 .position(|ready_at| (*ready_at <= now) == prefer_ready)
1085 .or(if waiting.is_empty() { None } else { Some(0) });
1086 if let Some(idx) = idx {
1087 waiting.remove(idx);
1088 }
1089 }
1090 }
1091
1092 pub fn record_success(&self, name: &str) {
1094 if let Ok(mut guard) = self.inner.write()
1095 && let Some(status) = guard.get_mut(name)
1096 {
1097 status.in_flight = status.in_flight.saturating_sub(1);
1098 status.total_successes = status.total_successes.saturating_add(1);
1099 status.last_error = None;
1100 }
1101 }
1102
1103 pub fn record_retry(&self, name: &str, error: &str, _attempt: u32) {
1105 if let Ok(mut guard) = self.inner.write()
1106 && let Some(status) = guard.get_mut(name)
1107 {
1108 status.in_flight = status.in_flight.saturating_sub(1);
1109 status.last_error = Some(error.to_string());
1110 }
1111 }
1112
1113 pub fn record_failure(&self, name: &str, error: String, dead_lettered: bool) {
1115 if let Ok(mut guard) = self.inner.write()
1116 && let Some(status) = guard.get_mut(name)
1117 {
1118 status.in_flight = status.in_flight.saturating_sub(1);
1119 status.total_failures = status.total_failures.saturating_add(1);
1120 status.last_error = Some(error);
1121 if dead_lettered {
1122 status.dead_letters = status.dead_letters.saturating_add(1);
1123 }
1124 }
1125 }
1126
1127 #[must_use]
1129 pub fn snapshot(&self) -> HashMap<String, JobStatus> {
1130 self.inner.read().map(|g| g.clone()).unwrap_or_default()
1131 }
1132}
1133
1134impl Default for JobRegistry {
1135 fn default() -> Self {
1136 Self::new()
1137 }
1138}
1139
1140impl TaskRegistry {
1141 #[must_use]
1143 pub fn new() -> Self {
1144 Self {
1145 inner: Arc::new(RwLock::new(HashMap::new())),
1146 }
1147 }
1148
1149 pub fn register(&self, name: &str, schedule: &str) {
1151 self.register_scheduled(
1152 name,
1153 schedule,
1154 crate::task::TaskCoordination::Fleet,
1155 "in_process",
1156 "unknown",
1157 );
1158 }
1159
1160 pub fn register_scheduled(
1162 &self,
1163 name: &str,
1164 schedule: &str,
1165 coordination: crate::task::TaskCoordination,
1166 scheduler_backend: &str,
1167 replica_id: &str,
1168 ) {
1169 let Ok(mut guard) = self.inner.write() else {
1170 return;
1171 };
1172 guard.insert(
1173 name.to_string(),
1174 TaskStatus {
1175 schedule: schedule.to_string(),
1176 coordination,
1177 scheduler_backend: scheduler_backend.to_string(),
1178 replica_id: replica_id.to_string(),
1179 current_leader: None,
1180 last_tick: None,
1181 last_fired_at: None,
1182 next_run_at: None,
1183 status: "idle".to_string(),
1184 last_run: None,
1185 last_duration_ms: None,
1186 last_result: None,
1187 last_error: None,
1188 total_runs: 0,
1189 total_failures: 0,
1190 },
1191 );
1192 }
1193
1194 pub fn record_leader(&self, name: &str, leader_id: &str, tick_key: &str) {
1196 let Ok(mut guard) = self.inner.write() else {
1197 return;
1198 };
1199 let Some(task) = guard.get_mut(name) else {
1200 return;
1201 };
1202 task.current_leader = Some(leader_id.to_string());
1203 task.last_tick = Some(tick_key.to_string());
1204 }
1205
1206 pub fn record_start(&self, name: &str) {
1208 let Ok(mut guard) = self.inner.write() else {
1209 return;
1210 };
1211 let Some(task) = guard.get_mut(name) else {
1212 return;
1213 };
1214 task.status = "running".to_string();
1215 task.next_run_at = None;
1216 }
1217
1218 pub fn record_next_run_at(&self, name: &str, next_run_at: &str) {
1220 let Ok(mut guard) = self.inner.write() else {
1221 return;
1222 };
1223 let Some(task) = guard.get_mut(name) else {
1224 return;
1225 };
1226 task.next_run_at = Some(next_run_at.to_string());
1227 }
1228
1229 pub fn record_success(&self, name: &str, duration_ms: u64) {
1231 let Ok(mut guard) = self.inner.write() else {
1232 return;
1233 };
1234 let Some(task) = guard.get_mut(name) else {
1235 return;
1236 };
1237 task.status = "idle".to_string();
1238 let now = chrono::Utc::now().to_rfc3339();
1239 task.last_run = Some(now.clone());
1240 task.last_fired_at = Some(now);
1241 task.last_duration_ms = Some(duration_ms);
1242 task.last_result = Some("ok".to_string());
1243 task.last_error = None;
1244 task.total_runs += 1;
1245 }
1246
1247 pub fn record_failure(&self, name: &str, duration_ms: u64, error: &str) {
1249 let Ok(mut guard) = self.inner.write() else {
1250 return;
1251 };
1252 let Some(task) = guard.get_mut(name) else {
1253 return;
1254 };
1255 task.status = "idle".to_string();
1256 let now = chrono::Utc::now().to_rfc3339();
1257 task.last_run = Some(now.clone());
1258 task.last_fired_at = Some(now);
1259 task.last_duration_ms = Some(duration_ms);
1260 task.last_result = Some("failed".to_string());
1261 task.last_error = Some(error.to_string());
1262 task.total_runs += 1;
1263 task.total_failures += 1;
1264 }
1265
1266 #[must_use]
1268 pub fn snapshot(&self) -> HashMap<String, TaskStatus> {
1269 self.inner
1270 .read()
1271 .map(|guard| guard.clone())
1272 .unwrap_or_default()
1273 }
1274}
1275
1276impl Default for TaskRegistry {
1277 fn default() -> Self {
1278 Self::new()
1279 }
1280}
1281
1282impl std::fmt::Debug for TaskRegistry {
1283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1284 f.debug_struct("TaskRegistry")
1285 .field("count", &self.snapshot().len())
1286 .finish()
1287 }
1288}
1289
1290#[derive(Debug, Clone, Serialize, Deserialize)]
1292pub struct ConfigProperty {
1293 pub value: serde_json::Value,
1295 pub source: String,
1297}
1298
1299#[derive(Debug, Clone, Default)]
1301pub struct ConfigProperties {
1302 inner: Arc<RwLock<HashMap<String, ConfigProperty>>>,
1303}
1304
1305impl ConfigProperties {
1306 #[must_use]
1308 #[allow(clippy::too_many_lines)]
1309 pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
1310 let profile = config.profile.as_deref().unwrap_or("default");
1311 let defaults = crate::config::AutumnConfig::default();
1312
1313 let mut props = HashMap::with_capacity(32);
1315 let profile_str = profile.to_string();
1316
1317 Self::track_server_props(&mut props, config, &defaults, &profile_str);
1318 Self::track_db_props(&mut props, config, &defaults, &profile_str);
1319 Self::track_log_props(&mut props, config, &defaults, &profile_str);
1320 Self::track_telemetry_props(&mut props, config, &defaults, &profile_str);
1321 Self::track_health_props(&mut props, config, &defaults, &profile_str);
1322 Self::track_actuator_props(&mut props, config, &defaults, &profile_str);
1323 Self::track_session_props(&mut props, config, &defaults, &profile_str);
1324 Self::track_channels_props(&mut props, config, &defaults, &profile_str);
1325
1326 Self {
1327 inner: Arc::new(RwLock::new(props)),
1328 }
1329 }
1330
1331 fn track_server_props(
1332 props: &mut HashMap<String, ConfigProperty>,
1333 config: &crate::config::AutumnConfig,
1334 defaults: &crate::config::AutumnConfig,
1335 profile_str: &str,
1336 ) {
1337 Self::track_property(
1338 props,
1339 "server.host",
1340 &config.server.host,
1341 &defaults.server.host,
1342 profile_str,
1343 );
1344 Self::track_property(
1345 props,
1346 "server.port",
1347 &config.server.port.to_string(),
1348 &defaults.server.port.to_string(),
1349 profile_str,
1350 );
1351 Self::track_property(
1352 props,
1353 "server.shutdown_timeout_secs",
1354 &config.server.shutdown_timeout_secs.to_string(),
1355 &defaults.server.shutdown_timeout_secs.to_string(),
1356 profile_str,
1357 );
1358 }
1359
1360 fn track_db_props(
1361 props: &mut HashMap<String, ConfigProperty>,
1362 config: &crate::config::AutumnConfig,
1363 defaults: &crate::config::AutumnConfig,
1364 profile_str: &str,
1365 ) {
1366 let db_url = config.database.url.as_deref().unwrap_or("").to_string();
1367 let primary_url = config
1368 .database
1369 .primary_url
1370 .as_deref()
1371 .unwrap_or("")
1372 .to_string();
1373 let replica_url = config
1374 .database
1375 .replica_url
1376 .as_deref()
1377 .unwrap_or("")
1378 .to_string();
1379 Self::track_property(props, "database.url", &db_url, "", profile_str);
1380 Self::track_property(props, "database.primary_url", &primary_url, "", profile_str);
1381 Self::track_property(props, "database.replica_url", &replica_url, "", profile_str);
1382 Self::track_property(
1383 props,
1384 "database.pool_size",
1385 &config.database.pool_size.to_string(),
1386 &defaults.database.pool_size.to_string(),
1387 profile_str,
1388 );
1389 Self::track_property(
1390 props,
1391 "database.primary_pool_size",
1392 &config.database.effective_primary_pool_size().to_string(),
1393 &defaults.database.effective_primary_pool_size().to_string(),
1394 profile_str,
1395 );
1396 Self::track_property(
1397 props,
1398 "database.replica_pool_size",
1399 &config.database.effective_replica_pool_size().to_string(),
1400 &defaults.database.effective_replica_pool_size().to_string(),
1401 profile_str,
1402 );
1403 Self::track_property(
1404 props,
1405 "database.replica_fallback",
1406 &format!("{:?}", config.database.replica_fallback),
1407 &format!("{:?}", defaults.database.replica_fallback),
1408 profile_str,
1409 );
1410 }
1411
1412 fn track_log_props(
1413 props: &mut HashMap<String, ConfigProperty>,
1414 config: &crate::config::AutumnConfig,
1415 defaults: &crate::config::AutumnConfig,
1416 profile_str: &str,
1417 ) {
1418 Self::track_property(
1419 props,
1420 "log.level",
1421 &config.log.level,
1422 &defaults.log.level,
1423 profile_str,
1424 );
1425 Self::track_property(
1426 props,
1427 "log.format",
1428 &format!("{:?}", config.log.format),
1429 &format!("{:?}", defaults.log.format),
1430 profile_str,
1431 );
1432 Self::track_property(
1433 props,
1434 "log.capture.enabled",
1435 &config.log.capture.enabled.to_string(),
1436 &defaults.log.capture.enabled.to_string(),
1437 profile_str,
1438 );
1439 Self::track_property(
1440 props,
1441 "log.capture.capacity",
1442 &config.log.capture.capacity.to_string(),
1443 &defaults.log.capture.capacity.to_string(),
1444 profile_str,
1445 );
1446 }
1447
1448 fn track_telemetry_props(
1449 props: &mut HashMap<String, ConfigProperty>,
1450 config: &crate::config::AutumnConfig,
1451 defaults: &crate::config::AutumnConfig,
1452 profile_str: &str,
1453 ) {
1454 Self::track_property(
1455 props,
1456 "telemetry.enabled",
1457 &config.telemetry.enabled.to_string(),
1458 &defaults.telemetry.enabled.to_string(),
1459 profile_str,
1460 );
1461 Self::track_property(
1462 props,
1463 "telemetry.service_name",
1464 &config.telemetry.service_name,
1465 &defaults.telemetry.service_name,
1466 profile_str,
1467 );
1468 Self::track_property(
1469 props,
1470 "telemetry.service_namespace",
1471 config.telemetry.service_namespace.as_deref().unwrap_or(""),
1472 defaults
1473 .telemetry
1474 .service_namespace
1475 .as_deref()
1476 .unwrap_or(""),
1477 profile_str,
1478 );
1479 Self::track_property(
1480 props,
1481 "telemetry.service_version",
1482 &config.telemetry.service_version,
1483 &defaults.telemetry.service_version,
1484 profile_str,
1485 );
1486 Self::track_property(
1487 props,
1488 "telemetry.environment",
1489 &config.telemetry.environment,
1490 &defaults.telemetry.environment,
1491 profile_str,
1492 );
1493 Self::track_property(
1494 props,
1495 "telemetry.otlp_endpoint",
1496 config.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
1497 defaults.telemetry.otlp_endpoint.as_deref().unwrap_or(""),
1498 profile_str,
1499 );
1500 Self::track_property(
1501 props,
1502 "telemetry.protocol",
1503 &format!("{:?}", config.telemetry.protocol),
1504 &format!("{:?}", defaults.telemetry.protocol),
1505 profile_str,
1506 );
1507 Self::track_property(
1508 props,
1509 "telemetry.strict",
1510 &config.telemetry.strict.to_string(),
1511 &defaults.telemetry.strict.to_string(),
1512 profile_str,
1513 );
1514 }
1515
1516 fn track_health_props(
1517 props: &mut HashMap<String, ConfigProperty>,
1518 config: &crate::config::AutumnConfig,
1519 defaults: &crate::config::AutumnConfig,
1520 profile_str: &str,
1521 ) {
1522 Self::track_property(
1523 props,
1524 "health.path",
1525 &config.health.path,
1526 &defaults.health.path,
1527 profile_str,
1528 );
1529 Self::track_property(
1530 props,
1531 "health.live_path",
1532 &config.health.live_path,
1533 &defaults.health.live_path,
1534 profile_str,
1535 );
1536 Self::track_property(
1537 props,
1538 "health.ready_path",
1539 &config.health.ready_path,
1540 &defaults.health.ready_path,
1541 profile_str,
1542 );
1543 Self::track_property(
1544 props,
1545 "health.startup_path",
1546 &config.health.startup_path,
1547 &defaults.health.startup_path,
1548 profile_str,
1549 );
1550 Self::track_property(
1551 props,
1552 "health.detailed",
1553 &config.health.detailed.to_string(),
1554 &defaults.health.detailed.to_string(),
1555 profile_str,
1556 );
1557 }
1558
1559 fn track_actuator_props(
1560 props: &mut HashMap<String, ConfigProperty>,
1561 config: &crate::config::AutumnConfig,
1562 defaults: &crate::config::AutumnConfig,
1563 profile_str: &str,
1564 ) {
1565 Self::track_property(
1566 props,
1567 "actuator.prefix",
1568 &config.actuator.prefix,
1569 &defaults.actuator.prefix,
1570 profile_str,
1571 );
1572 Self::track_property(
1573 props,
1574 "actuator.sensitive",
1575 &config.actuator.sensitive.to_string(),
1576 &defaults.actuator.sensitive.to_string(),
1577 profile_str,
1578 );
1579 Self::track_property(
1580 props,
1581 "actuator.prometheus",
1582 &config.actuator.prometheus.to_string(),
1583 &defaults.actuator.prometheus.to_string(),
1584 profile_str,
1585 );
1586 }
1587
1588 fn track_session_props(
1589 props: &mut HashMap<String, ConfigProperty>,
1590 config: &crate::config::AutumnConfig,
1591 defaults: &crate::config::AutumnConfig,
1592 profile_str: &str,
1593 ) {
1594 Self::track_property(
1595 props,
1596 "session.backend",
1597 &format!("{:?}", config.session.backend),
1598 &format!("{:?}", defaults.session.backend),
1599 profile_str,
1600 );
1601 Self::track_property(
1602 props,
1603 "session.cookie_name",
1604 &config.session.cookie_name,
1605 &defaults.session.cookie_name,
1606 profile_str,
1607 );
1608 Self::track_property(
1609 props,
1610 "session.max_age_secs",
1611 &config.session.max_age_secs.to_string(),
1612 &defaults.session.max_age_secs.to_string(),
1613 profile_str,
1614 );
1615 Self::track_property(
1616 props,
1617 "session.secure",
1618 &config.session.secure.to_string(),
1619 &defaults.session.secure.to_string(),
1620 profile_str,
1621 );
1622 Self::track_property(
1623 props,
1624 "session.same_site",
1625 &config.session.same_site,
1626 &defaults.session.same_site,
1627 profile_str,
1628 );
1629 Self::track_property(
1630 props,
1631 "session.http_only",
1632 &config.session.http_only.to_string(),
1633 &defaults.session.http_only.to_string(),
1634 profile_str,
1635 );
1636 Self::track_property(
1637 props,
1638 "session.path",
1639 &config.session.path,
1640 &defaults.session.path,
1641 profile_str,
1642 );
1643 Self::track_property(
1644 props,
1645 "session.allow_memory_in_production",
1646 &config.session.allow_memory_in_production.to_string(),
1647 &defaults.session.allow_memory_in_production.to_string(),
1648 profile_str,
1649 );
1650 Self::track_property(
1651 props,
1652 "session.redis.url",
1653 config.session.redis.url.as_deref().unwrap_or(""),
1654 defaults.session.redis.url.as_deref().unwrap_or(""),
1655 profile_str,
1656 );
1657 Self::track_property(
1658 props,
1659 "session.redis.key_prefix",
1660 &config.session.redis.key_prefix,
1661 &defaults.session.redis.key_prefix,
1662 profile_str,
1663 );
1664 }
1665
1666 fn track_channels_props(
1667 props: &mut HashMap<String, ConfigProperty>,
1668 config: &crate::config::AutumnConfig,
1669 defaults: &crate::config::AutumnConfig,
1670 profile_str: &str,
1671 ) {
1672 Self::track_property(
1673 props,
1674 "channels.backend",
1675 &format!("{:?}", config.channels.backend),
1676 &format!("{:?}", defaults.channels.backend),
1677 profile_str,
1678 );
1679 Self::track_property(
1680 props,
1681 "channels.capacity",
1682 &config.channels.capacity.to_string(),
1683 &defaults.channels.capacity.to_string(),
1684 profile_str,
1685 );
1686 Self::track_property(
1687 props,
1688 "channels.redis.url",
1689 config.channels.redis.url.as_deref().unwrap_or(""),
1690 defaults.channels.redis.url.as_deref().unwrap_or(""),
1691 profile_str,
1692 );
1693 Self::track_property(
1694 props,
1695 "channels.redis.key_prefix",
1696 &config.channels.redis.key_prefix,
1697 &defaults.channels.redis.key_prefix,
1698 profile_str,
1699 );
1700 }
1701
1702 fn track_property(
1703 props: &mut HashMap<String, ConfigProperty>,
1704 key: &str,
1705 value: &str,
1706 default_value: &str,
1707 profile: &str,
1708 ) {
1709 let env_key = format!("AUTUMN_{}", key.replace('.', "__").to_uppercase());
1711 let source = if std::env::var(&env_key).is_ok() {
1712 env_key
1713 } else if value != default_value && (profile == "dev" || profile == "prod") {
1714 format!("profile_default:{profile}")
1715 } else if value != default_value {
1716 "autumn.toml".to_string()
1717 } else {
1718 "default".to_string()
1719 };
1720
1721 let display_value = if should_redact(key) {
1722 serde_json::Value::String("****".into())
1723 } else {
1724 serde_json::Value::String(value.to_string())
1725 };
1726
1727 props.insert(
1728 key.to_string(),
1729 ConfigProperty {
1730 value: display_value,
1731 source,
1732 },
1733 );
1734 }
1735
1736 #[must_use]
1738 pub fn snapshot(&self) -> HashMap<String, ConfigProperty> {
1739 self.inner
1740 .read()
1741 .map(|guard| guard.clone())
1742 .unwrap_or_default()
1743 }
1744}
1745
1746#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1753pub enum HealthStatus {
1754 #[serde(rename = "UP")]
1756 Up,
1757 #[serde(rename = "DOWN")]
1759 Down,
1760 #[serde(rename = "OUT_OF_SERVICE")]
1762 OutOfService,
1763 #[serde(rename = "UNKNOWN")]
1765 Unknown,
1766}
1767
1768impl HealthStatus {
1769 #[must_use]
1771 pub const fn as_str(self) -> &'static str {
1772 match self {
1773 Self::Up => "UP",
1774 Self::Down => "DOWN",
1775 Self::OutOfService => "OUT_OF_SERVICE",
1776 Self::Unknown => "UNKNOWN",
1777 }
1778 }
1779
1780 #[must_use]
1783 pub const fn is_healthy(self) -> bool {
1784 matches!(self, Self::Up | Self::Unknown)
1785 }
1786}
1787
1788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1793pub enum IndicatorGroup {
1794 Readiness,
1796 HealthOnly,
1798}
1799
1800#[derive(Debug, Clone)]
1802pub struct HealthCheckOutput {
1803 pub status: HealthStatus,
1805 pub details: HashMap<String, serde_json::Value>,
1807}
1808
1809impl HealthCheckOutput {
1810 #[must_use]
1812 pub fn up() -> Self {
1813 Self {
1814 status: HealthStatus::Up,
1815 details: HashMap::new(),
1816 }
1817 }
1818
1819 #[must_use]
1821 pub fn down() -> Self {
1822 Self {
1823 status: HealthStatus::Down,
1824 details: HashMap::new(),
1825 }
1826 }
1827
1828 #[must_use]
1830 pub fn with_details(mut self, details: HashMap<String, serde_json::Value>) -> Self {
1831 self.details = details;
1832 self
1833 }
1834}
1835
1836pub trait HealthIndicator: Send + Sync + 'static {
1859 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput>;
1865
1866 fn timeout_ms(&self) -> u64 {
1868 2000
1869 }
1870
1871 fn group(&self) -> IndicatorGroup {
1873 IndicatorGroup::Readiness
1874 }
1875}
1876
1877#[derive(Debug, Clone)]
1880pub struct HealthRunResult {
1881 pub name: String,
1883 pub group: IndicatorGroup,
1885 pub output: HealthCheckOutput,
1887}
1888
1889type IndicatorList = Vec<(String, IndicatorGroup, Arc<dyn HealthIndicator>)>;
1890
1891#[derive(Clone, Default)]
1897pub struct HealthIndicatorRegistry {
1898 inner: Arc<RwLock<IndicatorList>>,
1899}
1900
1901impl HealthIndicatorRegistry {
1902 #[must_use]
1904 pub fn new() -> Self {
1905 Self::default()
1906 }
1907
1908 pub fn register(
1916 &self,
1917 name: impl Into<String>,
1918 group: IndicatorGroup,
1919 indicator: Arc<dyn HealthIndicator>,
1920 ) -> Result<(), String> {
1921 let name = name.into();
1922 let mut inner = self
1923 .inner
1924 .write()
1925 .unwrap_or_else(std::sync::PoisonError::into_inner);
1926 if inner.iter().any(|(n, _, _)| n == &name) {
1927 return Err(format!(
1928 "HealthIndicator '{name}' is already registered; skipping duplicate"
1929 ));
1930 }
1931 inner.push((name, group, indicator));
1932 drop(inner);
1933 Ok(())
1934 }
1935
1936 #[must_use]
1938 pub fn is_empty(&self) -> bool {
1939 self.inner
1940 .read()
1941 .unwrap_or_else(std::sync::PoisonError::into_inner)
1942 .is_empty()
1943 }
1944
1945 pub async fn run_all(&self) -> Vec<HealthRunResult> {
1950 let entries = self
1951 .inner
1952 .read()
1953 .unwrap_or_else(std::sync::PoisonError::into_inner)
1954 .clone();
1955
1956 let mut results = futures::future::join_all(entries.into_iter().map(
1957 |(name, group, indicator)| async move {
1958 let output = run_with_timeout(indicator.as_ref()).await;
1959 HealthRunResult {
1960 name,
1961 group,
1962 output,
1963 }
1964 },
1965 ))
1966 .await;
1967
1968 for breaker in crate::circuit_breaker::global_registry().all_breakers() {
1969 let state = breaker.state();
1970 let status = match state {
1971 crate::circuit_breaker::CircuitState::Open
1972 | crate::circuit_breaker::CircuitState::HalfOpen => HealthStatus::Down,
1973 crate::circuit_breaker::CircuitState::Closed => HealthStatus::Up,
1974 };
1975
1976 let mut details = HashMap::new();
1977 details.insert(
1978 "state".to_string(),
1979 serde_json::Value::String(state.as_str().to_string()),
1980 );
1981 if let Some(ratio_num) = serde_json::Number::from_f64(breaker.failure_ratio()) {
1982 details.insert(
1983 "failure_ratio".to_string(),
1984 serde_json::Value::Number(ratio_num),
1985 );
1986 }
1987
1988 results.push(HealthRunResult {
1989 name: format!("circuit_breaker.{}", breaker.name()),
1990 group: IndicatorGroup::HealthOnly,
1991 output: HealthCheckOutput { status, details },
1992 });
1993 }
1994
1995 results
1996 }
1997
1998 pub async fn run_readiness(&self) -> Vec<HealthRunResult> {
2003 let entries = self
2005 .inner
2006 .read()
2007 .unwrap_or_else(std::sync::PoisonError::into_inner)
2008 .clone();
2009
2010 futures::future::join_all(
2011 entries
2012 .into_iter()
2013 .filter(|(_, g, _)| *g == IndicatorGroup::Readiness)
2014 .map(|(name, group, indicator)| async move {
2015 let output = run_with_timeout(indicator.as_ref()).await;
2016 HealthRunResult {
2017 name,
2018 group,
2019 output,
2020 }
2021 }),
2022 )
2023 .await
2024 }
2025
2026 #[must_use]
2031 pub fn aggregate_status(statuses: &[HealthStatus]) -> HealthStatus {
2032 let mut overall = HealthStatus::Up;
2033 for &s in statuses {
2034 overall = match (overall, s) {
2035 (_, HealthStatus::Down) | (HealthStatus::Down, _) => HealthStatus::Down,
2036 (_, HealthStatus::OutOfService) | (HealthStatus::OutOfService, _) => {
2037 HealthStatus::OutOfService
2038 }
2039 (_, HealthStatus::Unknown) | (HealthStatus::Unknown, _) => HealthStatus::Unknown,
2040 _ => HealthStatus::Up,
2041 };
2042 }
2043 overall
2044 }
2045}
2046
2047async fn run_with_timeout(indicator: &dyn HealthIndicator) -> HealthCheckOutput {
2050 let duration = tokio::time::Duration::from_millis(indicator.timeout_ms());
2051 match tokio::time::timeout(duration, indicator.check()).await {
2052 Ok(output) => output,
2053 Err(_elapsed) => {
2054 let mut details = HashMap::new();
2055 details.insert("timed_out".to_string(), serde_json::Value::Bool(true));
2056 HealthCheckOutput {
2057 status: HealthStatus::Unknown,
2058 details,
2059 }
2060 }
2061 }
2062}
2063
2064#[derive(Serialize)]
2068struct ActuatorHealth {
2069 status: &'static str,
2071 version: &'static str,
2072 profile: String,
2073 uptime: String,
2074 #[cfg(feature = "db")]
2075 autumn_after_commit_failures_total: u64,
2076 #[cfg(feature = "db")]
2078 autumn_tx_retries_total: u64,
2079 #[cfg(feature = "db")]
2081 autumn_tx_retry_exhausted_total: u64,
2082 #[serde(skip_serializing_if = "HashMap::is_empty")]
2084 components: HashMap<String, ComponentHealth>,
2085 #[serde(skip_serializing_if = "Option::is_none")]
2087 checks: Option<HealthChecks>,
2088}
2089
2090#[derive(Serialize, Clone)]
2091struct ComponentHealth {
2092 status: &'static str,
2093 #[serde(skip_serializing_if = "Option::is_none")]
2094 details: Option<serde_json::Value>,
2095}
2096
2097#[derive(Serialize)]
2098struct HealthChecks {
2099 #[serde(skip_serializing_if = "Option::is_none")]
2100 database: Option<DatabaseCheck>,
2101}
2102
2103#[derive(Serialize)]
2104struct DatabaseCheck {
2105 status: &'static str,
2106 pool_size: u64,
2107 active_connections: u64,
2108 idle_connections: u64,
2109}
2110
2111fn build_health_components(
2112 db_status: Option<HealthStatus>,
2113 db_check: Option<&DatabaseCheck>,
2114 indicator_results: &[HealthRunResult],
2115 detailed: bool,
2116) -> HashMap<String, ComponentHealth> {
2117 let mut components: HashMap<String, ComponentHealth> = HashMap::new();
2118 for result in indicator_results {
2121 if !detailed
2122 && result.name.starts_with("circuit_breaker.")
2123 && result.output.status.is_healthy()
2124 {
2125 continue;
2126 }
2127 let details = (detailed && !result.output.details.is_empty())
2128 .then(|| serde_json::to_value(&result.output.details).unwrap_or_default());
2129 components.insert(
2130 result.name.clone(),
2131 ComponentHealth {
2132 status: result.output.status.as_str(),
2133 details,
2134 },
2135 );
2136 }
2137 if let Some(s) = db_status {
2138 let details = detailed
2139 .then(|| {
2140 db_check.map(|d| {
2141 serde_json::json!({
2142 "status": d.status,
2143 "pool_size": d.pool_size,
2144 "active_connections": d.active_connections,
2145 "idle_connections": d.idle_connections,
2146 })
2147 })
2148 })
2149 .flatten();
2150 components.insert(
2151 "db".to_string(),
2152 ComponentHealth {
2153 status: s.as_str(),
2154 details,
2155 },
2156 );
2157 }
2158 components
2159}
2160
2161pub async fn health<S: ProvideActuatorState + Send + Sync + 'static>(
2163 State(state): State<S>,
2164) -> impl IntoResponse {
2165 let detailed = state.health_detailed();
2166
2167 let (db_component_status, db_check) = {
2169 #[cfg(feature = "db")]
2170 {
2171 #[allow(clippy::option_if_let_else)]
2172 if let Some(pool) = state.pool() {
2173 let status = pool.status();
2174 let available = status.available as u64;
2175 let size = status.max_size as u64;
2176 let waiting = status.waiting as u64;
2177 let idle = available;
2178 let active = size.saturating_sub(available);
2179
2180 let healthy = available > 0 || waiting == 0;
2181 let db_status = if healthy {
2182 HealthStatus::Up
2183 } else {
2184 HealthStatus::Down
2185 };
2186 let db_check = Some(DatabaseCheck {
2187 status: if healthy { "ok" } else { "down" },
2188 pool_size: size,
2189 active_connections: active,
2190 idle_connections: idle,
2191 });
2192 (Some(db_status), db_check)
2193 } else {
2194 (None, None)
2195 }
2196 }
2197 #[cfg(not(feature = "db"))]
2198 {
2199 (None::<HealthStatus>, None::<DatabaseCheck>)
2200 }
2201 };
2202
2203 let indicator_results = if let Some(registry) = state.health_indicator_registry() {
2205 registry.run_all().await
2206 } else {
2207 Vec::new()
2208 };
2209
2210 let mut all_statuses: Vec<HealthStatus> =
2212 indicator_results.iter().map(|r| r.output.status).collect();
2213 if let Some(s) = db_component_status {
2214 all_statuses.push(s);
2215 }
2216 let overall = HealthIndicatorRegistry::aggregate_status(&all_statuses);
2217
2218 let components = build_health_components(
2220 db_component_status,
2221 db_check.as_ref(),
2222 &indicator_results,
2223 detailed,
2224 );
2225
2226 let checks = db_check.map(|db| HealthChecks { database: Some(db) });
2227
2228 let body = ActuatorHealth {
2229 status: overall.as_str(),
2230 version: env!("CARGO_PKG_VERSION"),
2231 profile: state.profile().to_owned(),
2232 uptime: state.uptime_display(),
2233 #[cfg(feature = "db")]
2234 autumn_after_commit_failures_total: crate::db::AFTER_COMMIT_FAILURES_TOTAL
2235 .load(std::sync::atomic::Ordering::Relaxed),
2236 #[cfg(feature = "db")]
2237 autumn_tx_retries_total: crate::db::TX_RETRIES_TOTAL
2238 .load(std::sync::atomic::Ordering::Relaxed),
2239 #[cfg(feature = "db")]
2240 autumn_tx_retry_exhausted_total: crate::db::TX_RETRY_EXHAUSTED_TOTAL
2241 .load(std::sync::atomic::Ordering::Relaxed),
2242 components,
2243 checks,
2244 };
2245
2246 let code = if overall.is_healthy() {
2247 StatusCode::OK
2248 } else {
2249 StatusCode::SERVICE_UNAVAILABLE
2250 };
2251 (code, Json(body))
2252}
2253
2254#[derive(Serialize)]
2258pub(crate) struct ActuatorInfo {
2259 app: AppInfo,
2260 autumn: FrameworkInfo,
2261 runtime: RuntimeInfo,
2262 build: crate::build_info::BuildProvenance,
2264}
2265
2266#[derive(Serialize)]
2267struct AppInfo {
2268 name: String,
2269 version: String,
2270}
2271
2272#[derive(Serialize)]
2273struct FrameworkInfo {
2274 version: &'static str,
2275 profile: String,
2276}
2277
2278#[derive(Serialize)]
2279struct RuntimeInfo {
2280 uptime: String,
2281}
2282
2283pub(crate) async fn info<S: ProvideActuatorState + Send + Sync + 'static>(
2285 State(state): State<S>,
2286) -> Json<ActuatorInfo> {
2287 Json(ActuatorInfo {
2288 app: AppInfo {
2289 name: crate::build_info::app_name(),
2293 version: crate::build_info::app_version(),
2294 },
2295 autumn: FrameworkInfo {
2296 version: env!("CARGO_PKG_VERSION"),
2297 profile: state.profile().to_owned(),
2298 },
2299 runtime: RuntimeInfo {
2300 uptime: state.uptime_display(),
2301 },
2302 build: crate::build_info::build_provenance(),
2303 })
2304}
2305
2306#[derive(Serialize)]
2310pub(crate) struct ActuatorEnv {
2311 active_profile: String,
2312 properties: std::collections::HashMap<String, serde_json::Value>,
2313}
2314
2315const REDACT_PATTERNS: &[&str] = &[
2317 "password",
2318 "secret",
2319 "key",
2320 "token",
2321 "credential",
2322 "auth",
2323 "url",
2324];
2325
2326fn should_redact(key: &str) -> bool {
2327 let lower = key.to_lowercase();
2328 REDACT_PATTERNS.iter().any(|p| lower.contains(p))
2329}
2330
2331pub(crate) async fn env_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
2333 State(state): State<S>,
2334) -> Json<ActuatorEnv> {
2335 let properties = state
2336 .config_props()
2337 .snapshot()
2338 .into_iter()
2339 .map(|(key, prop)| (key, prop.value))
2340 .collect();
2341
2342 Json(ActuatorEnv {
2343 active_profile: state.profile().to_owned(),
2344 properties,
2345 })
2346}
2347
2348pub(crate) async fn metrics_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
2352 State(state): State<S>,
2353) -> Json<serde_json::Value> {
2354 let snapshot = state.metrics().snapshot();
2355 let mut result = serde_json::to_value(&snapshot).unwrap_or_default();
2356
2357 if let serde_json::Value::Object(ref mut map) = result {
2360 map.insert(
2361 "cache".to_string(),
2362 serde_json::to_value(crate::cache::read_through_metrics().snapshot())
2363 .unwrap_or_default(),
2364 );
2365 }
2366
2367 #[cfg(feature = "db")]
2369 if let Some(pool) = state.pool() {
2370 let status = pool.status();
2371 let db_stats = serde_json::json!({
2372 "pool_size": status.max_size,
2373 "active_connections": (status.size as u64).saturating_sub(status.available as u64),
2374 "idle_connections": status.available,
2375 });
2376 if let serde_json::Value::Object(ref mut map) = result {
2377 map.insert("database".to_string(), db_stats);
2378 }
2379 }
2380
2381 #[cfg(feature = "db")]
2383 if let Some(shards) = state.shards() {
2384 let mut shard_stats = serde_json::Map::new();
2385 for shard in shards.iter() {
2386 let status = shard.primary_pool().status();
2387 let mut entry = serde_json::json!({
2388 "pool_size": status.max_size,
2389 "active_connections":
2390 (status.size as u64).saturating_sub(status.available as u64),
2391 "idle_connections": status.available,
2392 "slots": shard.slots().len(),
2393 });
2394 if let Some(replica) = shard.replica_pool() {
2395 let replica_status = replica.status();
2396 if let serde_json::Value::Object(ref mut entry_map) = entry {
2397 entry_map.insert(
2398 "replica".to_string(),
2399 serde_json::json!({
2400 "pool_size": replica_status.max_size,
2401 "active_connections": (replica_status.size as u64)
2402 .saturating_sub(replica_status.available as u64),
2403 "idle_connections": replica_status.available,
2404 }),
2405 );
2406 }
2407 }
2408 shard_stats.insert(shard.name().to_owned(), entry);
2409 }
2410 if let serde_json::Value::Object(ref mut map) = result {
2411 map.insert(
2412 "database_shards".to_string(),
2413 serde_json::Value::Object(shard_stats),
2414 );
2415 }
2416 }
2417
2418 if let Some(registry) = state.metrics_source_registry() {
2420 let all = registry.collect_all();
2421 let mut sources = serde_json::Map::new();
2422 for (source_name, families) in all {
2423 let families_json: Vec<serde_json::Value> = families
2424 .iter()
2425 .map(|f| {
2426 serde_json::json!({
2427 "name": f.name,
2428 "help": f.help,
2429 "kind": f.kind.as_str(),
2430 "samples": f.samples.iter().map(|s| {
2431 let labels: serde_json::Map<String, serde_json::Value> = s.labels
2432 .iter()
2433 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
2434 .collect();
2435 serde_json::json!({
2436 "labels": labels,
2437 "value": s.value,
2438 })
2439 }).collect::<Vec<_>>(),
2440 })
2441 })
2442 .collect();
2443 sources.insert(source_name, serde_json::Value::Array(families_json));
2444 }
2445 if let serde_json::Value::Object(ref mut map) = result {
2446 map.insert("sources".to_string(), serde_json::Value::Object(sources));
2447 }
2448 }
2449
2450 Json(result)
2451}
2452
2453#[derive(Serialize)]
2454pub(crate) struct CircuitBreakerActuatorResponse {
2455 pub name: String,
2456 pub state: &'static str,
2457 pub failure_ratio: f64,
2458 #[serde(skip_serializing_if = "Option::is_none")]
2459 pub failure_ratio_threshold: Option<f64>,
2460 #[serde(skip_serializing_if = "Option::is_none")]
2461 pub sample_window_secs: Option<u64>,
2462 #[serde(skip_serializing_if = "Option::is_none")]
2463 pub minimum_sample_count: Option<u64>,
2464 #[serde(skip_serializing_if = "Option::is_none")]
2465 pub open_duration_secs: Option<u64>,
2466 #[serde(skip_serializing_if = "Option::is_none")]
2467 pub half_open_trial_count: Option<u64>,
2468}
2469
2470pub(crate) async fn circuitbreakers_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
2472 State(state): State<S>,
2473) -> Json<Vec<CircuitBreakerActuatorResponse>> {
2474 let detailed = state.health_detailed();
2475 let mut responses = Vec::new();
2476
2477 for breaker in crate::circuit_breaker::global_registry().all_breakers() {
2478 let policy = breaker.config();
2479 responses.push(CircuitBreakerActuatorResponse {
2480 name: breaker.name().to_string(),
2481 state: breaker.state().as_str(),
2482 failure_ratio: breaker.failure_ratio(),
2483 failure_ratio_threshold: detailed.then_some(policy.failure_ratio_threshold),
2484 sample_window_secs: detailed.then_some(policy.sample_window.as_secs()),
2485 minimum_sample_count: detailed.then_some(policy.minimum_sample_count),
2486 open_duration_secs: detailed.then_some(policy.open_duration.as_secs()),
2487 half_open_trial_count: detailed.then_some(policy.half_open_trial_count),
2488 });
2489 }
2490
2491 Json(responses)
2492}
2493
2494fn render_labels(labels: &[(String, String)]) -> String {
2500 if labels.is_empty() {
2501 return String::new();
2502 }
2503 let mut out = String::with_capacity(64);
2504 out.push('{');
2505 for (i, (k, v)) in labels.iter().enumerate() {
2506 if i > 0 {
2507 out.push(',');
2508 }
2509 out.push_str(k);
2510 out.push_str("=\"");
2511 for c in v.chars() {
2512 match c {
2513 '\\' => out.push_str("\\\\"),
2514 '\n' => out.push_str("\\n"),
2515 '"' => out.push_str("\\\""),
2516 other => out.push(other),
2517 }
2518 }
2519 out.push('"');
2520 }
2521 out.push('}');
2522 out
2523}
2524
2525fn is_valid_metric_name(s: &str) -> bool {
2527 let mut it = s.chars();
2528 matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':')
2529 && it.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
2530}
2531
2532fn is_valid_label_name(s: &str) -> bool {
2534 let mut it = s.chars();
2535 matches!(it.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
2536 && it.all(|c| c.is_ascii_alphanumeric() || c == '_')
2537}
2538
2539fn escape_prometheus_label_value(value: &str) -> String {
2541 let mut out = String::with_capacity(value.len());
2542 for c in value.chars() {
2543 match c {
2544 '\\' => out.push_str("\\\\"),
2545 '\n' => out.push_str("\\n"),
2546 '"' => out.push_str("\\\""),
2547 other => out.push(other),
2548 }
2549 }
2550 out
2551}
2552
2553fn escape_help_text(text: &str) -> String {
2555 let mut out = String::with_capacity(text.len());
2556 for c in text.chars() {
2557 match c {
2558 '\\' => out.push_str("\\\\"),
2559 '\n' => out.push_str("\\n"),
2560 other => out.push(other),
2561 }
2562 }
2563 out
2564}
2565
2566fn format_sample_value(v: f64) -> String {
2568 if v == f64::INFINITY {
2569 "+Inf".to_string()
2570 } else if v == f64::NEG_INFINITY {
2571 "-Inf".to_string()
2572 } else if v.is_nan() {
2573 "NaN".to_string()
2574 } else {
2575 v.to_string()
2576 }
2577}
2578
2579fn render_plugin_sources(
2585 registry: &MetricsSourceRegistry,
2586 out: &mut String,
2587 emitted_families: &mut std::collections::HashSet<String>,
2588) {
2589 use std::fmt::Write;
2590
2591 let all_sources = registry.collect_all();
2592 for (_source_name, families) in &all_sources {
2593 for family in families {
2594 if !is_valid_metric_name(&family.name) {
2595 tracing::warn!(name = %family.name, "MetricsSource returned invalid metric name; skipping family");
2596 continue;
2597 }
2598 if !emitted_families.insert(family.name.clone()) {
2599 tracing::warn!(name = %family.name, "MetricsSource returned duplicate metric family name; skipping family");
2600 continue;
2601 }
2602 let _ = writeln!(
2603 out,
2604 "# HELP {} {}",
2605 family.name,
2606 escape_help_text(&family.help)
2607 );
2608 let _ = writeln!(out, "# TYPE {} {}", family.name, family.kind.as_str());
2609 let mut emitted_series: std::collections::HashSet<String> =
2610 std::collections::HashSet::new();
2611 for sample in &family.samples {
2612 let mut bad_key = false;
2613 let mut seen_keys = std::collections::HashSet::new();
2614 let mut valid_labels: Vec<(String, String)> = Vec::new();
2615 for (k, v) in &sample.labels {
2616 if !is_valid_label_name(k) {
2617 tracing::warn!(
2618 label_name = %k,
2619 metric = %family.name,
2620 "MetricsSource returned invalid label name; skipping sample"
2621 );
2622 bad_key = true;
2623 break;
2624 }
2625 if !seen_keys.insert(k.as_str()) {
2626 tracing::warn!(label_name = %k, "MetricsSource returned duplicate label name; dropping duplicate");
2627 continue;
2628 }
2629 valid_labels.push((k.clone(), v.clone()));
2630 }
2631 if bad_key {
2632 continue;
2633 }
2634 valid_labels.sort_by(|(a, _), (b, _)| a.cmp(b));
2637 let labels = render_labels(&valid_labels);
2638 if !emitted_series.insert(labels.clone()) {
2639 tracing::warn!(
2640 metric = %family.name,
2641 labels = %labels,
2642 "MetricsSource returned duplicate series; skipping sample"
2643 );
2644 continue;
2645 }
2646 let _ = writeln!(
2647 out,
2648 "{}{} {}",
2649 family.name,
2650 labels,
2651 format_sample_value(sample.value)
2652 );
2653 }
2654 }
2655 }
2656
2657 let error_counts = registry.error_counts();
2658 if !error_counts.is_empty() {
2659 out.push_str(
2660 "# HELP autumn_metrics_source_errors_total \
2661 Number of scrape errors (panics) per plugin metrics source\n",
2662 );
2663 out.push_str("# TYPE autumn_metrics_source_errors_total counter\n");
2664 let mut names: Vec<&String> = error_counts.keys().collect();
2665 names.sort();
2666 for name in names {
2667 let label = render_labels(&[("source".to_string(), name.clone())]);
2668 let _ = writeln!(
2669 out,
2670 "autumn_metrics_source_errors_total{} {}",
2671 label, error_counts[name]
2672 );
2673 }
2674 }
2675}
2676
2677fn write_builtin_http_metrics(
2681 out: &mut String,
2682 version: &str,
2683 snapshot: &crate::middleware::metrics::MetricsSnapshot,
2684) {
2685 use std::fmt::Write;
2686
2687 out.push_str("# HELP autumn_http_requests_total Total number of HTTP requests\n");
2689 out.push_str("# TYPE autumn_http_requests_total counter\n");
2690 let _ = writeln!(
2691 out,
2692 "autumn_http_requests_total{{version=\"{version}\"}} {}",
2693 snapshot.http.requests_total
2694 );
2695
2696 out.push_str("# HELP autumn_http_requests_active Currently active HTTP requests\n");
2698 out.push_str("# TYPE autumn_http_requests_active gauge\n");
2699 let _ = writeln!(
2700 out,
2701 "autumn_http_requests_active{{version=\"{version}\"}} {}",
2702 snapshot.http.requests_active
2703 );
2704
2705 out.push_str("# HELP autumn_http_responses_total HTTP responses by status code\n");
2707 out.push_str("# TYPE autumn_http_responses_total counter\n");
2708 for (status, count) in [
2709 ("2xx", snapshot.http.by_status.s2xx),
2710 ("3xx", snapshot.http.by_status.s3xx),
2711 ("4xx", snapshot.http.by_status.s4xx),
2712 ("5xx", snapshot.http.by_status.s5xx),
2713 ] {
2714 let _ = writeln!(
2715 out,
2716 "autumn_http_responses_total{{version=\"{version}\",status=\"{status}\"}} {count}"
2717 );
2718 }
2719
2720 out.push_str(
2724 "# HELP autumn_http_request_duration_seconds HTTP request latency percentiles in seconds\n",
2725 );
2726 out.push_str("# TYPE autumn_http_request_duration_seconds summary\n");
2727 for (quantile, millis) in [
2728 ("0.5", snapshot.http.latency_ms.p50),
2729 ("0.95", snapshot.http.latency_ms.p95),
2730 ("0.99", snapshot.http.latency_ms.p99),
2731 ] {
2732 #[allow(clippy::cast_precision_loss)]
2733 let seconds = millis as f64 / 1000.0;
2734 let _ = writeln!(
2735 out,
2736 "autumn_http_request_duration_seconds{{version=\"{version}\",quantile=\"{quantile}\"}} {seconds}"
2737 );
2738 }
2739
2740 out.push_str(
2742 "# HELP autumn_shutdown_aborted_requests_total \
2743 HTTP requests forcibly dropped when the graceful-shutdown drain deadline expired\n",
2744 );
2745 out.push_str("# TYPE autumn_shutdown_aborted_requests_total counter\n");
2746 let _ = writeln!(
2747 out,
2748 "autumn_shutdown_aborted_requests_total{{version=\"{version}\"}} {}",
2749 snapshot.http.shutdown_aborted_requests_total
2750 );
2751
2752 out.push_str(
2754 "# HELP autumn_request_timeouts_total \
2755 HTTP requests that exceeded the configured per-request timeout\n",
2756 );
2757 out.push_str("# TYPE autumn_request_timeouts_total counter\n");
2758 let _ = writeln!(
2759 out,
2760 "autumn_request_timeouts_total{{version=\"{version}\"}} {}",
2761 snapshot.http.request_timeouts_total
2762 );
2763
2764 out.push_str(
2766 "# HELP autumn_read_your_writes_pins_total \
2767 Replica reads redirected to the primary by the read-your-own-writes pin\n",
2768 );
2769 out.push_str("# TYPE autumn_read_your_writes_pins_total counter\n");
2770 let _ = writeln!(
2771 out,
2772 "autumn_read_your_writes_pins_total{{version=\"{version}\"}} {}",
2773 snapshot.read_your_writes_pins_total
2774 );
2775
2776 out.push_str(
2778 "# HELP autumn_requests_shed_total \
2779 HTTP requests rejected by admission control because server.max_concurrent_requests was at its ceiling\n",
2780 );
2781 out.push_str("# TYPE autumn_requests_shed_total counter\n");
2782 let _ = writeln!(
2783 out,
2784 "autumn_requests_shed_total{{version=\"{version}\"}} {}",
2785 snapshot.http.requests_shed_total
2786 );
2787
2788 if !snapshot.http.by_route.is_empty() {
2790 out.push_str("# HELP autumn_http_route_requests_total HTTP requests by route and method\n");
2791 out.push_str("# TYPE autumn_http_route_requests_total counter\n");
2792 let mut route_keys: Vec<&String> = snapshot.http.by_route.keys().collect();
2793 route_keys.sort();
2794 for route_key in route_keys {
2795 let metrics = &snapshot.http.by_route[route_key];
2796 if let Some((method, path)) = route_key.split_once(' ') {
2798 let _ = writeln!(
2799 out,
2800 "autumn_http_route_requests_total{{version=\"{version}\",method=\"{method}\",route=\"{path}\"}} {}",
2801 metrics.count
2802 );
2803 }
2804 }
2805 }
2806}
2807
2808fn write_builtin_cache_metrics(
2813 out: &mut String,
2814 version: &str,
2815 snapshot: &crate::cache::ReadThroughMetricsSnapshot,
2816) {
2817 use std::fmt::Write;
2818
2819 for (name, help, value) in [
2820 (
2821 "autumn_cache_read_through_hits_total",
2822 "Read-through cache reads served from a fresh cached value",
2823 snapshot.hits,
2824 ),
2825 (
2826 "autumn_cache_read_through_misses_total",
2827 "Read-through cache reads that found no fresh cached value",
2828 snapshot.misses,
2829 ),
2830 (
2831 "autumn_cache_read_through_coalesced_waits_total",
2832 "Read-through callers that awaited a concurrent in-process fill instead of \
2833 computing their own (single-flight coalescing)",
2834 snapshot.coalesced_waits,
2835 ),
2836 (
2837 "autumn_cache_read_through_fills_total",
2838 "Read-through fill closures that completed successfully",
2839 snapshot.fills,
2840 ),
2841 (
2842 "autumn_cache_read_through_fill_failures_total",
2843 "Read-through fill closures that returned an error",
2844 snapshot.fill_failures,
2845 ),
2846 (
2847 "autumn_cache_read_through_stale_serves_total",
2848 "Stale-while-revalidate reads that served a stale value while a background \
2849 refresh ran",
2850 snapshot.stale_serves,
2851 ),
2852 (
2853 "autumn_cache_fill_lock_acquires_total",
2854 "Distributed cache fill locks acquired by this process",
2855 snapshot.fill_lock_acquires,
2856 ),
2857 (
2858 "autumn_cache_fill_lock_contended_total",
2859 "Distributed cache fill lock attempts that found the lock held by another replica",
2860 snapshot.fill_lock_contended,
2861 ),
2862 ] {
2863 let _ = writeln!(out, "# HELP {name} {help}");
2864 let _ = writeln!(out, "# TYPE {name} counter");
2865 let _ = writeln!(out, "{name}{{version=\"{version}\"}} {value}");
2866 }
2867}
2868
2869pub(crate) async fn prometheus_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
2871 State(state): State<S>,
2872) -> impl IntoResponse {
2873 let snapshot = state.metrics().snapshot();
2874 let version = escape_prometheus_label_value(&state.deploy_version());
2878 let mut out = String::with_capacity(2048);
2879
2880 write_builtin_http_metrics(&mut out, &version, &snapshot);
2881 write_builtin_cache_metrics(
2882 &mut out,
2883 &version,
2884 &crate::cache::read_through_metrics().snapshot(),
2885 );
2886
2887 if let Some(registry) = state.metrics_source_registry() {
2890 let mut emitted_families: std::collections::HashSet<String> = [
2891 "autumn_http_requests_total",
2892 "autumn_http_requests_active",
2893 "autumn_http_responses_total",
2894 "autumn_http_request_duration_seconds",
2895 "autumn_shutdown_aborted_requests_total",
2896 "autumn_request_timeouts_total",
2897 "autumn_read_your_writes_pins_total",
2898 "autumn_requests_shed_total",
2899 "autumn_http_route_requests_total",
2900 "autumn_metrics_source_errors_total",
2901 "autumn_cache_read_through_hits_total",
2902 "autumn_cache_read_through_misses_total",
2903 "autumn_cache_read_through_coalesced_waits_total",
2904 "autumn_cache_read_through_fills_total",
2905 "autumn_cache_read_through_fill_failures_total",
2906 "autumn_cache_read_through_stale_serves_total",
2907 "autumn_cache_fill_lock_acquires_total",
2908 "autumn_cache_fill_lock_contended_total",
2909 ]
2910 .iter()
2911 .map(|s| (*s).to_string())
2912 .collect();
2913 render_plugin_sources(registry, &mut out, &mut emitted_families);
2914 }
2915
2916 (
2917 [(
2918 axum::http::header::CONTENT_TYPE,
2919 "text/plain; version=0.0.4",
2920 )],
2921 out,
2922 )
2923}
2924
2925pub(crate) async fn configprops_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
2929 State(state): State<S>,
2930) -> Json<serde_json::Value> {
2931 let props = state.config_props().snapshot();
2932
2933 Json(serde_json::json!({
2934 "active_profile": state.profile(),
2935 "properties": props,
2936 }))
2937}
2938
2939const AVAILABLE_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
2943
2944#[derive(Serialize)]
2946pub(crate) struct LoggersResponse {
2947 current_level: String,
2948 available_levels: Vec<&'static str>,
2949 loggers: HashMap<String, String>,
2950}
2951
2952pub(crate) async fn loggers_get<S: ProvideActuatorState + Send + Sync + 'static>(
2954 State(state): State<S>,
2955) -> Json<LoggersResponse> {
2956 Json(LoggersResponse {
2957 current_level: state.log_levels().current_level(),
2958 available_levels: AVAILABLE_LEVELS.to_vec(),
2959 loggers: state.log_levels().logger_overrides(),
2960 })
2961}
2962
2963#[derive(Deserialize)]
2965pub(crate) struct SetLoggerRequest {
2966 level: String,
2967}
2968
2969fn is_valid_logger_name(name: &str) -> bool {
2980 if name.is_empty() || name == "root" {
2981 return true;
2982 }
2983 name.chars()
2984 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':' || ch == '.' || ch == '-')
2985}
2986
2987pub(crate) async fn loggers_put<S: ProvideActuatorState + Send + Sync + 'static>(
2989 State(state): State<S>,
2990 Path(name): Path<String>,
2991 Json(body): Json<SetLoggerRequest>,
2992) -> impl IntoResponse {
2993 let level = body.level.to_lowercase();
2994
2995 if !AVAILABLE_LEVELS.contains(&level.as_str()) {
2997 return (
2998 StatusCode::BAD_REQUEST,
2999 Json(serde_json::json!({
3000 "status": "error",
3001 "message": format!(
3002 "Invalid level '{}'. Available levels: {}",
3003 level,
3004 AVAILABLE_LEVELS.join(", ")
3005 ),
3006 })),
3007 );
3008 }
3009
3010 if !is_valid_logger_name(&name) {
3013 return (
3014 StatusCode::BAD_REQUEST,
3015 Json(serde_json::json!({
3016 "status": "error",
3017 "message": format!(
3018 "Invalid logger name '{name}'. Names may contain only \
3019 alphanumerics, '_', ':', '.' and '-' (or 'root')."
3020 ),
3021 })),
3022 );
3023 }
3024
3025 match state.log_levels().set_logger_level(&name, &level) {
3029 LogLevelChange::Applied { previous } => (
3030 StatusCode::OK,
3031 Json(serde_json::json!({
3032 "status": "ok",
3033 "message": format!("Logger '{name}' set to '{level}'"),
3034 "previous": previous,
3035 "applied": true,
3036 })),
3037 ),
3038 LogLevelChange::Recorded { previous } => (
3039 StatusCode::OK,
3040 Json(serde_json::json!({
3041 "status": "recorded",
3042 "message": format!(
3043 "Logger '{name}' recorded as '{level}' but not applied: no reload-capable subscriber is installed"
3044 ),
3045 "previous": previous,
3046 "applied": false,
3047 })),
3048 ),
3049 LogLevelChange::Rejected { reason } => (
3050 StatusCode::INTERNAL_SERVER_ERROR,
3051 Json(serde_json::json!({
3052 "status": "error",
3053 "message": format!("Logger '{name}' could not be set to '{level}': {reason}"),
3054 "applied": false,
3055 })),
3056 ),
3057 }
3058}
3059
3060#[derive(Debug, Deserialize, Default)]
3064pub(crate) struct LogfileQuery {
3065 pub level: Option<String>,
3070 pub limit: Option<usize>,
3072}
3073
3074#[derive(Debug, Serialize)]
3076pub(crate) struct LogfileResponse {
3077 pub entries: Vec<crate::log::capture::CapturedLogEntry>,
3079 pub total: usize,
3081 pub capture_enabled: bool,
3083}
3084
3085pub(crate) async fn logfile_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3097 State(state): State<S>,
3098 axum::extract::Query(query): axum::extract::Query<LogfileQuery>,
3099) -> Result<axum::Json<LogfileResponse>, (StatusCode, axum::Json<serde_json::Value>)> {
3100 let min_level = match query.level.as_deref() {
3101 None => None,
3102 Some(s) => match crate::log::capture::level_from_str(s) {
3103 Some(level) => Some(level),
3104 None => {
3105 return Err((
3106 StatusCode::BAD_REQUEST,
3107 axum::Json(serde_json::json!({
3108 "error": format!(
3109 "invalid level {:?}; valid values: TRACE, DEBUG, INFO, WARN, ERROR",
3110 s
3111 )
3112 })),
3113 ));
3114 }
3115 },
3116 };
3117
3118 Ok(match state.log_buffer() {
3119 None => axum::Json(LogfileResponse {
3120 entries: vec![],
3121 total: 0,
3122 capture_enabled: false,
3123 }),
3124 Some(buf) => {
3125 let total = buf.len();
3126 let entries = buf.snapshot(min_level, query.limit);
3127 axum::Json(LogfileResponse {
3128 entries,
3129 total,
3130 capture_enabled: true,
3131 })
3132 }
3133 })
3134}
3135
3136pub(crate) async fn tasks_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3140 State(state): State<S>,
3141) -> Json<serde_json::Value> {
3142 let tasks = state.task_registry().snapshot();
3143
3144 Json(serde_json::json!({
3145 "scheduled_tasks": tasks,
3146 }))
3147}
3148
3149pub(crate) async fn jobs_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3151 State(state): State<S>,
3152) -> Json<serde_json::Value> {
3153 let jobs = state.job_registry().snapshot();
3154 let queues = state.job_registry().queue_snapshot();
3155 Json(serde_json::json!({ "jobs": jobs, "queues": queues }))
3156}
3157
3158#[cfg(feature = "http-client")]
3159#[derive(Deserialize)]
3161pub(crate) struct ReplayRequest {
3162 log_id: String,
3163}
3164
3165#[cfg(feature = "http-client")]
3166async fn enqueue_webhook_replay_job(log_id: &str) -> Result<(), String> {
3167 let job_payload = serde_json::json!({
3168 "log_id": log_id,
3169 "replay": true,
3170 });
3171
3172 let Some(job_client) = crate::job::global_job_client() else {
3173 return Err("Global job client is not available".to_string());
3174 };
3175
3176 job_client
3177 .enqueue("autumn_webhook_delivery", job_payload)
3178 .await
3179 .map_err(|e| format!("Failed to enqueue job: {e}"))
3180}
3181
3182#[cfg(feature = "http-client")]
3183pub(crate) async fn webhooks_dlq_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3185 State(state): State<S>,
3186) -> impl IntoResponse {
3187 let Some(manager) = state.webhook_outbound() else {
3188 return (
3189 StatusCode::NOT_IMPLEMENTED,
3190 Json(serde_json::json!({
3191 "status": "error",
3192 "message": "Outbound webhook support is not configured or enabled"
3193 })),
3194 )
3195 .into_response();
3196 };
3197
3198 match manager.store().get_dlq_logs().await {
3199 Ok(logs) => (StatusCode::OK, Json(logs)).into_response(),
3200 Err(e) => (
3201 StatusCode::INTERNAL_SERVER_ERROR,
3202 Json(serde_json::json!({
3203 "status": "error",
3204 "message": format!("Failed to fetch DLQ logs: {}", e)
3205 })),
3206 )
3207 .into_response(),
3208 }
3209}
3210
3211#[cfg(feature = "http-client")]
3212pub(crate) async fn webhooks_replay_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3214 State(state): State<S>,
3215 Json(body): Json<ReplayRequest>,
3216) -> impl IntoResponse {
3217 let Some(manager) = state.webhook_outbound() else {
3218 return (
3219 StatusCode::NOT_IMPLEMENTED,
3220 Json(serde_json::json!({
3221 "status": "error",
3222 "message": "Outbound webhook support is not configured or enabled"
3223 })),
3224 )
3225 .into_response();
3226 };
3227
3228 let log_opt = match manager.store().get_delivery_log(&body.log_id).await {
3229 Ok(log) => log,
3230 Err(e) => {
3231 return (
3232 StatusCode::INTERNAL_SERVER_ERROR,
3233 Json(serde_json::json!({
3234 "status": "error",
3235 "message": format!("Failed to retrieve log: {}", e)
3236 })),
3237 )
3238 .into_response();
3239 }
3240 };
3241
3242 let Some(log) = log_opt else {
3243 return (
3244 StatusCode::NOT_FOUND,
3245 Json(serde_json::json!({
3246 "status": "error",
3247 "message": format!("Log with ID {} not found", body.log_id)
3248 })),
3249 )
3250 .into_response();
3251 };
3252
3253 if !log.is_dlq {
3254 return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
3255 "status": "error",
3256 "message": format!("Log with ID {} is not in the Dead Letter Queue (DLQ)", body.log_id)
3257 }))).into_response();
3258 }
3259
3260 if let Some(response) = blocked_webhook_replay_response(&manager, &log, &body.log_id).await {
3261 return response;
3262 }
3263
3264 let subscription_id = log.subscription_id.clone();
3265 let original_log = log.clone();
3266 let log = reset_webhook_replay_log(log);
3267
3268 if let Err(e) = manager.store().log_delivery(log).await {
3269 return (
3270 StatusCode::INTERNAL_SERVER_ERROR,
3271 Json(serde_json::json!({
3272 "status": "error",
3273 "message": format!("Failed to update delivery log state: {}", e)
3274 })),
3275 )
3276 .into_response();
3277 }
3278
3279 if let Err(message) = enqueue_webhook_replay_job(&body.log_id).await {
3281 if let Err(rollback_error) = manager.store().replace_delivery_log(original_log).await {
3282 tracing::error!(
3283 log_id = %body.log_id,
3284 "Failed to roll back webhook replay log after enqueue failure: {}",
3285 rollback_error
3286 );
3287 return (
3288 StatusCode::INTERNAL_SERVER_ERROR,
3289 Json(serde_json::json!({
3290 "status": "error",
3291 "message": format!("{message}; failed to restore DLQ log state: {rollback_error}")
3292 })),
3293 )
3294 .into_response();
3295 }
3296
3297 return (
3298 StatusCode::INTERNAL_SERVER_ERROR,
3299 Json(serde_json::json!({
3300 "status": "error",
3301 "message": message
3302 })),
3303 )
3304 .into_response();
3305 }
3306
3307 if let Err(e) = manager
3309 .store()
3310 .reactivate_failed_subscription(&subscription_id)
3311 .await
3312 {
3313 tracing::warn!(subscription_id = %subscription_id, "Failed to reactivate subscription during replay: {}", e);
3314 }
3315
3316 (
3317 StatusCode::OK,
3318 Json(serde_json::json!({
3319 "status": "ok",
3320 "message": format!("Replay successfully enqueued for log {}", body.log_id)
3321 })),
3322 )
3323 .into_response()
3324}
3325
3326#[cfg(feature = "http-client")]
3327fn reset_webhook_replay_log(
3328 mut log: crate::webhook_outbound::WebhookDeliveryLog,
3329) -> crate::webhook_outbound::WebhookDeliveryLog {
3330 log.is_dlq = false;
3331 log.attempt = 1;
3332 log.last_error = None;
3333 log.response_status = None;
3334 log.response_body = None;
3335 log.timestamp = chrono::Utc::now();
3336 log
3337}
3338
3339#[cfg(feature = "http-client")]
3340async fn blocked_webhook_replay_response(
3341 manager: &crate::webhook_outbound::WebhookOutboundManager,
3342 log: &crate::webhook_outbound::WebhookDeliveryLog,
3343 log_id: &str,
3344) -> Option<axum::response::Response> {
3345 let subscription = match manager.store().get_subscription(&log.subscription_id).await {
3346 Ok(subscription) => subscription,
3347 Err(e) => {
3348 return Some(
3349 (
3350 StatusCode::INTERNAL_SERVER_ERROR,
3351 Json(serde_json::json!({
3352 "status": "error",
3353 "message": format!("Failed to retrieve subscription: {}", e)
3354 })),
3355 )
3356 .into_response(),
3357 );
3358 }
3359 };
3360
3361 let Some(subscription) = subscription else {
3362 return Some(
3363 (
3364 StatusCode::NOT_FOUND,
3365 Json(serde_json::json!({
3366 "status": "error",
3367 "message": format!(
3368 "Subscription {} for replay log {} was not found",
3369 log.subscription_id, log_id
3370 )
3371 })),
3372 )
3373 .into_response(),
3374 );
3375 };
3376
3377 if subscription.status != crate::webhook_outbound::WebhookSubscriptionStatus::Disabled {
3378 return None;
3379 }
3380
3381 Some(
3382 (
3383 StatusCode::CONFLICT,
3384 Json(serde_json::json!({
3385 "status": "error",
3386 "message": format!(
3387 "Subscription {} is disabled; re-enable it before replaying log {}",
3388 log.subscription_id, log_id
3389 )
3390 })),
3391 )
3392 .into_response(),
3393 )
3394}
3395
3396pub(crate) async fn a11y_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3403 State(state): State<S>,
3404) -> Json<A11yPosture> {
3405 Json(state.a11y_posture())
3406}
3407
3408#[cfg(feature = "ws")]
3412pub(crate) async fn channels_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3413 State(state): State<S>,
3414) -> Json<serde_json::Value> {
3415 let channels = state.channels().snapshot();
3416 Json(serde_json::json!({
3417 "channels": channels,
3418 }))
3419}
3420
3421#[cfg(feature = "ws")]
3425pub(crate) async fn tasks_stream_endpoint<S: ProvideActuatorState + Send + Sync + 'static>(
3426 State(state): State<S>,
3427 ws: axum::extract::ws::WebSocketUpgrade,
3428) -> impl IntoResponse {
3429 ws.on_upgrade(move |mut socket| async move {
3430 let mut rx = state.channels().subscribe("sys:tasks");
3431 let shutdown = state.shutdown_token();
3432
3433 loop {
3434 tokio::select! {
3435 res = rx.recv() => {
3436 match res {
3437 Ok(msg) => {
3438 let ws_msg = axum::extract::ws::Message::Text(msg.into_string().into());
3439 if socket.send(ws_msg).await.is_err() {
3440 break;
3441 }
3442 }
3443 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
3444 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
3445 }
3446 }
3447 () = shutdown.cancelled() => {
3448 let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
3449 break;
3450 }
3451 else => break,
3452 }
3453 }
3454 })
3455}
3456
3457pub(crate) fn normalize_actuator_prefix(prefix: &str) -> String {
3460 let trimmed = prefix.trim();
3461 if trimmed.is_empty() || trimmed == "/" {
3462 String::new()
3463 } else {
3464 let trimmed = trimmed.trim_end_matches('/');
3465 if trimmed.starts_with('/') {
3466 trimmed.to_owned()
3467 } else {
3468 format!("/{trimmed}")
3469 }
3470 }
3471}
3472
3473pub(crate) fn actuator_route_glob(prefix: &str) -> String {
3474 let prefix = normalize_actuator_prefix(prefix);
3475 if prefix.is_empty() {
3476 "/*".to_owned()
3477 } else {
3478 format!("{prefix}/*")
3479 }
3480}
3481
3482pub(crate) fn actuator_route_path(prefix: &str, suffix: &str) -> String {
3483 let prefix = normalize_actuator_prefix(prefix);
3484 if prefix.is_empty() {
3485 suffix.to_owned()
3486 } else {
3487 format!("{prefix}{suffix}")
3488 }
3489}
3490
3491pub(crate) fn actuator_endpoint_paths(
3492 prefix: &str,
3493 sensitive: bool,
3494 prometheus_enabled: bool,
3495) -> Vec<String> {
3496 let mut paths = vec![
3497 actuator_route_path(prefix, "/health"),
3498 actuator_route_path(prefix, "/info"),
3499 actuator_route_path(prefix, "/metrics"),
3500 actuator_route_path(prefix, "/a11y"),
3501 actuator_route_path(prefix, "/ui"),
3502 actuator_route_path(prefix, "/ui/metrics"),
3503 ];
3504
3505 if prometheus_enabled {
3506 paths.push(actuator_route_path(prefix, "/prometheus"));
3507 }
3508
3509 if sensitive {
3510 paths.push(actuator_route_path(prefix, "/circuitbreakers"));
3511 paths.push(actuator_route_path(prefix, "/env"));
3512 paths.push(actuator_route_path(prefix, "/configprops"));
3513 paths.push(actuator_route_path(prefix, "/loggers"));
3514 paths.push(actuator_route_path(prefix, "/logfile"));
3515 paths.push(actuator_route_path(prefix, "/tasks"));
3516 paths.push(actuator_route_path(prefix, "/jobs"));
3517 paths.push(actuator_route_path(prefix, "/ui/tasks"));
3518 #[cfg(feature = "system-info")]
3519 {
3520 paths.push(actuator_route_path(prefix, "/system"));
3521 }
3522 #[cfg(feature = "http-client")]
3523 {
3524 paths.push(actuator_route_path(prefix, "/webhooks/dlq"));
3525 paths.push(actuator_route_path(prefix, "/webhooks/replay"));
3535 }
3536 #[cfg(feature = "ws")]
3537 {
3538 paths.push(actuator_route_path(prefix, "/channels"));
3539 paths.push(actuator_route_path(prefix, "/tasks/stream"));
3540 }
3541 }
3542
3543 paths
3544}
3545
3546pub(crate) fn actuator_mutating_routes(
3555 prefix: &str,
3556 sensitive: bool,
3557) -> Vec<(&'static str, String)> {
3558 let mut routes: Vec<(&'static str, String)> = Vec::new();
3559
3560 if sensitive {
3561 routes.push(("PUT", actuator_route_path(prefix, "/loggers/{name}")));
3562 #[cfg(feature = "http-client")]
3563 {
3564 routes.push(("POST", actuator_route_path(prefix, "/webhooks/replay")));
3565 }
3566 }
3567
3568 routes
3569}
3570
3571pub fn actuator_router<S: ProvideActuatorState + Send + Sync + Clone + 'static>(
3580 sensitive: bool,
3581) -> axum::Router<S> {
3582 actuator_router_with_prefix("/actuator", sensitive, true)
3583}
3584
3585#[allow(clippy::too_many_lines)]
3593pub(crate) fn actuator_router_with_prefix<
3594 S: ProvideActuatorState + Send + Sync + Clone + 'static,
3595>(
3596 prefix: &str,
3597 sensitive: bool,
3598 prometheus_enabled: bool,
3599) -> axum::Router<S> {
3600 let mut router = axum::Router::new()
3601 .route(
3602 &actuator_route_path(prefix, "/health"),
3603 axum::routing::get(health::<S>),
3604 )
3605 .route(
3606 &actuator_route_path(prefix, "/info"),
3607 axum::routing::get(info::<S>),
3608 )
3609 .route(
3610 &actuator_route_path(prefix, "/metrics"),
3611 axum::routing::get(metrics_endpoint::<S>),
3612 )
3613 .route(
3614 &actuator_route_path(prefix, "/a11y"),
3615 axum::routing::get(a11y_endpoint::<S>),
3616 );
3617
3618 if prometheus_enabled {
3619 router = router.route(
3620 &actuator_route_path(prefix, "/prometheus"),
3621 axum::routing::get(prometheus_endpoint::<S>),
3622 );
3623 }
3624
3625 if sensitive {
3626 router = router
3627 .route(
3628 &actuator_route_path(prefix, "/circuitbreakers"),
3629 axum::routing::get(circuitbreakers_endpoint::<S>),
3630 )
3631 .route(
3632 &actuator_route_path(prefix, "/env"),
3633 axum::routing::get(env_endpoint::<S>),
3634 )
3635 .route(
3636 &actuator_route_path(prefix, "/configprops"),
3637 axum::routing::get(configprops_endpoint::<S>),
3638 )
3639 .route(
3640 &actuator_route_path(prefix, "/loggers"),
3641 axum::routing::get(loggers_get::<S>),
3642 )
3643 .route(
3644 &actuator_route_path(prefix, "/loggers/{name}"),
3645 axum::routing::put(loggers_put::<S>),
3646 )
3647 .route(
3648 &actuator_route_path(prefix, "/logfile"),
3649 axum::routing::get(logfile_endpoint::<S>),
3650 )
3651 .route(
3652 &actuator_route_path(prefix, "/tasks"),
3653 axum::routing::get(tasks_endpoint::<S>),
3654 )
3655 .route(
3656 &actuator_route_path(prefix, "/jobs"),
3657 axum::routing::get(jobs_endpoint::<S>),
3658 )
3659 .route(
3660 &actuator_route_path(prefix, "/ui/tasks"),
3661 axum::routing::get(ui_tasks::<S>),
3662 );
3663 #[cfg(feature = "http-client")]
3664 {
3665 router = router
3666 .route(
3667 &actuator_route_path(prefix, "/webhooks/dlq"),
3668 axum::routing::get(webhooks_dlq_endpoint::<S>),
3669 )
3670 .route(
3671 &actuator_route_path(prefix, "/webhooks/replay"),
3672 axum::routing::post(webhooks_replay_endpoint::<S>),
3673 );
3674 }
3675
3676 #[cfg(feature = "system-info")]
3677 {
3678 router = router.route(
3679 &actuator_route_path(prefix, "/system"),
3680 axum::routing::get(crate::system_info::system_info_handler),
3681 );
3682 }
3683
3684 #[cfg(feature = "ws")]
3685 {
3686 router = router
3687 .route(
3688 &actuator_route_path(prefix, "/channels"),
3689 axum::routing::get(channels_endpoint::<S>),
3690 )
3691 .route(
3692 &actuator_route_path(prefix, "/tasks/stream"),
3693 axum::routing::get(tasks_stream_endpoint::<S>),
3694 );
3695 }
3696 }
3697
3698 router
3700 .route(
3701 &actuator_route_path(prefix, "/ui"),
3702 axum::routing::get(ui_dashboard),
3703 )
3704 .route(
3705 &actuator_route_path(prefix, "/ui/metrics"),
3706 axum::routing::get(ui_metrics::<S>),
3707 )
3708}
3709
3710#[cfg(test)]
3711mod tests {
3712 use super::*;
3713 use crate::config::AutumnConfig;
3714
3715 #[test]
3716 fn task_registry_flow() {
3717 let registry = TaskRegistry::new();
3718
3719 registry.register_scheduled(
3720 "my_task",
3721 "0 * * * * *",
3722 crate::task::TaskCoordination::Fleet,
3723 "mock",
3724 "node-1",
3725 );
3726 let snap1 = registry.snapshot();
3727 assert_eq!(snap1.get("my_task").unwrap().total_runs, 0);
3728
3729 registry.record_leader("my_task", "node-1", "mock_tick");
3730 let snap3 = registry.snapshot();
3731 assert_eq!(
3732 snap3.get("my_task").unwrap().current_leader.as_deref(),
3733 Some("node-1")
3734 );
3735
3736 registry.record_start("my_task");
3737 let snap4 = registry.snapshot();
3738 assert_eq!(snap4.get("my_task").unwrap().status, "running");
3739
3740 registry.record_next_run_at("my_task", "tomorrow");
3741 let snap5 = registry.snapshot();
3742 assert_eq!(
3743 snap5.get("my_task").unwrap().next_run_at.as_deref(),
3744 Some("tomorrow")
3745 );
3746
3747 registry.record_success("my_task", 100);
3748 let snap6 = registry.snapshot();
3749 assert_eq!(snap6.get("my_task").unwrap().total_runs, 1);
3750 assert_eq!(snap6.get("my_task").unwrap().last_error, None);
3751
3752 registry.record_failure("my_task", 150, "error message");
3753 let snap7 = registry.snapshot();
3754 assert_eq!(snap7.get("my_task").unwrap().total_runs, 2);
3755 assert_eq!(snap7.get("my_task").unwrap().total_failures, 1);
3756 assert_eq!(
3757 snap7.get("my_task").unwrap().last_error.as_deref(),
3758 Some("error message")
3759 );
3760
3761 let registry2 = TaskRegistry::default();
3762 assert!(registry2.snapshot().is_empty());
3763 }
3764 #[test]
3765 fn job_registry_flow() {
3766 let registry = JobRegistry::new();
3767
3768 registry.register("my_job");
3769 let snap1 = registry.snapshot();
3770 assert_eq!(snap1.get("my_job").unwrap().queued, 0);
3771
3772 registry.record_enqueue("my_job");
3773 let snap2 = registry.snapshot();
3774 assert_eq!(snap2.get("my_job").unwrap().queued, 1);
3775
3776 registry.record_start("my_job");
3777 let snap3 = registry.snapshot();
3778 assert_eq!(snap3.get("my_job").unwrap().queued, 0);
3779 assert_eq!(snap3.get("my_job").unwrap().in_flight, 1);
3780
3781 registry.record_retry("my_job", "timeout", 1);
3782 let snap4 = registry.snapshot();
3783 assert_eq!(snap4.get("my_job").unwrap().in_flight, 0);
3784 assert_eq!(
3785 snap4.get("my_job").unwrap().last_error.as_deref(),
3786 Some("timeout")
3787 );
3788
3789 registry.record_enqueue("my_job");
3790 registry.record_start("my_job");
3791 registry.record_success("my_job");
3792 let snap5 = registry.snapshot();
3793 assert_eq!(snap5.get("my_job").unwrap().in_flight, 0);
3794 assert_eq!(snap5.get("my_job").unwrap().total_successes, 1);
3795 assert_eq!(snap5.get("my_job").unwrap().last_error, None);
3796
3797 registry.record_enqueue("my_job");
3798 registry.record_cancel("my_job");
3799 let snap6 = registry.snapshot();
3800 assert_eq!(snap6.get("my_job").unwrap().queued, 0);
3801 assert_eq!(snap6.get("my_job").unwrap().in_flight, 0);
3802
3803 registry.record_enqueue("my_job");
3804 registry.record_start("my_job");
3805 registry.record_failure("my_job", "failure".to_string(), true);
3806 let snap7 = registry.snapshot();
3807 assert_eq!(snap7.get("my_job").unwrap().in_flight, 0);
3808 assert_eq!(snap7.get("my_job").unwrap().total_failures, 1);
3809 assert_eq!(snap7.get("my_job").unwrap().dead_letters, 1);
3810 assert_eq!(
3811 snap7.get("my_job").unwrap().last_error.as_deref(),
3812 Some("failure")
3813 );
3814
3815 let registry2 = JobRegistry::default();
3816 let snap8 = registry2.snapshot();
3817 assert!(snap8.is_empty());
3818 }
3819
3820 #[test]
3821 fn job_registry_tracks_per_queue_depth_and_oldest_age() {
3822 let registry = JobRegistry::new();
3823 registry.register_on_queue("reset_email", "critical");
3824 registry.register_on_queue("reindex", "bulk");
3825
3826 registry.record_enqueue("reset_email");
3828 registry.record_enqueue("reset_email");
3829 registry.record_enqueue("reindex");
3830
3831 let queues = registry.queue_snapshot();
3832 assert_eq!(queues.get("critical").unwrap().depth, 2);
3833 assert_eq!(queues.get("bulk").unwrap().depth, 1);
3834 std::thread::sleep(std::time::Duration::from_millis(5));
3837 assert!(
3838 registry
3839 .queue_snapshot()
3840 .get("critical")
3841 .unwrap()
3842 .oldest_waiting_age_ms
3843 > 0,
3844 "a job waiting for ~5ms must report a positive age"
3845 );
3846
3847 registry.record_start("reset_email");
3849 assert_eq!(registry.queue_snapshot().get("critical").unwrap().depth, 1);
3850
3851 registry.record_start("reset_email");
3853 registry.record_start("reindex");
3854 let drained = registry.queue_snapshot();
3855 assert_eq!(drained.get("critical").unwrap().depth, 0);
3856 assert_eq!(drained.get("critical").unwrap().oldest_waiting_age_ms, 0);
3857 assert_eq!(drained.get("bulk").unwrap().depth, 0);
3858 }
3859
3860 #[test]
3861 fn survey_setter_overwrites_queue_gauges_and_resets_absent_queues() {
3862 let registry = JobRegistry::new();
3863 registry.register_on_queue("reset_email", "critical");
3864 registry.register_on_queue("reindex", "bulk");
3865
3866 registry.record_enqueue("reset_email");
3870
3871 let now = now_epoch_ms();
3872 let mut survey = HashMap::new();
3873 survey.insert(
3875 "critical".to_string(),
3876 (4_u64, Some(now.saturating_sub(5_000))),
3877 );
3878 survey.insert("bulk".to_string(), (0_u64, None));
3880 registry.set_queue_depth_gauges(&survey);
3881
3882 let snap = registry.queue_snapshot();
3883 assert_eq!(
3884 snap.get("critical").unwrap().depth,
3885 4,
3886 "survey depth overrides the local enqueue mark"
3887 );
3888 let age = snap.get("critical").unwrap().oldest_waiting_age_ms;
3889 assert!(
3890 (5_000..=6_000).contains(&age),
3891 "age is derived from the surveyed oldest ready-at timestamp, got {age}"
3892 );
3893 assert_eq!(snap.get("bulk").unwrap().depth, 0);
3894 assert_eq!(snap.get("bulk").unwrap().oldest_waiting_age_ms, 0);
3895
3896 let mut survey2 = HashMap::new();
3899 survey2.insert("bulk".to_string(), (2_u64, Some(now)));
3900 registry.set_queue_depth_gauges(&survey2);
3901 let snap2 = registry.queue_snapshot();
3902 assert_eq!(
3903 snap2.get("critical").unwrap().depth,
3904 0,
3905 "a queue absent from the newest survey resets to 0"
3906 );
3907 assert_eq!(snap2.get("bulk").unwrap().depth, 2);
3908 }
3909
3910 #[test]
3911 fn survey_setter_overwrites_per_job_queued_and_resets_absent_names() {
3912 let registry = JobRegistry::new();
3913 registry.register("reset_email");
3914 registry.register("reindex");
3915
3916 registry.record_enqueue("reset_email");
3917 registry.record_enqueue("reset_email");
3918
3919 let mut counts = HashMap::new();
3920 counts.insert("reset_email".to_string(), 7_u64);
3921 registry.set_queued_counts(&counts);
3922 assert_eq!(
3923 registry.snapshot()["reset_email"].queued,
3924 7,
3925 "survey overwrites the local enqueue-driven queued count"
3926 );
3927 assert_eq!(
3928 registry.snapshot()["reindex"].queued,
3929 0,
3930 "a name absent from the survey resets to 0"
3931 );
3932
3933 registry.set_queued_counts(&HashMap::new());
3934 assert_eq!(registry.snapshot()["reset_email"].queued, 0);
3935 }
3936
3937 #[test]
3938 fn future_scheduled_jobs_do_not_inflate_ready_queue_depth() {
3939 let registry = JobRegistry::new();
3940 registry.register_on_queue("nightly_report", "reports");
3941 registry.register_on_queue("send_email", "reports");
3942
3943 let far_future = now_epoch_ms() + 60_000;
3948 registry.record_enqueue_scheduled("nightly_report", far_future);
3949
3950 let scheduled_only = registry.queue_snapshot();
3951 let reports = scheduled_only
3952 .get("reports")
3953 .expect("reports queue tracked");
3954 assert_eq!(
3955 reports.depth, 0,
3956 "a future-scheduled job is not ready backlog"
3957 );
3958 assert_eq!(
3959 reports.oldest_waiting_age_ms, 0,
3960 "a future-scheduled job must not age the ready queue"
3961 );
3962
3963 registry.record_enqueue("send_email");
3965 assert_eq!(
3966 registry.queue_snapshot().get("reports").unwrap().depth,
3967 1,
3968 "an immediately-runnable job still counts toward ready depth"
3969 );
3970
3971 let already_ready = now_epoch_ms().saturating_sub(5);
3974 registry.record_enqueue_scheduled("nightly_report", already_ready);
3975 let promoted = registry.queue_snapshot();
3976 assert_eq!(
3977 promoted.get("reports").unwrap().depth,
3978 2,
3979 "a scheduled job counts once its ready time has passed"
3980 );
3981 assert!(
3982 promoted.get("reports").unwrap().oldest_waiting_age_ms > 0,
3983 "a job whose ready time has passed contributes to oldest-waiting age"
3984 );
3985
3986 registry.record_start("send_email");
3989 registry.record_start("nightly_report");
3990 let drained = registry.queue_snapshot();
3991 assert_eq!(
3992 drained.get("reports").unwrap().depth,
3993 0,
3994 "with both ready jobs started only the future mark remains, counting as 0"
3995 );
3996 assert_eq!(
3997 drained.get("reports").unwrap().oldest_waiting_age_ms,
3998 0,
3999 "a lone future-scheduled mark ages nothing"
4000 );
4001 }
4002
4003 #[test]
4004 fn canceling_a_scheduled_job_preserves_a_coqueued_ready_mark() {
4005 let registry = JobRegistry::new();
4011 registry.register_on_queue("nightly_report", "reports");
4012 registry.register_on_queue("send_email", "reports");
4013
4014 registry.record_enqueue("send_email");
4017 let far_future = now_epoch_ms() + 60_000;
4018 registry.record_enqueue_scheduled("nightly_report", far_future);
4019 assert_eq!(
4020 registry.queue_snapshot().get("reports").unwrap().depth,
4021 1,
4022 "only the ready job counts toward ready depth"
4023 );
4024
4025 registry.record_cancel_scheduled("nightly_report");
4029 assert_eq!(
4030 registry.queue_snapshot().get("reports").unwrap().depth,
4031 1,
4032 "canceling the scheduled job must not steal the co-queued ready job's mark"
4033 );
4034
4035 registry.record_start("send_email");
4038 assert_eq!(
4039 registry.queue_snapshot().get("reports").unwrap().depth,
4040 0,
4041 "starting the ready job drains the queue; the scheduled mark was the one removed"
4042 );
4043 }
4044
4045 #[test]
4046 fn canceling_a_ready_job_removes_a_ready_mark() {
4047 let registry = JobRegistry::new();
4050 registry.register_on_queue("send_email", "mail");
4051
4052 registry.record_enqueue("send_email");
4053 registry.record_enqueue("send_email");
4054 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
4055
4056 registry.record_cancel("send_email");
4057 assert_eq!(
4058 registry.queue_snapshot().get("mail").unwrap().depth,
4059 1,
4060 "canceling a ready job removes exactly one ready mark"
4061 );
4062
4063 registry.record_start("send_email");
4064 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
4065 }
4066
4067 #[test]
4068 fn retry_dedup_without_enqueue_mark_keeps_real_duplicate_waiting() {
4069 let registry = JobRegistry::new();
4070 registry.register_on_queue("send_email", "mail");
4071
4072 registry.record_enqueue("send_email");
4074 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 1);
4075
4076 registry.record_deduplicated("send_email", false, false);
4080 assert_eq!(
4081 registry.queue_snapshot().get("mail").unwrap().depth,
4082 1,
4083 "retry-dedup with no prior enqueue mark must not steal a waiting duplicate's mark"
4084 );
4085
4086 registry.record_enqueue("send_email");
4089 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 2);
4090 registry.record_deduplicated("send_email", true, false);
4091 assert_eq!(
4092 registry.queue_snapshot().get("mail").unwrap().depth,
4093 1,
4094 "a normal coalesced enqueue removes exactly its own mark (no leak)"
4095 );
4096
4097 registry.record_start("send_email");
4099 assert_eq!(registry.queue_snapshot().get("mail").unwrap().depth, 0);
4100 }
4101
4102 #[test]
4103 fn deduplicating_a_scheduled_duplicate_preserves_a_coqueued_ready_mark() {
4104 let registry = JobRegistry::new();
4112 registry.register_on_queue("nightly_report", "reports");
4113 registry.register_on_queue("send_email", "reports");
4114
4115 let far_future = now_epoch_ms() + 60_000;
4117 registry.record_enqueue_scheduled("nightly_report", far_future);
4118 let already_ready = now_epoch_ms().saturating_sub(5);
4121 registry.record_enqueue_scheduled("send_email", already_ready);
4122
4123 let before = registry.queue_snapshot();
4124 assert_eq!(
4125 before.get("reports").unwrap().depth,
4126 1,
4127 "only the ready job counts toward ready depth"
4128 );
4129 assert!(
4130 before.get("reports").unwrap().oldest_waiting_age_ms > 0,
4131 "the ready job ages the queue before the dedup"
4132 );
4133
4134 registry.record_deduplicated("nightly_report", true, true);
4139 let after = registry.queue_snapshot();
4140 assert_eq!(
4141 after.get("reports").unwrap().depth,
4142 1,
4143 "deduplicating the scheduled duplicate must not steal the ready job's mark"
4144 );
4145 assert!(
4146 after.get("reports").unwrap().oldest_waiting_age_ms > 0,
4147 "the ready job's mark is preserved, so it still ages the queue"
4148 );
4149
4150 registry.record_start("send_email");
4153 assert_eq!(
4154 registry.queue_snapshot().get("reports").unwrap().depth,
4155 0,
4156 "starting the ready job drains the queue; the scheduled mark was removed"
4157 );
4158 }
4159
4160 use axum::body::Body;
4161 use axum::http::Request;
4162 use tower::ServiceExt;
4163
4164 #[derive(Clone)]
4165 struct TestActuatorState {
4166 profile: String,
4167 deploy_version: String,
4168 metrics: crate::middleware::MetricsCollector,
4169 log_levels: LogLevels,
4170 task_registry: TaskRegistry,
4171 job_registry: JobRegistry,
4172 config_props: ConfigProperties,
4173 metrics_source_registry: MetricsSourceRegistry,
4174 health_indicator_registry: HealthIndicatorRegistry,
4175 health_detailed: bool,
4176 log_buffer: Option<crate::log::capture::LogBuffer>,
4177 #[cfg(feature = "http-client")]
4178 webhook_outbound: Option<crate::webhook_outbound::WebhookOutboundManager>,
4179 #[cfg(feature = "db")]
4180 pool: Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
4181 #[cfg(feature = "db")]
4182 shards: Option<crate::sharding::ShardSet>,
4183 #[cfg(feature = "ws")]
4184 channels: crate::channels::Channels,
4185 #[cfg(feature = "ws")]
4186 shutdown: tokio_util::sync::CancellationToken,
4187 }
4188
4189 impl ProvideActuatorState for TestActuatorState {
4190 fn metrics(&self) -> &crate::middleware::MetricsCollector {
4191 &self.metrics
4192 }
4193 fn log_levels(&self) -> &LogLevels {
4194 &self.log_levels
4195 }
4196 fn task_registry(&self) -> &TaskRegistry {
4197 &self.task_registry
4198 }
4199 fn job_registry(&self) -> &JobRegistry {
4200 &self.job_registry
4201 }
4202 fn config_props(&self) -> &ConfigProperties {
4203 &self.config_props
4204 }
4205 fn profile(&self) -> &str {
4206 &self.profile
4207 }
4208 fn uptime_display(&self) -> String {
4209 "test_uptime".to_string()
4210 }
4211 fn deploy_version(&self) -> String {
4212 self.deploy_version.clone()
4213 }
4214 fn metrics_source_registry(&self) -> Option<&MetricsSourceRegistry> {
4215 Some(&self.metrics_source_registry)
4216 }
4217 #[cfg(feature = "http-client")]
4218 fn webhook_outbound(&self) -> Option<crate::webhook_outbound::WebhookOutboundManager> {
4219 self.webhook_outbound.clone()
4220 }
4221 #[cfg(feature = "db")]
4222 fn pool(
4223 &self,
4224 ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
4225 {
4226 self.pool.as_ref()
4227 }
4228 #[cfg(feature = "db")]
4229 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
4230 self.shards.as_ref()
4231 }
4232 #[cfg(feature = "ws")]
4233 fn channels(&self) -> &crate::channels::Channels {
4234 &self.channels
4235 }
4236 #[cfg(feature = "ws")]
4237 fn shutdown_token(&self) -> tokio_util::sync::CancellationToken {
4238 self.shutdown.clone()
4239 }
4240 fn health_indicator_registry(&self) -> Option<&HealthIndicatorRegistry> {
4241 Some(&self.health_indicator_registry)
4242 }
4243 fn health_detailed(&self) -> bool {
4244 self.health_detailed
4245 }
4246 fn log_buffer(&self) -> Option<crate::log::capture::LogBuffer> {
4247 self.log_buffer.clone()
4248 }
4249 }
4250
4251 fn test_state() -> TestActuatorState {
4252 test_state_with_config(&AutumnConfig::default())
4253 }
4254
4255 fn test_state_with_config(config: &AutumnConfig) -> TestActuatorState {
4256 TestActuatorState {
4257 profile: config.profile.clone().unwrap_or_else(|| "dev".into()),
4258 deploy_version: crate::canary::STABLE.to_owned(),
4259 metrics: crate::middleware::MetricsCollector::new(),
4260 log_levels: LogLevels::new("info"),
4261 task_registry: TaskRegistry::new(),
4262 job_registry: JobRegistry::new(),
4263 config_props: ConfigProperties::from_config(config),
4264 metrics_source_registry: MetricsSourceRegistry::new(),
4265 health_indicator_registry: HealthIndicatorRegistry::new(),
4266 health_detailed: config.health.detailed,
4267 log_buffer: None,
4268 #[cfg(feature = "http-client")]
4269 webhook_outbound: None,
4270 #[cfg(feature = "db")]
4271 pool: None,
4272 #[cfg(feature = "db")]
4273 shards: None,
4274 #[cfg(feature = "ws")]
4275 channels: crate::channels::Channels::new(32),
4276 #[cfg(feature = "ws")]
4277 shutdown: tokio_util::sync::CancellationToken::new(),
4278 }
4279 }
4280
4281 #[cfg(feature = "http-client")]
4282 fn test_state_with_webhook_outbound(
4283 manager: crate::webhook_outbound::WebhookOutboundManager,
4284 ) -> TestActuatorState {
4285 let mut state = test_state();
4286 state.webhook_outbound = Some(manager);
4287 state
4288 }
4289
4290 #[cfg(feature = "http-client")]
4291 fn replay_test_subscription() -> crate::webhook_outbound::WebhookSubscription {
4292 crate::webhook_outbound::WebhookSubscription {
4293 id: "sub-replay".to_string(),
4294 target_url: "https://example.test/webhook".to_string(),
4295 event_topics: vec!["order.created".to_string()],
4296 secret: "secret".to_string(),
4297 status: crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
4298 consecutive_failures: 50,
4299 }
4300 }
4301
4302 #[cfg(feature = "http-client")]
4303 fn replay_test_dlq_log() -> crate::webhook_outbound::WebhookDeliveryLog {
4304 crate::webhook_outbound::WebhookDeliveryLog {
4305 id: "log-replay".to_string(),
4306 subscription_id: "sub-replay".to_string(),
4307 topic: "order.created".to_string(),
4308 payload: "{\"id\":123}".to_string(),
4309 request_headers: std::collections::HashMap::new(),
4310 response_status: Some(503),
4311 response_body: Some("unavailable".to_string()),
4312 elapsed_ms: 42,
4313 attempt: 5,
4314 max_attempts: 5,
4315 is_dlq: true,
4316 last_error: Some("server returned status: 503".to_string()),
4317 timestamp: chrono::Utc::now(),
4318 }
4319 }
4320
4321 #[cfg(feature = "http-client")]
4322 #[tokio::test]
4323 async fn webhooks_replay_preserves_dlq_log_and_failures_when_enqueue_is_unavailable() {
4324 use crate::webhook_outbound::{
4325 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
4326 };
4327
4328 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
4329 crate::job::clear_global_job_client();
4330
4331 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
4332 handler
4333 .create_subscription(replay_test_subscription())
4334 .await
4335 .expect("subscription setup");
4336 let original_log = replay_test_dlq_log();
4337 handler
4338 .log_delivery(original_log.clone())
4339 .await
4340 .expect("dlq log setup");
4341 let failures_before_replay = handler
4342 .get_subscription("sub-replay")
4343 .await
4344 .expect("subscription lookup")
4345 .expect("subscription should exist")
4346 .consecutive_failures;
4347
4348 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
4349 let response = webhooks_replay_endpoint(
4350 State(state),
4351 Json(ReplayRequest {
4352 log_id: original_log.id.clone(),
4353 }),
4354 )
4355 .await
4356 .into_response();
4357
4358 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
4359
4360 let stored_log = handler
4361 .get_delivery_log(&original_log.id)
4362 .await
4363 .expect("delivery log lookup")
4364 .expect("delivery log should still exist");
4365 assert!(stored_log.is_dlq, "failed enqueue must keep log in DLQ");
4366 assert_eq!(stored_log.attempt, original_log.attempt);
4367 assert_eq!(stored_log.last_error, original_log.last_error);
4368 assert_eq!(stored_log.response_status, original_log.response_status);
4369 assert_eq!(stored_log.response_body, original_log.response_body);
4370
4371 let subscription = handler
4372 .get_subscription("sub-replay")
4373 .await
4374 .expect("subscription lookup")
4375 .expect("subscription should exist");
4376 assert_eq!(
4377 subscription.consecutive_failures, failures_before_replay,
4378 "failed enqueue must not reset subscription failure history"
4379 );
4380 assert_eq!(
4381 subscription.status,
4382 crate::webhook_outbound::WebhookSubscriptionStatus::Failed,
4383 "failed enqueue must not reactivate an auto-failed subscription"
4384 );
4385
4386 crate::job::clear_global_job_client();
4387 }
4388
4389 #[cfg(feature = "http-client")]
4390 #[tokio::test]
4391 async fn webhooks_replay_rejects_disabled_subscription_without_removing_dlq() {
4392 use crate::webhook_outbound::{
4393 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
4394 WebhookSubscriptionStatus,
4395 };
4396
4397 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
4398 crate::job::clear_global_job_client();
4399
4400 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
4401 let mut subscription = replay_test_subscription();
4402 subscription.status = WebhookSubscriptionStatus::Disabled;
4403 subscription.consecutive_failures = 0;
4404 handler
4405 .create_subscription(subscription)
4406 .await
4407 .expect("subscription setup");
4408 let original_log = replay_test_dlq_log();
4409 handler
4410 .log_delivery(original_log.clone())
4411 .await
4412 .expect("dlq log setup");
4413
4414 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
4415 let response = webhooks_replay_endpoint(
4416 State(state),
4417 Json(ReplayRequest {
4418 log_id: original_log.id.clone(),
4419 }),
4420 )
4421 .await
4422 .into_response();
4423
4424 assert_eq!(response.status(), StatusCode::CONFLICT);
4425
4426 let stored_log = handler
4427 .get_delivery_log(&original_log.id)
4428 .await
4429 .expect("delivery log lookup")
4430 .expect("delivery log should still exist");
4431 assert!(stored_log.is_dlq);
4432 assert_eq!(stored_log.attempt, original_log.attempt);
4433 assert_eq!(stored_log.response_status, original_log.response_status);
4434 assert_eq!(stored_log.last_error, original_log.last_error);
4435
4436 let subscription = handler
4437 .get_subscription("sub-replay")
4438 .await
4439 .expect("subscription lookup")
4440 .expect("subscription should exist");
4441 assert_eq!(subscription.status, WebhookSubscriptionStatus::Disabled);
4442
4443 crate::job::clear_global_job_client();
4444 }
4445
4446 #[cfg(feature = "http-client")]
4447 #[tokio::test]
4448 async fn webhooks_replay_rejects_missing_subscription_without_removing_dlq() {
4449 use crate::webhook_outbound::{
4450 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
4451 };
4452
4453 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
4454 crate::job::clear_global_job_client();
4455
4456 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
4457 let original_log = replay_test_dlq_log();
4458 handler
4459 .log_delivery(original_log.clone())
4460 .await
4461 .expect("dlq log setup");
4462
4463 let runtime_state = crate::AppState::for_test().with_profile("test");
4464 let shutdown = tokio_util::sync::CancellationToken::new();
4465 crate::job::start_runtime(
4466 vec![crate::job::JobInfo {
4467 version: 1,
4468 name: "autumn_webhook_delivery".to_string(),
4469 max_attempts: 1,
4470 initial_backoff_ms: 1,
4471 queue: "default".to_string(),
4472 uniqueness: None,
4473 concurrency: None,
4474 handler: |_state, _payload| Box::pin(async move { Ok(()) }),
4475 }],
4476 &runtime_state,
4477 &shutdown,
4478 &crate::config::JobConfig::default(),
4479 true,
4480 )
4481 .expect("job runtime should start");
4482
4483 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
4484 let response = webhooks_replay_endpoint(
4485 State(state),
4486 Json(ReplayRequest {
4487 log_id: original_log.id.clone(),
4488 }),
4489 )
4490 .await
4491 .into_response();
4492
4493 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4494
4495 let stored_log = handler
4496 .get_delivery_log(&original_log.id)
4497 .await
4498 .expect("delivery log lookup")
4499 .expect("delivery log should still exist");
4500 assert!(stored_log.is_dlq);
4501 assert_eq!(stored_log.attempt, original_log.attempt);
4502 assert_eq!(stored_log.response_status, original_log.response_status);
4503 assert_eq!(stored_log.response_body, original_log.response_body);
4504 assert_eq!(stored_log.last_error, original_log.last_error);
4505
4506 assert!(
4507 handler
4508 .get_subscription("sub-replay")
4509 .await
4510 .expect("subscription lookup")
4511 .is_none(),
4512 "test setup should leave the subscription missing"
4513 );
4514
4515 shutdown.cancel();
4516 crate::job::clear_global_job_client();
4517 }
4518
4519 #[cfg(feature = "http-client")]
4520 #[tokio::test]
4521 async fn webhooks_replay_resets_log_and_failures_after_enqueue_succeeds() {
4522 use crate::webhook_outbound::{
4523 InMemoryOutboundWebhookHandler, OutboundWebhookHandler, WebhookOutboundManager,
4524 };
4525
4526 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
4527 crate::job::clear_global_job_client();
4528
4529 let handler = Arc::new(InMemoryOutboundWebhookHandler::new());
4530 handler
4531 .create_subscription(replay_test_subscription())
4532 .await
4533 .expect("subscription setup");
4534 let original_log = replay_test_dlq_log();
4535 handler
4536 .log_delivery(original_log.clone())
4537 .await
4538 .expect("dlq log setup");
4539
4540 let runtime_state = crate::AppState::for_test().with_profile("test");
4541 let shutdown = tokio_util::sync::CancellationToken::new();
4542 crate::job::start_runtime(
4543 vec![crate::job::JobInfo {
4544 version: 1,
4545 name: "autumn_webhook_delivery".to_string(),
4546 max_attempts: 1,
4547 initial_backoff_ms: 1,
4548 queue: "default".to_string(),
4549 uniqueness: None,
4550 concurrency: None,
4551 handler: |_state, _payload| Box::pin(async move { Ok(()) }),
4552 }],
4553 &runtime_state,
4554 &shutdown,
4555 &crate::config::JobConfig::default(),
4556 true,
4557 )
4558 .expect("job runtime should start");
4559
4560 let state = test_state_with_webhook_outbound(WebhookOutboundManager::new(handler.clone()));
4561 let response = webhooks_replay_endpoint(
4562 State(state),
4563 Json(ReplayRequest {
4564 log_id: original_log.id.clone(),
4565 }),
4566 )
4567 .await
4568 .into_response();
4569
4570 assert_eq!(response.status(), StatusCode::OK);
4571
4572 let stored_log = handler
4573 .get_delivery_log(&original_log.id)
4574 .await
4575 .expect("delivery log lookup")
4576 .expect("delivery log should still exist");
4577 assert!(!stored_log.is_dlq);
4578 assert_eq!(stored_log.attempt, 1);
4579 assert_eq!(stored_log.last_error, None);
4580 assert_eq!(stored_log.response_status, None);
4581 assert_eq!(stored_log.response_body, None);
4582
4583 let subscription = handler
4584 .get_subscription("sub-replay")
4585 .await
4586 .expect("subscription lookup")
4587 .expect("subscription should exist");
4588 assert_eq!(subscription.consecutive_failures, 0);
4589 assert_eq!(
4590 subscription.status,
4591 crate::webhook_outbound::WebhookSubscriptionStatus::Active
4592 );
4593
4594 shutdown.cancel();
4595 crate::job::clear_global_job_client();
4596 }
4597
4598 #[tokio::test]
4599 async fn actuator_health_returns_ok() {
4600 let app = actuator_router(true).with_state(test_state());
4601 let resp = app
4602 .oneshot(
4603 Request::builder()
4604 .uri("/actuator/health")
4605 .body(Body::empty())
4606 .unwrap(),
4607 )
4608 .await
4609 .unwrap();
4610
4611 assert_eq!(resp.status(), StatusCode::OK);
4612 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4613 .await
4614 .unwrap();
4615 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4616 assert_eq!(json["status"], "UP");
4617 assert_eq!(json["profile"], "dev");
4618 assert!(json["uptime"].is_string());
4619 }
4620
4621 #[cfg(feature = "db")]
4622 #[tokio::test]
4623 async fn actuator_health_exposes_after_commit_failure_counter() {
4624 let app = actuator_router(true).with_state(test_state());
4625 let resp = app
4626 .oneshot(
4627 Request::builder()
4628 .uri("/actuator/health")
4629 .body(Body::empty())
4630 .unwrap(),
4631 )
4632 .await
4633 .unwrap();
4634
4635 assert_eq!(resp.status(), StatusCode::OK);
4636 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4637 .await
4638 .unwrap();
4639 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4640 assert_eq!(
4641 json["autumn_after_commit_failures_total"],
4642 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed),
4643 "/actuator/health should expose the documented after_commit counter"
4644 );
4645 }
4646
4647 #[tokio::test]
4648 #[allow(clippy::await_holding_lock)]
4649 async fn actuator_circuitbreakers_returns_breakers() {
4650 let _lock = crate::circuit_breaker::TEST_LOCK
4651 .lock()
4652 .unwrap_or_else(std::sync::PoisonError::into_inner);
4653 crate::circuit_breaker::global_registry().clear();
4654 let breaker = crate::circuit_breaker::global_registry().get_or_create(
4655 "actuator_endpoint_test_breaker",
4656 crate::circuit_breaker::CircuitBreakerPolicy {
4657 failure_ratio_threshold: 0.5,
4658 sample_window: std::time::Duration::from_secs(10),
4659 minimum_sample_count: 2,
4660 open_duration: std::time::Duration::from_secs(60),
4661 half_open_trial_count: 2,
4662 },
4663 );
4664 assert_eq!(
4665 breaker.state(),
4666 crate::circuit_breaker::CircuitState::Closed
4667 );
4668
4669 let mut detailed_config = AutumnConfig::default();
4670 detailed_config.health.detailed = true;
4671 let state = test_state_with_config(&detailed_config);
4672 let app = actuator_router(true).with_state(state);
4673 let resp = app
4674 .clone()
4675 .oneshot(
4676 Request::builder()
4677 .uri("/actuator/circuitbreakers")
4678 .body(Body::empty())
4679 .unwrap(),
4680 )
4681 .await
4682 .unwrap();
4683
4684 assert_eq!(resp.status(), StatusCode::OK);
4685 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4686 .await
4687 .unwrap();
4688 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4689 let list = json.as_array().expect("Should be a JSON array");
4690 let item = list
4691 .iter()
4692 .find(|i| i["name"] == "actuator_endpoint_test_breaker")
4693 .expect("Should find our breaker");
4694 assert_eq!(item["state"], "CLOSED");
4695 assert_eq!(item["failure_ratio_threshold"], 0.5);
4696 assert_eq!(item["minimum_sample_count"], 2);
4697
4698 let mut undetailed_config = AutumnConfig::default();
4699 undetailed_config.health.detailed = false;
4700 let undetailed_state = test_state_with_config(&undetailed_config);
4701 let app_undetailed = actuator_router(true).with_state(undetailed_state);
4702 let resp_undetailed = app_undetailed
4703 .oneshot(
4704 Request::builder()
4705 .uri("/actuator/circuitbreakers")
4706 .body(Body::empty())
4707 .unwrap(),
4708 )
4709 .await
4710 .unwrap();
4711
4712 assert_eq!(resp_undetailed.status(), StatusCode::OK);
4713 let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
4714 .await
4715 .unwrap();
4716 let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
4717 let list_undetailed = json_undetailed.as_array().expect("Should be a JSON array");
4718 let item_undetailed = list_undetailed
4719 .iter()
4720 .find(|i| i["name"] == "actuator_endpoint_test_breaker")
4721 .expect("Should find our breaker");
4722 assert_eq!(item_undetailed["state"], "CLOSED");
4723 assert!(item_undetailed.get("failure_ratio_threshold").is_none());
4724 assert!(item_undetailed.get("minimum_sample_count").is_none());
4725 crate::circuit_breaker::global_registry().clear();
4726 }
4727
4728 #[tokio::test]
4729 #[allow(clippy::await_holding_lock)]
4730 async fn test_health_hides_circuit_breakers_when_undetailed() {
4731 let _lock = crate::circuit_breaker::TEST_LOCK
4732 .lock()
4733 .unwrap_or_else(std::sync::PoisonError::into_inner);
4734 crate::circuit_breaker::global_registry().clear();
4735
4736 let _breaker = crate::circuit_breaker::global_registry().get_or_create(
4737 "test_health_hide_breaker",
4738 crate::circuit_breaker::CircuitBreakerPolicy {
4739 failure_ratio_threshold: 0.5,
4740 sample_window: std::time::Duration::from_secs(10),
4741 minimum_sample_count: 2,
4742 open_duration: std::time::Duration::from_secs(60),
4743 half_open_trial_count: 2,
4744 },
4745 );
4746
4747 let mut detailed_config = AutumnConfig::default();
4748 detailed_config.health.detailed = true;
4749 let state = test_state_with_config(&detailed_config);
4750 let app = actuator_router(true).with_state(state);
4751 let resp = app
4752 .oneshot(
4753 Request::builder()
4754 .uri("/actuator/health")
4755 .body(Body::empty())
4756 .unwrap(),
4757 )
4758 .await
4759 .unwrap();
4760
4761 assert_eq!(resp.status(), StatusCode::OK);
4762 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4763 .await
4764 .unwrap();
4765 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4766 assert!(json["components"]["circuit_breaker.test_health_hide_breaker"].is_object());
4767
4768 let mut undetailed_config = AutumnConfig::default();
4769 undetailed_config.health.detailed = false;
4770 let undetailed_state = test_state_with_config(&undetailed_config);
4771 let app_undetailed = actuator_router(true).with_state(undetailed_state);
4772 let resp_undetailed = app_undetailed
4773 .oneshot(
4774 Request::builder()
4775 .uri("/actuator/health")
4776 .body(Body::empty())
4777 .unwrap(),
4778 )
4779 .await
4780 .unwrap();
4781
4782 assert_eq!(resp_undetailed.status(), StatusCode::OK);
4783 let body_undetailed = axum::body::to_bytes(resp_undetailed.into_body(), usize::MAX)
4784 .await
4785 .unwrap();
4786 let json_undetailed: serde_json::Value = serde_json::from_slice(&body_undetailed).unwrap();
4787
4788 if let Some(components) = json_undetailed.get("components") {
4789 assert!(
4790 components
4791 .get("circuit_breaker.test_health_hide_breaker")
4792 .is_none()
4793 );
4794 }
4795
4796 crate::circuit_breaker::global_registry().clear();
4797 }
4798
4799 #[tokio::test]
4800 async fn actuator_routes_respect_custom_prefix() {
4801 let app = actuator_router_with_prefix("/ops", true, true).with_state(test_state());
4802
4803 let prefixed = app
4804 .clone()
4805 .oneshot(
4806 Request::builder()
4807 .uri("/ops/health")
4808 .body(Body::empty())
4809 .unwrap(),
4810 )
4811 .await
4812 .unwrap();
4813 assert_eq!(prefixed.status(), StatusCode::OK);
4814
4815 let legacy = app
4816 .oneshot(
4817 Request::builder()
4818 .uri("/actuator/health")
4819 .body(Body::empty())
4820 .unwrap(),
4821 )
4822 .await
4823 .unwrap();
4824 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
4825 }
4826
4827 #[test]
4828 fn actuator_route_helpers_normalize_prefixes() {
4829 assert_eq!(actuator_route_glob("ops/"), "/ops/*");
4830 assert_eq!(actuator_route_path("ops/", "/health"), "/ops/health");
4831 assert_eq!(actuator_route_glob("/"), "/*");
4832 }
4833
4834 #[tokio::test]
4835 async fn actuator_info_returns_metadata() {
4836 let app = actuator_router(true).with_state(test_state());
4837 let resp = app
4838 .oneshot(
4839 Request::builder()
4840 .uri("/actuator/info")
4841 .body(Body::empty())
4842 .unwrap(),
4843 )
4844 .await
4845 .unwrap();
4846
4847 assert_eq!(resp.status(), StatusCode::OK);
4848 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4849 .await
4850 .unwrap();
4851 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4852 assert!(json["autumn"]["version"].is_string());
4853 assert_eq!(json["autumn"]["profile"], "dev");
4854 }
4855
4856 #[tokio::test]
4857 async fn actuator_info_reports_build_and_git_provenance() {
4858 fn leak(value: String) -> &'static str {
4862 Box::leak(value.into_boxed_str())
4863 }
4864
4865 let head = std::process::Command::new("git")
4868 .args(["rev-parse", "HEAD"])
4869 .output()
4870 .ok()
4871 .filter(|out| out.status.success())
4872 .and_then(|out| String::from_utf8(out.stdout).ok())
4873 .map(|out| out.trim().to_owned())
4874 .expect("git rev-parse HEAD should succeed in the repo");
4875 let short: String = head.chars().take(7).collect();
4876
4877 crate::build_info::__set_build_context(
4878 "provenance_probe_app",
4879 "9.9.9",
4880 Some(leak(head.clone())),
4881 Some(leak(short.clone())),
4882 Some("provenance-branch"),
4883 Some("false"),
4884 Some("2026-07-09T00:00:00Z"),
4885 );
4886
4887 let app = actuator_router(true).with_state(test_state());
4888 let resp = app
4889 .oneshot(
4890 Request::builder()
4891 .uri("/actuator/info")
4892 .body(Body::empty())
4893 .unwrap(),
4894 )
4895 .await
4896 .unwrap();
4897 assert_eq!(resp.status(), StatusCode::OK);
4898 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4899 .await
4900 .unwrap();
4901 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4902
4903 assert_eq!(json["app"]["name"], "provenance_probe_app");
4906 assert_eq!(json["app"]["version"], "9.9.9");
4907 assert_ne!(json["app"]["version"], "unknown");
4908
4909 assert_eq!(json["build"]["git"]["commit"], head);
4912 assert_eq!(json["build"]["git"]["commit_short"], short);
4913 assert_eq!(json["build"]["git"]["branch"], "provenance-branch");
4914 assert_eq!(json["build"]["git"]["dirty"], false);
4915 assert_eq!(json["build"]["timestamp"], "2026-07-09T00:00:00Z");
4916 assert_eq!(json["build"]["version"], "9.9.9");
4917 }
4918
4919 #[tokio::test]
4920 async fn actuator_env_available_in_sensitive_mode() {
4921 let config = AutumnConfig {
4922 profile: Some("prod".into()),
4923 server: crate::config::ServerConfig {
4924 port: 4100,
4925 ..crate::config::ServerConfig::default()
4926 },
4927 telemetry: crate::config::TelemetryConfig {
4928 enabled: true,
4929 service_name: "cloud-app".into(),
4930 ..crate::config::TelemetryConfig::default()
4931 },
4932 health: crate::config::HealthConfig {
4933 path: "/healthz".into(),
4934 ..crate::config::HealthConfig::default()
4935 },
4936 ..AutumnConfig::default()
4937 };
4938
4939 let app = actuator_router(true).with_state(test_state_with_config(&config));
4940 let resp = app
4941 .oneshot(
4942 Request::builder()
4943 .uri("/actuator/env")
4944 .body(Body::empty())
4945 .unwrap(),
4946 )
4947 .await
4948 .unwrap();
4949 assert_eq!(resp.status(), StatusCode::OK);
4950 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4951 .await
4952 .unwrap();
4953 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4954 assert_eq!(json["active_profile"], "prod");
4955 assert_eq!(json["properties"]["server.port"], "4100");
4956 assert_eq!(json["properties"]["telemetry.enabled"], "true");
4957 assert_eq!(json["properties"]["telemetry.service_name"], "cloud-app");
4958 assert_eq!(json["properties"]["health.path"], "/healthz");
4959 }
4960
4961 #[tokio::test]
4962 async fn actuator_env_hidden_in_nonsensitive_mode() {
4963 let app = actuator_router(false).with_state(test_state());
4964 let resp = app
4965 .oneshot(
4966 Request::builder()
4967 .uri("/actuator/env")
4968 .body(Body::empty())
4969 .unwrap(),
4970 )
4971 .await
4972 .unwrap();
4973 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4974 }
4975
4976 #[tokio::test]
4977 async fn actuator_circuitbreakers_hidden_in_nonsensitive_mode() {
4978 let app = actuator_router(false).with_state(test_state());
4979 let resp = app
4980 .oneshot(
4981 Request::builder()
4982 .uri("/actuator/circuitbreakers")
4983 .body(Body::empty())
4984 .unwrap(),
4985 )
4986 .await
4987 .unwrap();
4988 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4989 }
4990
4991 #[test]
4992 fn redaction_patterns() {
4993 assert!(should_redact("database.url"));
4994 assert!(should_redact("api_token"));
4995 assert!(should_redact("secret_key"));
4996 assert!(!should_redact("server.port"));
4997 assert!(!should_redact("log.level"));
4998 }
4999
5000 #[tokio::test]
5003 async fn actuator_metrics_returns_http_stats() {
5004 let state = test_state();
5005 state.metrics().record("GET", "/test", 200, 10);
5006 state.metrics().record("POST", "/test", 500, 50);
5007
5008 let app = actuator_router(true).with_state(state);
5009 let resp = app
5010 .oneshot(
5011 Request::builder()
5012 .uri("/actuator/metrics")
5013 .body(Body::empty())
5014 .unwrap(),
5015 )
5016 .await
5017 .unwrap();
5018
5019 assert_eq!(resp.status(), StatusCode::OK);
5020 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5021 .await
5022 .unwrap();
5023 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5024 assert_eq!(json["http"]["requests_total"], 2);
5025 assert_eq!(json["http"]["by_status"]["2xx"], 1);
5026 assert_eq!(json["http"]["by_status"]["5xx"], 1);
5027 }
5028
5029 #[tokio::test]
5030 async fn actuator_metrics_available_in_nonsensitive_mode() {
5031 let app = actuator_router(false).with_state(test_state());
5032 let resp = app
5033 .oneshot(
5034 Request::builder()
5035 .uri("/actuator/metrics")
5036 .body(Body::empty())
5037 .unwrap(),
5038 )
5039 .await
5040 .unwrap();
5041 assert_eq!(resp.status(), StatusCode::OK);
5042 }
5043
5044 #[tokio::test]
5045 #[cfg(feature = "db")]
5046 async fn actuator_metrics_returns_per_shard_stats_when_sharded() {
5047 let mut state = test_state();
5048 let config = crate::config::DatabaseConfig {
5049 shards: vec![
5050 crate::config::ShardConfig {
5051 name: "alpha".to_owned(),
5052 primary_url: "postgres://localhost/alpha".to_owned(),
5053 slots: None,
5054 replica_url: None,
5055 primary_pool_size: Some(4),
5056 replica_pool_size: None,
5057 replica_fallback: None,
5058 },
5059 crate::config::ShardConfig {
5060 name: "beta".to_owned(),
5061 primary_url: "postgres://localhost/beta".to_owned(),
5062 slots: None,
5063 replica_url: Some("postgres://localhost/beta_ro".to_owned()),
5064 primary_pool_size: None,
5065 replica_pool_size: Some(2),
5066 replica_fallback: None,
5067 },
5068 ],
5069 ..Default::default()
5070 };
5071 state.shards = crate::sharding::create_shard_set(
5072 &config,
5073 std::sync::Arc::new(crate::sharding::HashShardRouter),
5074 )
5075 .expect("lazy pools build");
5076
5077 let app = actuator_router(true).with_state(state);
5078 let resp = app
5079 .oneshot(
5080 Request::builder()
5081 .uri("/actuator/metrics")
5082 .body(Body::empty())
5083 .unwrap(),
5084 )
5085 .await
5086 .unwrap();
5087
5088 assert_eq!(resp.status(), StatusCode::OK);
5089 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5090 .await
5091 .unwrap();
5092 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5093
5094 let shards = json
5095 .get("database_shards")
5096 .expect("sharded state exposes database_shards");
5097 assert_eq!(shards["alpha"]["pool_size"], 4);
5098 assert_eq!(shards["alpha"]["slots"], 8192);
5099 assert_eq!(shards["beta"]["replica"]["pool_size"], 2);
5100 }
5101
5102 #[tokio::test]
5103 #[cfg(feature = "db")]
5104 async fn actuator_metrics_returns_db_stats_when_pool_present() {
5105 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
5106 use diesel_async::pooled_connection::deadpool::Pool;
5107
5108 let mut state = test_state();
5109
5110 let manager = AsyncDieselConnectionManager::<crate::db::RuntimeConnection>::new(
5115 "postgres://postgres:postgres@localhost:5432/postgres",
5116 );
5117 let pool = Pool::builder(manager).build().unwrap();
5118
5119 state.pool = Some(pool);
5120
5121 let app = actuator_router(true).with_state(state);
5122 let resp = app
5123 .oneshot(
5124 Request::builder()
5125 .uri("/actuator/metrics")
5126 .body(Body::empty())
5127 .unwrap(),
5128 )
5129 .await
5130 .unwrap();
5131
5132 assert_eq!(resp.status(), StatusCode::OK);
5133 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5134 .await
5135 .unwrap();
5136 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5137
5138 assert!(json.get("database").is_some());
5139 }
5140
5141 #[tokio::test]
5144 async fn actuator_configprops_returns_properties() {
5145 let app = actuator_router(true).with_state(test_state());
5146 let resp = app
5147 .oneshot(
5148 Request::builder()
5149 .uri("/actuator/configprops")
5150 .body(Body::empty())
5151 .unwrap(),
5152 )
5153 .await
5154 .unwrap();
5155
5156 assert_eq!(resp.status(), StatusCode::OK);
5157 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5158 .await
5159 .unwrap();
5160 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5161 assert_eq!(json["active_profile"], "dev");
5162 assert!(json["properties"].is_object());
5163 }
5164
5165 #[tokio::test]
5166 async fn actuator_configprops_hidden_in_nonsensitive_mode() {
5167 let app = actuator_router(false).with_state(test_state());
5168 let resp = app
5169 .oneshot(
5170 Request::builder()
5171 .uri("/actuator/configprops")
5172 .body(Body::empty())
5173 .unwrap(),
5174 )
5175 .await
5176 .unwrap();
5177 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5178 }
5179
5180 #[test]
5181 fn configprops_redacts_sensitive_values() {
5182 let mut props = HashMap::new();
5183 ConfigProperties::track_property(
5184 &mut props,
5185 "database.url",
5186 "postgres://user:pass@host/db",
5187 "",
5188 "dev",
5189 );
5190 assert_eq!(props["database.url"].value, "****");
5191 }
5192
5193 #[test]
5194 fn configprops_tracks_default_source() {
5195 let mut props = HashMap::new();
5196 ConfigProperties::track_property(&mut props, "server.port", "3000", "3000", "dev");
5197 assert_eq!(props["server.port"].source, "default");
5198 assert_eq!(props["server.port"].value, "3000");
5199 }
5200
5201 #[test]
5202 fn configprops_tracks_profile_source() {
5203 let mut props = HashMap::new();
5204 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
5205 assert_eq!(props["log.level"].source, "profile_default:dev");
5206 }
5207
5208 #[tokio::test]
5211 async fn actuator_loggers_get_returns_levels() {
5212 let app = actuator_router(true).with_state(test_state());
5213 let resp = app
5214 .oneshot(
5215 Request::builder()
5216 .uri("/actuator/loggers")
5217 .body(Body::empty())
5218 .unwrap(),
5219 )
5220 .await
5221 .unwrap();
5222
5223 assert_eq!(resp.status(), StatusCode::OK);
5224 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5225 .await
5226 .unwrap();
5227 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5228 assert_eq!(json["current_level"], "info");
5229 assert!(json["available_levels"].is_array());
5230 }
5231
5232 #[tokio::test]
5233 async fn actuator_loggers_put_changes_level() {
5234 let state = test_state();
5235 let app = actuator_router(true).with_state(state.clone());
5236 let resp = app
5237 .oneshot(
5238 Request::builder()
5239 .method("PUT")
5240 .uri("/actuator/loggers/autumn_web")
5241 .header("content-type", "application/json")
5242 .body(Body::from(r#"{"level": "debug"}"#))
5243 .unwrap(),
5244 )
5245 .await
5246 .unwrap();
5247
5248 assert_eq!(resp.status(), StatusCode::OK);
5249 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5250 .await
5251 .unwrap();
5252 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5253 assert_eq!(json["status"], "recorded");
5259 assert_eq!(json["applied"], false);
5260
5261 let overrides = state.log_levels().logger_overrides();
5262 assert_eq!(
5263 overrides.get("autumn_web").map(String::as_str),
5264 Some("debug")
5265 );
5266 }
5267
5268 #[tokio::test]
5269 async fn actuator_loggers_put_rejects_invalid_level() {
5270 let app = actuator_router(true).with_state(test_state());
5271 let resp = app
5272 .oneshot(
5273 Request::builder()
5274 .method("PUT")
5275 .uri("/actuator/loggers/autumn_web")
5276 .header("content-type", "application/json")
5277 .body(Body::from(r#"{"level": "banana"}"#))
5278 .unwrap(),
5279 )
5280 .await
5281 .unwrap();
5282
5283 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5284 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5285 .await
5286 .unwrap();
5287 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5288 assert_eq!(json["status"], "error");
5289 }
5290
5291 #[tokio::test]
5292 async fn actuator_loggers_put_rejects_invalid_name() {
5293 let state = test_state();
5300 for bogus in ["a=b", "a,b", "has%20space", "a::b=trace"] {
5301 let app = actuator_router(true).with_state(state.clone());
5302 let resp = app
5303 .oneshot(
5304 Request::builder()
5305 .method("PUT")
5306 .uri(format!("/actuator/loggers/{bogus}"))
5307 .header("content-type", "application/json")
5308 .body(Body::from(r#"{"level": "debug"}"#))
5309 .unwrap(),
5310 )
5311 .await
5312 .unwrap();
5313
5314 assert_eq!(
5315 resp.status(),
5316 StatusCode::BAD_REQUEST,
5317 "logger name {bogus:?} should be rejected"
5318 );
5319 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5320 .await
5321 .unwrap();
5322 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5323 assert_eq!(json["status"], "error");
5324 }
5325
5326 let app = actuator_router(true).with_state(state.clone());
5328 let resp = app
5329 .oneshot(
5330 Request::builder()
5331 .uri("/actuator/loggers")
5332 .body(Body::empty())
5333 .unwrap(),
5334 )
5335 .await
5336 .unwrap();
5337 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5338 .await
5339 .unwrap();
5340 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5341 let loggers = json["loggers"].as_object().unwrap();
5342 assert!(
5343 loggers.is_empty(),
5344 "no bogus override should be recorded, got {loggers:?}"
5345 );
5346 }
5347
5348 #[tokio::test]
5349 async fn actuator_loggers_put_accepts_dotted_and_hyphenated_names() {
5350 let state = test_state();
5357 for name in ["tower-http", "my.custom.target", "h2::proto"] {
5358 let app = actuator_router(true).with_state(state.clone());
5359 let resp = app
5360 .oneshot(
5361 Request::builder()
5362 .method("PUT")
5363 .uri(format!("/actuator/loggers/{name}"))
5364 .header("content-type", "application/json")
5365 .body(Body::from(r#"{"level": "debug"}"#))
5366 .unwrap(),
5367 )
5368 .await
5369 .unwrap();
5370
5371 assert_eq!(
5372 resp.status(),
5373 StatusCode::OK,
5374 "logger name {name:?} should be accepted, not rejected as invalid"
5375 );
5376 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5377 .await
5378 .unwrap();
5379 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5380 assert_ne!(
5384 json["status"], "error",
5385 "valid name {name:?} must not hit the invalid-name error path"
5386 );
5387 }
5388
5389 let app = actuator_router(true).with_state(state.clone());
5391 let resp = app
5392 .oneshot(
5393 Request::builder()
5394 .uri("/actuator/loggers")
5395 .body(Body::empty())
5396 .unwrap(),
5397 )
5398 .await
5399 .unwrap();
5400 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5401 .await
5402 .unwrap();
5403 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5404 let loggers = json["loggers"].as_object().unwrap();
5405 for name in ["tower-http", "my.custom.target", "h2::proto"] {
5406 assert!(
5407 loggers.contains_key(name),
5408 "accepted override {name:?} should be recorded, got {loggers:?}"
5409 );
5410 }
5411 }
5412
5413 #[tokio::test]
5414 async fn actuator_loggers_put_applied_ok_with_live_subscriber() {
5415 let state = test_state();
5420 state
5421 .log_levels()
5422 .attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
5423
5424 let app = actuator_router(true).with_state(state.clone());
5425 let resp = app
5426 .oneshot(
5427 Request::builder()
5428 .method("PUT")
5429 .uri("/actuator/loggers/autumn_web")
5430 .header("content-type", "application/json")
5431 .body(Body::from(r#"{"level": "debug"}"#))
5432 .unwrap(),
5433 )
5434 .await
5435 .unwrap();
5436
5437 assert_eq!(resp.status(), StatusCode::OK);
5438 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5439 .await
5440 .unwrap();
5441 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5442 assert_eq!(json["status"], "ok");
5443 assert_eq!(json["applied"], true);
5444
5445 let overrides = state.log_levels().logger_overrides();
5446 assert_eq!(
5447 overrides.get("autumn_web").map(String::as_str),
5448 Some("debug")
5449 );
5450 }
5451
5452 #[tokio::test]
5453 async fn actuator_loggers_hidden_in_nonsensitive_mode() {
5454 let app = actuator_router(false).with_state(test_state());
5455 let resp = app
5456 .oneshot(
5457 Request::builder()
5458 .uri("/actuator/loggers")
5459 .body(Body::empty())
5460 .unwrap(),
5461 )
5462 .await
5463 .unwrap();
5464 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5465 }
5466
5467 #[test]
5468 fn log_levels_set_and_get() {
5469 let levels = LogLevels::new("info");
5470 assert_eq!(levels.current_level(), "info");
5471
5472 let _ = levels.set_logger_level("my_crate", "debug");
5473 let overrides = levels.logger_overrides();
5474 assert_eq!(overrides.get("my_crate").map(String::as_str), Some("debug"));
5475 }
5476
5477 #[test]
5478 fn log_levels_root_updates_current() {
5479 let levels = LogLevels::new("info");
5480 let change = levels.set_logger_level("root", "trace");
5481 assert_eq!(change.previous(), Some("info"));
5482 assert_eq!(levels.current_level(), "trace");
5483 }
5484
5485 #[test]
5486 fn root_level_change_preserves_startup_per_target_directives() {
5487 let levels = LogLevels::new("info,tower_http=warn,my_app=debug");
5491 levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
5492
5493 let seeded = levels.logger_overrides();
5496 assert_eq!(seeded.get("tower_http").map(String::as_str), Some("warn"));
5497 assert_eq!(seeded.get("my_app").map(String::as_str), Some("debug"));
5498 assert_eq!(levels.current_level(), "info");
5499
5500 let change = levels.set_logger_level("root", "warn");
5502 assert!(matches!(change, LogLevelChange::Applied { .. }));
5503 assert_eq!(levels.current_level(), "warn");
5504
5505 let overrides = levels.logger_overrides();
5506 assert_eq!(
5507 overrides.get("tower_http").map(String::as_str),
5508 Some("warn"),
5509 "tower_http directive must survive a root-level change"
5510 );
5511 assert_eq!(
5512 overrides.get("my_app").map(String::as_str),
5513 Some("debug"),
5514 "my_app directive must survive a root-level change"
5515 );
5516
5517 let directive = levels.rebuilt_directive_for_test();
5519 assert!(
5520 directive.contains("tower_http=warn"),
5521 "rebuilt directive dropped tower_http: {directive}"
5522 );
5523 assert!(
5524 directive.contains("my_app=debug"),
5525 "rebuilt directive dropped my_app: {directive}"
5526 );
5527 }
5528
5529 #[test]
5530 fn bare_non_level_segment_is_a_trace_target_not_the_global_level() {
5531 let levels = LogLevels::new("info,my_app");
5536 assert_eq!(levels.current_level(), "info");
5537 let overrides = levels.logger_overrides();
5538 assert_eq!(
5539 overrides.get("my_app").map(String::as_str),
5540 Some("trace"),
5541 "bare non-level segment must become a trace target"
5542 );
5543
5544 assert_eq!(levels.rebuilt_directive_for_test(), "info,my_app=trace");
5546
5547 levels.attach_reload_handle(crate::telemetry::FilterReloadHandle::accept_all_for_test());
5549 let change = levels.set_logger_level("root", "warn");
5550 assert!(matches!(change, LogLevelChange::Applied { .. }));
5551 assert_eq!(levels.current_level(), "warn");
5552 assert_eq!(
5553 levels.logger_overrides().get("my_app").map(String::as_str),
5554 Some("trace"),
5555 "my_app target must survive a root-level change"
5556 );
5557 }
5558
5559 #[test]
5560 fn mixed_bare_level_explicit_target_and_bare_target() {
5561 let levels = LogLevels::new("debug,tower_http=warn,my_app");
5564 assert_eq!(levels.current_level(), "debug");
5565 let overrides = levels.logger_overrides();
5566 assert_eq!(
5567 overrides.get("tower_http").map(String::as_str),
5568 Some("warn")
5569 );
5570 assert_eq!(overrides.get("my_app").map(String::as_str), Some("trace"));
5571
5572 assert_eq!(
5574 levels.rebuilt_directive_for_test(),
5575 "debug,my_app=trace,tower_http=warn"
5576 );
5577 }
5578
5579 #[test]
5580 fn purely_bare_level_config_has_no_target_overrides() {
5581 let levels = LogLevels::new("info");
5584 assert_eq!(levels.current_level(), "info");
5585 assert!(levels.logger_overrides().is_empty());
5586 assert_eq!(levels.rebuilt_directive_for_test(), "info");
5587 }
5588
5589 #[tokio::test]
5592 async fn actuator_prometheus_returns_metrics() {
5593 let state = test_state();
5594 state.metrics().record("GET", "/test", 200, 10);
5595 state.metrics().record("POST", "/test", 500, 50);
5596
5597 let app = actuator_router(true).with_state(state);
5598 let resp = app
5599 .oneshot(
5600 Request::builder()
5601 .uri("/actuator/prometheus")
5602 .body(Body::empty())
5603 .unwrap(),
5604 )
5605 .await
5606 .unwrap();
5607
5608 assert_eq!(resp.status(), StatusCode::OK);
5609 assert_eq!(
5610 resp.headers().get("content-type").unwrap(),
5611 "text/plain; version=0.0.4"
5612 );
5613
5614 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5615 .await
5616 .unwrap();
5617 let text = String::from_utf8(body.to_vec()).unwrap();
5618
5619 assert!(text.contains("# HELP autumn_http_requests_total Total number of HTTP requests"));
5620 assert!(text.contains("# TYPE autumn_http_requests_total counter"));
5621 assert!(text.contains("autumn_http_requests_total{version=\"stable\"} 2"));
5622
5623 assert!(text.contains("autumn_http_requests_active{version=\"stable\"} "));
5624 assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"2xx\"} 1"));
5625 assert!(text.contains("autumn_http_responses_total{version=\"stable\",status=\"5xx\"} 1"));
5626
5627 assert!(text.contains("# TYPE autumn_http_request_duration_seconds summary"));
5629 assert!(text.contains(
5630 "autumn_http_request_duration_seconds{version=\"stable\",quantile=\"0.99\"}"
5631 ));
5632
5633 assert!(text.contains(
5634 "autumn_http_route_requests_total{version=\"stable\",method=\"GET\",route=\"/test\"} 1"
5635 ));
5636 assert!(text.contains(
5637 "autumn_http_route_requests_total{version=\"stable\",method=\"POST\",route=\"/test\"} 1"
5638 ));
5639
5640 assert!(text.contains("# HELP autumn_request_timeouts_total"));
5641 assert!(text.contains("# TYPE autumn_request_timeouts_total counter"));
5642 assert!(text.contains("autumn_request_timeouts_total{version=\"stable\"} 0"));
5643
5644 assert!(text.contains("# HELP autumn_read_your_writes_pins_total"));
5645 assert!(text.contains("# TYPE autumn_read_your_writes_pins_total counter"));
5646 assert!(text.contains("autumn_read_your_writes_pins_total{version=\"stable\"} 0"));
5647
5648 assert!(text.contains("# HELP autumn_requests_shed_total"));
5649 assert!(text.contains("# TYPE autumn_requests_shed_total counter"));
5650 assert!(text.contains("autumn_requests_shed_total{version=\"stable\"} 0"));
5651 }
5652
5653 #[cfg(feature = "cache-moka")]
5654 #[tokio::test]
5655 async fn prometheus_includes_cache_read_through_counters() {
5656 let before = crate::cache::read_through_metrics().snapshot();
5660 let cache: std::sync::Arc<dyn crate::cache::Cache> =
5661 std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
5662 let key = format!("actuator-prometheus-cache-test-{before:?}");
5663 let _: i32 =
5664 crate::cache::get_or_compute(
5665 &cache,
5666 &key,
5667 None,
5668 || async move { Ok::<i32, String>(1) },
5669 )
5670 .await
5671 .unwrap();
5672 let after = crate::cache::read_through_metrics().snapshot();
5673 assert!(after.fills > before.fills, "fills counter must increase");
5674
5675 let state = test_state();
5676 let app = actuator_router(true).with_state(state);
5677 let resp = app
5678 .oneshot(
5679 Request::builder()
5680 .uri("/actuator/prometheus")
5681 .body(Body::empty())
5682 .unwrap(),
5683 )
5684 .await
5685 .unwrap();
5686
5687 assert_eq!(resp.status(), StatusCode::OK);
5688 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5689 .await
5690 .unwrap();
5691 let text = String::from_utf8(body.to_vec()).unwrap();
5692
5693 for name in [
5694 "autumn_cache_read_through_hits_total",
5695 "autumn_cache_read_through_misses_total",
5696 "autumn_cache_read_through_coalesced_waits_total",
5697 "autumn_cache_read_through_fills_total",
5698 "autumn_cache_read_through_fill_failures_total",
5699 "autumn_cache_read_through_stale_serves_total",
5700 "autumn_cache_fill_lock_acquires_total",
5701 "autumn_cache_fill_lock_contended_total",
5702 ] {
5703 assert!(
5704 text.contains(&format!("# TYPE {name} counter")),
5705 "missing TYPE line for {name} in:\n{text}"
5706 );
5707 assert!(
5708 text.contains(&format!("{name}{{version=\"stable\"}}")),
5709 "missing value line for {name} in:\n{text}"
5710 );
5711 }
5712 }
5713
5714 #[cfg(feature = "cache-moka")]
5715 #[tokio::test]
5716 async fn metrics_json_includes_cache_section() {
5717 let cache: std::sync::Arc<dyn crate::cache::Cache> =
5718 std::sync::Arc::new(crate::cache::MokaCache::new(10, None));
5719 let key = "actuator-metrics-json-cache-test";
5720 let _: i32 =
5721 crate::cache::get_or_compute(&cache, key, None, || async move { Ok::<i32, String>(1) })
5722 .await
5723 .unwrap();
5724
5725 let state = test_state();
5726 let app = actuator_router(true).with_state(state);
5727 let resp = app
5728 .oneshot(
5729 Request::builder()
5730 .uri("/actuator/metrics")
5731 .body(Body::empty())
5732 .unwrap(),
5733 )
5734 .await
5735 .unwrap();
5736
5737 assert_eq!(resp.status(), StatusCode::OK);
5738 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5739 .await
5740 .unwrap();
5741 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5742 assert!(json["cache"]["fills"].as_u64().unwrap() >= 1);
5743 assert!(json["cache"]["hits"].is_u64());
5744 assert!(json["cache"]["misses"].is_u64());
5745 assert!(json["cache"]["coalesced_waits"].is_u64());
5746 assert!(json["cache"]["fill_failures"].is_u64());
5747 assert!(json["cache"]["stale_serves"].is_u64());
5748 assert!(json["cache"]["fill_lock_acquires"].is_u64());
5749 assert!(json["cache"]["fill_lock_contended"].is_u64());
5750 }
5751
5752 #[tokio::test]
5753 async fn actuator_prometheus_labels_metrics_with_canary_version() {
5754 let mut state = test_state();
5757 state.deploy_version = crate::canary::CANARY.to_owned();
5758 state.metrics().record("GET", "/test", 200, 10);
5760 state.metrics().record("GET", "/test", 200, 20);
5761 state.metrics().record("GET", "/test", 500, 1200);
5762
5763 let app = actuator_router(true).with_state(state);
5764 let resp = app
5765 .oneshot(
5766 Request::builder()
5767 .uri("/actuator/prometheus")
5768 .body(Body::empty())
5769 .unwrap(),
5770 )
5771 .await
5772 .unwrap();
5773 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5774 .await
5775 .unwrap();
5776 let text = String::from_utf8(body.to_vec()).unwrap();
5777
5778 assert!(text.contains("autumn_http_requests_total{version=\"canary\"} 3"));
5779 assert!(text.contains("autumn_http_responses_total{version=\"canary\",status=\"5xx\"} 1"));
5780 assert!(!text.contains("version=\"stable\""));
5782
5783 let quantile = |q: &str| -> f64 {
5786 let needle = format!(
5787 "autumn_http_request_duration_seconds{{version=\"canary\",quantile=\"{q}\"}} "
5788 );
5789 let line = text
5790 .lines()
5791 .find(|l| l.starts_with(&needle))
5792 .unwrap_or_else(|| panic!("missing duration line for quantile {q}"));
5793 line[needle.len()..].trim().parse().unwrap()
5794 };
5795 let (p50, p95, p99) = (quantile("0.5"), quantile("0.95"), quantile("0.99"));
5796 assert!(p50 <= p95, "p50 ({p50}) must be <= p95 ({p95})");
5797 assert!(p95 <= p99, "p95 ({p95}) must be <= p99 ({p99})");
5798 assert!(
5800 (p99 - 1.2).abs() < f64::EPSILON,
5801 "p99 should be 1.2s, got {p99}"
5802 );
5803 }
5804
5805 #[tokio::test]
5806 async fn actuator_prometheus_available_in_nonsensitive_mode() {
5807 let app = actuator_router(false).with_state(test_state());
5808 let resp = app
5809 .oneshot(
5810 Request::builder()
5811 .uri("/actuator/prometheus")
5812 .body(Body::empty())
5813 .unwrap(),
5814 )
5815 .await
5816 .unwrap();
5817 assert_eq!(resp.status(), StatusCode::OK);
5818 }
5819
5820 #[tokio::test]
5821 async fn actuator_prometheus_available_when_export_enabled_and_nonsensitive() {
5822 let app = actuator_router_with_prefix("/actuator", false, true).with_state(test_state());
5825 let resp = app
5826 .clone()
5827 .oneshot(
5828 Request::builder()
5829 .uri("/actuator/prometheus")
5830 .body(Body::empty())
5831 .unwrap(),
5832 )
5833 .await
5834 .unwrap();
5835 assert_eq!(resp.status(), StatusCode::OK);
5836
5837 for sensitive_path in [
5839 "/actuator/env",
5840 "/actuator/configprops",
5841 "/actuator/loggers",
5842 "/actuator/tasks",
5843 "/actuator/jobs",
5844 "/actuator/ui/tasks",
5845 ] {
5846 let resp = app
5847 .clone()
5848 .oneshot(
5849 Request::builder()
5850 .uri(sensitive_path)
5851 .body(Body::empty())
5852 .unwrap(),
5853 )
5854 .await
5855 .unwrap();
5856 assert_eq!(
5857 resp.status(),
5858 StatusCode::NOT_FOUND,
5859 "{sensitive_path} should be unavailable when actuator is non-sensitive"
5860 );
5861 }
5862 }
5863
5864 #[tokio::test]
5865 async fn actuator_prometheus_unavailable_when_export_disabled() {
5866 let app = actuator_router_with_prefix("/actuator", false, false).with_state(test_state());
5868 let resp = app
5869 .oneshot(
5870 Request::builder()
5871 .uri("/actuator/prometheus")
5872 .body(Body::empty())
5873 .unwrap(),
5874 )
5875 .await
5876 .unwrap();
5877 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5878 }
5879
5880 #[tokio::test]
5881 async fn actuator_prometheus_unavailable_when_export_disabled_even_if_sensitive() {
5882 let app = actuator_router_with_prefix("/actuator", true, false).with_state(test_state());
5884 let resp = app
5885 .oneshot(
5886 Request::builder()
5887 .uri("/actuator/prometheus")
5888 .body(Body::empty())
5889 .unwrap(),
5890 )
5891 .await
5892 .unwrap();
5893 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5894 }
5895
5896 #[test]
5897 fn actuator_endpoint_paths_respects_prometheus_toggle() {
5898 let enabled = actuator_endpoint_paths("/actuator", false, true);
5899 assert!(
5900 enabled.iter().any(|p| p == "/actuator/prometheus"),
5901 "prometheus path should be listed when export is enabled: {enabled:?}"
5902 );
5903
5904 let disabled = actuator_endpoint_paths("/actuator", false, false);
5905 assert!(
5906 !disabled.iter().any(|p| p == "/actuator/prometheus"),
5907 "prometheus path should be absent when export is disabled: {disabled:?}"
5908 );
5909 }
5910
5911 #[tokio::test]
5914 async fn actuator_tasks_returns_registered_tasks() {
5915 let state = test_state();
5916 state.task_registry().register("cleanup", "every 5m");
5917 state.task_registry().record_start("cleanup");
5918 state.task_registry().record_success("cleanup", 150);
5919
5920 let app = actuator_router(true).with_state(state);
5921 let resp = app
5922 .oneshot(
5923 Request::builder()
5924 .uri("/actuator/tasks")
5925 .body(Body::empty())
5926 .unwrap(),
5927 )
5928 .await
5929 .unwrap();
5930
5931 assert_eq!(resp.status(), StatusCode::OK);
5932 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5933 .await
5934 .unwrap();
5935 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5936 let task = &json["scheduled_tasks"]["cleanup"];
5937 assert_eq!(task["schedule"], "every 5m");
5938 assert_eq!(task["status"], "idle");
5939 assert_eq!(task["total_runs"], 1);
5940 assert_eq!(task["total_failures"], 0);
5941 assert_eq!(task["last_result"], "ok");
5942 assert_eq!(task["last_duration_ms"], 150);
5943 }
5944
5945 #[tokio::test]
5946 async fn actuator_jobs_returns_registered_jobs() {
5947 let state = test_state();
5948 state.job_registry().register("send_email");
5949 state.job_registry().record_enqueue("send_email");
5950 state.job_registry().record_start("send_email");
5951 state.job_registry().record_success("send_email");
5952
5953 let app = actuator_router(true).with_state(state);
5954 let resp = app
5955 .oneshot(
5956 Request::builder()
5957 .uri("/actuator/jobs")
5958 .body(Body::empty())
5959 .unwrap(),
5960 )
5961 .await
5962 .unwrap();
5963
5964 assert_eq!(resp.status(), StatusCode::OK);
5965 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5966 .await
5967 .unwrap();
5968 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5969 let job = &json["jobs"]["send_email"];
5970 assert_eq!(job["queued"], 0);
5971 assert_eq!(job["in_flight"], 0);
5972 assert_eq!(job["total_successes"], 1);
5973 assert_eq!(job["total_failures"], 0);
5974 }
5975
5976 #[cfg(feature = "ws")]
5977 #[tokio::test]
5978 async fn actuator_channels_returns_metrics() {
5979 let state = test_state();
5980 let mut rx = state.channels().subscribe("feed");
5981 state
5982 .channels()
5983 .broadcast()
5984 .publish("feed", "hello")
5985 .expect("publish should succeed");
5986 rx.try_recv().expect("subscriber should receive payload");
5987
5988 let app = actuator_router(true).with_state(state);
5989 let resp = app
5990 .oneshot(
5991 Request::builder()
5992 .uri("/actuator/channels")
5993 .body(Body::empty())
5994 .unwrap(),
5995 )
5996 .await
5997 .unwrap();
5998
5999 assert_eq!(resp.status(), StatusCode::OK);
6000 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6001 .await
6002 .unwrap();
6003 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
6004 let feed = &json["channels"]["feed"];
6005 assert_eq!(feed["subscriber_count"], 1);
6006 assert_eq!(feed["lifetime_publish_count"], 1);
6007 assert_eq!(feed["dropped_count"], 0);
6008 assert_eq!(feed["lagged_count"], 0);
6009 }
6010
6011 #[tokio::test]
6012 async fn actuator_tasks_hidden_in_nonsensitive_mode() {
6013 let app = actuator_router(false).with_state(test_state());
6014 let resp = app
6015 .oneshot(
6016 Request::builder()
6017 .uri("/actuator/tasks")
6018 .body(Body::empty())
6019 .unwrap(),
6020 )
6021 .await
6022 .unwrap();
6023 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6024 }
6025
6026 #[test]
6027 fn task_registry_records_failure() {
6028 let registry = TaskRegistry::new();
6029 registry.register("my_task", "cron 0 * * * *");
6030 registry.record_start("my_task");
6031 registry.record_failure("my_task", 200, "connection refused");
6032
6033 let snapshot = registry.snapshot();
6034 let task = &snapshot["my_task"];
6035 assert_eq!(task.status, "idle");
6036 assert_eq!(task.total_runs, 1);
6037 assert_eq!(task.total_failures, 1);
6038 assert_eq!(task.last_result.as_deref(), Some("failed"));
6039 assert_eq!(task.last_error.as_deref(), Some("connection refused"));
6040 }
6041
6042 #[test]
6043 fn task_registry_empty_snapshot() {
6044 let registry = TaskRegistry::new();
6045 assert!(registry.snapshot().is_empty());
6046 }
6047 #[test]
6048 fn log_levels_rejects_new_key_at_capacity() {
6049 let levels = LogLevels::new("info");
6050 for i in 0..1000 {
6052 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
6053 }
6054
6055 let result = levels.set_logger_level("logger_1000", "warn");
6057 assert!(matches!(result, LogLevelChange::Rejected { .. }));
6058 assert_eq!(levels.logger_overrides().len(), 1000);
6059 assert_eq!(levels.logger_overrides().get("logger_1000"), None);
6060 }
6061
6062 #[test]
6063 fn log_levels_accepts_existing_key_at_capacity() {
6064 let levels = LogLevels::new("info");
6065 for i in 0..1000 {
6067 let _ = levels.set_logger_level(&format!("logger_{i}"), "debug");
6068 }
6069
6070 let change = levels.set_logger_level("logger_999", "warn");
6072 assert_eq!(change.previous(), Some("debug"));
6073 assert_eq!(levels.logger_overrides().len(), 1000);
6074 assert_eq!(
6075 levels
6076 .logger_overrides()
6077 .get("logger_999")
6078 .map(String::as_str),
6079 Some("warn")
6080 );
6081 }
6082
6083 #[test]
6084 fn task_registry_records_multiple_successes_and_failures() {
6085 let registry = TaskRegistry::new();
6086 registry.register("my_task", "cron * * * * *");
6087
6088 registry.record_start("my_task");
6090 registry.record_success("my_task", 100);
6091
6092 registry.record_start("my_task");
6094 registry.record_success("my_task", 110);
6095
6096 let snapshot = registry.snapshot();
6097 let task = &snapshot["my_task"];
6098 assert_eq!(task.total_runs, 2);
6099 assert_eq!(task.total_failures, 0);
6100
6101 registry.record_start("my_task");
6103 registry.record_failure("my_task", 50, "failed");
6104
6105 let snapshot2 = registry.snapshot();
6106 let task2 = &snapshot2["my_task"];
6107 assert_eq!(task2.total_runs, 3);
6108 assert_eq!(task2.total_failures, 1);
6109 }
6110
6111 #[test]
6112 fn configprops_tracks_custom_profile() {
6113 let mut props = HashMap::new();
6114 ConfigProperties::track_property(
6115 &mut props,
6116 "log.level",
6117 "debug",
6118 "info",
6119 "custom_profile",
6120 );
6121 assert_eq!(props["log.level"].source, "autumn.toml");
6122 }
6123
6124 #[test]
6125 fn configprops_tracks_dev_prod_profiles() {
6126 let mut props = HashMap::new();
6127 ConfigProperties::track_property(&mut props, "log.level", "debug", "info", "dev");
6128 assert_eq!(props["log.level"].source, "profile_default:dev");
6129
6130 ConfigProperties::track_property(&mut props, "log.format", "json", "text", "prod");
6131 assert_eq!(props["log.format"].source, "profile_default:prod");
6132 }
6133
6134 #[test]
6135 fn configprops_returns_default_when_values_match() {
6136 let mut props = HashMap::new();
6137 ConfigProperties::track_property(&mut props, "log.level", "info", "info", "dev");
6138 assert_eq!(props["log.level"].source, "default");
6139 }
6140
6141 #[tokio::test]
6142 async fn actuator_ui_dashboard_returns_html_or_unimplemented() {
6143 let app = actuator_router(true).with_state(test_state());
6144
6145 let res = app
6146 .oneshot(
6147 Request::builder()
6148 .uri("/actuator/ui")
6149 .body(Body::empty())
6150 .unwrap(),
6151 )
6152 .await
6153 .unwrap();
6154
6155 if cfg!(feature = "maud") {
6156 assert_eq!(res.status(), StatusCode::OK);
6157 assert_eq!(
6158 res.headers().get("content-type").unwrap(),
6159 "text/html; charset=utf-8"
6160 );
6161 } else {
6162 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
6163 }
6164 }
6165
6166 #[tokio::test]
6167 async fn actuator_ui_metrics_returns_html_or_unimplemented() {
6168 let app = actuator_router(true).with_state(test_state());
6169
6170 let res = app
6171 .oneshot(
6172 Request::builder()
6173 .uri("/actuator/ui/metrics")
6174 .body(Body::empty())
6175 .unwrap(),
6176 )
6177 .await
6178 .unwrap();
6179
6180 if cfg!(feature = "maud") {
6181 assert_eq!(res.status(), StatusCode::OK);
6182 assert_eq!(
6183 res.headers().get("content-type").unwrap(),
6184 "text/html; charset=utf-8"
6185 );
6186 } else {
6187 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
6188 }
6189 }
6190
6191 #[tokio::test]
6192 async fn actuator_ui_tasks_returns_html_or_unimplemented() {
6193 let app = actuator_router(true).with_state(test_state());
6194
6195 let res = app
6196 .oneshot(
6197 Request::builder()
6198 .uri("/actuator/ui/tasks")
6199 .body(Body::empty())
6200 .unwrap(),
6201 )
6202 .await
6203 .unwrap();
6204
6205 if cfg!(feature = "maud") {
6206 assert_eq!(res.status(), StatusCode::OK);
6207 assert_eq!(
6208 res.headers().get("content-type").unwrap(),
6209 "text/html; charset=utf-8"
6210 );
6211 } else {
6212 assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
6213 }
6214 }
6215
6216 #[tokio::test]
6217 async fn test_actuator_router_calls_prefix_variant() {
6218 let app = actuator_router(false).with_state(test_state());
6221 let resp = app
6222 .oneshot(
6223 Request::builder()
6224 .uri("/actuator/health")
6225 .body(Body::empty())
6226 .unwrap(),
6227 )
6228 .await
6229 .unwrap();
6230
6231 assert_eq!(resp.status(), StatusCode::OK);
6232 }
6233
6234 #[tokio::test]
6237 async fn actuator_a11y_returns_posture_json() {
6238 let app = actuator_router(false).with_state(test_state());
6239 let resp = app
6240 .oneshot(
6241 Request::builder()
6242 .uri("/actuator/a11y")
6243 .body(Body::empty())
6244 .unwrap(),
6245 )
6246 .await
6247 .unwrap();
6248
6249 assert_eq!(resp.status(), StatusCode::OK);
6250 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6251 .await
6252 .unwrap();
6253 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
6254 assert!(json["lang_set"].is_boolean(), "{json}");
6255 assert!(json["skip_link_present"].is_boolean(), "{json}");
6256 assert!(json["landmark_regions_present"].is_boolean(), "{json}");
6257 }
6258
6259 #[tokio::test]
6260 async fn actuator_a11y_available_in_nonsensitive_mode() {
6261 let app = actuator_router(false).with_state(test_state());
6262 let resp = app
6263 .oneshot(
6264 Request::builder()
6265 .uri("/actuator/a11y")
6266 .body(Body::empty())
6267 .unwrap(),
6268 )
6269 .await
6270 .unwrap();
6271 assert_eq!(resp.status(), StatusCode::OK);
6272 }
6273
6274 #[tokio::test]
6275 async fn actuator_a11y_posture_default_values() {
6276 let app = actuator_router(true).with_state(test_state());
6277 let resp = app
6278 .oneshot(
6279 Request::builder()
6280 .uri("/actuator/a11y")
6281 .body(Body::empty())
6282 .unwrap(),
6283 )
6284 .await
6285 .unwrap();
6286
6287 assert_eq!(resp.status(), StatusCode::OK);
6288 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6289 .await
6290 .unwrap();
6291 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
6292 assert_eq!(json["lang_set"], false, "{json}");
6294 assert_eq!(json["skip_link_present"], false, "{json}");
6295 assert_eq!(json["landmark_regions_present"], false, "{json}");
6296 }
6297
6298 #[test]
6299 fn a11y_posture_all_passing_is_compliant() {
6300 let posture = A11yPosture {
6301 lang_set: true,
6302 skip_link_present: true,
6303 landmark_regions_present: true,
6304 };
6305 assert!(posture.is_compliant());
6306 }
6307
6308 #[test]
6309 fn a11y_posture_missing_lang_is_not_compliant() {
6310 let posture = A11yPosture {
6311 lang_set: false,
6312 skip_link_present: true,
6313 landmark_regions_present: true,
6314 };
6315 assert!(!posture.is_compliant());
6316 }
6317
6318 #[tokio::test]
6319 async fn actuator_a11y_endpoint_paths_includes_a11y() {
6320 let paths = actuator_endpoint_paths("/actuator", false, true);
6321 assert!(
6322 paths.iter().any(|p| p == "/actuator/a11y"),
6323 "a11y path not found in: {paths:?}"
6324 );
6325 }
6326
6327 #[test]
6330 fn metrics_source_registry_registers_and_collects() {
6331 struct FixedSource;
6332 impl MetricsSource for FixedSource {
6333 fn collect(&self) -> Vec<MetricFamily> {
6334 vec![MetricFamily {
6335 name: "plugin_requests_total".to_string(),
6336 help: "Plugin request count".to_string(),
6337 kind: MetricKind::Counter,
6338 samples: vec![MetricSample {
6339 labels: vec![],
6340 value: 42.0,
6341 }],
6342 }]
6343 }
6344 }
6345
6346 let registry = MetricsSourceRegistry::new();
6347 registry
6348 .register("myplugin", Arc::new(FixedSource))
6349 .unwrap();
6350
6351 let all = registry.collect_all();
6352 assert_eq!(all.len(), 1);
6353 assert_eq!(all[0].0, "myplugin");
6354 assert_eq!(all[0].1[0].name, "plugin_requests_total");
6355 assert!((all[0].1[0].samples[0].value - 42.0).abs() < f64::EPSILON);
6356 }
6357
6358 #[test]
6359 fn metrics_source_registry_rejects_duplicate_name() {
6360 struct EmptySource;
6361 impl MetricsSource for EmptySource {
6362 fn collect(&self) -> Vec<MetricFamily> {
6363 vec![]
6364 }
6365 }
6366
6367 let registry = MetricsSourceRegistry::new();
6368 registry.register("dup", Arc::new(EmptySource)).unwrap();
6369 let result = registry.register("dup", Arc::new(EmptySource));
6370 assert!(result.is_err());
6371 assert!(result.unwrap_err().contains("dup"));
6372 }
6373
6374 #[test]
6375 fn metrics_source_registry_isolates_panicking_source() {
6376 struct PanickingSource;
6377 impl MetricsSource for PanickingSource {
6378 fn collect(&self) -> Vec<MetricFamily> {
6379 panic!("source panicked!")
6380 }
6381 }
6382
6383 let registry = MetricsSourceRegistry::new();
6384 registry
6385 .register("panicker", Arc::new(PanickingSource))
6386 .unwrap();
6387
6388 let all = registry.collect_all();
6389 assert_eq!(all.len(), 1);
6390 assert_eq!(
6391 all[0].1.len(),
6392 0,
6393 "panicking source should yield no families"
6394 );
6395
6396 let errors = registry.error_counts();
6397 assert_eq!(errors.get("panicker"), Some(&1));
6398 }
6399
6400 #[tokio::test]
6401 async fn prometheus_endpoint_includes_plugin_source_families() {
6402 struct GaugeSource;
6403 impl MetricsSource for GaugeSource {
6404 fn collect(&self) -> Vec<MetricFamily> {
6405 vec![MetricFamily {
6406 name: "plugin_queue_depth".to_string(),
6407 help: "Plugin queue depth".to_string(),
6408 kind: MetricKind::Gauge,
6409 samples: vec![MetricSample {
6410 labels: vec![("shard".to_string(), "a".to_string())],
6411 value: 7.0,
6412 }],
6413 }]
6414 }
6415 }
6416
6417 let state = test_state();
6418 state
6419 .metrics_source_registry
6420 .register("gauge_plugin", Arc::new(GaugeSource))
6421 .unwrap();
6422
6423 let app = actuator_router(true).with_state(state);
6424 let resp = app
6425 .oneshot(
6426 Request::builder()
6427 .uri("/actuator/prometheus")
6428 .body(Body::empty())
6429 .unwrap(),
6430 )
6431 .await
6432 .unwrap();
6433
6434 assert_eq!(resp.status(), StatusCode::OK);
6435 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6436 .await
6437 .unwrap();
6438 let text = String::from_utf8(body.to_vec()).unwrap();
6439
6440 assert!(
6441 text.contains("# HELP plugin_queue_depth Plugin queue depth"),
6442 "missing HELP line in:\n{text}"
6443 );
6444 assert!(
6445 text.contains("# TYPE plugin_queue_depth gauge"),
6446 "missing TYPE line in:\n{text}"
6447 );
6448 assert!(
6449 text.contains("plugin_queue_depth{shard=\"a\"} 7"),
6450 "missing sample line in:\n{text}"
6451 );
6452 }
6453
6454 #[tokio::test]
6455 async fn prometheus_endpoint_emits_error_counter_for_panicking_source() {
6456 struct PanickingSource;
6457 impl MetricsSource for PanickingSource {
6458 fn collect(&self) -> Vec<MetricFamily> {
6459 panic!("oops")
6460 }
6461 }
6462
6463 let state = test_state();
6464 state
6465 .metrics_source_registry
6466 .register("panic_src", Arc::new(PanickingSource))
6467 .unwrap();
6468
6469 let app = actuator_router(true).with_state(state);
6470 let resp = app
6471 .oneshot(
6472 Request::builder()
6473 .uri("/actuator/prometheus")
6474 .body(Body::empty())
6475 .unwrap(),
6476 )
6477 .await
6478 .unwrap();
6479
6480 assert_eq!(resp.status(), StatusCode::OK);
6481 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6482 .await
6483 .unwrap();
6484 let text = String::from_utf8(body.to_vec()).unwrap();
6485
6486 assert!(
6487 text.contains("autumn_metrics_source_errors_total{source=\"panic_src\"} 1"),
6488 "missing error counter in:\n{text}"
6489 );
6490 }
6491
6492 #[tokio::test]
6493 async fn metrics_endpoint_includes_sources_section() {
6494 struct SampleSource;
6495 impl MetricsSource for SampleSource {
6496 fn collect(&self) -> Vec<MetricFamily> {
6497 vec![MetricFamily {
6498 name: "custom_counter".to_string(),
6499 help: "A custom counter".to_string(),
6500 kind: MetricKind::Counter,
6501 samples: vec![MetricSample {
6502 labels: vec![],
6503 value: 5.0,
6504 }],
6505 }]
6506 }
6507 }
6508
6509 let state = test_state();
6510 state
6511 .metrics_source_registry
6512 .register("my_source", Arc::new(SampleSource))
6513 .unwrap();
6514
6515 let app = actuator_router(true).with_state(state);
6516 let resp = app
6517 .oneshot(
6518 Request::builder()
6519 .uri("/actuator/metrics")
6520 .body(Body::empty())
6521 .unwrap(),
6522 )
6523 .await
6524 .unwrap();
6525
6526 assert_eq!(resp.status(), StatusCode::OK);
6527 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6528 .await
6529 .unwrap();
6530 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
6531
6532 assert!(
6533 json.get("sources").is_some(),
6534 "metrics JSON missing 'sources' key"
6535 );
6536 assert!(
6537 json["sources"].get("my_source").is_some(),
6538 "sources missing 'my_source' key"
6539 );
6540 }
6541
6542 #[test]
6543 fn metrics_source_registry_preserves_insertion_order() {
6544 struct NamedSource(&'static str);
6545 impl MetricsSource for NamedSource {
6546 fn collect(&self) -> Vec<MetricFamily> {
6547 vec![MetricFamily {
6548 name: self.0.to_string(),
6549 help: String::new(),
6550 kind: MetricKind::Counter,
6551 samples: vec![],
6552 }]
6553 }
6554 }
6555
6556 let registry = MetricsSourceRegistry::new();
6557 registry
6558 .register("alpha", Arc::new(NamedSource("alpha_metric")))
6559 .unwrap();
6560 registry
6561 .register("beta", Arc::new(NamedSource("beta_metric")))
6562 .unwrap();
6563 registry
6564 .register("gamma", Arc::new(NamedSource("gamma_metric")))
6565 .unwrap();
6566
6567 let all = registry.collect_all();
6568 assert_eq!(all[0].0, "alpha");
6569 assert_eq!(all[1].0, "beta");
6570 assert_eq!(all[2].0, "gamma");
6571 }
6572
6573 #[test]
6576 fn escape_help_text_escapes_backslash_and_newline() {
6577 assert_eq!(escape_help_text("a\\b\nc"), "a\\\\b\\nc");
6578 assert_eq!(escape_help_text("plain"), "plain");
6579 assert_eq!(escape_help_text(""), "");
6580 }
6581
6582 #[test]
6583 fn format_sample_value_handles_special_floats() {
6584 assert_eq!(format_sample_value(f64::INFINITY), "+Inf");
6585 assert_eq!(format_sample_value(f64::NEG_INFINITY), "-Inf");
6586 assert_eq!(format_sample_value(f64::NAN), "NaN");
6587 assert_eq!(format_sample_value(0.0), "0");
6588 assert_eq!(format_sample_value(1.5), "1.5");
6589 }
6590
6591 #[test]
6592 fn is_valid_metric_name_accepts_valid_and_rejects_invalid() {
6593 assert!(is_valid_metric_name("http_requests_total"));
6594 assert!(is_valid_metric_name("_private"));
6595 assert!(is_valid_metric_name("ns:metric"));
6596 assert!(!is_valid_metric_name(""));
6597 assert!(!is_valid_metric_name("0starts_with_digit"));
6598 assert!(!is_valid_metric_name("has-hyphen"));
6599 }
6600
6601 #[test]
6602 fn is_valid_label_name_accepts_valid_and_rejects_invalid() {
6603 assert!(is_valid_label_name("shard"));
6604 assert!(is_valid_label_name("_internal"));
6605 assert!(is_valid_label_name("a1"));
6606 assert!(!is_valid_label_name(""));
6607 assert!(!is_valid_label_name("0starts_digit"));
6608 assert!(!is_valid_label_name("has-hyphen"));
6609 assert!(!is_valid_label_name("has.dot"));
6610 }
6611
6612 #[tokio::test]
6613 async fn prometheus_endpoint_skips_family_with_invalid_metric_name() {
6614 struct BadNameSource;
6615 impl MetricsSource for BadNameSource {
6616 fn collect(&self) -> Vec<MetricFamily> {
6617 vec![
6618 MetricFamily {
6619 name: "invalid-name".to_string(),
6620 help: "should be skipped".to_string(),
6621 kind: MetricKind::Counter,
6622 samples: vec![],
6623 },
6624 MetricFamily {
6625 name: "valid_name".to_string(),
6626 help: "should appear".to_string(),
6627 kind: MetricKind::Counter,
6628 samples: vec![MetricSample {
6629 labels: vec![],
6630 value: 1.0,
6631 }],
6632 },
6633 ]
6634 }
6635 }
6636
6637 let state = test_state();
6638 state
6639 .metrics_source_registry
6640 .register("bad_name_src", Arc::new(BadNameSource))
6641 .unwrap();
6642
6643 let app = actuator_router(true).with_state(state);
6644 let resp = app
6645 .oneshot(
6646 Request::builder()
6647 .uri("/actuator/prometheus")
6648 .body(Body::empty())
6649 .unwrap(),
6650 )
6651 .await
6652 .unwrap();
6653 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6654 .await
6655 .unwrap();
6656 let text = String::from_utf8(body.to_vec()).unwrap();
6657
6658 assert!(
6659 !text.contains("invalid-name"),
6660 "invalid family must be skipped:\n{text}"
6661 );
6662 assert!(
6663 text.contains("valid_name"),
6664 "valid family must appear:\n{text}"
6665 );
6666 }
6667
6668 #[tokio::test]
6669 async fn prometheus_endpoint_skips_sample_with_invalid_label_key() {
6670 struct DirtyLabelsSource;
6674 impl MetricsSource for DirtyLabelsSource {
6675 fn collect(&self) -> Vec<MetricFamily> {
6676 vec![MetricFamily {
6677 name: "dirty_labels_metric".to_string(),
6678 help: "test".to_string(),
6679 kind: MetricKind::Counter,
6680 samples: vec![
6681 MetricSample {
6682 labels: vec![
6683 ("good".to_string(), "a".to_string()),
6684 ("bad-key".to_string(), "b".to_string()),
6685 ],
6686 value: 1.0,
6687 },
6688 MetricSample {
6689 labels: vec![("good".to_string(), "a".to_string())],
6690 value: 2.0,
6691 },
6692 ],
6693 }]
6694 }
6695 }
6696
6697 let state = test_state();
6698 state
6699 .metrics_source_registry
6700 .register("dirty", Arc::new(DirtyLabelsSource))
6701 .unwrap();
6702
6703 let app = actuator_router(true).with_state(state);
6704 let resp = app
6705 .oneshot(
6706 Request::builder()
6707 .uri("/actuator/prometheus")
6708 .body(Body::empty())
6709 .unwrap(),
6710 )
6711 .await
6712 .unwrap();
6713 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6714 .await
6715 .unwrap();
6716 let text = String::from_utf8(body.to_vec()).unwrap();
6717
6718 assert!(
6720 !text.contains("dirty_labels_metric{good=\"a\"} 1"),
6721 "sample with invalid label key must be skipped:\n{text}"
6722 );
6723 assert!(
6725 text.contains("dirty_labels_metric{good=\"a\"} 2"),
6726 "clean sample must appear:\n{text}"
6727 );
6728 }
6729
6730 #[tokio::test]
6731 async fn prometheus_endpoint_deduplicates_label_keys() {
6732 struct DupLabelSource;
6733 impl MetricsSource for DupLabelSource {
6734 fn collect(&self) -> Vec<MetricFamily> {
6735 vec![MetricFamily {
6736 name: "dup_label_metric".to_string(),
6737 help: "test".to_string(),
6738 kind: MetricKind::Counter,
6739 samples: vec![MetricSample {
6740 labels: vec![
6741 ("env".to_string(), "prod".to_string()),
6742 ("env".to_string(), "staging".to_string()),
6743 ],
6744 value: 5.0,
6745 }],
6746 }]
6747 }
6748 }
6749
6750 let state = test_state();
6751 state
6752 .metrics_source_registry
6753 .register("dup_src", Arc::new(DupLabelSource))
6754 .unwrap();
6755
6756 let app = actuator_router(true).with_state(state);
6757 let resp = app
6758 .oneshot(
6759 Request::builder()
6760 .uri("/actuator/prometheus")
6761 .body(Body::empty())
6762 .unwrap(),
6763 )
6764 .await
6765 .unwrap();
6766 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6767 .await
6768 .unwrap();
6769 let text = String::from_utf8(body.to_vec()).unwrap();
6770
6771 assert!(
6773 text.contains("dup_label_metric{env=\"prod\"} 5"),
6774 "first env value must be kept:\n{text}"
6775 );
6776 assert!(
6777 !text.contains("staging"),
6778 "duplicate env key value must be dropped:\n{text}"
6779 );
6780 }
6781
6782 #[tokio::test]
6783 async fn prometheus_endpoint_escapes_help_text_and_formats_inf() {
6784 struct SpecialSource;
6785 impl MetricsSource for SpecialSource {
6786 fn collect(&self) -> Vec<MetricFamily> {
6787 vec![MetricFamily {
6788 name: "inf_gauge".to_string(),
6789 help: "has\\backslash and\nnewline".to_string(),
6790 kind: MetricKind::Gauge,
6791 samples: vec![
6792 MetricSample {
6793 labels: vec![("dir".to_string(), "pos".to_string())],
6794 value: f64::INFINITY,
6795 },
6796 MetricSample {
6797 labels: vec![("dir".to_string(), "neg".to_string())],
6798 value: f64::NEG_INFINITY,
6799 },
6800 ],
6801 }]
6802 }
6803 }
6804
6805 let state = test_state();
6806 state
6807 .metrics_source_registry
6808 .register("special", Arc::new(SpecialSource))
6809 .unwrap();
6810
6811 let app = actuator_router(true).with_state(state);
6812 let resp = app
6813 .oneshot(
6814 Request::builder()
6815 .uri("/actuator/prometheus")
6816 .body(Body::empty())
6817 .unwrap(),
6818 )
6819 .await
6820 .unwrap();
6821 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6822 .await
6823 .unwrap();
6824 let text = String::from_utf8(body.to_vec()).unwrap();
6825
6826 assert!(
6827 text.contains("# HELP inf_gauge has\\\\backslash and\\nnewline"),
6828 "help text must be escaped in:\n{text}"
6829 );
6830 assert!(
6831 text.contains("inf_gauge{dir=\"pos\"} +Inf"),
6832 "must render +Inf in:\n{text}"
6833 );
6834 assert!(
6835 text.contains("inf_gauge{dir=\"neg\"} -Inf"),
6836 "must render -Inf in:\n{text}"
6837 );
6838 }
6839
6840 #[tokio::test]
6841 async fn prometheus_endpoint_skips_duplicate_family_name_across_sources() {
6842 struct FirstSource;
6843 impl MetricsSource for FirstSource {
6844 fn collect(&self) -> Vec<MetricFamily> {
6845 vec![MetricFamily {
6846 name: "shared_counter".to_string(),
6847 help: "from first".to_string(),
6848 kind: MetricKind::Counter,
6849 samples: vec![MetricSample {
6850 labels: vec![],
6851 value: 1.0,
6852 }],
6853 }]
6854 }
6855 }
6856 struct SecondSource;
6857 impl MetricsSource for SecondSource {
6858 fn collect(&self) -> Vec<MetricFamily> {
6859 vec![MetricFamily {
6860 name: "shared_counter".to_string(),
6861 help: "from second".to_string(),
6862 kind: MetricKind::Counter,
6863 samples: vec![MetricSample {
6864 labels: vec![],
6865 value: 2.0,
6866 }],
6867 }]
6868 }
6869 }
6870
6871 let state = test_state();
6872 state
6873 .metrics_source_registry
6874 .register("first", Arc::new(FirstSource))
6875 .unwrap();
6876 state
6877 .metrics_source_registry
6878 .register("second", Arc::new(SecondSource))
6879 .unwrap();
6880
6881 let app = actuator_router(true).with_state(state);
6882 let resp = app
6883 .oneshot(
6884 Request::builder()
6885 .uri("/actuator/prometheus")
6886 .body(Body::empty())
6887 .unwrap(),
6888 )
6889 .await
6890 .unwrap();
6891 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6892 .await
6893 .unwrap();
6894 let text = String::from_utf8(body.to_vec()).unwrap();
6895
6896 let occurrences = text.matches("# HELP shared_counter").count();
6897 assert_eq!(
6898 occurrences, 1,
6899 "must emit exactly one HELP block for shared_counter:\n{text}"
6900 );
6901 }
6902
6903 #[tokio::test]
6904 async fn prometheus_endpoint_skips_builtin_name_collision() {
6905 struct ShadowSource;
6906 impl MetricsSource for ShadowSource {
6907 fn collect(&self) -> Vec<MetricFamily> {
6908 vec![MetricFamily {
6909 name: "autumn_http_requests_total".to_string(),
6910 help: "plugin trying to shadow built-in".to_string(),
6911 kind: MetricKind::Counter,
6912 samples: vec![MetricSample {
6913 labels: vec![],
6914 value: 999.0,
6915 }],
6916 }]
6917 }
6918 }
6919
6920 let state = test_state();
6921 state
6922 .metrics_source_registry
6923 .register("shadow", Arc::new(ShadowSource))
6924 .unwrap();
6925
6926 let app = actuator_router(true).with_state(state);
6927 let resp = app
6928 .oneshot(
6929 Request::builder()
6930 .uri("/actuator/prometheus")
6931 .body(Body::empty())
6932 .unwrap(),
6933 )
6934 .await
6935 .unwrap();
6936 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6937 .await
6938 .unwrap();
6939 let text = String::from_utf8(body.to_vec()).unwrap();
6940
6941 let occurrences = text.matches("# HELP autumn_http_requests_total").count();
6942 assert_eq!(
6943 occurrences, 1,
6944 "built-in must not be shadowed by plugin:\n{text}"
6945 );
6946 assert!(
6947 !text.contains("999"),
6948 "plugin shadow value must not appear:\n{text}"
6949 );
6950 }
6951
6952 #[tokio::test]
6953 async fn prometheus_endpoint_skips_builtin_duration_family_collision() {
6954 struct ShadowLatency;
6957 impl MetricsSource for ShadowLatency {
6958 fn collect(&self) -> Vec<MetricFamily> {
6959 vec![MetricFamily {
6960 name: "autumn_http_request_duration_seconds".to_string(),
6961 help: "plugin trying to shadow built-in latency".to_string(),
6962 kind: MetricKind::Gauge,
6963 samples: vec![MetricSample {
6964 labels: vec![],
6965 value: 999.0,
6966 }],
6967 }]
6968 }
6969 }
6970
6971 let state = test_state();
6972 state
6973 .metrics_source_registry
6974 .register("shadow_latency", Arc::new(ShadowLatency))
6975 .unwrap();
6976
6977 let app = actuator_router(true).with_state(state);
6978 let resp = app
6979 .oneshot(
6980 Request::builder()
6981 .uri("/actuator/prometheus")
6982 .body(Body::empty())
6983 .unwrap(),
6984 )
6985 .await
6986 .unwrap();
6987 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6988 .await
6989 .unwrap();
6990 let text = String::from_utf8(body.to_vec()).unwrap();
6991
6992 let occurrences = text
6993 .matches("# HELP autumn_http_request_duration_seconds")
6994 .count();
6995 assert_eq!(
6996 occurrences, 1,
6997 "built-in latency family must not be shadowed by plugin:\n{text}"
6998 );
6999 assert!(
7000 !text.contains("999"),
7001 "plugin shadow value must not appear:\n{text}"
7002 );
7003 }
7004
7005 #[tokio::test]
7006 async fn prometheus_endpoint_skips_duplicate_series_within_family() {
7007 struct DupSeriesSource;
7008 impl MetricsSource for DupSeriesSource {
7009 fn collect(&self) -> Vec<MetricFamily> {
7010 vec![MetricFamily {
7011 name: "dup_series_metric".to_string(),
7012 help: "test".to_string(),
7013 kind: MetricKind::Counter,
7014 samples: vec![
7015 MetricSample {
7016 labels: vec![("region".to_string(), "us".to_string())],
7017 value: 10.0,
7018 },
7019 MetricSample {
7020 labels: vec![("region".to_string(), "us".to_string())],
7021 value: 20.0,
7022 },
7023 ],
7024 }]
7025 }
7026 }
7027
7028 let state = test_state();
7029 state
7030 .metrics_source_registry
7031 .register("dup_series", Arc::new(DupSeriesSource))
7032 .unwrap();
7033
7034 let app = actuator_router(true).with_state(state);
7035 let resp = app
7036 .oneshot(
7037 Request::builder()
7038 .uri("/actuator/prometheus")
7039 .body(Body::empty())
7040 .unwrap(),
7041 )
7042 .await
7043 .unwrap();
7044 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7045 .await
7046 .unwrap();
7047 let text = String::from_utf8(body.to_vec()).unwrap();
7048
7049 assert!(
7051 text.contains("dup_series_metric{region=\"us\"} 10"),
7052 "first sample must appear:\n{text}"
7053 );
7054 assert!(
7055 !text.contains("dup_series_metric{region=\"us\"} 20"),
7056 "duplicate series must be dropped:\n{text}"
7057 );
7058 }
7059
7060 fn make_log_buffer_with_entries() -> crate::log::capture::LogBuffer {
7063 use crate::log::capture::{CapturedLogEntry, LogBuffer};
7064 use crate::log::filter::ParameterFilter;
7065 let buf = LogBuffer::new(100, ParameterFilter::default());
7066 buf.push(CapturedLogEntry {
7067 timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
7068 level: "INFO".to_owned(),
7069 target: "myapp::orders".to_owned(),
7070 message: "order created".to_owned(),
7071 fields: {
7072 let mut m = serde_json::Map::new();
7073 m.insert("order_id".to_owned(), serde_json::json!("A-1001"));
7074 m
7075 },
7076 request_id: Some("req-abc".to_owned()),
7077 });
7078 buf.push(CapturedLogEntry {
7079 timestamp: "2024-01-01T00:00:01.000Z".to_owned(),
7080 level: "WARN".to_owned(),
7081 target: "myapp::payments".to_owned(),
7082 message: "payment slow".to_owned(),
7083 fields: serde_json::Map::new(),
7084 request_id: None,
7085 });
7086 buf.push(CapturedLogEntry {
7087 timestamp: "2024-01-01T00:00:02.000Z".to_owned(),
7088 level: "ERROR".to_owned(),
7089 target: "myapp::payments".to_owned(),
7090 message: "payment failed".to_owned(),
7091 fields: serde_json::Map::new(),
7092 request_id: None,
7093 });
7094 buf
7095 }
7096
7097 #[tokio::test]
7098 async fn green_logfile_returns_empty_when_capture_disabled() {
7099 let state = test_state(); let response =
7101 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
7102 .await
7103 .unwrap();
7104 let body = response.0;
7105 assert!(!body.capture_enabled);
7106 assert!(body.entries.is_empty());
7107 assert_eq!(body.total, 0);
7108 }
7109
7110 #[tokio::test]
7111 async fn green_logfile_returns_all_entries_when_no_filter() {
7112 let mut state = test_state();
7113 state.log_buffer = Some(make_log_buffer_with_entries());
7114
7115 let response =
7116 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
7117 .await
7118 .unwrap();
7119 let body = response.0;
7120 assert!(body.capture_enabled);
7121 assert_eq!(body.total, 3);
7122 assert_eq!(body.entries.len(), 3);
7123 assert_eq!(body.entries[0].level, "INFO");
7125 assert_eq!(body.entries[2].level, "ERROR");
7126 }
7127
7128 #[tokio::test]
7129 async fn green_logfile_level_filter_excludes_info_when_min_warn() {
7130 let mut state = test_state();
7131 state.log_buffer = Some(make_log_buffer_with_entries());
7132
7133 let response = logfile_endpoint(
7134 State(state),
7135 axum::extract::Query(LogfileQuery {
7136 level: Some("warn".to_owned()),
7137 limit: None,
7138 }),
7139 )
7140 .await
7141 .unwrap();
7142 let body = response.0;
7143 assert_eq!(body.entries.len(), 2);
7144 assert!(body.entries.iter().all(|e| e.level != "INFO"));
7145 }
7146
7147 #[tokio::test]
7148 async fn green_logfile_limit_returns_most_recent_n() {
7149 let mut state = test_state();
7150 state.log_buffer = Some(make_log_buffer_with_entries());
7151
7152 let response = logfile_endpoint(
7153 State(state),
7154 axum::extract::Query(LogfileQuery {
7155 level: None,
7156 limit: Some(2),
7157 }),
7158 )
7159 .await
7160 .unwrap();
7161 let body = response.0;
7162 assert_eq!(body.entries.len(), 2);
7163 assert_eq!(body.entries[0].level, "WARN");
7165 assert_eq!(body.entries[1].level, "ERROR");
7166 }
7167
7168 #[tokio::test]
7169 async fn green_logfile_sensitive_fields_in_response_are_served_scrubbed() {
7170 use crate::log::capture::{CapturedLogEntry, LogBuffer};
7171 use crate::log::filter::{FILTERED_PLACEHOLDER, ParameterFilter};
7172 let buf = LogBuffer::new(10, ParameterFilter::default());
7173 buf.push(CapturedLogEntry {
7175 timestamp: "2024-01-01T00:00:00.000Z".to_owned(),
7176 level: "INFO".to_owned(),
7177 target: "auth".to_owned(),
7178 message: "login attempt".to_owned(),
7179 fields: {
7180 let mut m = serde_json::Map::new();
7181 m.insert(
7182 "password".to_owned(),
7183 serde_json::Value::String(FILTERED_PLACEHOLDER.to_owned()),
7184 );
7185 m
7186 },
7187 request_id: None,
7188 });
7189
7190 let mut state = test_state();
7191 state.log_buffer = Some(buf);
7192
7193 let response =
7194 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
7195 .await
7196 .unwrap();
7197 let body = response.0;
7198 assert_eq!(
7199 body.entries[0].fields["password"].as_str().unwrap(),
7200 FILTERED_PLACEHOLDER,
7201 "sensitive value must remain scrubbed in the response"
7202 );
7203 }
7204
7205 #[tokio::test]
7206 async fn green_logfile_invalid_level_returns_400() {
7207 let state = test_state();
7208 let result = logfile_endpoint(
7209 State(state),
7210 axum::extract::Query(LogfileQuery {
7211 level: Some("warning".to_owned()), limit: None,
7213 }),
7214 )
7215 .await;
7216 let (status, _body) = result.unwrap_err();
7217 assert_eq!(status, StatusCode::BAD_REQUEST);
7218 }
7219
7220 #[tokio::test]
7221 async fn green_logfile_endpoint_in_sensitive_router() {
7222 let state = test_state();
7224 let app = actuator_router::<TestActuatorState>(true).with_state(state);
7225 let resp = app
7226 .oneshot(
7227 Request::builder()
7228 .uri("/actuator/logfile")
7229 .body(Body::empty())
7230 .unwrap(),
7231 )
7232 .await
7233 .unwrap();
7234 assert_eq!(resp.status(), StatusCode::OK);
7235 }
7236
7237 #[tokio::test]
7238 async fn green_logfile_endpoint_not_in_non_sensitive_router() {
7239 let state = test_state();
7241 let app = actuator_router::<TestActuatorState>(false).with_state(state);
7242 let resp = app
7243 .oneshot(
7244 Request::builder()
7245 .uri("/actuator/logfile")
7246 .body(Body::empty())
7247 .unwrap(),
7248 )
7249 .await
7250 .unwrap();
7251 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7252 }
7253
7254 #[tokio::test]
7255 async fn green_logfile_structured_fields_preserved() {
7256 let mut state = test_state();
7257 state.log_buffer = Some(make_log_buffer_with_entries());
7258
7259 let response =
7260 logfile_endpoint(State(state), axum::extract::Query(LogfileQuery::default()))
7261 .await
7262 .unwrap();
7263 let body = response.0;
7264 let first = &body.entries[0];
7265 assert_eq!(first.target, "myapp::orders");
7266 assert_eq!(first.fields["order_id"].as_str().unwrap(), "A-1001");
7267 assert_eq!(first.request_id.as_deref(), Some("req-abc"));
7268 }
7269}
7270
7271#[cfg(test)]
7272mod health_indicator_tests {
7273 use super::*;
7274
7275 struct AlwaysUp;
7276 impl HealthIndicator for AlwaysUp {
7277 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
7278 Box::pin(async { HealthCheckOutput::up() })
7279 }
7280 }
7281
7282 struct AlwaysDown;
7283 impl HealthIndicator for AlwaysDown {
7284 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
7285 Box::pin(async { HealthCheckOutput::down() })
7286 }
7287 }
7288
7289 #[test]
7290 fn health_status_as_str_values() {
7291 assert_eq!(HealthStatus::Up.as_str(), "UP");
7292 assert_eq!(HealthStatus::Down.as_str(), "DOWN");
7293 assert_eq!(HealthStatus::OutOfService.as_str(), "OUT_OF_SERVICE");
7294 assert_eq!(HealthStatus::Unknown.as_str(), "UNKNOWN");
7295 }
7296
7297 #[test]
7298 fn health_status_is_healthy() {
7299 assert!(HealthStatus::Up.is_healthy());
7300 assert!(HealthStatus::Unknown.is_healthy());
7301 assert!(!HealthStatus::Down.is_healthy());
7302 assert!(!HealthStatus::OutOfService.is_healthy());
7303 }
7304
7305 #[test]
7306 fn aggregate_status_precedence() {
7307 assert_eq!(
7308 HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up]),
7309 HealthStatus::Up
7310 );
7311 assert_eq!(
7312 HealthIndicatorRegistry::aggregate_status(&[HealthStatus::Up, HealthStatus::Unknown]),
7313 HealthStatus::Unknown
7314 );
7315 assert_eq!(
7316 HealthIndicatorRegistry::aggregate_status(&[
7317 HealthStatus::Unknown,
7318 HealthStatus::OutOfService
7319 ]),
7320 HealthStatus::OutOfService
7321 );
7322 assert_eq!(
7323 HealthIndicatorRegistry::aggregate_status(&[
7324 HealthStatus::OutOfService,
7325 HealthStatus::Down
7326 ]),
7327 HealthStatus::Down
7328 );
7329 assert_eq!(
7330 HealthIndicatorRegistry::aggregate_status(&[]),
7331 HealthStatus::Up
7332 );
7333 }
7334
7335 #[tokio::test]
7336 async fn registry_run_all_collects_results() {
7337 let registry = HealthIndicatorRegistry::new();
7338 registry
7339 .register("svc_a", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
7340 .unwrap();
7341 registry
7342 .register("svc_b", IndicatorGroup::HealthOnly, Arc::new(AlwaysDown))
7343 .unwrap();
7344
7345 let results = registry.run_all().await;
7346 assert!(
7347 results
7348 .iter()
7349 .any(|r| r.name == "svc_a" && r.output.status == HealthStatus::Up)
7350 );
7351 assert!(
7352 results
7353 .iter()
7354 .any(|r| r.name == "svc_b" && r.output.status == HealthStatus::Down)
7355 );
7356 }
7357
7358 #[tokio::test]
7359 async fn registry_run_readiness_filters_health_only() {
7360 let registry = HealthIndicatorRegistry::new();
7361 registry
7362 .register("probe_check", IndicatorGroup::Readiness, Arc::new(AlwaysUp))
7363 .unwrap();
7364 registry
7365 .register(
7366 "health_only",
7367 IndicatorGroup::HealthOnly,
7368 Arc::new(AlwaysDown),
7369 )
7370 .unwrap();
7371
7372 let results = registry.run_readiness().await;
7373 assert_eq!(results.len(), 1);
7374 assert_eq!(results[0].name, "probe_check");
7375 }
7376
7377 #[tokio::test]
7378 async fn timed_out_indicator_reports_unknown_with_flag() {
7379 struct SlowIndicator;
7380 impl HealthIndicator for SlowIndicator {
7381 fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
7382 Box::pin(async {
7383 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
7384 HealthCheckOutput::up()
7385 })
7386 }
7387 fn timeout_ms(&self) -> u64 {
7388 5
7389 }
7390 }
7391 let registry = HealthIndicatorRegistry::new();
7392 registry
7393 .register("slow", IndicatorGroup::Readiness, Arc::new(SlowIndicator))
7394 .unwrap();
7395 let results = registry.run_all().await;
7396 let slow_res = results
7397 .iter()
7398 .find(|r| r.name == "slow")
7399 .expect("slow indicator not found");
7400 assert_eq!(slow_res.output.status, HealthStatus::Unknown);
7401 assert_eq!(
7402 slow_res.output.details.get("timed_out"),
7403 Some(&serde_json::Value::Bool(true))
7404 );
7405 }
7406
7407 #[tokio::test]
7408 #[allow(clippy::await_holding_lock)]
7409 async fn test_circuit_breakers_in_health_indicator_registry() {
7410 let _lock = crate::circuit_breaker::TEST_LOCK
7411 .lock()
7412 .unwrap_or_else(std::sync::PoisonError::into_inner);
7413 crate::circuit_breaker::global_registry().clear();
7414 let registry = HealthIndicatorRegistry::new();
7415 let breaker = crate::circuit_breaker::global_registry().get_or_create(
7416 "actuator_test_breaker",
7417 crate::circuit_breaker::CircuitBreakerPolicy {
7418 failure_ratio_threshold: 0.5,
7419 sample_window: std::time::Duration::from_secs(10),
7420 minimum_sample_count: 2,
7421 open_duration: std::time::Duration::from_secs(60),
7422 half_open_trial_count: 2,
7423 },
7424 );
7425
7426 let results = registry.run_all().await;
7427 let found = results
7428 .iter()
7429 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
7430 assert!(found.is_some(), "Should find circuit breaker in run_all");
7431 let result = found.unwrap();
7432 assert_eq!(result.group, IndicatorGroup::HealthOnly);
7433 assert_eq!(result.output.status, HealthStatus::Up);
7434 assert_eq!(result.output.details.get("state").unwrap(), "CLOSED");
7435
7436 breaker.after_call(false);
7437 breaker.after_call(false);
7438 assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
7439
7440 let results = registry.run_all().await;
7441 let found = results
7442 .iter()
7443 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
7444 assert_eq!(found.unwrap().output.status, HealthStatus::Down);
7445 assert_eq!(found.unwrap().output.details.get("state").unwrap(), "OPEN");
7446
7447 {
7449 let mut inner = breaker.inner.lock().unwrap();
7450 inner.state = crate::circuit_breaker::CircuitState::HalfOpen;
7451 inner.half_open_in_flight = 0;
7452 inner.half_open_successes = 0;
7453 inner.half_open_failures = 0;
7454 }
7455 assert_eq!(
7456 breaker.state(),
7457 crate::circuit_breaker::CircuitState::HalfOpen
7458 );
7459
7460 let results = registry.run_all().await;
7461 let found = results
7462 .iter()
7463 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
7464 assert_eq!(found.unwrap().output.status, HealthStatus::Down);
7465 assert_eq!(
7466 found.unwrap().output.details.get("state").unwrap(),
7467 "HALF_OPEN"
7468 );
7469
7470 let readiness_results = registry.run_readiness().await;
7471 let found_readiness = readiness_results
7472 .iter()
7473 .find(|r| r.name == "circuit_breaker.actuator_test_breaker");
7474 assert!(
7475 found_readiness.is_none(),
7476 "Should NOT find circuit breaker in run_readiness"
7477 );
7478 crate::circuit_breaker::global_registry().clear();
7479 }
7480}
7481
7482#[cfg(test)]
7483mod havoc_proptest {
7484 use super::*;
7485 use proptest::prelude::*;
7486
7487 proptest! {
7488 #![proptest_config(ProptestConfig::with_cases(1))]
7489 #[test]
7490 fn log_levels_memory_exhaustion(names in proptest::collection::vec(".*", 5000)) {
7491 let levels = LogLevels::new("info");
7492 for name in names {
7493 let _ = levels.set_logger_level(&name, "debug");
7494 }
7495 assert!(levels.logger_overrides().len() <= 1000, "Memory leak: unbounded loggers inserted");
7496 }
7497 }
7498}
7499
7500#[cfg(all(feature = "maud", feature = "htmx"))]
7503async fn ui_dashboard() -> impl IntoResponse {
7504 let html = maud::html! {
7505 (maud::DOCTYPE)
7506 html lang="en" {
7507 head {
7508 meta charset="utf-8";
7509 meta name="viewport" content="width=device-width, initial-scale=1";
7510 title { "Autumn Actuator Dashboard" }
7511 script src="/static/js/htmx.min.js" {}
7512 style {
7513 (crate::ui::tokens::TOKENS_CSS)
7514 "body { font-family: var(--font-family); background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }"
7515 "h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }"
7516 ".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }"
7517 ".card { background: var(--surface); padding: 1.5rem; border-radius: var(--radius); box-shadow: var(--shadow); }"
7518 ".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; }"
7519 ".stat { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }"
7520 ".stat-label { color: var(--text-muted); }"
7521 ".stat-value { font-weight: 500; }"
7522 ".task-item { border: 1px solid var(--border); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 0.75rem; }"
7523 ".task-name { font-weight: 600; display: block; margin-bottom: 0.25rem; }"
7524 ".task-meta { font-size: 0.875rem; color: var(--text-muted); }"
7525 ".badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }"
7526 ".badge-green { background: #dcfce7; color: #166534; }"
7527 ".badge-gray { background: #f3f4f6; color: #374151; }"
7528 ".badge-red { background: #fee2e2; color: #991b1b; }"
7529 }
7530 }
7531 body {
7532 h1 { "🍂 Autumn Actuator Dashboard" }
7533 div class="grid" {
7534 div class="card" hx-get="ui/metrics" hx-trigger="load, every 2s" {
7535 "Loading metrics..."
7536 }
7537 div class="card" hx-get="ui/tasks" hx-trigger="load, every 2s" {
7538 "Loading tasks..."
7539 }
7540 }
7541 }
7542 }
7543 };
7544 (
7545 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7546 html.into_string(),
7547 )
7548}
7549
7550#[cfg(not(all(feature = "maud", feature = "htmx")))]
7551async fn ui_dashboard() -> impl IntoResponse {
7552 (
7553 StatusCode::NOT_IMPLEMENTED,
7554 "Maud feature is required for the UI dashboard",
7555 )
7556}
7557
7558#[cfg(all(feature = "maud", feature = "htmx"))]
7559async fn ui_metrics<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
7560 let metrics = state.metrics().snapshot();
7561 let uptime = state.uptime_display();
7562
7563 let html = maud::html! {
7564 h2 { "System Metrics" }
7565 div class="stat" {
7566 span class="stat-label" { "Uptime" }
7567 span class="stat-value" { (uptime) }
7568 }
7569 div class="stat" {
7570 span class="stat-label" { "Total Requests" }
7571 span class="stat-value" { (metrics.http.requests_total) }
7572 }
7573 div class="stat" {
7574 span class="stat-label" { "Active Requests" }
7575 span class="stat-value" { (metrics.http.requests_active) }
7576 }
7577 div class="stat" {
7578 span class="stat-label" { "P95 Latency" }
7579 span class="stat-value" { (metrics.http.latency_ms.p95) " ms" }
7580 }
7581 div class="stat" {
7582 span class="stat-label" { "P99 Latency" }
7583 span class="stat-value" { (metrics.http.latency_ms.p99) " ms" }
7584 }
7585 };
7586 (
7587 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7588 html.into_string(),
7589 )
7590}
7591
7592#[cfg(not(all(feature = "maud", feature = "htmx")))]
7593async fn ui_metrics<S: ProvideActuatorState>() -> impl IntoResponse {
7594 (
7595 StatusCode::NOT_IMPLEMENTED,
7596 "Maud feature is required for the UI dashboard",
7597 )
7598}
7599
7600#[cfg(all(feature = "maud", feature = "htmx"))]
7601async fn ui_tasks<S: ProvideActuatorState>(State(state): State<S>) -> impl IntoResponse {
7602 let tasks = state.task_registry().snapshot();
7603
7604 let html = maud::html! {
7605 h2 { "Background Tasks" }
7606 @if tasks.is_empty() {
7607 p class="stat-label" { "No tasks registered." }
7608 } @else {
7609 @for (name, task) in tasks.iter() {
7610 div class="task-item" {
7611 span class="task-name" { (name) }
7612 div class="task-meta" {
7613 @if task.status == "running" {
7614 span class="badge badge-green" { "Running" }
7615 } @else {
7616 span class="badge badge-gray" { "Idle" }
7617 }
7618 " "
7619 "Runs: " (task.total_runs)
7620 @if task.total_failures > 0 {
7621 " " span class="badge badge-red" { "Failures: " (task.total_failures) }
7622 }
7623 }
7624 }
7625 }
7626 }
7627 };
7628 (
7629 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7630 html.into_string(),
7631 )
7632}
7633
7634#[cfg(not(all(feature = "maud", feature = "htmx")))]
7635async fn ui_tasks<S: ProvideActuatorState>() -> impl IntoResponse {
7636 (
7637 StatusCode::NOT_IMPLEMENTED,
7638 "Maud feature is required for the UI dashboard",
7639 )
7640}