Skip to main content

moonlight_http/proxy/
execution.rs

1use super::{
2    target::{
3        forward_target, join_optional_target, join_required_target, optional_forward_target,
4        response_from_target, selected_response,
5    },
6    TargetRequest,
7};
8use crate::AppState;
9use axum::{
10    extract::{OriginalUri, State},
11    http::Method,
12    response::{IntoResponse, Response},
13};
14use bytes::Bytes;
15use chrono::Utc;
16use futures::future::{join3, BoxFuture};
17use moonlight_core::{
18    compare::{capture_body_with_redaction_patterns, capture_headers, CompareConfig},
19    config::{AppConfig, ResponseTiming, ReturnFallback, ReturnTarget},
20    run::{build_comparison_run, CapturedTargets, RunMetadata},
21    target::CapturedTarget,
22    Adapter, ComparisonRun, RunInput,
23};
24use std::sync::Arc;
25use tokio::task::JoinHandle;
26use tracing::Instrument;
27use uuid::Uuid;
28
29pub async fn proxy_handler(
30    State(state): State<Arc<AppState>>,
31    OriginalUri(uri): OriginalUri,
32    method: Method,
33    headers: axum::http::HeaderMap,
34    body: Bytes,
35) -> Response {
36    if body.len() > state.config.max_request_body_bytes {
37        return axum::http::StatusCode::PAYLOAD_TOO_LARGE.into_response();
38    }
39
40    let id = Uuid::new_v4();
41    let timestamp = Utc::now();
42    let method_label = method.to_string();
43    let path_label = uri.path().to_string();
44    let path = uri.path().to_string();
45    let query = uri
46        .query()
47        .map(|query| redact_query(query, &state.config.redact_query_params));
48    let path_and_query = uri
49        .path_and_query()
50        .map(|value| value.as_str().to_string())
51        .unwrap_or_else(|| path.clone());
52
53    let metadata = RunMetadata {
54        id,
55        timestamp,
56        adapter: Adapter::Http,
57        input: RunInput::Http {
58            method: method_label.clone(),
59            path,
60            query,
61        },
62        request_headers: capture_headers(&headers, &state.config.redact_headers),
63        request_body: capture_body_with_redaction_patterns(
64            &body,
65            state.config.max_body_capture_bytes,
66            &state.config.redact_json_paths,
67            &state.config.redact_json_path_patterns,
68        ),
69    };
70    let target_request = TargetRequest {
71        method,
72        path_and_query,
73        headers,
74        body,
75    };
76    state.metrics.record_comparison_started();
77    let span = tracing::info_span!(
78        "moonlight_proxy_run",
79        run_id = %id,
80        method = %method_label,
81        path = %path_label,
82        return_target = ?state.config.return_target,
83        response_timing = ?state.config.response_timing,
84    );
85
86    async move {
87        let primary = forward_target(
88            state.clone(),
89            "primary",
90            state.config.primary_url.clone(),
91            target_request.clone(),
92        );
93        let candidate = forward_target(
94            state.clone(),
95            "candidate",
96            state.config.candidate_url.clone(),
97            target_request.clone(),
98        );
99        let secondary = optional_forward_target(
100            state.clone(),
101            "secondary",
102            state.config.secondary_url.clone(),
103            state.config.enable_secondary,
104            target_request,
105        );
106
107        match state.config.response_timing {
108            ResponseTiming::WaitAll => {
109                proxy_wait_all(state, metadata, primary, candidate, secondary).await
110            }
111            ResponseTiming::ReturnSelected => {
112                proxy_return_selected(state, metadata, primary, candidate, secondary).await
113            }
114        }
115    }
116    .instrument(span)
117    .await
118}
119
120async fn proxy_wait_all(
121    state: Arc<AppState>,
122    metadata: RunMetadata,
123    primary: BoxFuture<'static, CapturedTarget>,
124    candidate: BoxFuture<'static, CapturedTarget>,
125    secondary: BoxFuture<'static, Option<CapturedTarget>>,
126) -> Response {
127    let (primary, candidate, secondary) = join3(primary, candidate, secondary).await;
128    let response = selected_response(&state, &primary, &candidate);
129    let id = metadata.id;
130    let run = build_run(metadata, primary, candidate, secondary, &state.config);
131
132    persist_run(state, id, run).await;
133
134    response
135}
136
137async fn proxy_return_selected(
138    state: Arc<AppState>,
139    metadata: RunMetadata,
140    primary: BoxFuture<'static, CapturedTarget>,
141    candidate: BoxFuture<'static, CapturedTarget>,
142    secondary: BoxFuture<'static, Option<CapturedTarget>>,
143) -> Response {
144    match state.config.return_target {
145        ReturnTarget::Primary => {
146            let candidate = tokio::spawn(candidate);
147            let secondary = tokio::spawn(secondary);
148            let primary = primary.await;
149            let response = response_from_target(&primary);
150            spawn_persist_run(state, metadata, primary, candidate, secondary);
151            response
152        }
153        ReturnTarget::Candidate => {
154            let primary = tokio::spawn(primary);
155            let secondary = tokio::spawn(secondary);
156            let candidate = candidate.await;
157            if candidate.observation.error.is_some()
158                && state.config.return_fallback == ReturnFallback::Primary
159            {
160                let primary = join_required_target(primary, "primary").await;
161                let response = response_from_target(&primary);
162                spawn_persist_run_with_primary(state, metadata, primary, candidate, secondary);
163                response
164            } else {
165                let response = response_from_target(&candidate);
166                spawn_persist_run_with_candidate(state, metadata, primary, candidate, secondary);
167                response
168            }
169        }
170    }
171}
172
173fn spawn_persist_run(
174    state: Arc<AppState>,
175    metadata: RunMetadata,
176    primary: CapturedTarget,
177    candidate: JoinHandle<CapturedTarget>,
178    secondary: JoinHandle<Option<CapturedTarget>>,
179) {
180    tokio::spawn(async move {
181        let candidate = join_required_target(candidate, "candidate").await;
182        persist_run_with_targets(state, metadata, primary, candidate, secondary).await;
183    });
184}
185
186fn spawn_persist_run_with_primary(
187    state: Arc<AppState>,
188    metadata: RunMetadata,
189    primary: CapturedTarget,
190    candidate: CapturedTarget,
191    secondary: JoinHandle<Option<CapturedTarget>>,
192) {
193    tokio::spawn(async move {
194        persist_run_with_targets(state, metadata, primary, candidate, secondary).await;
195    });
196}
197
198fn spawn_persist_run_with_candidate(
199    state: Arc<AppState>,
200    metadata: RunMetadata,
201    primary: JoinHandle<CapturedTarget>,
202    candidate: CapturedTarget,
203    secondary: JoinHandle<Option<CapturedTarget>>,
204) {
205    tokio::spawn(async move {
206        let primary = join_required_target(primary, "primary").await;
207        persist_run_with_targets(state, metadata, primary, candidate, secondary).await;
208    });
209}
210
211async fn persist_run_with_targets(
212    state: Arc<AppState>,
213    metadata: RunMetadata,
214    primary: CapturedTarget,
215    candidate: CapturedTarget,
216    secondary: JoinHandle<Option<CapturedTarget>>,
217) {
218    let secondary = join_optional_target(secondary, "secondary").await;
219    let id = metadata.id;
220    let run = build_run(metadata, primary, candidate, secondary, &state.config);
221
222    persist_run(state, id, run).await;
223}
224
225async fn persist_run(state: Arc<AppState>, id: Uuid, run: ComparisonRun) {
226    let classification = run.comparison.classification.clone();
227    let target_error_count = [
228        run.primary.error.as_ref(),
229        run.candidate.error.as_ref(),
230        run.secondary
231            .as_ref()
232            .and_then(|target| target.error.as_ref()),
233    ]
234    .into_iter()
235    .flatten()
236    .count();
237
238    match state.storage.insert(run.clone()).await {
239        Ok(()) => {
240            state.metrics.record_persisted_run(&run);
241            tracing::info!(
242                run_id = %id,
243                classification = ?classification,
244                target_error_count,
245                "persisted moonlight comparison run"
246            );
247        }
248        Err(error) => {
249            state.metrics.record_persistence_failure();
250            tracing::error!(
251                run_id = %id,
252                error = %error,
253                "failed to persist moonlight comparison run"
254            );
255        }
256    }
257}
258
259fn build_run(
260    metadata: RunMetadata,
261    primary: CapturedTarget,
262    candidate: CapturedTarget,
263    secondary: Option<CapturedTarget>,
264    config: &AppConfig,
265) -> ComparisonRun {
266    let compare_config = CompareConfig::new_with_patterns(
267        &config.ignore_json_paths,
268        &config.ignore_json_path_patterns,
269        &config.redact_json_paths,
270        &config.redact_json_path_patterns,
271        &config.ignore_headers,
272        config.ignore_stderr,
273    );
274    build_comparison_run(
275        metadata,
276        CapturedTargets {
277            primary,
278            candidate,
279            secondary,
280        },
281        &compare_config,
282    )
283}
284
285fn redact_query(query: &str, redact_query_params: &[String]) -> String {
286    query
287        .split('&')
288        .map(|part| {
289            let (key, separator, value) = match part.split_once('=') {
290                Some((key, value)) => (key, "=", value),
291                None => (part, "", ""),
292            };
293            if redact_query_params
294                .iter()
295                .any(|redacted| redacted.eq_ignore_ascii_case(key))
296            {
297                format!("{key}{separator}[redacted]")
298            } else if separator.is_empty() {
299                key.to_string()
300            } else {
301                format!("{key}={value}")
302            }
303        })
304        .collect::<Vec<_>>()
305        .join("&")
306}