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}