1use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use axum::extract::{Path, Query, State};
17use axum::http::StatusCode;
18use axum::response::IntoResponse;
19use axum::routing::{get, post};
20use axum::{Json, Router};
21use serde::{Deserialize, Serialize};
22
23use crate::debug::diff::{diff, Change};
24use crate::debug::sandbox::{synthesize_policy, SandboxRequest};
25use crate::debug::store::TraceSummary;
26use crate::debug::{new_trace_id, NodeStep, Trace, TraceRecorder, TraceSource};
27use crate::graph::{compile_policy, validate_policy};
28use crate::state::SharedState;
29
30pub fn router() -> Router<Arc<SharedState>> {
32 Router::new()
33 .route("/api/debug/config", get(get_config))
34 .route("/api/debug/traces", get(list_traces).delete(clear_traces))
35 .route("/api/debug/traces/{id}", get(get_trace))
36 .route("/api/debug/sandbox", post(run_sandbox))
37}
38
39fn disabled(what: &str) -> axum::response::Response {
42 tracing::warn!(
43 "{} was requested but debug mode is off; set `debug.enabled: true` in \
44 system.yaml (or FEATHERBIT_DEBUG=true) and restart",
45 what
46 );
47 (
48 StatusCode::NOT_FOUND,
49 Json(serde_json::json!({"error": "not_found"})),
50 )
51 .into_response()
52}
53
54async fn get_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
60 let d = &state.debug;
61 Json(serde_json::json!({
62 "enabled": d.enabled,
63 "sandbox": d.sandbox_enabled,
64 "trigger_header": d.trigger_header,
65 "trace_all": d.trace_all,
66 "capture_bodies": d.capture_bodies,
67 "max_traces": d.max_traces,
68 "max_steps": d.max_steps,
69 "max_body_bytes": d.max_body_bytes,
70 "traces_stored": d.len(),
71 }))
72}
73
74#[derive(Debug, Default, Deserialize)]
78struct TraceFilter {
79 route: Option<String>,
80 policy: Option<String>,
81 status: Option<u16>,
82 source: Option<String>,
84 limit: Option<usize>,
86}
87
88fn non_empty(s: &Option<String>) -> Option<&str> {
89 s.as_deref().map(str::trim).filter(|s| !s.is_empty())
90}
91
92fn source_str(source: TraceSource) -> &'static str {
93 match source {
94 TraceSource::Request => "request",
95 TraceSource::Sandbox => "sandbox",
96 }
97}
98
99fn apply_filter(mut traces: Vec<TraceSummary>, f: &TraceFilter) -> Vec<TraceSummary> {
101 if let Some(route) = non_empty(&f.route) {
102 traces.retain(|t| t.route.as_deref() == Some(route));
103 }
104 if let Some(policy) = non_empty(&f.policy) {
105 traces.retain(|t| t.policy == policy);
106 }
107 if let Some(status) = f.status {
108 traces.retain(|t| t.status == status);
109 }
110 if let Some(source) = non_empty(&f.source) {
111 traces.retain(|t| source_str(t.source).eq_ignore_ascii_case(source));
112 }
113 if let Some(limit) = f.limit {
114 traces.truncate(limit);
115 }
116 traces
117}
118
119async fn list_traces(
125 State(state): State<Arc<SharedState>>,
126 Query(filter): Query<TraceFilter>,
127) -> impl IntoResponse {
128 if !state.debug.enabled {
129 return disabled("trace listing");
130 }
131 let traces = apply_filter(state.debug.list(), &filter);
132 Json(serde_json::json!({ "traces": traces })).into_response()
133}
134
135#[derive(Serialize)]
137struct StepWithChanges<'a> {
138 #[serde(flatten)]
139 step: &'a NodeStep,
140 changes: Vec<Change>,
141}
142
143fn render_trace(trace: &Trace) -> serde_json::Value {
149 let mut prev = &trace.initial;
150 let mut steps = Vec::with_capacity(trace.steps.len());
151 for step in &trace.steps {
152 steps.push(StepWithChanges {
153 step,
154 changes: diff(prev, &step.after),
155 });
156 prev = &step.after;
157 }
158 let mut out = serde_json::to_value(trace).unwrap_or_else(|_| serde_json::json!({}));
159 out["steps"] = serde_json::to_value(steps).unwrap_or_else(|_| serde_json::json!([]));
160 out
161}
162
163async fn get_trace(
167 State(state): State<Arc<SharedState>>,
168 Path(id): Path<String>,
169) -> impl IntoResponse {
170 if !state.debug.enabled {
171 return disabled("trace retrieval");
172 }
173 match state.debug.get(&id) {
174 Some(trace) => Json(render_trace(&trace)).into_response(),
175 None => (
176 StatusCode::NOT_FOUND,
177 Json(serde_json::json!({"error": "not_found"})),
178 )
179 .into_response(),
180 }
181}
182
183async fn clear_traces(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
185 if !state.debug.enabled {
186 return disabled("trace clearing");
187 }
188 let removed = state.debug.clear();
189 Json(serde_json::json!({ "status": "cleared", "removed": removed })).into_response()
190}
191
192fn bad_request(msg: impl Into<String>) -> axum::response::Response {
193 (
194 StatusCode::BAD_REQUEST,
195 Json(serde_json::json!({"error": msg.into()})),
196 )
197 .into_response()
198}
199
200async fn run_sandbox(
213 State(state): State<Arc<SharedState>>,
214 Json(req): Json<SandboxRequest>,
215) -> impl IntoResponse {
216 if !state.debug.enabled {
217 return disabled("sandbox");
218 }
219 if !state.debug.sandbox_enabled {
220 return disabled("sandbox (debug.sandbox is false)");
221 }
222
223 let (mode, policy_name, policy) = match (req.nodes, req.policy) {
224 (Some(_), Some(_)) | (None, None) => {
225 return bad_request("provide exactly one of 'nodes' or 'policy'")
226 }
227 (Some(nodes), None) => match synthesize_policy(nodes, req.on_error) {
228 Ok(p) => ("nodes", "__sandbox".to_string(), p),
229 Err(e) => return bad_request(e),
230 },
231 (None, Some(name)) => {
232 let gw = state.gateway.read().await;
233 match gw.policies.iter().find(|p| p.name == name) {
234 Some(p) => ("policy", name.clone(), p.clone()),
237 None => {
238 return (
239 StatusCode::NOT_FOUND,
240 Json(serde_json::json!({"error": format!("unknown policy '{name}'")})),
241 )
242 .into_response()
243 }
244 }
245 }
246 };
247
248 if let Err(errors) = validate_policy(&policy) {
249 return bad_request(errors.join("; "));
250 }
251 let graph = match compile_policy(&policy, state.resources.clone()) {
252 Ok(g) => g,
253 Err(e) => return bad_request(e),
254 };
255
256 let ctx = match crate::debug::sandbox::materialize_context(req.context) {
257 Ok(c) => c,
258 Err(e) => return bad_request(e),
259 };
260
261 tracing::warn!(
262 "sandbox run ({}): plugins execute for real against live resources",
263 mode
264 );
265
266 let recorder = TraceRecorder::new(&ctx, state.debug.capture_options(), state.debug.max_steps);
267 let started = Instant::now();
268 let timeout = Duration::from_secs(state.debug.sandbox_timeout_seconds.max(1));
269
270 let run = graph.execute_traced(ctx, recorder);
271 let (out_ctx, recorder) = match tokio::time::timeout(timeout, run).await {
272 Ok(pair) => pair,
273 Err(_) => {
274 return (
275 StatusCode::GATEWAY_TIMEOUT,
276 Json(serde_json::json!({
277 "error": "sandbox_timeout",
278 "message": format!(
279 "run exceeded debug.sandbox_timeout_seconds ({}s)",
280 state.debug.sandbox_timeout_seconds
281 ),
282 })),
283 )
284 .into_response()
285 }
286 };
287
288 let id = new_trace_id();
289 let trace = recorder.finish(
290 id.clone(),
291 state.debug.next_seq(),
292 TraceSource::Sandbox,
293 None,
294 policy_name.clone(),
295 &out_ctx,
296 started.elapsed(),
297 );
298 let rendered = render_trace(&trace);
299 state.debug.record(trace);
302
303 Json(serde_json::json!({
304 "mode": mode,
305 "policy": policy_name,
306 "warning": "plugins executed for real: outbound calls were made and shared \
307 rate-limit/breaker state was mutated",
308 "stored_trace_id": id,
309 "trace": rendered,
310 }))
311 .into_response()
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::config::{DebugConfig, GatewayConfig, SystemConfig};
318 use crate::config_store::FileConfigStore;
319
320 fn state_with(debug: DebugConfig) -> Arc<SharedState> {
321 let system = SystemConfig {
324 debug,
325 ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
326 };
327 let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
328 Arc::new(
329 SharedState::new(
330 system,
331 gateway,
332 None,
333 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
334 "gateway.yaml",
335 ))),
336 )
337 .unwrap(),
338 )
339 }
340
341 #[test]
344 fn test_disabled_by_default() {
345 let s = state_with(DebugConfig::default());
346 assert!(!s.debug.enabled);
347 }
348
349 #[tokio::test]
350 async fn test_config_endpoint_answers_even_when_disabled() {
351 let s = state_with(DebugConfig::default());
352 let resp = get_config(State(s)).await.into_response();
353 assert_eq!(resp.status(), StatusCode::OK);
356 }
357
358 #[tokio::test]
359 async fn test_trace_routes_404_when_disabled() {
360 let s = state_with(DebugConfig::default());
361 assert_eq!(
362 list_traces(State(s.clone()), Query(TraceFilter::default()))
363 .await
364 .into_response()
365 .status(),
366 StatusCode::NOT_FOUND
367 );
368 assert_eq!(
369 get_trace(State(s.clone()), Path("any".to_string()))
370 .await
371 .into_response()
372 .status(),
373 StatusCode::NOT_FOUND
374 );
375 assert_eq!(
376 clear_traces(State(s.clone()))
377 .await
378 .into_response()
379 .status(),
380 StatusCode::NOT_FOUND
381 );
382 assert_eq!(
383 run_sandbox(State(s), Json(SandboxRequest::default()))
384 .await
385 .into_response()
386 .status(),
387 StatusCode::NOT_FOUND
388 );
389 }
390
391 #[tokio::test]
392 async fn test_sandbox_404s_when_only_sandbox_is_off() {
393 let s = state_with(DebugConfig {
394 enabled: true,
395 sandbox: false,
396 ..Default::default()
397 });
398 assert_eq!(
400 list_traces(State(s.clone()), Query(TraceFilter::default()))
401 .await
402 .into_response()
403 .status(),
404 StatusCode::OK
405 );
406 assert_eq!(
408 run_sandbox(State(s), Json(SandboxRequest::default()))
409 .await
410 .into_response()
411 .status(),
412 StatusCode::NOT_FOUND
413 );
414 }
415
416 #[tokio::test]
417 async fn test_sandbox_requires_exactly_one_mode() {
418 let s = state_with(DebugConfig {
419 enabled: true,
420 ..Default::default()
421 });
422 assert_eq!(
424 run_sandbox(State(s.clone()), Json(SandboxRequest::default()))
425 .await
426 .into_response()
427 .status(),
428 StatusCode::BAD_REQUEST
429 );
430 let both = SandboxRequest {
432 nodes: Some(Vec::new()),
433 policy: Some("p".to_string()),
434 ..Default::default()
435 };
436 assert_eq!(
437 run_sandbox(State(s), Json(both))
438 .await
439 .into_response()
440 .status(),
441 StatusCode::BAD_REQUEST
442 );
443 }
444
445 #[tokio::test]
446 async fn test_sandbox_unknown_policy_404s() {
447 let s = state_with(DebugConfig {
448 enabled: true,
449 ..Default::default()
450 });
451 let req = SandboxRequest {
452 policy: Some("nope".to_string()),
453 ..Default::default()
454 };
455 assert_eq!(
456 run_sandbox(State(s), Json(req))
457 .await
458 .into_response()
459 .status(),
460 StatusCode::NOT_FOUND
461 );
462 }
463
464 fn summary(route: &str, policy: &str, status: u16, source: TraceSource) -> TraceSummary {
465 TraceSummary {
466 id: format!("{route}-{status}"),
467 seq: 0,
468 source,
469 started_ms: 0,
470 route: Some(route.to_string()),
471 policy: policy.to_string(),
472 method: "GET".to_string(),
473 path: "/x".to_string(),
474 status,
475 duration_us: 1,
476 step_count: 1,
477 error_count: 0,
478 captured_bodies: false,
479 }
480 }
481
482 fn sample() -> Vec<TraceSummary> {
483 vec![
484 summary("echo-api", "echo-policy", 200, TraceSource::Request),
485 summary("secure-api", "secure-policy", 401, TraceSource::Request),
486 summary("echo-api", "echo-policy", 502, TraceSource::Request),
487 summary("s", "echo-policy", 200, TraceSource::Sandbox),
488 ]
489 }
490
491 fn ids(traces: Vec<TraceSummary>) -> Vec<String> {
492 traces.into_iter().map(|t| t.id).collect()
493 }
494
495 #[test]
496 fn test_no_filter_returns_all() {
497 assert_eq!(apply_filter(sample(), &TraceFilter::default()).len(), 4);
498 }
499
500 #[test]
501 fn test_filter_by_route() {
502 let f = TraceFilter {
503 route: Some("echo-api".to_string()),
504 ..Default::default()
505 };
506 assert_eq!(apply_filter(sample(), &f).len(), 2);
507 }
508
509 #[test]
510 fn test_filter_by_policy_spans_routes() {
511 let f = TraceFilter {
513 policy: Some("echo-policy".to_string()),
514 ..Default::default()
515 };
516 assert_eq!(apply_filter(sample(), &f).len(), 3);
517 }
518
519 #[test]
520 fn test_filters_are_anded() {
521 let f = TraceFilter {
522 policy: Some("echo-policy".to_string()),
523 status: Some(200),
524 source: Some("request".to_string()),
525 ..Default::default()
526 };
527 assert_eq!(ids(apply_filter(sample(), &f)), vec!["echo-api-200"]);
528 }
529
530 #[test]
531 fn test_empty_filter_string_is_ignored() {
532 let f = TraceFilter {
534 route: Some(" ".to_string()),
535 ..Default::default()
536 };
537 assert_eq!(apply_filter(sample(), &f).len(), 4);
538 }
539
540 #[test]
541 fn test_limit_applies_after_filtering() {
542 let f = TraceFilter {
543 policy: Some("echo-policy".to_string()),
544 limit: Some(2),
545 ..Default::default()
546 };
547 assert_eq!(apply_filter(sample(), &f).len(), 2);
548 }
549
550 #[test]
551 fn test_filter_by_source() {
552 let f = TraceFilter {
553 source: Some("sandbox".to_string()),
554 ..Default::default()
555 };
556 assert_eq!(ids(apply_filter(sample(), &f)), vec!["s-200"]);
557 }
558
559 #[tokio::test]
560 async fn test_sandbox_rejects_unknown_plugin_type() {
561 let s = state_with(DebugConfig {
562 enabled: true,
563 ..Default::default()
564 });
565 let req = SandboxRequest {
566 nodes: Some(vec![crate::config::NodeConfig {
567 id: "x".to_string(),
568 node_type: "no-such-plugin".to_string(),
569 config: Default::default(),
570 position: None,
571 }]),
572 ..Default::default()
573 };
574 assert_eq!(
575 run_sandbox(State(s), Json(req))
576 .await
577 .into_response()
578 .status(),
579 StatusCode::BAD_REQUEST
580 );
581 }
582}