1pub mod proxy;
2
3use crate::proxy::{
4 get_config, get_health, get_metrics, get_run, get_run_report, get_run_review, get_runs,
5 get_stats, proxy_handler, put_run_review,
6};
7use axum::{http::HeaderValue, routing::get, Router};
8use moonlight_core::{
9 config::AppConfig,
10 review::ReviewStore,
11 storage::{Storage, StorageOptions},
12 Classification, ComparisonRun, MetricsClassificationCounts, MetricsSnapshot,
13};
14use reqwest::Client;
15use std::sync::{
16 atomic::{AtomicU64, Ordering},
17 Arc,
18};
19use tower_http::{
20 cors::{AllowOrigin, Any, CorsLayer},
21 trace::TraceLayer,
22};
23
24#[derive(Clone)]
25pub struct AppState {
26 pub config: AppConfig,
27 pub client: Client,
28 pub storage: Storage,
29 pub review_store: ReviewStore,
30 pub metrics: AppMetrics,
31}
32
33#[derive(Clone, Default)]
34pub struct AppMetrics {
35 counters: Arc<MetricsCounters>,
36}
37
38#[derive(Default)]
39struct MetricsCounters {
40 total_proxied_comparisons_started: AtomicU64,
41 persisted_comparisons: AtomicU64,
42 persistence_failures: AtomicU64,
43 storage_refresh_failures: AtomicU64,
44 target_errors_observed: AtomicU64,
45 matches: AtomicU64,
46 suspicious_differences: AtomicU64,
47 reference_noise: AtomicU64,
48 suspicious_with_noise: AtomicU64,
49 target_errors: AtomicU64,
50}
51
52impl AppMetrics {
53 pub fn record_comparison_started(&self) {
54 self.counters
55 .total_proxied_comparisons_started
56 .fetch_add(1, Ordering::Relaxed);
57 }
58
59 pub fn record_persisted_run(&self, run: &ComparisonRun) {
60 self.counters
61 .persisted_comparisons
62 .fetch_add(1, Ordering::Relaxed);
63 let target_errors = [
64 run.primary.error.as_ref(),
65 run.candidate.error.as_ref(),
66 run.secondary
67 .as_ref()
68 .and_then(|target| target.error.as_ref()),
69 ]
70 .into_iter()
71 .flatten()
72 .count() as u64;
73 self.counters
74 .target_errors_observed
75 .fetch_add(target_errors, Ordering::Relaxed);
76 match run.comparison.classification {
77 Classification::Match => &self.counters.matches,
78 Classification::SuspiciousDifference => &self.counters.suspicious_differences,
79 Classification::ReferenceNoise => &self.counters.reference_noise,
80 Classification::SuspiciousWithNoise => &self.counters.suspicious_with_noise,
81 Classification::TargetError => &self.counters.target_errors,
82 }
83 .fetch_add(1, Ordering::Relaxed);
84 }
85
86 pub fn record_persistence_failure(&self) {
87 self.counters
88 .persistence_failures
89 .fetch_add(1, Ordering::Relaxed);
90 }
91
92 pub fn record_storage_refresh_failure(&self) {
93 self.counters
94 .storage_refresh_failures
95 .fetch_add(1, Ordering::Relaxed);
96 }
97
98 pub fn snapshot(&self) -> MetricsSnapshot {
99 MetricsSnapshot {
100 total_proxied_comparisons_started: self
101 .counters
102 .total_proxied_comparisons_started
103 .load(Ordering::Relaxed),
104 persisted_comparisons: self.counters.persisted_comparisons.load(Ordering::Relaxed),
105 persistence_failures: self.counters.persistence_failures.load(Ordering::Relaxed),
106 storage_refresh_failures: self
107 .counters
108 .storage_refresh_failures
109 .load(Ordering::Relaxed),
110 target_errors_observed: self.counters.target_errors_observed.load(Ordering::Relaxed),
111 classifications: MetricsClassificationCounts {
112 matches: self.counters.matches.load(Ordering::Relaxed),
113 suspicious_differences: self
114 .counters
115 .suspicious_differences
116 .load(Ordering::Relaxed),
117 reference_noise: self.counters.reference_noise.load(Ordering::Relaxed),
118 suspicious_with_noise: self.counters.suspicious_with_noise.load(Ordering::Relaxed),
119 target_errors: self.counters.target_errors.load(Ordering::Relaxed),
120 },
121 }
122 }
123}
124
125pub async fn build_state(config: AppConfig) -> anyhow::Result<Arc<AppState>> {
126 let storage = Storage::load_with_options(
127 config.storage_path.clone(),
128 StorageOptions {
129 retention_max_runs: config.retention_max_runs,
130 retention_max_bytes: config.retention_max_bytes,
131 },
132 )
133 .await?;
134 let review_store = ReviewStore::load(config.review_state_path.clone()).await?;
135 Ok(Arc::new(AppState {
136 config,
137 client: Client::new(),
138 storage,
139 review_store,
140 metrics: AppMetrics::default(),
141 }))
142}
143
144pub fn build_router(state: Arc<AppState>) -> Router {
145 Router::new()
146 .route("/api/health", get(get_health))
147 .route("/api/config", get(get_config))
148 .route("/api/runs", get(get_runs))
149 .route("/api/runs/{id}", get(get_run))
150 .route("/api/runs/{id}/report", get(get_run_report))
151 .route(
152 "/api/runs/{id}/review",
153 get(get_run_review).put(put_run_review),
154 )
155 .route("/api/stats", get(get_stats))
156 .route("/api/metrics", get(get_metrics))
157 .fallback(proxy_handler)
158 .layer(TraceLayer::new_for_http())
159 .layer(cors_layer(&state.config))
160 .with_state(state)
161}
162
163fn cors_layer(config: &AppConfig) -> CorsLayer {
164 if config.cors_origins.iter().any(|origin| origin == "*") {
165 return CorsLayer::new()
166 .allow_origin(Any)
167 .allow_methods(Any)
168 .allow_headers(Any);
169 }
170
171 let origins = config
172 .cors_origins
173 .iter()
174 .filter_map(|origin| HeaderValue::from_str(origin).ok())
175 .collect::<Vec<_>>();
176
177 CorsLayer::new()
178 .allow_origin(AllowOrigin::list(origins))
179 .allow_methods(Any)
180 .allow_headers(Any)
181}