1pub mod diff;
17pub mod sandbox;
18pub mod store;
19pub mod trace;
20
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::context::Context;
24
25#[allow(unused_imports)]
28pub use store::{DebugState, TraceSummary};
29pub use trace::{
30 CaptureOptions, ContextSnapshot, EdgeKind, NodeStep, PreviousBodies, StepOutcome, Trace,
31 TraceSource,
32};
33
34pub struct TraceRecorder {
41 opts: CaptureOptions,
42 max_steps: usize,
43 prev_bodies: PreviousBodies,
46 initial: Option<ContextSnapshot>,
47 steps: Vec<NodeStep>,
48 notes: Vec<String>,
49 truncated: bool,
50}
51
52impl TraceRecorder {
53 pub fn new(ctx: &Context, opts: CaptureOptions, max_steps: usize) -> Self {
55 let initial = ContextSnapshot::capture(ctx, &opts, &PreviousBodies::default());
56 Self {
57 opts,
58 max_steps,
59 prev_bodies: PreviousBodies {
60 request: Some(ctx.request.body.clone()),
61 response: Some(ctx.response.body.clone()),
62 },
63 initial: Some(initial),
64 steps: Vec::new(),
65 notes: Vec::new(),
66 truncated: false,
67 }
68 }
69
70 #[allow(clippy::too_many_arguments)]
75 pub fn record_step(
76 &mut self,
77 node_id: &str,
78 node_type: &str,
79 outcome: StepOutcome,
80 duration: Duration,
81 edge: EdgeKind,
82 next_node_id: Option<&str>,
83 ctx: &Context,
84 ) {
85 if self.steps.len() >= self.max_steps {
86 if !self.truncated {
87 self.truncated = true;
88 self.notes.push(format!(
89 "step limit of {} reached; later nodes are not recorded",
90 self.max_steps
91 ));
92 }
93 return;
94 }
95
96 let after = ContextSnapshot::capture(ctx, &self.opts, &self.prev_bodies);
97 self.prev_bodies = PreviousBodies {
98 request: Some(ctx.request.body.clone()),
99 response: Some(ctx.response.body.clone()),
100 };
101 if after.request.body.truncated || after.response.body.truncated {
102 let note = "a captured body was truncated".to_string();
103 if !self.notes.contains(¬e) {
104 self.notes.push(note);
105 }
106 }
107
108 self.steps.push(NodeStep {
109 index: self.steps.len(),
110 node_id: node_id.to_string(),
111 node_type: node_type.to_string(),
112 outcome,
113 duration_us: duration.as_micros() as u64,
114 edge,
115 next_node_id: next_node_id.map(str::to_string),
116 after,
117 });
118 }
119
120 #[allow(clippy::too_many_arguments)]
122 pub fn finish(
123 mut self,
124 id: String,
125 seq: u64,
126 source: TraceSource,
127 route: Option<String>,
128 policy: String,
129 final_ctx: &Context,
130 duration: Duration,
131 ) -> Trace {
132 Trace {
133 id,
134 seq,
135 source,
136 started_ms: now_ms(),
137 route,
138 policy,
139 method: final_ctx.request.method.clone(),
140 path: final_ctx.request.path.clone(),
141 status: final_ctx.response.status_code,
142 duration_us: duration.as_micros() as u64,
143 captured_bodies: self.opts.capture_bodies,
144 initial: self.initial.take().expect("initial snapshot taken once"),
145 steps: std::mem::take(&mut self.steps),
146 notes: std::mem::take(&mut self.notes),
147 }
148 }
149}
150
151pub fn now_ms() -> u64 {
153 SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .map(|d| d.as_millis() as u64)
156 .unwrap_or(0)
157}
158
159pub fn new_trace_id() -> String {
161 uuid::Uuid::new_v4().to_string()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
168 use bytes::Bytes;
169 use std::collections::HashMap;
170
171 fn ctx() -> Context {
172 Context {
173 request: GatewayRequest {
174 method: "GET".to_string(),
175 path: "/x".to_string(),
176 host: "h".to_string(),
177 scheme: "http".to_string(),
178 headers: HashMap::new(),
179 query_params: HashMap::new(),
180 body: Bytes::new(),
181 remote_addr: "1.2.3.4:5".to_string(),
182 protocol: Protocol::Http1,
183 },
184 response: GatewayResponse {
185 status_code: 0,
186 headers: HashMap::new(),
187 body: Bytes::new(),
188 },
189 message: HashMap::new(),
190 errors: Vec::new(),
191 }
192 }
193
194 fn record_n_with(rec: &mut TraceRecorder, c: &Context, n: usize) {
197 for i in 0..n {
198 rec.record_step(
199 &format!("n{i}"),
200 "cors",
201 StepOutcome::Success,
202 Duration::from_micros(5),
203 EdgeKind::Success,
204 None,
205 c,
206 );
207 }
208 }
209
210 fn record_n(rec: &mut TraceRecorder, n: usize) {
211 record_n_with(rec, &ctx(), n);
212 }
213
214 #[test]
215 fn test_steps_are_indexed_in_order() {
216 let c = ctx();
217 let mut rec = TraceRecorder::new(&c, CaptureOptions::default(), 100);
218 record_n(&mut rec, 3);
219 let t = rec.finish(
220 "id".to_string(),
221 0,
222 TraceSource::Request,
223 Some("r".to_string()),
224 "p".to_string(),
225 &c,
226 Duration::from_millis(1),
227 );
228 assert_eq!(t.steps.len(), 3);
229 let ids: Vec<&str> = t.steps.iter().map(|s| s.node_id.as_str()).collect();
230 assert_eq!(ids, vec!["n0", "n1", "n2"]);
231 assert_eq!(t.steps[2].index, 2);
232 assert!(t.notes.is_empty());
233 }
234
235 #[test]
236 fn test_step_limit_truncates_with_one_note() {
237 let c = ctx();
238 let mut rec = TraceRecorder::new(&c, CaptureOptions::default(), 2);
239 record_n(&mut rec, 6);
240 let t = rec.finish(
241 "id".to_string(),
242 0,
243 TraceSource::Request,
244 None,
245 "p".to_string(),
246 &c,
247 Duration::from_millis(1),
248 );
249 assert_eq!(t.steps.len(), 2);
250 assert_eq!(t.notes.len(), 1);
252 assert!(t.notes[0].contains("step limit"));
253 }
254
255 #[test]
258 fn test_body_dedup_across_steps() {
259 let mut c = ctx();
260 c.request.body = Bytes::from_static(b"payload");
261 let opts = CaptureOptions {
262 capture_bodies: true,
263 ..Default::default()
264 };
265 let mut rec = TraceRecorder::new(&c, opts, 100);
266 record_n_with(&mut rec, &c, 2);
269 let t = rec.finish(
270 "id".to_string(),
271 0,
272 TraceSource::Request,
273 None,
274 "p".to_string(),
275 &c,
276 Duration::from_millis(1),
277 );
278 assert_eq!(t.initial.request.body.text.as_deref(), Some("payload"));
280 assert!(t.steps[0].after.request.body.unchanged);
282 assert!(t.steps[1].after.request.body.unchanged);
283 assert_eq!(t.steps[0].after.request.body.len, 7);
284 }
285
286 #[test]
287 fn test_finish_reports_final_status() {
288 let c = ctx();
289 let mut end = ctx();
290 end.response.status_code = 403;
291 let mut rec = TraceRecorder::new(&c, CaptureOptions::default(), 10);
292 record_n(&mut rec, 1);
293 let t = rec.finish(
294 "id".to_string(),
295 7,
296 TraceSource::Sandbox,
297 None,
298 "p".to_string(),
299 &end,
300 Duration::from_micros(250),
301 );
302 assert_eq!(t.status, 403);
303 assert_eq!(t.seq, 7);
304 assert_eq!(t.source, TraceSource::Sandbox);
305 assert_eq!(t.duration_us, 250);
306 assert!(t.route.is_none());
307 }
308}