moonlight-http 0.1.2

HTTP shadow proxy for comparing candidate service behavior against reference targets.
Documentation
pub mod proxy;

use crate::proxy::{
    get_config, get_health, get_metrics, get_run, get_run_report, get_run_review, get_runs,
    get_stats, proxy_handler, put_run_review,
};
use axum::{http::HeaderValue, routing::get, Router};
use moonlight_core::{
    config::AppConfig,
    review::ReviewStore,
    storage::{Storage, StorageOptions},
    Classification, ComparisonRun, MetricsClassificationCounts, MetricsSnapshot,
};
use reqwest::Client;
use std::sync::{
    atomic::{AtomicU64, Ordering},
    Arc,
};
use tower_http::{
    cors::{AllowOrigin, Any, CorsLayer},
    trace::TraceLayer,
};

#[derive(Clone)]
pub struct AppState {
    pub config: AppConfig,
    pub client: Client,
    pub storage: Storage,
    pub review_store: ReviewStore,
    pub metrics: AppMetrics,
}

#[derive(Clone, Default)]
pub struct AppMetrics {
    counters: Arc<MetricsCounters>,
}

#[derive(Default)]
struct MetricsCounters {
    total_proxied_comparisons_started: AtomicU64,
    persisted_comparisons: AtomicU64,
    persistence_failures: AtomicU64,
    storage_refresh_failures: AtomicU64,
    target_errors_observed: AtomicU64,
    matches: AtomicU64,
    suspicious_differences: AtomicU64,
    reference_noise: AtomicU64,
    suspicious_with_noise: AtomicU64,
    target_errors: AtomicU64,
}

impl AppMetrics {
    pub fn record_comparison_started(&self) {
        self.counters
            .total_proxied_comparisons_started
            .fetch_add(1, Ordering::Relaxed);
    }

    pub fn record_persisted_run(&self, run: &ComparisonRun) {
        self.counters
            .persisted_comparisons
            .fetch_add(1, Ordering::Relaxed);
        let target_errors = [
            run.primary.error.as_ref(),
            run.candidate.error.as_ref(),
            run.secondary
                .as_ref()
                .and_then(|target| target.error.as_ref()),
        ]
        .into_iter()
        .flatten()
        .count() as u64;
        self.counters
            .target_errors_observed
            .fetch_add(target_errors, Ordering::Relaxed);
        match run.comparison.classification {
            Classification::Match => &self.counters.matches,
            Classification::SuspiciousDifference => &self.counters.suspicious_differences,
            Classification::ReferenceNoise => &self.counters.reference_noise,
            Classification::SuspiciousWithNoise => &self.counters.suspicious_with_noise,
            Classification::TargetError => &self.counters.target_errors,
        }
        .fetch_add(1, Ordering::Relaxed);
    }

    pub fn record_persistence_failure(&self) {
        self.counters
            .persistence_failures
            .fetch_add(1, Ordering::Relaxed);
    }

    pub fn record_storage_refresh_failure(&self) {
        self.counters
            .storage_refresh_failures
            .fetch_add(1, Ordering::Relaxed);
    }

    pub fn snapshot(&self) -> MetricsSnapshot {
        MetricsSnapshot {
            total_proxied_comparisons_started: self
                .counters
                .total_proxied_comparisons_started
                .load(Ordering::Relaxed),
            persisted_comparisons: self.counters.persisted_comparisons.load(Ordering::Relaxed),
            persistence_failures: self.counters.persistence_failures.load(Ordering::Relaxed),
            storage_refresh_failures: self
                .counters
                .storage_refresh_failures
                .load(Ordering::Relaxed),
            target_errors_observed: self.counters.target_errors_observed.load(Ordering::Relaxed),
            classifications: MetricsClassificationCounts {
                matches: self.counters.matches.load(Ordering::Relaxed),
                suspicious_differences: self
                    .counters
                    .suspicious_differences
                    .load(Ordering::Relaxed),
                reference_noise: self.counters.reference_noise.load(Ordering::Relaxed),
                suspicious_with_noise: self.counters.suspicious_with_noise.load(Ordering::Relaxed),
                target_errors: self.counters.target_errors.load(Ordering::Relaxed),
            },
        }
    }
}

pub async fn build_state(config: AppConfig) -> anyhow::Result<Arc<AppState>> {
    let storage = Storage::load_with_options(
        config.storage_path.clone(),
        StorageOptions {
            retention_max_runs: config.retention_max_runs,
            retention_max_bytes: config.retention_max_bytes,
        },
    )
    .await?;
    let review_store = ReviewStore::load(config.review_state_path.clone()).await?;
    Ok(Arc::new(AppState {
        config,
        client: Client::new(),
        storage,
        review_store,
        metrics: AppMetrics::default(),
    }))
}

pub fn build_router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/api/health", get(get_health))
        .route("/api/config", get(get_config))
        .route("/api/runs", get(get_runs))
        .route("/api/runs/{id}", get(get_run))
        .route("/api/runs/{id}/report", get(get_run_report))
        .route(
            "/api/runs/{id}/review",
            get(get_run_review).put(put_run_review),
        )
        .route("/api/stats", get(get_stats))
        .route("/api/metrics", get(get_metrics))
        .fallback(proxy_handler)
        .layer(TraceLayer::new_for_http())
        .layer(cors_layer(&state.config))
        .with_state(state)
}

fn cors_layer(config: &AppConfig) -> CorsLayer {
    if config.cors_origins.iter().any(|origin| origin == "*") {
        return CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any);
    }

    let origins = config
        .cors_origins
        .iter()
        .filter_map(|origin| HeaderValue::from_str(origin).ok())
        .collect::<Vec<_>>();

    CorsLayer::new()
        .allow_origin(AllowOrigin::list(origins))
        .allow_methods(Any)
        .allow_headers(Any)
}