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/// A single step in the execution trace
181///
182/// `#[non_exhaustive]`: construct through [`ExecutionStep::executed`],
183/// [`ExecutionStep::task_skipped`] or [`ExecutionStep::workflow_skipped`] and
184/// chain the `with_*` methods. Field reads and `..` patterns are unaffected.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[non_exhaustive]
187pub struct ExecutionStep {
188    /// ID of the workflow this step belongs to
189    pub workflow_id: String,
190    /// ID of the task (None for workflow-level skips)
191    pub task_id: Option<String>,
192    /// Result of the step execution
193    pub result: StepResult,
194    /// Message snapshot after this step. `None` for skipped steps, when
195    /// [`TraceOptions::snapshots`] is off, and for executed steps recorded after
196    /// [`TraceOptions::max_snapshot_bytes`] was exceeded.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub message: Option<Message>,
199    /// Context snapshots before each mapping (map tasks only, trace mode only).
200    /// `mapping_contexts[i]` is `message.context` before `mapping[i]` executed.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub mapping_contexts: Option<Vec<Value>>,
203    /// Wall-clock start of the task body. `Executed` steps only.
204    ///
205    /// `chrono`, not `std::time::Instant`: `Instant::now()` panics on
206    /// `wasm32-unknown-unknown`, and the wasm bindings run the trace path there.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub started_at: Option<DateTime<Utc>>,
209    /// Task body duration in microseconds. `Executed` steps only.
210    ///
211    /// Derived from two `Utc::now()` reads, which are not monotonic: a backward
212    /// clock step clamps to `0` rather than wrapping.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub duration_us: Option<u64>,
215    /// This task's own writes, when [`TraceOptions::changes`] is set. `Some(vec![])`
216    /// means the task wrote nothing (or change capture is off), which is
217    /// distinct from `None` meaning "not recorded".
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub changes: Option<Vec<Change>>,
220}
221
222impl ExecutionStep {
223    /// Create a new executed step with a message snapshot
224    pub fn executed(workflow_id: &str, task_id: &str, message: &Message) -> Self {
225        Self {
226            workflow_id: workflow_id.to_string(),
227            task_id: Some(task_id.to_string()),
228            result: StepResult::Executed,
229            message: Some(message.clone()),
230            mapping_contexts: None,
231            started_at: None,
232            duration_us: None,
233            changes: None,
234        }
235    }
236
237    /// Create a skipped task step
238    pub fn task_skipped(workflow_id: &str, task_id: &str) -> Self {
239        Self {
240            workflow_id: workflow_id.to_string(),
241            task_id: Some(task_id.to_string()),
242            result: StepResult::Skipped,
243            message: None,
244            mapping_contexts: None,
245            started_at: None,
246            duration_us: None,
247            changes: None,
248        }
249    }
250
251    /// Create a skipped workflow step
252    pub fn workflow_skipped(workflow_id: &str) -> Self {
253        Self {
254            workflow_id: workflow_id.to_string(),
255            task_id: None,
256            result: StepResult::Skipped,
257            message: None,
258            mapping_contexts: None,
259            started_at: None,
260            duration_us: None,
261            changes: None,
262        }
263    }
264
265    /// Set mapping context snapshots (for map tasks in trace mode)
266    pub fn with_mapping_contexts(mut self, contexts: Vec<Value>) -> Self {
267        self.mapping_contexts = Some(contexts);
268        self
269    }
270
271    /// Attach task-body timing. Chains after [`Self::executed`].
272    pub fn with_timing(mut self, started_at: DateTime<Utc>, duration_us: u64) -> Self {
273        self.started_at = Some(started_at);
274        self.duration_us = Some(duration_us);
275        self
276    }
277
278    /// Attach this task's own diff. Chains after [`Self::executed`].
279    pub fn with_changes(mut self, changes: Vec<Change>) -> Self {
280        self.changes = Some(changes);
281        self
282    }
283}
284
285/// Complete execution trace containing all steps
286///
287/// `#[non_exhaustive]`: construct with [`ExecutionTrace::new`] or
288/// [`ExecutionTrace::with_options`]. `steps` stays public for reads.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[non_exhaustive]
291pub struct ExecutionTrace {
292    /// All execution steps in order
293    pub steps: Vec<ExecutionStep>,
294
295    /// Set when `max_snapshot_bytes` was exceeded. Serialized only when `true`,
296    /// so a complete trace keeps the historical wire shape.
297    #[serde(default, skip_serializing_if = "is_false")]
298    truncated: bool,
299
300    /// Capture policy. In-memory only — a deserialized trace carries the
301    /// default, which is why `options()` is documented as the policy this trace
302    /// *records* with rather than the one it was recorded with.
303    #[serde(skip)]
304    options: TraceOptions,
305
306    /// Pre-split `options.redact_paths`, so the split cost is paid once.
307    #[serde(skip)]
308    redact_segments: Vec<Vec<String>>,
309
310    /// Approximate accumulated in-memory size of snapshots taken so far.
311    #[serde(skip)]
312    snapshot_bytes: usize,
313}
314
315impl ExecutionTrace {
316    /// Create a new empty execution trace with default capture policy.
317    pub fn new() -> Self {
318        Self::with_options(TraceOptions::default())
319    }
320
321    /// Empty trace that records according to `options`.
322    pub fn with_options(options: TraceOptions) -> Self {
323        Self {
324            steps: Vec::new(),
325            truncated: false,
326            redact_segments: options.redact_segments(),
327            options,
328            snapshot_bytes: 0,
329        }
330    }
331
332    /// The capture policy this trace records with.
333    pub fn options(&self) -> &TraceOptions {
334        &self.options
335    }
336
337    /// Whether [`TraceOptions::max_snapshot_bytes`] was exceeded, and one or
338    /// more executed steps therefore carry `message: None`, missing mapping
339    /// contexts, or both.
340    ///
341    /// The budget is shared across both: with [`TraceOptions::snapshots`] off
342    /// but [`TraceOptions::mapping_contexts`] on, this can still return `true`
343    /// from the mapping-context term alone, even though no step was ever going
344    /// to carry a `message`.
345    pub fn truncated(&self) -> bool {
346        self.truncated
347    }
348
349    /// Add a step to the trace
350    pub fn add_step(&mut self, step: ExecutionStep) {
351        self.steps.push(step);
352    }
353
354    /// Record an executed step under this trace's capture policy.
355    ///
356    /// Owns every policy decision — snapshot scope, pruning clone, audit-trail
357    /// scope, budget check, timing and diff — so the executor's two dispatch
358    /// sites do not duplicate any of it.
359    pub(crate) fn add_executed_step(
360        &mut self,
361        workflow_id: &str,
362        task_id: &str,
363        message: &Message,
364        started_at: DateTime<Utc>,
365        duration_us: u64,
366        mapping_contexts: Option<Vec<Value>>,
367    ) {
368        let mut step = ExecutionStep {
369            workflow_id: workflow_id.to_string(),
370            task_id: Some(task_id.to_string()),
371            result: StepResult::Executed,
372            message: None,
373            mapping_contexts: None,
374            started_at: Some(started_at),
375            duration_us: Some(duration_us),
376            changes: if self.options.changes {
377                // Derived from this task's own audit entry rather than
378                // `audit_trail.last()` unconditionally — that is the
379                // mis-attribution being fixed. A `TaskOutcome::Skip` records no
380                // entry, so it correctly reports an empty diff instead of
381                // inheriting the previous task's (or another workflow's).
382                Some(
383                    own_audit_entry(message, workflow_id, task_id)
384                        .map(|e| e.changes.clone())
385                        .unwrap_or_default(),
386                )
387            } else {
388                None
389            },
390        };
391
392        if self.options.snapshots {
393            // Size is *probed* without cloning, so a snapshot that would not fit
394            // is never built. Checking after the clone would be pointless: the
395            // peak memory this budget exists to bound has already been paid.
396            let projected = self.projected_snapshot_size(message, workflow_id, task_id);
397            if self.would_exceed(projected) {
398                self.truncated = true;
399            } else {
400                self.snapshot_bytes += projected;
401                step.message = Some(self.build_snapshot(message, workflow_id, task_id));
402            }
403        }
404
405        if self.options.mapping_contexts {
406            if let Some(mut contexts) = mapping_contexts {
407                // These arrive already cloned by the map function, so redact
408                // first and then decide whether to retain them.
409                for ctx in &mut contexts {
410                    redact_json_in_place(ctx, &self.redact_segments);
411                }
412                let size: usize = contexts.iter().map(approx_json_size).sum();
413                if self.would_exceed(size) {
414                    self.truncated = true;
415                } else {
416                    self.snapshot_bytes += size;
417                    step.mapping_contexts = Some(contexts);
418                }
419            }
420        }
421
422        self.steps.push(step);
423    }
424
425    /// Whether retaining `additional` bytes would cross a finite budget.
426    #[inline]
427    fn would_exceed(&self, additional: usize) -> bool {
428        self.options.max_snapshot_bytes != 0
429            && self.snapshot_bytes + additional > self.options.max_snapshot_bytes
430    }
431
432    /// Approximate size the snapshot for this step would occupy, computed
433    /// without cloning anything.
434    ///
435    /// Must agree with what [`Self::build_snapshot`] actually retains; a unit
436    /// test pins the two together.
437    fn projected_snapshot_size(
438        &self,
439        message: &Message,
440        workflow_id: &str,
441        task_id: &str,
442    ) -> usize {
443        let mut size = redacted_size(&message.context, &self.redact_segments);
444        for entry in self.scoped_audit_trail(message, workflow_id, task_id) {
445            size += NODE_SIZE;
446            for change in &entry.changes {
447                size += change.path.len()
448                    + approx_owned_size(&change.old_value)
449                    + approx_owned_size(&change.new_value);
450            }
451        }
452        size
453    }
454
455    /// The audit entries this step's snapshot will carry, per
456    /// [`TraceOptions::snapshot_audit_trail`].
457    fn scoped_audit_trail<'m>(
458        &self,
459        message: &'m Message,
460        workflow_id: &str,
461        task_id: &str,
462    ) -> Vec<&'m AuditTrail> {
463        match self.options.snapshot_audit_trail {
464            AuditTrailScope::Full => message.audit_trail.iter().collect(),
465            AuditTrailScope::Own => own_audit_entry(message, workflow_id, task_id)
466                .map(|e| vec![e])
467                .unwrap_or_default(),
468            AuditTrailScope::None => Vec::new(),
469        }
470    }
471
472    /// Build this step's snapshot: context pruned per `redact_paths`, audit
473    /// trail scoped per `snapshot_audit_trail`. Returns the approximate
474    /// in-memory size charged to the budget.
475    ///
476    /// `payload` is an `Arc`, so it is shared rather than deep-cloned here —
477    /// same as the derived `Message::clone` this replaces.
478    fn build_snapshot(&self, message: &Message, workflow_id: &str, task_id: &str) -> Message {
479        let (context, _) = redacting_clone(&message.context, &self.redact_segments);
480        let audit_trail: Vec<AuditTrail> = self
481            .scoped_audit_trail(message, workflow_id, task_id)
482            .into_iter()
483            .cloned()
484            .collect();
485
486        Message {
487            id: message.id.clone(),
488            payload: Arc::clone(&message.payload),
489            context,
490            audit_trail,
491            errors: message.errors.clone(),
492            capture_changes: message.capture_changes,
493            routing_bucket: message.routing_bucket,
494        }
495    }
496
497    /// Get the final message (from the last executed step)
498    ///
499    /// Returns `None` when [`TraceOptions::snapshots`] is off, or when every
500    /// executed step was recorded after the snapshot budget was exceeded.
501    pub fn final_message(&self) -> Option<&Message> {
502        self.steps
503            .iter()
504            .rev()
505            .find(|s| s.result == StepResult::Executed)
506            .and_then(|s| s.message.as_ref())
507    }
508
509    /// Check if execution was successful (no errors in final message)
510    ///
511    /// Degenerates to `true` when there is no snapshot to inspect — with
512    /// [`TraceOptions::snapshots`] off, read `Message::errors` on the message you
513    /// passed in instead.
514    pub fn is_success(&self) -> bool {
515        self.final_message()
516            .map(|m| m.errors.is_empty())
517            .unwrap_or(true)
518    }
519
520    /// Get number of executed steps
521    pub fn executed_count(&self) -> usize {
522        self.steps
523            .iter()
524            .filter(|s| s.result == StepResult::Executed)
525            .count()
526    }
527
528    /// Get number of skipped steps
529    pub fn skipped_count(&self) -> usize {
530        self.steps
531            .iter()
532            .filter(|s| s.result == StepResult::Skipped)
533            .count()
534    }
535}
536
537impl Default for ExecutionTrace {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543/// The audit entry this task produced, if any.
544///
545/// The executor pushes at most one entry per task and pushes it immediately, so
546/// the last entry is this task's exactly when both its workflow and task id
547/// match. A `TaskOutcome::Skip` records none — which is why reading
548/// `audit_trail.last()` unconditionally mis-attributes the previous task's diff
549/// to a skipped step. Comparing `task_id` alone is not enough either: two
550/// workflows can share a task id (or the same task can run again in a later
551/// workflow), so a `Skip` right after a same-named task in a different
552/// workflow would otherwise inherit that other workflow's entry.
553#[inline]
554fn own_audit_entry<'m>(
555    message: &'m Message,
556    workflow_id: &str,
557    task_id: &str,
558) -> Option<&'m AuditTrail> {
559    match message.audit_trail.last() {
560        Some(entry)
561            if entry.task_id.as_ref() == task_id && entry.workflow_id.as_ref() == workflow_id =>
562        {
563            Some(entry)
564        }
565        _ => None,
566    }
567}
568
569/// Microseconds between two non-monotonic clock reads, clamped at `0`.
570///
571/// `Utc::now()` can step backwards, and `num_microseconds()` returns `None` on
572/// overflow; both collapse to `0` rather than panicking or wrapping.
573#[inline]
574pub(crate) fn duration_us_between(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
575    (end - start)
576        .num_microseconds()
577        .unwrap_or(0)
578        .max(0)
579        .try_into()
580        .unwrap_or(0)
581}
582
583/// Deep-clone `value`, substituting `OwnedDataValue::Null` for every subtree
584/// named by `paths`, and return the approximate in-memory size of the result.
585///
586/// `paths` holds the path suffixes still in play at this node; an empty suffix
587/// means this node is itself a redaction target, so its subtree is never
588/// cloned. A suffix that matches nothing is dropped, which is what makes an
589/// unresolvable path a no-op rather than a `set_nested_value`-style create.
590fn redacting_clone(value: &OwnedDataValue, paths: &[Vec<String>]) -> (OwnedDataValue, usize) {
591    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
592    redacting_clone_inner(value, &refs)
593}
594
595/// Narrow `paths` to the child suffixes that still apply under object key
596/// `key` — the entries whose head segment (after the `#`-prefix escape)
597/// matches `key`, each with that head segment dropped. Shared by every
598/// redact/size walker below so the "does this path element apply here"
599/// filter has one definition instead of one copy per walker per value type.
600fn narrow_for_object_key<'a>(paths: &[&'a [String]], key: &str) -> Vec<&'a [String]> {
601    paths
602        .iter()
603        .filter(|p| strip_hash_prefix(&p[0]) == key)
604        .map(|p| &p[1..])
605        .collect()
606}
607
608/// Same as [`narrow_for_object_key`] but for an array index: the entries
609/// whose head segment parses as `idx`.
610fn narrow_for_array_index<'a>(paths: &[&'a [String]], idx: usize) -> Vec<&'a [String]> {
611    paths
612        .iter()
613        .filter(|p| p[0].parse::<usize>() == Ok(idx))
614        .map(|p| &p[1..])
615        .collect()
616}
617
618fn redacting_clone_inner(value: &OwnedDataValue, paths: &[&[String]]) -> (OwnedDataValue, usize) {
619    // An exhausted suffix names this node: redact without descending.
620    if paths.iter().any(|p| p.is_empty()) {
621        return (OwnedDataValue::Null, NODE_SIZE);
622    }
623
624    match value {
625        OwnedDataValue::Object(pairs) => {
626            let mut out = Vec::with_capacity(pairs.len());
627            let mut size = NODE_SIZE;
628            for (key, child) in pairs {
629                let sub = narrow_for_object_key(paths, key);
630                let (cloned, child_size) = redacting_clone_inner(child, &sub);
631                size += key.len() + child_size;
632                out.push((key.clone(), cloned));
633            }
634            (OwnedDataValue::Object(out), size)
635        }
636        OwnedDataValue::Array(items) => {
637            let mut out = Vec::with_capacity(items.len());
638            let mut size = NODE_SIZE;
639            for (idx, child) in items.iter().enumerate() {
640                let sub = narrow_for_array_index(paths, idx);
641                let (cloned, child_size) = redacting_clone_inner(child, &sub);
642                size += child_size;
643                out.push(cloned);
644            }
645            (OwnedDataValue::Array(out), size)
646        }
647        // Scalars: any leftover suffix cannot resolve, so it is dropped.
648        OwnedDataValue::String(s) => (value.clone(), NODE_SIZE + s.len()),
649        other => (other.clone(), NODE_SIZE),
650    }
651}
652
653/// Size [`redacting_clone`] would produce, computed without cloning.
654///
655/// Exists so the snapshot budget can decline a capture *before* paying for it.
656/// Must stay in step with `redacting_clone`; `redacted_size_agrees_with_redacting_clone`
657/// pins that.
658fn redacted_size(value: &OwnedDataValue, paths: &[Vec<String>]) -> usize {
659    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
660    redacted_size_inner(value, &refs)
661}
662
663fn redacted_size_inner(value: &OwnedDataValue, paths: &[&[String]]) -> usize {
664    if paths.iter().any(|p| p.is_empty()) {
665        return NODE_SIZE;
666    }
667    match value {
668        OwnedDataValue::Object(pairs) => {
669            let mut size = NODE_SIZE;
670            for (key, child) in pairs {
671                let sub = narrow_for_object_key(paths, key);
672                size += key.len() + redacted_size_inner(child, &sub);
673            }
674            size
675        }
676        OwnedDataValue::Array(items) => {
677            let mut size = NODE_SIZE;
678            for (idx, child) in items.iter().enumerate() {
679                let sub = narrow_for_array_index(paths, idx);
680                size += redacted_size_inner(child, &sub);
681            }
682            size
683        }
684        OwnedDataValue::String(s) => NODE_SIZE + s.len(),
685        _ => NODE_SIZE,
686    }
687}
688
689/// Approximate in-memory size of an `OwnedDataValue`, on the same scale as
690/// [`redacting_clone`].
691fn approx_owned_size(value: &OwnedDataValue) -> usize {
692    match value {
693        OwnedDataValue::Object(pairs) => {
694            NODE_SIZE
695                + pairs
696                    .iter()
697                    .map(|(k, v)| k.len() + approx_owned_size(v))
698                    .sum::<usize>()
699        }
700        OwnedDataValue::Array(items) => {
701            NODE_SIZE + items.iter().map(approx_owned_size).sum::<usize>()
702        }
703        OwnedDataValue::String(s) => NODE_SIZE + s.len(),
704        _ => NODE_SIZE,
705    }
706}
707
708/// Null out every subtree named by `paths` in a `serde_json::Value`, in place.
709///
710/// Mirrors [`redacting_clone`]'s path semantics so `mapping_contexts` are
711/// redacted the same way message snapshots are. In place because the caller
712/// already owns the value — there is no second clone to save.
713fn redact_json_in_place(value: &mut Value, paths: &[Vec<String>]) {
714    let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
715    redact_json_inner(value, &refs);
716}
717
718fn redact_json_inner(value: &mut Value, paths: &[&[String]]) {
719    if paths.is_empty() {
720        return;
721    }
722    if paths.iter().any(|p| p.is_empty()) {
723        *value = Value::Null;
724        return;
725    }
726
727    match value {
728        Value::Object(map) => {
729            for (key, child) in map.iter_mut() {
730                let sub = narrow_for_object_key(paths, key);
731                redact_json_inner(child, &sub);
732            }
733        }
734        Value::Array(items) => {
735            for (idx, child) in items.iter_mut().enumerate() {
736                let sub = narrow_for_array_index(paths, idx);
737                redact_json_inner(child, &sub);
738            }
739        }
740        _ => {}
741    }
742}
743
744/// Approximate in-memory size of a `serde_json::Value`, on the same scale as
745/// [`approx_owned_size`].
746fn approx_json_size(value: &Value) -> usize {
747    match value {
748        Value::Object(map) => {
749            NODE_SIZE
750                + map
751                    .iter()
752                    .map(|(k, v)| k.len() + approx_json_size(v))
753                    .sum::<usize>()
754        }
755        Value::Array(items) => NODE_SIZE + items.iter().map(approx_json_size).sum::<usize>(),
756        Value::String(s) => NODE_SIZE + s.len(),
757        _ => NODE_SIZE,
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use serde_json::json;
765
766    fn dv(v: serde_json::Value) -> OwnedDataValue {
767        OwnedDataValue::from(&v)
768    }
769
770    fn segments(paths: &[&str]) -> Vec<Vec<String>> {
771        TraceOptions {
772            redact_paths: paths.iter().map(|s| s.to_string()).collect(),
773            ..Default::default()
774        }
775        .redact_segments()
776    }
777
778    #[test]
779    fn test_step_result_serialization() {
780        assert_eq!(
781            serde_json::to_string(&StepResult::Executed).unwrap(),
782            "\"executed\""
783        );
784        assert_eq!(
785            serde_json::to_string(&StepResult::Skipped).unwrap(),
786            "\"skipped\""
787        );
788    }
789
790    #[test]
791    fn test_execution_step_executed() {
792        let message = Message::from_value(&json!({"test": "data"}));
793        let step = ExecutionStep::executed("workflow1", "task1", &message);
794
795        assert_eq!(step.workflow_id, "workflow1");
796        assert_eq!(step.task_id, Some("task1".to_string()));
797        assert_eq!(step.result, StepResult::Executed);
798        assert!(step.message.is_some());
799    }
800
801    #[test]
802    fn test_execution_step_task_skipped() {
803        let step = ExecutionStep::task_skipped("workflow1", "task1");
804
805        assert_eq!(step.workflow_id, "workflow1");
806        assert_eq!(step.task_id, Some("task1".to_string()));
807        assert_eq!(step.result, StepResult::Skipped);
808        assert!(step.message.is_none());
809    }
810
811    #[test]
812    fn test_execution_step_workflow_skipped() {
813        let step = ExecutionStep::workflow_skipped("workflow1");
814
815        assert_eq!(step.workflow_id, "workflow1");
816        assert_eq!(step.task_id, None);
817        assert_eq!(step.result, StepResult::Skipped);
818        assert!(step.message.is_none());
819    }
820
821    #[test]
822    fn test_execution_step_with_mapping_contexts() {
823        let message = Message::from_value(&json!({"test": "data"}));
824        let contexts = vec![json!({"data": {"a": 1}}), json!({"data": {"a": 1, "b": 2}})];
825
826        let step = ExecutionStep::executed("workflow1", "task1", &message)
827            .with_mapping_contexts(contexts.clone());
828
829        assert_eq!(step.mapping_contexts, Some(contexts));
830
831        // Verify serialization includes mapping_contexts
832        let serialized = serde_json::to_value(&step).unwrap();
833        assert!(serialized.get("mapping_contexts").is_some());
834        assert_eq!(serialized["mapping_contexts"].as_array().unwrap().len(), 2);
835    }
836
837    #[test]
838    fn test_execution_step_without_mapping_contexts_serialization() {
839        let message = Message::from_value(&json!({"test": "data"}));
840        let step = ExecutionStep::executed("workflow1", "task1", &message);
841
842        // Every optional field is None, so all are omitted in serialization.
843        let serialized = serde_json::to_value(&step).unwrap();
844        assert!(serialized.get("mapping_contexts").is_none());
845        assert!(serialized.get("started_at").is_none());
846        assert!(serialized.get("duration_us").is_none());
847        assert!(serialized.get("changes").is_none());
848    }
849
850    #[test]
851    fn test_execution_trace() {
852        let mut trace = ExecutionTrace::new();
853        let message = Message::from_value(&json!({"test": "data"}));
854
855        trace.add_step(ExecutionStep::workflow_skipped("workflow0"));
856        trace.add_step(ExecutionStep::executed("workflow1", "task1", &message));
857        trace.add_step(ExecutionStep::task_skipped("workflow1", "task2"));
858
859        assert_eq!(trace.steps.len(), 3);
860        assert_eq!(trace.executed_count(), 1);
861        assert_eq!(trace.skipped_count(), 2);
862        assert!(trace.final_message().is_some());
863        assert!(trace.is_success());
864    }
865
866    #[test]
867    fn default_options_reproduce_historical_capture() {
868        let o = TraceOptions::default();
869        assert!(o.snapshots);
870        assert!(o.mapping_contexts);
871        assert!(!o.changes);
872        assert_eq!(o.max_snapshot_bytes, 0);
873        assert!(o.redact_paths.is_empty());
874        assert_eq!(o.snapshot_audit_trail, AuditTrailScope::Full);
875    }
876
877    #[test]
878    fn timings_only_drops_snapshots_and_keeps_the_diff() {
879        let o = TraceOptions::timings_only();
880        assert!(!o.snapshots);
881        assert!(!o.mapping_contexts);
882        assert!(o.changes);
883    }
884
885    #[test]
886    fn a_complete_trace_does_not_serialize_the_truncated_flag() {
887        let trace = ExecutionTrace::new();
888        let serialized = serde_json::to_value(&trace).unwrap();
889        assert!(
890            serialized.get("truncated").is_none(),
891            "a complete trace keeps the historical wire shape"
892        );
893        assert!(!trace.truncated());
894    }
895
896    #[test]
897    fn a_trace_deserializes_from_a_payload_without_the_truncated_flag() {
898        let trace: ExecutionTrace = serde_json::from_value(json!({ "steps": [] })).unwrap();
899        assert!(!trace.truncated());
900    }
901
902    #[test]
903    fn duration_clamps_a_backward_clock_step_to_zero() {
904        let start = Utc::now();
905        let earlier = start - chrono::Duration::seconds(5);
906        assert_eq!(duration_us_between(start, earlier), 0);
907        assert_eq!(duration_us_between(start, start), 0);
908        assert_eq!(
909            duration_us_between(start, start + chrono::Duration::microseconds(1500)),
910            1500
911        );
912    }
913
914    #[test]
915    fn redaction_nulls_only_the_named_subtree() {
916        let ctx = dv(json!({"data": {"secret": {"k": "v"}, "keep": 1}}));
917        let (out, _) = redacting_clone(&ctx, &segments(&["data.secret"]));
918        assert_eq!(
919            serde_json::Value::from(&out),
920            json!({"data": {"secret": null, "keep": 1}})
921        );
922    }
923
924    #[test]
925    fn redaction_of_an_unresolvable_path_creates_nothing() {
926        // `set_nested_value` would pad the array to index 99 with nulls; this
927        // must not.
928        let ctx = dv(json!({"data": {"items": [1, 2, 3]}}));
929        let (out, _) = redacting_clone(&ctx, &segments(&["data.items.99"]));
930        assert_eq!(
931            serde_json::Value::from(&out),
932            json!({"data": {"items": [1, 2, 3]}})
933        );
934    }
935
936    #[test]
937    fn redaction_through_a_non_container_is_a_noop() {
938        let ctx = dv(json!({"data": {"name": "alice"}}));
939        let (out, _) = redacting_clone(&ctx, &segments(&["data.name.first"]));
940        assert_eq!(
941            serde_json::Value::from(&out),
942            json!({"data": {"name": "alice"}})
943        );
944    }
945
946    #[test]
947    fn an_empty_redact_path_is_ignored() {
948        let ctx = dv(json!({"data": {"a": 1}}));
949        let (out, _) = redacting_clone(&ctx, &segments(&[""]));
950        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": 1}}));
951    }
952
953    #[test]
954    fn redaction_honours_the_hash_escape() {
955        // `data.#20` names the object key "20"; `data.20` indexes an array.
956        let obj = dv(json!({"data": {"20": "secret", "other": 1}}));
957        let (out, _) = redacting_clone(&obj, &segments(&["data.#20"]));
958        assert_eq!(
959            serde_json::Value::from(&out),
960            json!({"data": {"20": null, "other": 1}})
961        );
962
963        let arr = dv(json!({"data": [0, 1, 2]}));
964        let (out, _) = redacting_clone(&arr, &segments(&["data.1"]));
965        assert_eq!(serde_json::Value::from(&out), json!({"data": [0, null, 2]}));
966
967        // The escaped form does not index an array.
968        let (out, _) = redacting_clone(&arr, &segments(&["data.#1"]));
969        assert_eq!(serde_json::Value::from(&out), json!({"data": [0, 1, 2]}));
970    }
971
972    #[test]
973    fn nested_and_duplicated_redact_paths_are_safe() {
974        let ctx = dv(json!({"data": {"a": {"b": 1, "c": 2}}}));
975
976        let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a.b"]));
977        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
978
979        let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a"]));
980        assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
981    }
982
983    #[test]
984    fn redaction_matches_unicode_keys_and_sizes_strings_by_bytes() {
985        let ctx = dv(json!({"data": {"café": "secret", "keep": "née"}}));
986        let (out, size) = redacting_clone(&ctx, &segments(&["data.café"]));
987        assert_eq!(
988            serde_json::Value::from(&out),
989            json!({"data": {"café": null, "keep": "née"}})
990        );
991
992        // "née" is 4 bytes, 3 chars — the budget counts bytes.
993        let (_, unredacted) = redacting_clone(&ctx, &segments(&[]));
994        assert!(unredacted > size, "redacting must lower the counted size");
995        assert!(
996            approx_owned_size(&dv(json!("née"))) == NODE_SIZE + 4,
997            "str::len() bytes, not chars().count()"
998        );
999    }
1000
1001    #[test]
1002    fn redacted_size_agrees_with_redacting_clone() {
1003        // The budget probes with `redacted_size` and then builds with
1004        // `redacting_clone`; if they drift, the budget stops meaning anything.
1005        let shapes = [
1006            json!({}),
1007            json!({"data": {"a": 1, "b": "hello"}}),
1008            json!({"data": {"items": [1, "two", {"three": 3}], "nested": {"x": {"y": "z"}}}}),
1009            json!({"data": {"secret": {"deep": [1, 2, 3]}, "keep": "café"}}),
1010        ];
1011        let path_sets: [&[&str]; 4] = [&[], &["data.secret"], &["data.items.1"], &["data.nope"]];
1012
1013        for shape in &shapes {
1014            for paths in path_sets {
1015                let v = dv(shape.clone());
1016                let segs = segments(paths);
1017                let (_, cloned_size) = redacting_clone(&v, &segs);
1018                assert_eq!(
1019                    redacted_size(&v, &segs),
1020                    cloned_size,
1021                    "probe and clone disagree for {shape:?} with {paths:?}"
1022                );
1023            }
1024        }
1025    }
1026
1027    #[test]
1028    fn redaction_applies_to_mapping_contexts_too() {
1029        let mut ctx = json!({"data": {"secret": {"k": "v"}, "keep": 1}});
1030        redact_json_in_place(&mut ctx, &segments(&["data.secret"]));
1031        assert_eq!(ctx, json!({"data": {"secret": null, "keep": 1}}));
1032    }
1033
1034    #[test]
1035    fn json_redaction_shares_the_owned_path_semantics() {
1036        let mut arr = json!({"data": {"items": [1, 2, 3]}});
1037        redact_json_in_place(&mut arr, &segments(&["data.items.99"]));
1038        assert_eq!(arr, json!({"data": {"items": [1, 2, 3]}}));
1039
1040        let mut scalar = json!({"data": {"name": "alice"}});
1041        redact_json_in_place(&mut scalar, &segments(&["data.name.first"]));
1042        assert_eq!(scalar, json!({"data": {"name": "alice"}}));
1043
1044        let mut hash = json!({"data": {"20": "secret"}});
1045        redact_json_in_place(&mut hash, &segments(&["data.#20"]));
1046        assert_eq!(hash, json!({"data": {"20": null}}));
1047    }
1048}