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