Skip to main content

featherbit/debug/
diff.rs

1//! What a plugin changed, derived from two consecutive snapshots.
2//!
3//! This is computed at **read time** (when a trace is fetched), not while
4//! recording. Two reasons: the traced request path stays as cheap as possible,
5//! and the comparison stays a pure function with no engine coupling — trivially
6//! unit-testable in isolation.
7//!
8//! The comparison is purpose-built rather than a generic JSON differ: it knows
9//! that headers are name → values maps and that `errors` is append-only, so it
10//! can emit paths a policy author recognises (`request.headers.x-userinfo`,
11//! `message.user_id`) instead of structural noise.
12
13use serde::Serialize;
14
15use super::trace::ContextSnapshot;
16
17/// How a field changed between two steps.
18#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21    Added,
22    Removed,
23    Modified,
24}
25
26/// One field-level difference.
27#[derive(Debug, Clone, Serialize, PartialEq)]
28pub struct Change {
29    /// Dotted path, e.g. `request.headers.x-userinfo` or `response.status_code`.
30    pub path: String,
31    pub kind: ChangeKind,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub before: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub after: Option<String>,
36}
37
38impl Change {
39    fn added(path: String, after: String) -> Self {
40        Self {
41            path,
42            kind: ChangeKind::Added,
43            before: None,
44            after: Some(after),
45        }
46    }
47    fn removed(path: String, before: String) -> Self {
48        Self {
49            path,
50            kind: ChangeKind::Removed,
51            before: Some(before),
52            after: None,
53        }
54    }
55    fn modified(path: String, before: String, after: String) -> Self {
56        Self {
57            path,
58            kind: ChangeKind::Modified,
59            before: Some(before),
60            after: Some(after),
61        }
62    }
63}
64
65/// Computes what changed between two consecutive snapshots.
66///
67/// Ordering is deterministic (request scalars, request headers, query params,
68/// request body, response, message, errors — each map walked in `BTreeMap`
69/// order), so the UI and tests see a stable list.
70pub fn diff(prev: &ContextSnapshot, next: &ContextSnapshot) -> Vec<Change> {
71    let mut out = Vec::new();
72
73    scalar(
74        &mut out,
75        "request.method",
76        &prev.request.method,
77        &next.request.method,
78    );
79    scalar(
80        &mut out,
81        "request.path",
82        &prev.request.path,
83        &next.request.path,
84    );
85    scalar(
86        &mut out,
87        "request.host",
88        &prev.request.host,
89        &next.request.host,
90    );
91    scalar(
92        &mut out,
93        "request.scheme",
94        &prev.request.scheme,
95        &next.request.scheme,
96    );
97
98    multi_map(
99        &mut out,
100        "request.headers",
101        &prev.request.headers,
102        &next.request.headers,
103    );
104    multi_map(
105        &mut out,
106        "request.query_params",
107        &prev.request.query_params,
108        &next.request.query_params,
109    );
110    if prev.request.body.len != next.request.body.len {
111        out.push(Change::modified(
112            "request.body".to_string(),
113            format!("{} bytes", prev.request.body.len),
114            format!("{} bytes", next.request.body.len),
115        ));
116    }
117
118    if prev.response.status_code != next.response.status_code {
119        out.push(Change::modified(
120            "response.status_code".to_string(),
121            prev.response.status_code.to_string(),
122            next.response.status_code.to_string(),
123        ));
124    }
125    multi_map(
126        &mut out,
127        "response.headers",
128        &prev.response.headers,
129        &next.response.headers,
130    );
131    if prev.response.body.len != next.response.body.len {
132        out.push(Change::modified(
133            "response.body".to_string(),
134            format!("{} bytes", prev.response.body.len),
135            format!("{} bytes", next.response.body.len),
136        ));
137    }
138
139    // `message` values are arbitrary JSON; render them compactly.
140    for (k, v) in &next.message {
141        match prev.message.get(k) {
142            None => out.push(Change::added(format!("message.{k}"), compact(v))),
143            Some(old) if old != v => out.push(Change::modified(
144                format!("message.{k}"),
145                compact(old),
146                compact(v),
147            )),
148            Some(_) => {}
149        }
150    }
151    for k in prev.message.keys() {
152        if !next.message.contains_key(k) {
153            out.push(Change::removed(
154                format!("message.{k}"),
155                compact(&prev.message[k]),
156            ));
157        }
158    }
159
160    // `errors` is append-only in the engine, so report the new entries.
161    if next.errors.len() > prev.errors.len() {
162        for (i, e) in next.errors.iter().enumerate().skip(prev.errors.len()) {
163            out.push(Change::added(
164                format!("errors[{i}]"),
165                format!("{}: {}", e.code, e.message),
166            ));
167        }
168    }
169
170    out
171}
172
173fn scalar(out: &mut Vec<Change>, path: &str, prev: &str, next: &str) {
174    if prev != next {
175        out.push(Change::modified(
176            path.to_string(),
177            prev.to_string(),
178            next.to_string(),
179        ));
180    }
181}
182
183/// Compares a `name -> values` map, reporting per-name adds/removes/modifies.
184fn multi_map(
185    out: &mut Vec<Change>,
186    prefix: &str,
187    prev: &std::collections::BTreeMap<String, Vec<String>>,
188    next: &std::collections::BTreeMap<String, Vec<String>>,
189) {
190    for (k, v) in next {
191        match prev.get(k) {
192            None => out.push(Change::added(format!("{prefix}.{k}"), v.join(", "))),
193            Some(old) if old != v => out.push(Change::modified(
194                format!("{prefix}.{k}"),
195                old.join(", "),
196                v.join(", "),
197            )),
198            Some(_) => {}
199        }
200    }
201    for (k, v) in prev {
202        if !next.contains_key(k) {
203            out.push(Change::removed(format!("{prefix}.{k}"), v.join(", ")));
204        }
205    }
206}
207
208fn compact(v: &serde_json::Value) -> String {
209    match v {
210        serde_json::Value::String(s) => s.clone(),
211        other => other.to_string(),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::context::{Context, GatewayError, GatewayRequest, GatewayResponse, Protocol};
219    use crate::debug::trace::{CaptureOptions, PreviousBodies};
220    use bytes::Bytes;
221    use std::collections::HashMap;
222
223    fn base() -> Context {
224        Context {
225            request: GatewayRequest {
226                method: "GET".to_string(),
227                path: "/api/hello".to_string(),
228                host: "h".to_string(),
229                scheme: "http".to_string(),
230                headers: HashMap::new(),
231                query_params: HashMap::new(),
232                body: Bytes::new(),
233                remote_addr: "1.2.3.4:5".to_string(),
234                protocol: Protocol::Http1,
235            },
236            response: GatewayResponse {
237                status_code: 0,
238                headers: HashMap::new(),
239                body: Bytes::new(),
240                stream: None,
241            },
242            message: HashMap::new(),
243            errors: Vec::new(),
244        }
245    }
246
247    fn snap(c: &Context) -> ContextSnapshot {
248        ContextSnapshot::capture(c, &CaptureOptions::default(), &PreviousBodies::default())
249    }
250
251    fn find<'a>(changes: &'a [Change], path: &str) -> &'a Change {
252        changes
253            .iter()
254            .find(|c| c.path == path)
255            .unwrap_or_else(|| panic!("no change at {path}; got {changes:?}"))
256    }
257
258    #[test]
259    fn test_identical_snapshots_have_no_changes() {
260        let c = base();
261        assert!(diff(&snap(&c), &snap(&c)).is_empty());
262    }
263
264    #[test]
265    fn test_path_rewrite_detected() {
266        let before = snap(&base());
267        let mut after_ctx = base();
268        after_ctx.request.path = "/hello".to_string();
269        let changes = diff(&before, &snap(&after_ctx));
270        let c = find(&changes, "request.path");
271        assert_eq!(c.kind, ChangeKind::Modified);
272        assert_eq!(c.before.as_deref(), Some("/api/hello"));
273        assert_eq!(c.after.as_deref(), Some("/hello"));
274    }
275
276    #[test]
277    fn test_header_added_modified_removed() {
278        let mut start = base();
279        start
280            .request
281            .headers
282            .insert("x-keep".to_string(), vec!["1".to_string()]);
283        start
284            .request
285            .headers
286            .insert("x-drop".to_string(), vec!["old".to_string()]);
287        let before = snap(&start);
288
289        let mut end = base();
290        end.request
291            .headers
292            .insert("x-keep".to_string(), vec!["2".to_string()]);
293        end.request
294            .headers
295            .insert("x-new".to_string(), vec!["fresh".to_string()]);
296        let changes = diff(&before, &snap(&end));
297
298        assert_eq!(
299            find(&changes, "request.headers.x-keep").kind,
300            ChangeKind::Modified
301        );
302        assert_eq!(
303            find(&changes, "request.headers.x-new").kind,
304            ChangeKind::Added
305        );
306        assert_eq!(
307            find(&changes, "request.headers.x-drop").kind,
308            ChangeKind::Removed
309        );
310    }
311
312    #[test]
313    fn test_status_and_response_header_change() {
314        let before = snap(&base());
315        let mut end = base();
316        end.response.status_code = 403;
317        end.response.headers.insert(
318            "access-control-allow-origin".to_string(),
319            vec!["*".to_string()],
320        );
321        let changes = diff(&before, &snap(&end));
322        assert_eq!(
323            find(&changes, "response.status_code").after.as_deref(),
324            Some("403")
325        );
326        assert_eq!(
327            find(&changes, "response.headers.access-control-allow-origin").kind,
328            ChangeKind::Added
329        );
330    }
331
332    #[test]
333    fn test_message_key_added() {
334        let before = snap(&base());
335        let mut end = base();
336        end.message
337            .insert("user_id".to_string(), serde_json::json!("alice"));
338        let changes = diff(&before, &snap(&end));
339        let c = find(&changes, "message.user_id");
340        assert_eq!(c.kind, ChangeKind::Added);
341        // Strings render bare, not JSON-quoted.
342        assert_eq!(c.after.as_deref(), Some("alice"));
343    }
344
345    #[test]
346    fn test_body_size_change_reported_without_capture() {
347        let before = snap(&base());
348        let mut end = base();
349        end.response.body = Bytes::from_static(b"hello");
350        let changes = diff(&before, &snap(&end));
351        // Bodies are not captured here, yet the size delta is still visible.
352        assert_eq!(
353            find(&changes, "response.body").after.as_deref(),
354            Some("5 bytes")
355        );
356    }
357
358    #[test]
359    fn test_appended_error_reported() {
360        let before = snap(&base());
361        let mut end = base();
362        end.errors.push(GatewayError {
363            node_id: "auth".to_string(),
364            code: "UNAUTHORIZED".to_string(),
365            message: "no token".to_string(),
366            metadata: HashMap::new(),
367        });
368        let changes = diff(&before, &snap(&end));
369        let c = find(&changes, "errors[0]");
370        assert_eq!(c.kind, ChangeKind::Added);
371        assert_eq!(c.after.as_deref(), Some("UNAUTHORIZED: no token"));
372    }
373
374    /// Deterministic ordering is what makes the UI stable and these tests
375    /// meaningful; it comes from the snapshot's BTreeMaps.
376    #[test]
377    fn test_change_order_is_deterministic() {
378        let before = snap(&base());
379        let mut end = base();
380        for name in ["x-c", "x-a", "x-b"] {
381            end.request
382                .headers
383                .insert(name.to_string(), vec!["v".to_string()]);
384        }
385        let first = diff(&before, &snap(&end));
386        let again = diff(&before, &snap(&end));
387        assert_eq!(first, again);
388        let paths: Vec<&str> = first.iter().map(|c| c.path.as_str()).collect();
389        assert_eq!(
390            paths,
391            vec![
392                "request.headers.x-a",
393                "request.headers.x-b",
394                "request.headers.x-c"
395            ]
396        );
397    }
398}