Skip to main content

datalogic_rs/
trace.rs

1//! Execution tracing for step-by-step debugging.
2//!
3//! This module provides execution tracing capabilities for debugging JSONLogic
4//! expressions. It generates an expression tree with unique IDs and records
5//! each evaluation step for replay in the Web UI.
6//!
7//! # Feature gating
8//!
9//! Gated on `feature = "trace"`. Trace transitively pulls in
10//! `feature = "serde_json"` (the `Cargo.toml` declares
11//! `trace = ["serde_json"]`) because the per-step expression tree and
12//! recorded values are `serde_json::Value`-shaped — the structured-trace
13//! consumers (the Web UI, JSON exporters) need the JSON↔arena bridge to
14//! render steps. `--features trace` implicitly enables `serde_json`.
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::node_serialize;
20use crate::{CompiledNode, Error};
21
22/// Represents a node in the expression tree for flow diagram rendering.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ExpressionNode {
25    /// Unique identifier for this node
26    pub id: u32,
27    /// JSON string of this sub-expression
28    pub expression: String,
29    /// Child nodes (arguments/operands that are operators, not literals)
30    pub children: Vec<ExpressionNode>,
31}
32
33impl ExpressionNode {
34    /// Build an expression tree from a CompiledNode.
35    ///
36    /// Every tree node inherits its compile-time id from the source
37    /// [`CompiledNode::id`]. No side-table is needed: both tracing and error
38    /// reporting look the id up directly on the node.
39    pub(crate) fn build_from_compiled(node: &CompiledNode) -> ExpressionNode {
40        Self::build_node(node)
41    }
42
43    fn build_node(node: &CompiledNode) -> ExpressionNode {
44        let id = node.id();
45        match node {
46            CompiledNode::Value { value, .. } => Self::leaf(id, value.to_json_string()),
47            CompiledNode::Array { nodes, .. } => ExpressionNode {
48                id,
49                expression: node_serialize::node_to_json_string(node),
50                children: Self::op_children(nodes),
51            },
52            CompiledNode::BuiltinOperator { opcode, args, .. } => ExpressionNode {
53                id,
54                expression: node_serialize::builtin_to_json_string(opcode, args),
55                children: Self::op_children(args),
56            },
57            CompiledNode::CustomOperator(data) => ExpressionNode {
58                id,
59                expression: node_serialize::custom_to_json_string(&data.name, &data.args),
60                children: Self::op_children(&data.args),
61            },
62            // Memo wrappers are invisible in the trace tree.
63            CompiledNode::Cse(data) => Self::build_node(&data.inner),
64            #[cfg(feature = "templating")]
65            CompiledNode::StructuredObject(data) => ExpressionNode {
66                id,
67                expression: node_serialize::structured_to_json_string(&data.fields),
68                children: Self::op_children_from_fields(&data.fields),
69            },
70            CompiledNode::Var {
71                scope_level,
72                segments,
73                default_value,
74                ..
75            } => Self::build_compiled_var(id, *scope_level, segments, default_value.as_deref()),
76            #[cfg(feature = "ext-control")]
77            CompiledNode::Exists(data) => Self::leaf(
78                id,
79                node_serialize::compiled_exists_to_json_string(&data.segments),
80            ),
81            #[cfg(feature = "error-handling")]
82            CompiledNode::Throw(_) | CompiledNode::Missing(_) | CompiledNode::MissingSome(_) => {
83                Self::leaf(id, node_serialize::node_to_json_string(node))
84            }
85            #[cfg(not(feature = "error-handling"))]
86            CompiledNode::Missing(_) | CompiledNode::MissingSome(_) => {
87                Self::leaf(id, node_serialize::node_to_json_string(node))
88            }
89            // Delegate like every arm above rather than carrying a second
90            // copy of the rendering. The duplicated literal here is what
91            // kept the flow diagram showing `{"<invalid args>": null}`
92            // while the step beside it named the real operator.
93            CompiledNode::InvalidArgs { .. } => {
94                Self::leaf(id, node_serialize::node_to_json_string(node))
95            }
96        }
97    }
98
99    /// Build a leaf `ExpressionNode` (no children).
100    #[inline]
101    fn leaf(id: u32, expression: String) -> ExpressionNode {
102        ExpressionNode {
103            id,
104            expression,
105            children: vec![],
106        }
107    }
108
109    /// Recurse into a compiled-node slice, keeping only the operator nodes
110    /// (literals don't appear as flow-diagram children).
111    #[inline]
112    fn op_children(nodes: &[CompiledNode]) -> Vec<ExpressionNode> {
113        nodes
114            .iter()
115            .filter(|n| Self::is_operator_node(n))
116            .map(Self::build_node)
117            .collect()
118    }
119
120    /// `op_children` for the `(name, CompiledNode)` shape used by structured
121    /// object fields.
122    #[cfg(feature = "templating")]
123    #[inline]
124    fn op_children_from_fields(fields: &[(String, CompiledNode)]) -> Vec<ExpressionNode> {
125        fields
126            .iter()
127            .filter(|(_, n)| Self::is_operator_node(n))
128            .map(|(_, n)| Self::build_node(n))
129            .collect()
130    }
131
132    /// `CompiledVar`'s expression node — the only operator-shaped variant
133    /// whose "child" is the optional default value rather than a fixed args
134    /// slice.
135    fn build_compiled_var(
136        id: u32,
137        scope_level: u32,
138        segments: &[crate::node::PathSegment],
139        default_value: Option<&CompiledNode>,
140    ) -> ExpressionNode {
141        let mut children = Vec::new();
142        if let Some(def) = default_value
143            && Self::is_operator_node(def)
144        {
145            children.push(Self::build_node(def));
146        }
147        ExpressionNode {
148            id,
149            expression: node_serialize::compiled_var_to_json_string(
150                scope_level,
151                segments,
152                default_value,
153            ),
154            children,
155        }
156    }
157
158    /// Check if a node is an operator (not a literal value)
159    fn is_operator_node(node: &CompiledNode) -> bool {
160        !matches!(node, CompiledNode::Value { .. })
161    }
162}
163
164/// Captures state at each evaluation step.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ExecutionStep {
167    /// Sequential step number (assigned by the trace collector in
168    /// recording order). Distinct from `node_id`, which is the
169    /// compiled-node id of the expression being evaluated — `step_id`
170    /// is the *order* this step occurred, `node_id` is *which node* ran.
171    pub step_id: u32,
172    /// ID of the node being evaluated
173    pub node_id: u32,
174    /// Current context/scope data at this step
175    pub context: Value,
176    /// Result after evaluating this node (None if error)
177    pub result: Option<Value>,
178    /// Error message if evaluation failed (None if success)
179    pub error: Option<String>,
180    /// Current iteration index (only for iterator body evaluations)
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub iteration_index: Option<u32>,
183    /// Total iteration count (only for iterator body evaluations)
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub iteration_total: Option<u32>,
186}
187
188/// Collector for execution steps during traced evaluation.
189pub(crate) struct TraceCollector {
190    /// Recorded execution steps
191    steps: Vec<ExecutionStep>,
192    /// Counter for generating step IDs
193    step_counter: u32,
194    /// Stack of iteration info (index, total) for nested iterations
195    iteration_stack: Vec<(u32, u32)>,
196}
197
198impl TraceCollector {
199    /// Create a new trace collector
200    pub(crate) fn new() -> Self {
201        Self {
202            steps: Vec::new(),
203            step_counter: 0,
204            iteration_stack: Vec::new(),
205        }
206    }
207
208    /// Record a successful execution step
209    pub(crate) fn record_step(&mut self, node_id: u32, context: Value, result: Value) {
210        self.record(node_id, context, Some(result), None);
211    }
212
213    /// Record an error execution step
214    pub(crate) fn record_error(&mut self, node_id: u32, context: Value, error: String) {
215        self.record(node_id, context, None, Some(error));
216    }
217
218    /// Shared step constructor behind [`Self::record_step`] /
219    /// [`Self::record_error`]: stamp the step with the next sequential id
220    /// and the current iteration context.
221    fn record(
222        &mut self,
223        node_id: u32,
224        context: Value,
225        result: Option<Value>,
226        error: Option<String>,
227    ) {
228        let (iteration_index, iteration_total) = self.current_iteration();
229        self.steps.push(ExecutionStep {
230            step_id: self.step_counter,
231            node_id,
232            context,
233            result,
234            error,
235            iteration_index,
236            iteration_total,
237        });
238        self.step_counter += 1;
239    }
240
241    /// Push iteration context for map/filter/reduce operations
242    pub(crate) fn push_iteration(&mut self, index: u32, total: u32) {
243        self.iteration_stack.push((index, total));
244    }
245
246    /// Pop iteration context
247    pub(crate) fn pop_iteration(&mut self) {
248        self.iteration_stack.pop();
249    }
250
251    /// Get current iteration info if inside an iteration
252    fn current_iteration(&self) -> (Option<u32>, Option<u32>) {
253        self.iteration_stack
254            .last()
255            .map(|(i, t)| (Some(*i), Some(*t)))
256            .unwrap_or((None, None))
257    }
258
259    /// Consume the collector and return the recorded steps
260    pub(crate) fn into_steps(self) -> Vec<ExecutionStep> {
261        self.steps
262    }
263}
264
265impl Default for TraceCollector {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271// ============================================================================
272// v5 trace surface — `engine.trace().evaluate*(...)` returning `TracedRun`.
273// ============================================================================
274
275/// Result of a traced evaluation produced by [`TracedSession`]. Always
276/// includes the trace data; the value-or-error split lives on
277/// [`Self::result`].
278#[derive(Debug, Clone)]
279pub struct TracedRun<R> {
280    /// `Ok(value)` on success, `Err(error)` on failure. The error always
281    /// carries the operator + path metadata populated by the engine.
282    pub result: Result<R, Error>,
283    /// Per-node execution log captured during the run.
284    pub steps: Vec<ExecutionStep>,
285    /// Compile-time expression tree for flow-diagram rendering.
286    pub expression_tree: ExpressionNode,
287}
288
289impl<R> TracedRun<R> {
290    /// Rebuild the run around a converted result, preserving the recorded
291    /// steps and expression tree. Internal helper shared by the
292    /// owned-result entry points, which each project the arena-borrowed
293    /// result into an owned shape before the arena drops.
294    fn convert<T>(self, f: impl FnOnce(Result<R, Error>) -> Result<T, Error>) -> TracedRun<T> {
295        TracedRun {
296            result: f(self.result),
297            steps: self.steps,
298            expression_tree: self.expression_tree,
299        }
300    }
301}
302
303/// Trace-enabled view over a [`crate::Engine`] engine. Constructed via
304/// [`crate::Engine::trace`]. Every `eval*` returns a [`TracedRun<R>`]
305/// carrying the trace alongside the result, where `R` is the same shape
306/// the corresponding `Session::eval*` would return. The inputs differ
307/// from [`crate::Session`], though: [`Self::eval`] and
308/// [`Self::eval_borrowed`] take compiled [`crate::Logic`], while
309/// [`Self::eval_str`] and [`Self::eval_into`] take a rule *source*
310/// ([`crate::IntoLogic`]) and compile it with the optimizer disabled so
311/// every operator surfaces a step. The trace path allocates a fresh
312/// [`bumpalo::Bump`] per call (nothing is retained between runs) to keep
313/// the borrowed-result lifetime tied to the run.
314pub struct TracedSession<'e> {
315    engine: &'e crate::Engine,
316}
317
318impl<'e> TracedSession<'e> {
319    /// Construct a session over `engine`. Invoked from
320    /// [`crate::Engine::trace`].
321    #[inline]
322    pub(crate) fn new(engine: &'e crate::Engine) -> Self {
323        Self { engine }
324    }
325
326    /// Traced evaluation of a pre-compiled [`crate::Logic`] returning
327    /// [`datavalue::OwnedDataValue`]. The trace surfaces only the
328    /// operators that survived compilation — constant sub-expressions
329    /// folded by [`crate::Engine::compile`] won't appear as steps. For
330    /// full coverage on a one-shot run, prefer [`Self::eval_str`].
331    pub fn eval<D>(&self, compiled: &crate::Logic, data: D) -> TracedRun<datavalue::OwnedDataValue>
332    where
333        D: crate::OwnedInput,
334    {
335        let owned_data = match data.into_owned_input() {
336            Ok(d) => d,
337            Err(e) => return Self::compile_failed(e),
338        };
339        let arena = bumpalo::Bump::new();
340        self.eval_borrowed_in(compiled, &owned_data, &arena)
341            .convert(|result| result.and_then(crate::FromDataValue::from_arena))
342    }
343
344    /// One-shot traced evaluation with JSON-string boundary on both
345    /// sides. Compiles internally with the optimizer + constant-fold
346    /// passes disabled, so the trace surfaces every operator in the
347    /// rule.
348    pub fn eval_str<R, D>(&self, rule: R, data: D) -> TracedRun<String>
349    where
350        R: crate::IntoLogic,
351        D: crate::OwnedInput,
352    {
353        let (compiled, owned_data) = match self.prepare(rule, data) {
354            Ok(prepared) => prepared,
355            Err(e) => return Self::compile_failed(e),
356        };
357        let arena = bumpalo::Bump::new();
358        self.eval_borrowed_in(&compiled, &owned_data, &arena)
359            .convert(|result| result.map(|v| v.to_string()))
360    }
361
362    /// Typed traced evaluation: deserialise the result into
363    /// `T: DeserializeOwned`. Routes through `serde_json`.
364    #[cfg(feature = "serde_json")]
365    #[cfg_attr(docsrs, doc(cfg(feature = "serde_json")))]
366    pub fn eval_into<T, R, D>(&self, rule: R, data: D) -> TracedRun<T>
367    where
368        T: serde::de::DeserializeOwned,
369        R: crate::IntoLogic,
370        D: crate::OwnedInput,
371    {
372        let (compiled, owned_data) = match self.prepare(rule, data) {
373            Ok(prepared) => prepared,
374            Err(e) => return Self::compile_failed(e),
375        };
376        let arena = bumpalo::Bump::new();
377        self.eval_borrowed_in(&compiled, &owned_data, &arena)
378            .convert(|result| {
379                result.and_then(|v| {
380                    let value: serde_json::Value = crate::FromDataValue::from_arena(v)?;
381                    serde_json::from_value(value).map_err(crate::Error::from)
382                })
383            })
384    }
385
386    /// Shared front half of the one-shot traced entry points
387    /// ([`Self::eval_str`] / [`Self::eval_into`]): normalise the rule,
388    /// compile it with the optimizer + constant-fold passes disabled, and
389    /// normalise the data into an owned value the arena run can borrow.
390    fn prepare<R, D>(
391        &self,
392        rule: R,
393        data: D,
394    ) -> crate::Result<(crate::Logic, datavalue::OwnedDataValue)>
395    where
396        R: crate::IntoLogic,
397        D: crate::OwnedInput,
398    {
399        let owned = rule.into_owned_logic()?;
400        let compiled = crate::Logic::compile_for_trace(&owned, self.engine)?;
401        let owned_data = data.into_owned_input()?;
402        Ok((compiled, owned_data))
403    }
404
405    /// Traced borrowed evaluation against a caller-owned arena. Mirrors
406    /// [`crate::Session::eval_borrowed`] / [`crate::Engine::evaluate`]
407    /// — the result references `arena`, while the trace data is owned
408    /// and outlives the arena.
409    pub fn eval_borrowed<'a, D>(
410        &self,
411        compiled: &'a crate::Logic,
412        data: D,
413        arena: &'a bumpalo::Bump,
414    ) -> TracedRun<&'a crate::DataValue<'a>>
415    where
416        D: crate::EvalInput<'a>,
417    {
418        self.eval_borrowed_in(compiled, data, arena)
419    }
420
421    /// Internal: shared body for the borrowed-result trace runs.
422    fn eval_borrowed_in<'a, D>(
423        &self,
424        compiled: &'a crate::Logic,
425        data: D,
426        arena: &'a bumpalo::Bump,
427    ) -> TracedRun<&'a crate::DataValue<'a>>
428    where
429        D: crate::EvalInput<'a>,
430    {
431        let expression_tree = ExpressionNode::build_from_compiled(&compiled.root);
432        let _depth_guard = match self.engine.enter_dispatch_boundary() {
433            Ok(g) => g,
434            Err(e) => return Self::failed(expression_tree, e),
435        };
436        let data_ref = match data.into_arena_value(arena) {
437            Ok(av) => av,
438            Err(e) => return Self::failed(expression_tree, e),
439        };
440        // Same context as an untraced evaluation, engine-wide budget
441        // included: a rule the engine would refuse must not quietly
442        // succeed in the debugger.
443        let mut ctx = self.engine.new_context(compiled, data_ref);
444        ctx.attach_tracer(TraceCollector::new());
445
446        let outcome = self.engine.dispatch_node(&compiled.root, &mut ctx, arena);
447        let result = match outcome {
448            Ok(av) => Ok(av),
449            Err(e) => Err(e.decorated(ctx.take_error_path(), compiled, false)),
450        };
451        let collector = ctx.detach_tracer().expect("attach_tracer was called above");
452        TracedRun {
453            result,
454            steps: collector.into_steps(),
455            expression_tree,
456        }
457    }
458
459    /// A run that failed before any step could be recorded: carries the
460    /// given expression tree and an empty step log.
461    fn failed<R>(expression_tree: ExpressionNode, error: crate::Error) -> TracedRun<R> {
462        TracedRun {
463            result: Err(error),
464            steps: Vec::new(),
465            expression_tree,
466        }
467    }
468
469    /// [`Self::failed`] for errors raised before an expression tree exists
470    /// (rule normalisation / compilation / data conversion): the tree is an
471    /// empty placeholder.
472    fn compile_failed<R>(error: crate::Error) -> TracedRun<R> {
473        Self::failed(
474            ExpressionNode {
475                id: 0,
476                expression: String::new(),
477                children: Vec::new(),
478            },
479            error,
480        )
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::OpCode;
488
489    #[test]
490    fn test_expression_node_from_simple_operator() {
491        // Create a simple {"val": "age"} node (var is normalized to Val).
492        let node = CompiledNode::BuiltinOperator {
493            id: crate::node::SYNTHETIC_ID,
494            opcode: OpCode::Val,
495            args: vec![CompiledNode::synthetic_value(
496                datavalue::OwnedDataValue::from("age"),
497            )]
498            .into_boxed_slice(),
499            predicate_hint: None,
500            iter_arg_kind: crate::operators::array::IterArgKind::General,
501        };
502
503        let tree = ExpressionNode::build_from_compiled(&node);
504
505        // Synthetic test nodes all share SYNTHETIC_ID, which surfaces as 0
506        // through the public `ExpressionNode::id` (u32) shape; the
507        // structural assertions below still hold.
508        assert_eq!(tree.id, 0);
509        assert_eq!(tree.expression, r#"{"val": "age"}"#);
510        assert!(tree.children.is_empty()); // "age" is a literal, not a child
511    }
512
513    #[test]
514    fn test_expression_node_from_nested_operator() {
515        // Create {">=": [{"val": "age"}, 18]}
516        let var_node = CompiledNode::BuiltinOperator {
517            id: crate::node::SYNTHETIC_ID,
518            opcode: OpCode::Val,
519            args: vec![CompiledNode::synthetic_value(
520                datavalue::OwnedDataValue::from("age"),
521            )]
522            .into_boxed_slice(),
523            predicate_hint: None,
524            iter_arg_kind: crate::operators::array::IterArgKind::General,
525        };
526        let node = CompiledNode::BuiltinOperator {
527            id: crate::node::SYNTHETIC_ID,
528            opcode: OpCode::GreaterThanEqual,
529            args: vec![
530                var_node,
531                CompiledNode::synthetic_value(datavalue::OwnedDataValue::Number(
532                    datavalue::NumberValue::Integer(18),
533                )),
534            ]
535            .into_boxed_slice(),
536            predicate_hint: None,
537            iter_arg_kind: crate::operators::array::IterArgKind::General,
538        };
539
540        let tree = ExpressionNode::build_from_compiled(&node);
541
542        assert_eq!(tree.id, 0);
543        assert!(tree.expression.contains(">="));
544        assert_eq!(tree.children.len(), 1); // var node is a child
545        assert!(tree.children[0].expression.contains("val"));
546    }
547
548    #[test]
549    fn test_trace_collector_records_steps() {
550        let mut collector = TraceCollector::new();
551
552        collector.record_step(0, serde_json::json!({"age": 25}), serde_json::json!(25));
553        collector.record_step(1, serde_json::json!({"age": 25}), serde_json::json!(true));
554
555        let steps = collector.into_steps();
556        assert_eq!(steps.len(), 2);
557        assert_eq!(steps[0].step_id, 0);
558        assert_eq!(steps[0].node_id, 0);
559        assert_eq!(steps[1].step_id, 1);
560        assert_eq!(steps[1].node_id, 1);
561    }
562
563    #[test]
564    fn test_trace_collector_iteration_context() {
565        let mut collector = TraceCollector::new();
566
567        collector.push_iteration(0, 3);
568        collector.record_step(2, serde_json::json!(1), serde_json::json!(2));
569
570        let steps = collector.into_steps();
571        assert_eq!(steps[0].iteration_index, Some(0));
572        assert_eq!(steps[0].iteration_total, Some(3));
573    }
574
575    #[test]
576    fn traced_session_evaluate_str_smoke() {
577        let engine = crate::Engine::new();
578        let run = engine.trace().eval_str(r#"{"+": [1, 2, 3]}"#, "null");
579        assert_eq!(run.result.unwrap(), "6");
580        // The one-shot trace path skips static folding internally, so the
581        // `+` operator survives and produces a step.
582        assert!(!run.steps.is_empty(), "expected non-empty steps");
583        assert_ne!(run.expression_tree.id, 0);
584    }
585
586    #[test]
587    fn traced_pre_compiled_inherits_fold() {
588        // Pre-compiled trace inherits the shape from `Engine::compile`, which
589        // folds. A fully-constant rule has no surviving operator → no steps.
590        let engine = crate::Engine::new();
591        let compiled = engine.compile(r#"{"+": [1, 2]}"#).unwrap();
592        let arena = bumpalo::Bump::new();
593        let data = datavalue::DataValue::from_str("null", &arena).unwrap();
594        let run = engine.trace().eval_borrowed(&compiled, data, &arena);
595        assert_eq!(run.result.as_ref().unwrap().as_i64(), Some(3));
596        assert!(
597            run.steps.is_empty(),
598            "folded rule should not produce trace steps"
599        );
600    }
601
602    #[test]
603    fn traced_session_carries_error_metadata() {
604        let engine = crate::Engine::new();
605        let run = engine.trace().eval_str(r#"{"+": ["x", 1]}"#, "null");
606        let err = run.result.expect_err("string-arith should fail");
607        assert_eq!(err.operator(), Some("+"));
608        assert!(!err.node_ids().is_empty(), "expected populated breadcrumb");
609    }
610}