Skip to main content

moonlight_http/
lib.rs

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}