Skip to main content

featherbit/debug/
render.rs

1//! Trace presentation shared by the Admin API and the MCP tools: list
2//! filtering and the rendered trace shape (each step with its `changes`).
3
4use serde::{Deserialize, Serialize};
5
6use crate::debug::diff::{diff, Change};
7use crate::debug::store::TraceSummary;
8use crate::debug::{NodeStep, Trace};
9
10/// Optional filters for the trace list. All are ANDed; blank strings are
11/// ignored (a bare `?route=` is not a filter).
12#[derive(Debug, Default, Deserialize)]
13pub struct TraceFilter {
14    pub route: Option<String>,
15    pub policy: Option<String>,
16    pub status: Option<u16>,
17    /// `request` or `sandbox` (case-insensitive).
18    pub source: Option<String>,
19    /// Cap on the number of rows returned, applied after filtering.
20    pub limit: Option<usize>,
21}
22
23fn non_empty(s: &Option<String>) -> Option<&str> {
24    s.as_deref().map(str::trim).filter(|s| !s.is_empty())
25}
26
27fn source_str(source: crate::debug::TraceSource) -> &'static str {
28    match source {
29        crate::debug::TraceSource::Request => "request",
30        crate::debug::TraceSource::Sandbox => "sandbox",
31    }
32}
33
34/// Applies `f` to a newest-first summary list.
35pub fn apply_filter(mut traces: Vec<TraceSummary>, f: &TraceFilter) -> Vec<TraceSummary> {
36    if let Some(route) = non_empty(&f.route) {
37        traces.retain(|t| t.route.as_deref() == Some(route));
38    }
39    if let Some(policy) = non_empty(&f.policy) {
40        traces.retain(|t| t.policy == policy);
41    }
42    if let Some(status) = f.status {
43        traces.retain(|t| t.status == status);
44    }
45    if let Some(source) = non_empty(&f.source) {
46        traces.retain(|t| source_str(t.source).eq_ignore_ascii_case(source));
47    }
48    if let Some(limit) = f.limit {
49        traces.truncate(limit);
50    }
51    traces
52}
53
54/// A step plus the changes derived from the preceding snapshot.
55#[derive(Serialize)]
56struct StepWithChanges<'a> {
57    #[serde(flatten)]
58    step: &'a NodeStep,
59    changes: Vec<Change>,
60}
61
62/// Renders a trace with per-step `changes` computed at read time.
63///
64/// The diff is derived here rather than stored because the context flows
65/// linearly: `before(step N) == after(step N-1)`, so the snapshots already hold
66/// everything needed.
67pub fn render_trace(trace: &Trace) -> serde_json::Value {
68    let mut prev = &trace.initial;
69    let mut steps = Vec::with_capacity(trace.steps.len());
70    for step in &trace.steps {
71        steps.push(StepWithChanges {
72            step,
73            changes: diff(prev, &step.after),
74        });
75        prev = &step.after;
76    }
77    let mut out = serde_json::to_value(trace).unwrap_or_else(|_| serde_json::json!({}));
78    out["steps"] = serde_json::to_value(steps).unwrap_or_else(|_| serde_json::json!([]));
79    out
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::debug::{StepOutcome, TraceSource};
86
87    fn summary(policy: &str, status: u16, source: TraceSource) -> TraceSummary {
88        TraceSummary {
89            id: format!("{policy}-{status}"),
90            seq: 1,
91            source,
92            started_ms: 0,
93            route: Some("r".into()),
94            policy: policy.into(),
95            method: "GET".into(),
96            path: "/".into(),
97            status,
98            duration_us: 1,
99            step_count: 1,
100            error_count: 0,
101            captured_bodies: false,
102        }
103    }
104
105    #[test]
106    fn filter_ands_fields_and_ignores_blank_strings() {
107        let all = vec![
108            summary("a", 200, TraceSource::Request),
109            summary("a", 500, TraceSource::Sandbox),
110            summary("b", 200, TraceSource::Request),
111        ];
112        let f = TraceFilter {
113            policy: Some("a".into()),
114            status: Some(200),
115            ..Default::default()
116        };
117        assert_eq!(apply_filter(all.clone(), &f).len(), 1);
118        let f = TraceFilter {
119            source: Some("SANDBOX".into()),
120            ..Default::default()
121        };
122        assert_eq!(apply_filter(all.clone(), &f)[0].id, "a-500");
123        let f = TraceFilter {
124            route: Some("  ".into()),
125            limit: Some(2),
126            ..Default::default()
127        };
128        assert_eq!(apply_filter(all, &f).len(), 2);
129    }
130
131    #[test]
132    fn render_attaches_changes_per_step() {
133        let ctx = crate::debug::sandbox::SandboxContextInput::default()
134            .into_context()
135            .unwrap();
136        let opts = crate::debug::CaptureOptions::default();
137        let mut rec = crate::debug::TraceRecorder::new(&ctx, opts, 10);
138        let mut after = crate::debug::sandbox::SandboxContextInput::default()
139            .into_context()
140            .unwrap();
141        after.response.status_code = 418;
142        rec.record_step(
143            "n1",
144            "echo",
145            StepOutcome::Success,
146            std::time::Duration::from_micros(5),
147            crate::debug::EdgeKind::Success,
148            Some("success"),
149            None,
150            &after,
151        );
152        let trace = rec.finish(
153            "t1".into(),
154            1,
155            TraceSource::Request,
156            None,
157            "p".into(),
158            &after,
159            std::time::Duration::from_micros(7),
160        );
161        let v = render_trace(&trace);
162        let steps = v["steps"].as_array().unwrap();
163        assert_eq!(steps.len(), 1);
164        let changes = steps[0]["changes"].as_array().unwrap();
165        assert!(
166            changes.iter().any(|c| c["path"] == "response.status_code"),
167            "{changes:?}"
168        );
169        assert_eq!(steps[0]["node_id"], "n1");
170    }
171}