Skip to main content

dataflow_rs/engine/
trace.rs

1//! # Execution Trace Module
2//!
3//! This module provides step-by-step execution tracing for debugging workflows.
4//! It captures message snapshots after each step, including which workflows/tasks
5//! were executed or skipped.
6//!
7//! [`TraceOptions`] controls what a trace-mode run records. The default
8//! reproduces the historical behaviour — a full [`Message`] snapshot per
9//! executed step — which is unbounded in message size and quadratic in task
10//! count, because each snapshot clones the accumulated audit trail. Hosts that
11//! persist traces should set a snapshot budget, an audit-trail scope, or both.
12
13use crate::engine::message::{AuditTrail, Change, Message};
14use crate::engine::utils::strip_hash_prefix;
15use chrono::{DateTime, Utc};
16use datavalue::OwnedDataValue;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::sync::Arc;
20
21/// Approximate in-memory cost charged for a single container or scalar node.
22/// One machine word, per [`TraceOptions::max_snapshot_bytes`]'s contract.
23const NODE_SIZE: usize = std::mem::size_of::<usize>();
24
25/// `skip_serializing_if` predicate — omit `false` so a complete trace keeps the
26/// historical wire shape and only a truncated one carries the flag.
27#[inline]
28fn is_false(b: &bool) -> bool {
29    !*b
30}
31
32/// Result of executing a step (workflow or task)
33///
34/// Deliberately **not** `#[non_exhaustive]`. Downstream code matches on this to
35/// classify a step, and the npm wire type mirrors it as a string union; adding a
36/// variant should break those matches at compile time rather than silently
37/// reclassify them through a `_` arm.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum StepResult {
41    /// The step was executed
42    Executed,
43    /// The step was skipped due to condition being false
44    Skipped,
45}
46
47/// How much of [`Message::audit_trail`] a step's snapshot carries.
48///
49/// This is the knob for the *quadratic* term of trace size: under
50/// [`AuditTrailScope::Full`], step `i` clones `i` audit entries, so an N-task
51/// workflow retains `N*(N+1)/2` of them.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AuditTrailScope {
55    /// Every entry accumulated so far — the historical behaviour. Total entries
56    /// across the trace grow as `N*(N+1)/2` in task count.
57    #[default]
58    Full,
59    /// Only the entry this task produced, and none for a `TaskOutcome::Skip`
60    /// step. Linear in task count, and sufficient for the `dataflow-ui` step
61    /// debugger, which reads only the last entry.
62    Own,
63    /// Empty `audit_trail` in every snapshot.
64    None,
65}
66
67/// What a trace-mode run records for each executed step.
68///
69/// [`TraceOptions::default`] reproduces the historical capture behaviour: a full
70/// [`Message`] snapshot and per-mapping contexts on every executed step, no
71/// budget, no redaction, and the whole accumulated audit trail.
72///
73/// Trace mode reads the clock twice per executed task to populate
74/// [`ExecutionStep::duration_us`]. The non-trace path
75/// ([`crate::Engine::process_message`]) is unaffected and still takes a single
76/// `Utc::now()` per message.
77#[derive(Clone, Debug)]
78pub struct TraceOptions {
79    /// Full `Message` snapshot per executed step. `true` (default) is the
80    /// historical behaviour and is what the `dataflow-ui` step debugger
81    /// requires. With `false`, [`ExecutionTrace::final_message`] returns `None`
82    /// and [`ExecutionTrace::is_success`] degenerates to `true` — inspect
83    /// `Message::errors` on the message you passed in instead.
84    pub snapshots: bool,
85
86    /// Per-mapping context snapshots for `map` tasks. `true` (default). These
87    /// are whole-context clones, one per mapping, so a multi-mapping `map` task
88    /// can snapshot more than the step's own `Message` does.
89    pub mapping_contexts: bool,
90
91    /// Per-step diff: the changes produced by this task and nothing else.
92    /// `false` (default) preserves the historical payload byte for byte.
93    ///
94    /// Prefer this over reading `audit_trail.last()`, which mis-attributes on a
95    /// `TaskOutcome::Skip` step — no audit entry is recorded for a skip, so the
96    /// last entry belongs to a different task.
97    ///
98    /// Empty when the message was built with
99    /// `MessageBuilder::capture_changes(false)`: this flag reports the diff, it
100    /// does not turn capture on.
101    pub changes: bool,
102
103    /// Soft budget over the *approximate accumulated in-memory size* of the
104    /// snapshots taken so far — container and scalar nodes counted as one
105    /// machine word, `String` contents by `str::len()`. This is **not**
106    /// serialized JSON length; measuring that would mean serializing every step,
107    /// which defeats the purpose of a pre-capture budget.
108    ///
109    /// Once exceeded, later executed steps are still recorded — ids, result,
110    /// timing, and `changes` if enabled — with `message: None`, and
111    /// [`ExecutionTrace::truncated`] returns `true`. `0` (default) is unbounded.
112    pub max_snapshot_bytes: usize,
113
114    /// Dot-paths under the message context whose subtrees are replaced with
115    /// `OwnedDataValue::Null` as the snapshot is built, via a *pruning clone*:
116    /// the redacted subtree is never cloned, so this bounds the snapshot's
117    /// memory as well as its content. The live message is untouched, so later
118    /// tasks still read the real values.
119    ///
120    /// Also applied to `mapping_contexts`, which are whole-context clones and
121    /// would otherwise carry the redacted subtree through unchanged.
122    ///
123    /// Path syntax is the [`crate::engine::utils::get_nested_value`] vocabulary:
124    /// dot segments, numeric segments index arrays, one leading `#` escapes a
125    /// numerically-named object key. Unlike `set_nested_value`, a path that does
126    /// not resolve creates nothing and is a no-op, and an empty path is ignored.
127    ///
128    /// This is a literal path list and nothing more — no value scanning, no
129    /// pattern matching, no credential heuristics. It is not a masking engine.
130    pub redact_paths: Vec<String>,
131
132    /// How much of the accumulated audit trail each snapshot carries. See
133    /// [`AuditTrailScope`]; this is the lever for trace size in task count.
134    pub snapshot_audit_trail: AuditTrailScope,
135}
136
137impl Default for TraceOptions {
138    fn default() -> Self {
139        Self {
140            snapshots: true,
141            mapping_contexts: true,
142            changes: false,
143            max_snapshot_bytes: 0,
144            redact_paths: Vec::new(),
145            snapshot_audit_trail: AuditTrailScope::Full,
146        }
147    }
148}
149
150impl TraceOptions {
151    /// Ids, result, timing and the per-task diff; no message snapshots and no
152    /// mapping contexts. A step costs a few hundred bytes plus its diff,
153    /// regardless of message size or task count.
154    ///
155    /// Note this is UI-incompatible: the `dataflow-ui` step debugger needs
156    /// snapshots to render the step view.
157    pub fn timings_only() -> Self {
158        Self {
159            snapshots: false,
160            mapping_contexts: false,
161            changes: true,
162            ..Default::default()
163        }
164    }
165
166    /// Pre-split `redact_paths` into raw segments once per trace.
167    ///
168    /// Segments stay raw so object-key matching can apply the `#` escape while
169    /// array matching parses the segment, exactly as `get_nested_value` does.
170    /// Empty paths are dropped here, which is what makes them a no-op.
171    fn redact_segments(&self) -> Vec<Vec<String>> {
172        self.redact_paths
173            .iter()
174            .filter(|p| !p.is_empty())
175            .map(|p| p.split('.').map(str::to_string).collect())
176            .collect()
177    }
178}
179
180/// Task-body timing for an executed step: when the body started and how long
181/// it ran.
182///
183/// Bundled because both executor call sites always have the pair — it is
184/// exactly what [`ExecutionStep::with_timing`] sets — and because it keeps
185/// [`ExecutionTrace::add_executed_step`] inside clippy's argument-count
186/// threshold now that the loop counter rides along too.
187#[derive(Clone, Copy, Debug)]
188pub(crate) struct StepTiming {
189    /// Wall-clock start of the task body.
190    pub started_at: DateTime<Utc>,
191    /// Task body duration in microseconds.
192    pub duration_us: u64,
193}
194
195/// A single step in the execution trace
196///
197/// `#[non_exhaustive]`: construct through [`ExecutionStep::executed`],
198/// [`ExecutionStep::task_skipped`] or [`ExecutionStep::workflow_skipped`] and
199/// chain the `with_*` methods. Field reads and `..` patterns are unaffected.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[non_exhaustive]
202pub struct ExecutionStep {
203    /// ID of the workflow this step belongs to
204    pub workflow_id: String,
205    /// ID of the task (None for workflow-level skips)
206    pub task_id: Option<String>,
207    /// Result of the step execution
208    pub result: StepResult,
209    /// Message snapshot after this step. `None` for skipped steps, when
210    /// [`TraceOptions::snapshots`] is off, and for executed steps recorded after
211    /// [`TraceOptions::max_snapshot_bytes`] was exceeded.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub message: Option<Message>,
214    /// Context snapshots before each mapping (map tasks only, trace mode only).
215    /// `mapping_contexts[i]` is `message.context` before `mapping[i]` executed.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub mapping_contexts: Option<Vec<Value>>,
218    /// Wall-clock start of the task body. `Executed` steps only.
219    ///
220    /// `chrono`, not `std::time::Instant`: `Instant::now()` panics on
221    /// `wasm32-unknown-unknown`, and the wasm bindings run the trace path there.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub started_at: Option<DateTime<Utc>>,
224    /// Task body duration in microseconds. `Executed` steps only.
225    ///
226    /// Derived from two `Utc::now()` reads, which are not monotonic: a backward
227    /// clock step clamps to `0` rather than wrapping.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub duration_us: Option<u64>,
230    /// This task's own writes, when [`TraceOptions::changes`] is set. `Some(vec![])`
231    /// means the task wrote nothing (or change capture is off), which is
232    /// distinct from `None` meaning "not recorded".
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub changes: Option<Vec<Change>>,
235    /// Loop counter of the sweep this step belongs to, for workflows carrying
236    /// a [`crate::engine::workflow::LoopConfig`]; `None` otherwise. Group
237    /// steps by it to reconstruct per-iteration execution.
238    ///
239    /// Mirrors [`crate::engine::message::AuditTrail::loop_counter`], including
240    /// being skipped when `None` so a non-looping trace keeps its historical
241    /// wire shape.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub loop_counter: Option<i64>,
244}
245
246impl ExecutionStep {
247    /// Create a new executed step with a message snapshot
248    pub fn executed(workflow_id: &str, task_id: &str, message: &Message) -> Self {
249        Self {
250            workflow_id: workflow_id.to_string(),
251            task_id: Some(task_id.to_string()),
252            result: StepResult::Executed,
253            message: Some(message.clone()),
254            mapping_contexts: None,
255            started_at: None,
256            duration_us: None,
257            changes: None,
258            loop_counter: None,
259        }
260    }
261
262    /// Create a skipped task step
263    pub fn task_skipped(workflow_id: &str, task_id: &str) -> Self {
264        Self {
265            workflow_id: workflow_id.to_string(),
266            task_id: Some(task_id.to_string()),
267            result: StepResult::Skipped,
268            message: None,
269            mapping_contexts: None,
270            started_at: None,
271            duration_us: None,
272            changes: None,
273            loop_counter: None,
274        }
275    }
276
277    /// Create a skipped workflow step
278    pub fn workflow_skipped(workflow_id: &str) -> Self {
279        Self {
280            workflow_id: workflow_id.to_string(),
281            task_id: None,
282            result: StepResult::Skipped,
283            message: None,
284            mapping_contexts: None,
285            started_at: None,
286            duration_us: None,
287            changes: None,
288            loop_counter: None,
289        }
290    }
291
292    /// Set mapping context snapshots (for map tasks in trace mode)
293    pub fn with_mapping_contexts(mut self, contexts: Vec<Value>) -> Self {
294        self.mapping_contexts = Some(contexts);
295        self
296    }
297
298    /// Attach task-body timing. Chains after [`Self::executed`].
299    pub fn with_timing(mut self, started_at: DateTime<Utc>, duration_us: u64) -> Self {
300        self.started_at = Some(started_at);
301        self.duration_us = Some(duration_us);
302        self
303    }
304
305    /// Attach this task's own diff. Chains after [`Self::executed`].
306    pub fn with_changes(mut self, changes: Vec<Change>) -> Self {
307        self.changes = Some(changes);
308        self
309    }
310
311    /// Attach the loop counter of the sweep this step belongs to. `None` is
312    /// the non-looping case and leaves the step's JSON unchanged.
313    pub fn with_loop_counter(mut self, loop_counter: Option<i64>) -> Self {
314        self.loop_counter = loop_counter;
315        self
316    }
317}
318
319/// Complete execution trace containing all steps
320///
321/// `#[non_exhaustive]`: construct with [`ExecutionTrace::new`] or
322/// [`ExecutionTrace::with_options`]. `steps` stays public for reads.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[non_exhaustive]
325pub struct ExecutionTrace {
326    /// All execution steps in order
327    pub steps: Vec<ExecutionStep>,
328
329    /// Set when `max_snapshot_bytes` was exceeded. Serialized only when `true`,
330    /// so a complete trace keeps the historical wire shape.
331    #[serde(default, skip_serializing_if = "is_false")]
332    truncated: bool,
333
334    /// Capture policy. In-memory only — a deserialized trace carries the
335    /// default, which is why `options()` is documented as the policy this trace
336    /// *records* with rather than the one it was recorded with.
337    #[serde(skip)]
338    options: TraceOptions,
339
340    /// Pre-split `options.redact_paths`, so the split cost is paid once.
341    #[serde(skip)]
342    redact_segments: Vec<Vec<String>>,
343
344    /// Approximate accumulated in-memory size of snapshots taken so far.
345    #[serde(skip)]
346    snapshot_bytes: usize,
347}
348
349impl ExecutionTrace {
350    /// Create a new empty execution trace with default capture policy.
351    pub fn new() -> Self {
352        Self::with_options(TraceOptions::default())
353    }
354
355    /// Empty trace that records according to `options`.
356    pub fn with_options(options: TraceOptions) -> Self {
357        Self {
358            steps: Vec::new(),
359            truncated: false,
360            redact_segments: options.redact_segments(),
361            options,
362            snapshot_bytes: 0,
363        }
364    }
365
366    /// The capture policy this trace records with.
367    pub fn options(&self) -> &TraceOptions {
368        &self.options
369    }
370
371    /// Whether [`TraceOptions::max_snapshot_bytes`] was exceeded, and one or
372    /// more executed steps therefore carry `message: None`, missing mapping
373    /// contexts, or both.
374    ///
375    /// The budget is shared across both: with [`TraceOptions::snapshots`] off
376    /// but [`TraceOptions::mapping_contexts`] on, this can still return `true`
377    /// from the mapping-context term alone, even though no step was ever going
378    /// to carry a `message`.
379    pub fn truncated(&self) -> bool {
380        self.truncated
381    }
382
383    /// Add a step to the trace
384    pub fn add_step(&mut self, step: ExecutionStep) {
385        self.steps.push(step);
386    }
387
388    /// Record an executed step under this trace's capture policy.
389    ///
390    /// Owns every policy decision — snapshot scope, pruning clone, audit-trail
391    /// scope, budget check, timing and diff — so the executor's two dispatch
392    /// sites do not duplicate any of it.
393    pub(crate) fn add_executed_step(
394        &mut self,
395        workflow_id: &str,
396        task_id: &str,
397        message: &Message,
398        timing: StepTiming,
399        mapping_contexts: Option<Vec<Value>>,
400        loop_counter: Option<i64>,
401    ) {
402        let mut step = ExecutionStep {
403            workflow_id: workflow_id.to_string(),
404            task_id: Some(task_id.to_string()),
405            result: StepResult::Executed,
406            message: None,
407            mapping_contexts: None,
408            started_at: Some(timing.started_at),
409            duration_us: Some(timing.duration_us),
410            loop_counter,
411            changes: if self.options.changes {
412                // Derived from this task's own audit entry rather than
413                // `audit_trail.last()` unconditionally — that is the
414                // mis-attribution being fixed. A `TaskOutcome::Skip` records no
415                // entry, so it correctly reports an empty diff instead of
416                // inheriting the previous task's (or another workflow's).
417                Some(
418                    own_audit_entry(message, workflow_id, task_id)
419                        .map(|e| e.changes.clone())
420                        .unwrap_or_default(),
421                )
422            } else {
423                None
424            },
425        };
426
427        if self.options.snapshots {
428            // Size is *probed* without cloning, so a snapshot that would not fit
429            // is never built. Checking after the clone would be pointless: the
430            // peak memory this budget exists to bound has already been paid.
431            let projected = self.projected_snapshot_size(message, workflow_id, task_id);
432            if self.would_exceed(projected) {
433                self.truncated = true;
434            } else {
435                self.snapshot_bytes += projected;
436                step.message = Some(self.build_snapshot(message, workflow_id, task_id));
437            }
438        }
439
440        if self.options.mapping_contexts {
441            if let Some(mut contexts) = mapping_contexts {
442                // These arrive already cloned by the map function, so redact
443                // first and then decide whether to retain them.
444                for ctx in &mut contexts {
445                    redact_json_in_place(ctx, &self.redact_segments);
446                }
447                let size: usize = contexts.iter().map(approx_json_size).sum();
448                if self.would_exceed(size) {
449                    self.truncated = true;
450                } else {
451                    self.snapshot_bytes += size;
452                    step.mapping_contexts = Some(contexts);
453                }
454            }
455        }
456
457        self.steps.push(step);
458    }
459
460    /// Whether retaining `additional` bytes would cross a finite budget.
461    #[inline]
462    fn would_exceed(&self, additional: usize) -> bool {
463        self.options.max_snapshot_bytes != 0
464            && self.snapshot_bytes + additional > self.options.max_snapshot_bytes
465    }
466
467    /// Approximate size the snapshot for this step would occupy, computed
468    /// without cloning anything.
469    ///
470    /// Must agree with what [`Self::build_snapshot`] actually retains; a unit
471    /// test pins the two together.
472    fn projected_snapshot_size(
473        &self,
474        message: &Message,
475        workflow_id: &str,
476        task_id: &str,
477    ) -> usize {
478        let mut size = redacted_size(&message.context, &self.redact_segments);
479        for entry in self.scoped_audit_trail(message, workflow_id, task_id) {
480            size += NODE_SIZE;
481            for change in &entry.changes {
482                size += change.path.len()
483                    + approx_owned_size(&change.old_value)
484                    + approx_owned_size(&change.new_value);
485            }
486        }
487        size
488    }
489
490    /// The audit entries this step's snapshot will carry, per
491    /// [`TraceOptions::snapshot_audit_trail`].
492    fn scoped_audit_trail<'m>(
493        &self,
494        message: &'m Message,
495        workflow_id: &str,
496        task_id: &str,
497    ) -> Vec<&'m AuditTrail> {
498        match self.options.snapshot_audit_trail {
499            AuditTrailScope::Full => message.audit_trail.iter().collect(),
500            AuditTrailScope::Own => own_audit_entry(message, workflow_id, task_id)
501                .map(|e| vec![e])
502                .unwrap_or_default(),
503            AuditTrailScope::None => Vec::new(),
504        }
505    }
506
507    /// Build this step's snapshot: context pruned per `redact_paths`, audit
508    /// trail scoped per `snapshot_audit_trail`. Returns the approximate
509    /// in-memory size charged to the budget.
510    ///
511    /// `payload` is an `Arc`, so it is shared rather than deep-cloned here —
512    /// same as the derived `Message::clone` this replaces.
513    fn build_snapshot(&self, message: &Message, workflow_id: &str, task_id: &str) -> Message {
514        let (context, _) = redacting_clone(&message.context, &self.redact_segments);
515        let audit_trail: Vec<AuditTrail> = self
516            .scoped_audit_trail(message, workflow_id, task_id)
517            .into_iter()
518            .cloned()
519            .collect();
520
521        Message {
522            id: message.id.clone(),
523            payload: Arc::clone(&message.payload),
524            context,
525            audit_trail,
526            errors: message.errors.clone(),
527            capture_changes: message.capture_changes,
528            routing_bucket: message.routing_bucket,
529        }
530    }
531
532    /// Get the final message (from the last executed step)
533    ///
534    /// Returns `None` when [`TraceOptions::snapshots`] is off, or when every
535    /// executed step was recorded after the snapshot budget was exceeded.
536    pub fn final_message(&self) -> Option<&Message> {
537        self.steps
538            .iter()
539            .rev()
540            .find(|s| s.result == StepResult::Executed)
541            .and_then(|s| s.message.as_ref())
542    }
543
544    /// Check if execution was successful (no errors in final message)
545    ///
546    /// Degenerates to `true` when there is no snapshot to inspect — with
547    /// [`TraceOptions::snapshots`] off, read `Message::errors` on the message you
548    /// passed in instead.
549    pub fn is_success(&self) -> bool {
550        self.final_message()
551            .map(|m| m.errors.is_empty())
552            .unwrap_or(true)
553    }
554
555    /// Get number of executed steps
556    pub fn executed_count(&self) -> usize {
557        self.steps
558            .iter()
559            .filter(|s| s.result == StepResult::Executed)
560            .count()
561    }
562
563    /// Get number of skipped steps
564    pub fn skipped_count(&self) -> usize {
565        self.steps
566            .iter()
567            .filter(|s| s.result == StepResult::Skipped)
568            .count()
569    }
570}
571
572impl Default for ExecutionTrace {
573    fn default() -> Self {
574        Self::new()
575    }
576}
577
578/// The audit entry this task produced, if any.
579///
580/// The executor pushes at most one entry per task and pushes it immediately, so
581/// the last entry is this task's exactly when both its workflow and task id
582/// match. A `TaskOutcome::Skip` records none — which is why reading
583/// `audit_trail.last()` unconditionally mis-attributes the previous task's diff
584/// to a skipped step. Comparing `task_id` alone is not enough either: two
585/// workflows can share a task id (or the same task can run again in a later
586/// workflow), so a `Skip` right after a same-named task in a different
587/// workflow would otherwise inherit that other workflow's entry.
588#[inline]
589fn own_audit_entry<'m>(
590    message: &'m Message,
591    workflow_id: &str,
592    task_id: &str,
593) -> Option<&'m AuditTrail> {
594    match message.audit_trail.last() {
595        Some(entry)
596            if entry.task_id.as_ref() == task_id && entry.workflow_id.as_ref() == workflow_id =>
597        {
598            Some(entry)
599        }
600        _ => None,
601    }
602}
603
604/// Microseconds between two non-monotonic clock reads, clamped at `0`.
605///
606/// `Utc::now()` can step backwards, and `num_microseconds()` returns `None` on
607/// overflow; both collapse to `0` rather than panicking or wrapping.
608#[inline]
609pub(crate) fn duration_us_between(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
610    (end - start)
611        .num_microseconds()
612        .unwrap_or(0)
613        .max(0)
614        .try_into()
615        .unwrap_or(0)
616}
617
618/// Deep-clone `value`, substituting `OwnedDataValue::Null` for every subtree
619/// named by `paths`, and return the approximate in-memory size of the result.
620///
621/// `paths` holds the path suffixes still in play at this node; an empty suffix
622/// means this node is itself a redaction target, so its subtree is never
623/// cloned. A suffix that matches nothing is dropped, which is what makes an
624/// unresolvable path a no-op rather than a `set_nested_value`-style create.
625fn redacting_clone(value: &OwnedDataValue, paths: &[Vec<String>]) -> (OwnedDataValue, usize) {
626    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
627    redacting_clone_inner(value, &refs)
628}
629
630/// Narrow `paths` to the child suffixes that still apply under object key
631/// `key` — the entries whose head segment (after the `#`-prefix escape)
632/// matches `key`, each with that head segment dropped. Shared by every
633/// redact/size walker below so the "does this path element apply here"
634/// filter has one definition instead of one copy per walker per value type.
635fn narrow_for_object_key<'a>(paths: &[&'a [String]], key: &str) -> Vec<&'a [String]> {
636    paths
637        .iter()
638        .filter(|p| strip_hash_prefix(&p[0]) == key)
639        .map(|p| &p[1..])
640        .collect()
641}
642
643/// Same as [`narrow_for_object_key`] but for an array index: the entries
644/// whose head segment parses as `idx`.
645fn narrow_for_array_index<'a>(paths: &[&'a [String]], idx: usize) -> Vec<&'a [String]> {
646    paths
647        .iter()
648        .filter(|p| p[0].parse::<usize>() == Ok(idx))
649        .map(|p| &p[1..])
650        .collect()
651}
652
653fn redacting_clone_inner(value: &OwnedDataValue, paths: &[&[String]]) -> (OwnedDataValue, usize) {
654    // An exhausted suffix names this node: redact without descending.
655    if paths.iter().any(|p| p.is_empty()) {
656        return (OwnedDataValue::Null, NODE_SIZE);
657    }
658
659    match value {
660        OwnedDataValue::Object(pairs) => {
661            let mut out = Vec::with_capacity(pairs.len());
662            let mut size = NODE_SIZE;
663            for (key, child) in pairs {
664                let sub = narrow_for_object_key(paths, key);
665                let (cloned, child_size) = redacting_clone_inner(child, &sub);
666                size += key.len() + child_size;
667                out.push((key.clone(), cloned));
668            }
669            (OwnedDataValue::Object(out), size)
670        }
671        OwnedDataValue::Array(items) => {
672            let mut out = Vec::with_capacity(items.len());
673            let mut size = NODE_SIZE;
674            for (idx, child) in items.iter().enumerate() {
675                let sub = narrow_for_array_index(paths, idx);
676                let (cloned, child_size) = redacting_clone_inner(child, &sub);
677                size += child_size;
678                out.push(cloned);
679            }
680            (OwnedDataValue::Array(out), size)
681        }
682        // Scalars: any leftover suffix cannot resolve, so it is dropped.
683        OwnedDataValue::String(s) => (value.clone(), NODE_SIZE + s.len()),
684        other => (other.clone(), NODE_SIZE),
685    }
686}
687
688/// Size [`redacting_clone`] would produce, computed without cloning.
689///
690/// Exists so the snapshot budget can decline a capture *before* paying for it.
691/// Must stay in step with `redacting_clone`; `redacted_size_agrees_with_redacting_clone`
692/// pins that.
693fn redacted_size(value: &OwnedDataValue, paths: &[Vec<String>]) -> usize {
694    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
695    redacted_size_inner(value, &refs)
696}
697
698fn redacted_size_inner(value: &OwnedDataValue, paths: &[&[String]]) -> usize {
699    if paths.iter().any(|p| p.is_empty()) {
700        return NODE_SIZE;
701    }
702    match value {
703        OwnedDataValue::Object(pairs) => {
704            let mut size = NODE_SIZE;
705            for (key, child) in pairs {
706                let sub = narrow_for_object_key(paths, key);
707                size += key.len() + redacted_size_inner(child, &sub);
708            }
709            size
710        }
711        OwnedDataValue::Array(items) => {
712            let mut size = NODE_SIZE;
713            for (idx, child) in items.iter().enumerate() {
714                let sub = narrow_for_array_index(paths, idx);
715                size += redacted_size_inner(child, &sub);
716            }
717            size
718        }
719        OwnedDataValue::String(s) => NODE_SIZE + s.len(),
720        _ => NODE_SIZE,
721    }
722}
723
724/// Approximate in-memory size of an `OwnedDataValue`, on the same scale as
725/// [`redacting_clone`].
726fn approx_owned_size(value: &OwnedDataValue) -> usize {
727    match value {
728        OwnedDataValue::Object(pairs) => {
729            NODE_SIZE
730                + pairs
731                    .iter()
732                    .map(|(k, v)| k.len() + approx_owned_size(v))
733                    .sum::<usize>()
734        }
735        OwnedDataValue::Array(items) => {
736            NODE_SIZE + items.iter().map(approx_owned_size).sum::<usize>()
737        }
738        OwnedDataValue::String(s) => NODE_SIZE + s.len(),
739        _ => NODE_SIZE,
740    }
741}
742
743/// Null out every subtree named by `paths` in a `serde_json::Value`, in place.
744///
745/// Mirrors [`redacting_clone`]'s path semantics so `mapping_contexts` are
746/// redacted the same way message snapshots are. In place because the caller
747/// already owns the value — there is no second clone to save.
748fn redact_json_in_place(value: &mut Value, paths: &[Vec<String>]) {
749    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
750    redact_json_inner(value, &refs);
751}
752
753fn redact_json_inner(value: &mut Value, paths: &[&[String]]) {
754    if paths.is_empty() {
755        return;
756    }
757    if paths.iter().any(|p| p.is_empty()) {
758        *value = Value::Null;
759        return;
760    }
761
762    match value {
763        Value::Object(map) => {
764            for (key, child) in map.iter_mut() {
765                let sub = narrow_for_object_key(paths, key);
766                redact_json_inner(child, &sub);
767            }
768        }
769        Value::Array(items) => {
770            for (idx, child) in items.iter_mut().enumerate() {
771                let sub = narrow_for_array_index(paths, idx);
772                redact_json_inner(child, &sub);
773            }
774        }
775        _ => {}
776    }
777}
778
779/// Approximate in-memory size of a `serde_json::Value`, on the same scale as
780/// [`approx_owned_size`].
781fn approx_json_size(value: &Value) -> usize {
782    match value {
783        Value::Object(map) => {
784            NODE_SIZE
785                + map
786                    .iter()
787                    .map(|(k, v)| k.len() + approx_json_size(v))
788                    .sum::<usize>()
789        }
790        Value::Array(items) => NODE_SIZE + items.iter().map(approx_json_size).sum::<usize>(),
791        Value::String(s) => NODE_SIZE + s.len(),
792        _ => NODE_SIZE,
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use serde_json::json;
800
801    fn dv(v: serde_json::Value) -> OwnedDataValue {
802        OwnedDataValue::from(&v)
803    }
804
805    fn segments(paths: &[&str]) -> Vec<Vec<String>> {
806        TraceOptions {
807            redact_paths: paths.iter().map(|s| s.to_string()).collect(),
808            ..Default::default()
809        }
810        .redact_segments()
811    }
812
813    #[test]
814    fn test_step_result_serialization() {
815        assert_eq!(
816            serde_json::to_string(&StepResult::Executed).unwrap(),
817            "\"executed\""
818        );
819        assert_eq!(
820            serde_json::to_string(&StepResult::Skipped).unwrap(),
821            "\"skipped\""
822        );
823    }
824
825    #[test]
826    fn test_execution_step_executed() {
827        let message = Message::from_value(&json!({"test": "data"}));
828        let step = ExecutionStep::executed("workflow1", "task1", &message);
829
830        assert_eq!(step.workflow_id, "workflow1");
831        assert_eq!(step.task_id, Some("task1".to_string()));
832        assert_eq!(step.result, StepResult::Executed);
833        assert!(step.message.is_some());
834    }
835
836    #[test]
837    fn test_execution_step_task_skipped() {
838        let step = ExecutionStep::task_skipped("workflow1", "task1");
839
840        assert_eq!(step.workflow_id, "workflow1");
841        assert_eq!(step.task_id, Some("task1".to_string()));
842        assert_eq!(step.result, StepResult::Skipped);
843        assert!(step.message.is_none());
844    }
845
846    #[test]
847    fn test_execution_step_workflow_skipped() {
848        let step = ExecutionStep::workflow_skipped("workflow1");
849
850        assert_eq!(step.workflow_id, "workflow1");
851        assert_eq!(step.task_id, None);
852        assert_eq!(step.result, StepResult::Skipped);
853        assert!(step.message.is_none());
854    }
855
856    #[test]
857    fn test_execution_step_with_mapping_contexts() {
858        let message = Message::from_value(&json!({"test": "data"}));
859        let contexts = vec![json!({"data": {"a": 1}}), json!({"data": {"a": 1, "b": 2}})];
860
861        let step = ExecutionStep::executed("workflow1", "task1", &message)
862            .with_mapping_contexts(contexts.clone());
863
864        assert_eq!(step.mapping_contexts, Some(contexts));
865
866        // Verify serialization includes mapping_contexts
867        let serialized = serde_json::to_value(&step).unwrap();
868        assert!(serialized.get("mapping_contexts").is_some());
869        assert_eq!(serialized["mapping_contexts"].as_array().unwrap().len(), 2);
870    }
871
872    #[test]
873    fn test_execution_step_without_mapping_contexts_serialization() {
874        let message = Message::from_value(&json!({"test": "data"}));
875        let step = ExecutionStep::executed("workflow1", "task1", &message);
876
877        // Every optional field is None, so all are omitted in serialization.
878        let serialized = serde_json::to_value(&step).unwrap();
879        assert!(serialized.get("mapping_contexts").is_none());
880        assert!(serialized.get("started_at").is_none());
881        assert!(serialized.get("duration_us").is_none());
882        assert!(serialized.get("changes").is_none());
883    }
884
885    #[test]
886    fn test_execution_trace() {
887        let mut trace = ExecutionTrace::new();
888        let message = Message::from_value(&json!({"test": "data"}));
889
890        trace.add_step(ExecutionStep::workflow_skipped("workflow0"));
891        trace.add_step(ExecutionStep::executed("workflow1", "task1", &message));
892        trace.add_step(ExecutionStep::task_skipped("workflow1", "task2"));
893
894        assert_eq!(trace.steps.len(), 3);
895        assert_eq!(trace.executed_count(), 1);
896        assert_eq!(trace.skipped_count(), 2);
897        assert!(trace.final_message().is_some());
898        assert!(trace.is_success());
899    }
900
901    #[test]
902    fn default_options_reproduce_historical_capture() {
903        let o = TraceOptions::default();
904        assert!(o.snapshots);
905        assert!(o.mapping_contexts);
906        assert!(!o.changes);
907        assert_eq!(o.max_snapshot_bytes, 0);
908        assert!(o.redact_paths.is_empty());
909        assert_eq!(o.snapshot_audit_trail, AuditTrailScope::Full);
910    }
911
912    #[test]
913    fn timings_only_drops_snapshots_and_keeps_the_diff() {
914        let o = TraceOptions::timings_only();
915        assert!(!o.snapshots);
916        assert!(!o.mapping_contexts);
917        assert!(o.changes);
918    }
919
920    #[test]
921    fn a_complete_trace_does_not_serialize_the_truncated_flag() {
922        let trace = ExecutionTrace::new();
923        let serialized = serde_json::to_value(&trace).unwrap();
924        assert!(
925            serialized.get("truncated").is_none(),
926            "a complete trace keeps the historical wire shape"
927        );
928        assert!(!trace.truncated());
929    }
930
931    #[test]
932    fn a_trace_deserializes_from_a_payload_without_the_truncated_flag() {
933        let trace: ExecutionTrace = serde_json::from_value(json!({ "steps": [] })).unwrap();
934        assert!(!trace.truncated());
935    }
936
937    #[test]
938    fn duration_clamps_a_backward_clock_step_to_zero() {
939        let start = Utc::now();
940        let earlier = start - chrono::Duration::seconds(5);
941        assert_eq!(duration_us_between(start, earlier), 0);
942        assert_eq!(duration_us_between(start, start), 0);
943        assert_eq!(
944            duration_us_between(start, start + chrono::Duration::microseconds(1500)),
945            1500
946        );
947    }
948
949    #[test]
950    fn redaction_nulls_only_the_named_subtree() {
951        let ctx = dv(json!({"data": {"secret": {"k": "v"}, "keep": 1}}));
952        let (out, _) = redacting_clone(&ctx, &segments(&["data.secret"]));
953        assert_eq!(
954            serde_json::Value::from(&out),
955            json!({"data": {"secret": null, "keep": 1}})
956        );
957    }
958
959    #[test]
960    fn redaction_of_an_unresolvable_path_creates_nothing() {
961        // `set_nested_value` would pad the array to index 99 with nulls; this
962        // must not.
963        let ctx = dv(json!({"data": {"items": [1, 2, 3]}}));
964        let (out, _) = redacting_clone(&ctx, &segments(&["data.items.99"]));
965        assert_eq!(
966            serde_json::Value::from(&out),
967            json!({"data": {"items": [1, 2, 3]}})
968        );
969    }
970
971    #[test]
972    fn redaction_through_a_non_container_is_a_noop() {
973        let ctx = dv(json!({"data": {"name": "alice"}}));
974        let (out, _) = redacting_clone(&ctx, &segments(&["data.name.first"]));
975        assert_eq!(
976            serde_json::Value::from(&out),
977            json!({"data": {"name": "alice"}})
978        );
979    }
980
981    #[test]
982    fn an_empty_redact_path_is_ignored() {
983        let ctx = dv(json!({"data": {"a": 1}}));
984        let (out, _) = redacting_clone(&ctx, &segments(&[""]));
985        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": 1}}));
986    }
987
988    #[test]
989    fn redaction_honours_the_hash_escape() {
990        // `data.#20` names the object key "20"; `data.20` indexes an array.
991        let obj = dv(json!({"data": {"20": "secret", "other": 1}}));
992        let (out, _) = redacting_clone(&obj, &segments(&["data.#20"]));
993        assert_eq!(
994            serde_json::Value::from(&out),
995            json!({"data": {"20": null, "other": 1}})
996        );
997
998        let arr = dv(json!({"data": [0, 1, 2]}));
999        let (out, _) = redacting_clone(&arr, &segments(&["data.1"]));
1000        assert_eq!(serde_json::Value::from(&out), json!({"data": [0, null, 2]}));
1001
1002        // The escaped form does not index an array.
1003        let (out, _) = redacting_clone(&arr, &segments(&["data.#1"]));
1004        assert_eq!(serde_json::Value::from(&out), json!({"data": [0, 1, 2]}));
1005    }
1006
1007    #[test]
1008    fn nested_and_duplicated_redact_paths_are_safe() {
1009        let ctx = dv(json!({"data": {"a": {"b": 1, "c": 2}}}));
1010
1011        let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a.b"]));
1012        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1013
1014        let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a"]));
1015        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1016    }
1017
1018    #[test]
1019    fn redaction_matches_unicode_keys_and_sizes_strings_by_bytes() {
1020        let ctx = dv(json!({"data": {"café": "secret", "keep": "née"}}));
1021        let (out, size) = redacting_clone(&ctx, &segments(&["data.café"]));
1022        assert_eq!(
1023            serde_json::Value::from(&out),
1024            json!({"data": {"café": null, "keep": "née"}})
1025        );
1026
1027        // "née" is 4 bytes, 3 chars — the budget counts bytes.
1028        let (_, unredacted) = redacting_clone(&ctx, &segments(&[]));
1029        assert!(unredacted > size, "redacting must lower the counted size");
1030        assert!(
1031            approx_owned_size(&dv(json!("née"))) == NODE_SIZE + 4,
1032            "str::len() bytes, not chars().count()"
1033        );
1034    }
1035
1036    #[test]
1037    fn redacted_size_agrees_with_redacting_clone() {
1038        // The budget probes with `redacted_size` and then builds with
1039        // `redacting_clone`; if they drift, the budget stops meaning anything.
1040        let shapes = [
1041            json!({}),
1042            json!({"data": {"a": 1, "b": "hello"}}),
1043            json!({"data": {"items": [1, "two", {"three": 3}], "nested": {"x": {"y": "z"}}}}),
1044            json!({"data": {"secret": {"deep": [1, 2, 3]}, "keep": "café"}}),
1045        ];
1046        let path_sets: [&[&str]; 4] = [&[], &["data.secret"], &["data.items.1"], &["data.nope"]];
1047
1048        for shape in &shapes {
1049            for paths in path_sets {
1050                let v = dv(shape.clone());
1051                let segs = segments(paths);
1052                let (_, cloned_size) = redacting_clone(&v, &segs);
1053                assert_eq!(
1054                    redacted_size(&v, &segs),
1055                    cloned_size,
1056                    "probe and clone disagree for {shape:?} with {paths:?}"
1057                );
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn redaction_applies_to_mapping_contexts_too() {
1064        let mut ctx = json!({"data": {"secret": {"k": "v"}, "keep": 1}});
1065        redact_json_in_place(&mut ctx, &segments(&["data.secret"]));
1066        assert_eq!(ctx, json!({"data": {"secret": null, "keep": 1}}));
1067    }
1068
1069    #[test]
1070    fn json_redaction_shares_the_owned_path_semantics() {
1071        let mut arr = json!({"data": {"items": [1, 2, 3]}});
1072        redact_json_in_place(&mut arr, &segments(&["data.items.99"]));
1073        assert_eq!(arr, json!({"data": {"items": [1, 2, 3]}}));
1074
1075        let mut scalar = json!({"data": {"name": "alice"}});
1076        redact_json_in_place(&mut scalar, &segments(&["data.name.first"]));
1077        assert_eq!(scalar, json!({"data": {"name": "alice"}}));
1078
1079        let mut hash = json!({"data": {"20": "secret"}});
1080        redact_json_in_place(&mut hash, &segments(&["data.#20"]));
1081        assert_eq!(hash, json!({"data": {"20": null}}));
1082    }
1083
1084    #[test]
1085    fn execution_step_loop_counter_defaults_to_none_on_every_constructor() {
1086        assert_eq!(
1087            ExecutionStep::executed("w", "t", &Message::from_value(&json!({}))).loop_counter,
1088            None
1089        );
1090        assert_eq!(ExecutionStep::task_skipped("w", "t").loop_counter, None);
1091        assert_eq!(ExecutionStep::workflow_skipped("w").loop_counter, None);
1092    }
1093
1094    #[test]
1095    fn with_loop_counter_sets_and_clears_the_field() {
1096        let step = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(3));
1097        assert_eq!(step.loop_counter, Some(3));
1098        // Passing None must clear rather than be a no-op, so the non-looping
1099        // caller can chain unconditionally.
1100        assert_eq!(step.with_loop_counter(None).loop_counter, None);
1101    }
1102
1103    #[test]
1104    fn execution_step_loop_counter_is_absent_from_json_when_none() {
1105        // A non-looping trace must keep the historical wire shape byte for
1106        // byte — dataflow-ui parses these.
1107        let step = ExecutionStep::task_skipped("w", "t");
1108        let json = serde_json::to_value(&step).expect("should serialize");
1109        assert!(json.get("loop_counter").is_none());
1110
1111        let looped = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(0));
1112        assert_eq!(
1113            serde_json::to_value(&looped).expect("should serialize")["loop_counter"],
1114            json!(0),
1115            "counter 0 must serialize, not be elided as a default"
1116        );
1117    }
1118
1119    #[test]
1120    fn execution_step_without_a_loop_counter_key_deserializes() {
1121        // Trace JSON written before loops existed must still round-trip.
1122        let step: ExecutionStep = serde_json::from_value(json!({
1123            "workflow_id": "w",
1124            "task_id": "t",
1125            "result": "skipped"
1126        }))
1127        .expect("legacy trace JSON should deserialize");
1128        assert_eq!(step.loop_counter, None);
1129        assert_eq!(step.workflow_id, "w");
1130    }
1131
1132    #[test]
1133    fn add_executed_step_records_the_timing_bundle_and_the_loop_counter() {
1134        let mut trace = ExecutionTrace::with_options(TraceOptions::timings_only());
1135        let started_at = Utc::now();
1136        trace.add_executed_step(
1137            "w",
1138            "t",
1139            &Message::from_value(&json!({})),
1140            StepTiming {
1141                started_at,
1142                duration_us: 42,
1143            },
1144            None,
1145            Some(7),
1146        );
1147
1148        let step = &trace.steps[0];
1149        assert_eq!(step.started_at, Some(started_at));
1150        assert_eq!(step.duration_us, Some(42));
1151        assert_eq!(step.loop_counter, Some(7));
1152        assert_eq!(step.result, StepResult::Executed);
1153    }
1154}