Skip to main content

dataflow_rs/observe/
trace.rs

1//! The trace: a run's initial context, then one step per
2//! recorded call, skipped step and engine write, each with its changes, so a
3//! replay from `initial` rebuilds the context after every step.
4
5use crate::base::{Change, Context, Error, ErrorKind, ErrorRecord, TaskIx, WorkflowIx};
6use crate::expr::{Path, path};
7use datavalue::OwnedDataValue;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::sync::Arc;
10use std::time::Duration;
11
12/// What a run's trace records. A run is traced when
13/// [`RunOptions::trace`](crate::RunOptions::trace()) is given these.
14///
15/// ```
16/// use dataflow_rs::observe::TraceOptions;
17///
18/// let opts = TraceOptions::default()
19///     .timings(true)
20///     .redact(vec!["data.card".parse().unwrap()])
21///     .max_bytes(1 << 20);
22/// assert!(opts.changes && opts.timings);
23/// ```
24#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct TraceOptions {
27    /// Time each call, into its step's `elapsed`. `false` by default: no
28    /// clock is read for a trace without it.
29    pub timings: bool,
30    /// Record each step's changes. `true` by default. A trace records its
31    /// own changes whatever `RunOptions::capture_changes` says, which governs
32    /// only the audit trail, so turning that off cannot empty a debugger's
33    /// replay. With `false` the steps carry statuses and timings, for
34    /// profiling, and cannot be replayed.
35    pub changes: bool,
36    /// Prefixes whose values read `null` in `initial` and in every change,
37    /// applied as each is captured; the message keeps the real values. A
38    /// change at or below one is recorded as a write of `null` at it, a
39    /// removal's included, so no key below it reaches the trace and a replay
40    /// reads `null` there from the first write on. A path that resolves
41    /// nothing in `initial` creates nothing there. Empty by default.
42    ///
43    /// It masks values at those paths and nothing else: a step's `errors`,
44    /// like a value derived from a redacted one and written elsewhere, are
45    /// carried as the message keeps them, so a record whose message quotes
46    /// a redacted value quotes it in the trace too.
47    pub redact: Vec<Path>,
48    /// A ceiling on what the trace holds, on v3's scale: one machine word
49    /// per node, plus each string's and each key's length, counting
50    /// `initial` and every change. The step that would cross it, and every
51    /// later one, keeps everything but its changes, and
52    /// [`Trace::truncated`] is set. `None`, the default, is unbounded.
53    pub max_bytes: Option<usize>,
54}
55
56impl Default for TraceOptions {
57    fn default() -> Self {
58        Self {
59            timings: false,
60            changes: true,
61            redact: Vec::new(),
62            max_bytes: None,
63        }
64    }
65}
66
67impl TraceOptions {
68    /// Times each call, or not.
69    #[must_use]
70    pub fn timings(mut self, on: bool) -> Self {
71        self.timings = on;
72        self
73    }
74
75    /// Records each step's changes, or not.
76    #[must_use]
77    pub fn changes(mut self, on: bool) -> Self {
78        self.changes = on;
79        self
80    }
81
82    /// Redacts `paths`, as the `redact` field says.
83    #[must_use]
84    pub fn redact(mut self, paths: Vec<Path>) -> Self {
85        self.redact = paths;
86        self
87    }
88
89    /// Bounds the trace at `max` bytes, as the `max_bytes` field says.
90    #[must_use]
91    pub fn max_bytes(mut self, max: usize) -> Self {
92        self.max_bytes = Some(max);
93        self
94    }
95}
96
97/// A run's trace: [`initial`](Self::initial), then each step's changes.
98///
99/// Written as `{initial, steps, truncated}`, and read back from what it
100/// writes: a trace read back writes the text it was read from, and replays
101/// to the original's contexts.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
104#[non_exhaustive]
105pub struct Trace {
106    /// The context after the run's stamps and the error context's reset,
107    /// before any workflow, redacted.
108    pub initial: Context,
109    /// Every step, in the order the run made them.
110    pub steps: Vec<TraceStep>,
111    /// The byte budget was crossed: from the step that crossed it on, every
112    /// step's changes are empty, so the contexts replayed from there on are
113    /// not whole.
114    pub truncated: bool,
115}
116
117impl Trace {
118    /// The context after each step, replaying the steps' changes in order
119    /// from `initial`: a removal removes its path; a change at a root merges
120    /// its object's keys into the root, unless it says
121    /// [`replaced`](Change::replaced); anything else is set at its path as
122    /// [`Path::set`] sets it, except that a root takes any value whole (a
123    /// redacted root's `null`, which `Path::set` refuses, included): a hop
124    /// that cannot continue writes nothing, and a segment that is an index
125    /// on an array names its element. A replay elsewhere, such as the UI's,
126    /// follows the same rules. Each context yielded is a whole copy.
127    pub fn contexts(&self) -> impl Iterator<Item = Context> + '_ {
128        let mut root = self.initial.as_value().clone();
129        self.steps.iter().map(move |step| {
130            for change in &step.changes {
131                apply(&mut root, change);
132            }
133            Context::from_root(root.clone())
134        })
135    }
136
137    /// The context after each mapping of the `map` step at `step`, replaying
138    /// `initial` through the steps before it and then the step's changes up
139    /// to each of its [`mapping_ends`](TraceStep::mapping_ends), by
140    /// [`contexts`](Self::contexts)'s rules: one context per mapping, in
141    /// order, a mapping that wrote nothing repeating the one before it. The
142    /// last is the context after the last mapping and before the step's own
143    /// trailing changes (the progress write, and the error context's), so it
144    /// differs from the context [`contexts`](Self::contexts) yields for the
145    /// step by those alone. `None` when `step` is past the last step, when
146    /// the step has no `mapping_ends`, or when an end exceeds the step's
147    /// `changes` or precedes the end before it, as a trace edited or read
148    /// from another build may have.
149    pub fn mapping_contexts(&self, step: usize) -> Option<Vec<Context>> {
150        let at = self.steps.get(step)?;
151        if at.mapping_ends.is_empty() || !valid_mapping_ends(at) {
152            return None;
153        }
154        let mut root = self.initial.as_value().clone();
155        for earlier in &self.steps[..step] {
156            for change in &earlier.changes {
157                apply(&mut root, change);
158            }
159        }
160        let mut out = Vec::with_capacity(at.mapping_ends.len());
161        let mut from = 0usize;
162        for &end in &at.mapping_ends {
163            let end = end as usize;
164            for change in &at.changes[from..end] {
165                apply(&mut root, change);
166            }
167            out.push(Context::from_root(root.clone()));
168            from = end;
169        }
170        Some(out)
171    }
172
173    /// Checks what a replay needs and a trace read from elsewhere may lack:
174    /// every step's [`mapping_ends`](TraceStep::mapping_ends) fits its
175    /// changes, every change's path parses, and no change leaves a root
176    /// anything but an object, or the `null` a redaction writes.
177    ///
178    /// [`contexts`](Self::contexts) and [`mapping_contexts`](Self::mapping_contexts)
179    /// trust their trace: a change that does not parse is skipped and a
180    /// root written whole stands, as the splice and `Ctx::set` allow. A host
181    /// that reads a trace from a caller (the wasm bindings do) calls this
182    /// first and reports a failure as `INVALID_TRACE`.
183    ///
184    /// # Errors
185    ///
186    /// [`ErrorKind::Config`] naming the step and what is wrong.
187    pub fn validate(&self) -> crate::base::Result<()> {
188        match self.initial.as_value() {
189            OwnedDataValue::Object(roots) => {
190                for (root, value) in roots {
191                    check_root(root, value)?;
192                }
193            }
194            _ => {
195                return Err(Error::new(
196                    ErrorKind::Config,
197                    "a trace's initial context is not an object of roots",
198                ));
199            }
200        }
201        for (i, step) in self.steps.iter().enumerate() {
202            if !valid_mapping_ends(step) {
203                return Err(Error::new(
204                    ErrorKind::Config,
205                    format!(
206                        "step {i}'s mapping_ends {:?} do not fit its {} change(s)",
207                        step.mapping_ends,
208                        step.changes.len()
209                    ),
210                ));
211            }
212            for change in &step.changes {
213                let at = change.path.parse::<Path>().map_err(|_| {
214                    Error::new(
215                        ErrorKind::Config,
216                        format!(
217                            "step {i}'s change path {:?} is not a context path",
218                            change.path
219                        ),
220                    )
221                })?;
222                if !at.is_root() {
223                    continue;
224                }
225                if change.removed {
226                    return Err(Error::new(
227                        ErrorKind::Config,
228                        format!(
229                            "step {i} removes the root `{}`, which a context always keeps",
230                            change.path
231                        ),
232                    ));
233                }
234                check_root(&change.path, &change.new_value)?;
235            }
236        }
237        Ok(())
238    }
239}
240
241/// Whether every end in `step.mapping_ends` is non-decreasing and fits
242/// within `step.changes`.
243pub(super) fn valid_mapping_ends(step: &TraceStep) -> bool {
244    let mut before = 0u32;
245    for &end in &step.mapping_ends {
246        if end < before || usize::try_from(end).map_or(true, |e| e > step.changes.len()) {
247            return false;
248        }
249        before = end;
250    }
251    true
252}
253
254/// Checks a context root's value: an object, or the `null` a redaction
255/// writes. `Path::set` refuses anything else at a root, so a trace holding
256/// one did not come from a run.
257fn check_root(name: &str, value: &OwnedDataValue) -> crate::base::Result<()> {
258    if matches!(value, OwnedDataValue::Object(_) | OwnedDataValue::Null) {
259        return Ok(());
260    }
261    Err(Error::new(
262        ErrorKind::Config,
263        format!("`{name}` is a context root and holds neither an object nor null"),
264    ))
265}
266
267/// One change applied to `root`, the object holding the three roots, by
268/// [`Trace::contexts`]'s rule.
269pub(super) fn apply(root: &mut OwnedDataValue, change: &Change) {
270    let Ok(at) = change.path.parse::<Path>() else {
271        return;
272    };
273    if change.removed {
274        path::remove(root, at.segs());
275        return;
276    }
277    if at.is_root() && !change.replaced {
278        // `map`'s merge, as the splice does it: a key there is replaced
279        // where it is, a new one appended; a non-object replaces the slot.
280        path::merge_root(root, at.root().as_str(), change.new_value.clone());
281        return;
282    }
283    at.assign(root, change.new_value.clone());
284}
285
286/// What a step was. Serialized, and read, as its lowercase name.
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
289#[serde(rename_all = "lowercase")]
290#[non_exhaustive]
291pub enum StepResult {
292    /// A call ran, or the engine wrote, and the run went on: a failure its
293    /// task's `on_failure: continue` or `halt` stepped past included, with
294    /// its status and its records.
295    Executed,
296    /// A condition or a rollout refused the task, the group's tasks or the
297    /// workflow.
298    Skipped,
299    /// The failure went to the workflow's policy, or stopped the run: the
300    /// step v3 dropped. A workflow's own failure is one too.
301    Failed,
302}
303
304/// One step of a [`Trace`].
305///
306/// Written with its keys in this order. `mapping_ends` and `errors` are
307/// absent when empty, and `elapsed_us` (in whole microseconds),
308/// `loop_counter` and `element_index` when there is none.
309/// Read back from what it writes: `elapsed_us` as the whole microseconds
310/// written, and an absent `mapping_ends` or `errors` as empty.
311#[derive(Clone, Debug, Serialize, Deserialize)]
312#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
313#[non_exhaustive]
314pub struct TraceStep {
315    /// The workflow's position in the program.
316    pub workflow: WorkflowIx,
317    /// The workflow's id.
318    pub workflow_id: Arc<str>,
319    /// The task's position in the program; `null` for the workflow's own
320    /// step: its skip, its failure, or the engine's writes as a sweep is
321    /// entered.
322    pub task: Option<TaskIx>,
323    /// The task's id; `null` when `task` is.
324    pub task_id: Option<Arc<str>>,
325    /// What the step was.
326    pub result: StepResult,
327    /// The call's status; `null` for a skip, a call whose outcome was a
328    /// skip, and the engine's step.
329    pub status: Option<u16>,
330    /// The step's changes in order: the engine's writes made for it first (a
331    /// fan-out's `into` pre-fill), then the call's own, then the progress
332    /// write, then the error context's. Empty past the byte budget and when
333    /// the trace records none.
334    pub changes: Vec<Change>,
335    /// Where each of a `map`'s mappings ends in `changes`: one entry per
336    /// mapping, in order, the index just past its last change, so mapping
337    /// `i`'s changes run from the end before it (0 for the first) up to its
338    /// own. A mapping that wrote nothing repeats the end before it.
339    /// Replaying the changes before mapping `i`'s end gives the context
340    /// after mapping `i`. Empty for every other step, and whenever `changes`
341    /// is.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub mapping_ends: Vec<u32>,
344    /// Every record the step added to the message, in the message's order:
345    /// the call's own, then the engine's; for a workflow's own failure, its
346    /// record. A workflow whose condition failed to evaluate adds none to
347    /// the message, and its `skipped` step holds that failure's record.
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub errors: Vec<ErrorRecord>,
350    /// How long the call ran, written in whole microseconds, when the trace
351    /// asked for timings.
352    #[serde(
353        rename = "elapsed_us",
354        default,
355        serialize_with = "micros",
356        deserialize_with = "from_micros",
357        skip_serializing_if = "Option::is_none"
358    )]
359    #[cfg_attr(feature = "ts", ts(type = "number", optional))]
360    pub elapsed: Option<Duration>,
361    /// The loop counter of the sweep: the call's, or the one the engine's
362    /// step wrote.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    #[cfg_attr(feature = "ts", ts(optional))]
365    pub loop_counter: Option<i64>,
366    /// The fan-out element the call ran for.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    #[cfg_attr(feature = "ts", ts(optional))]
369    pub element_index: Option<usize>,
370}
371
372impl TraceStep {
373    /// A step of workflow `workflow`, `id`, with nothing set: the workflow's
374    /// own until its task is set.
375    pub(crate) fn new(workflow: WorkflowIx, id: &Arc<str>, result: StepResult) -> Self {
376        Self {
377            workflow,
378            workflow_id: Arc::clone(id),
379            task: None,
380            task_id: None,
381            result,
382            status: None,
383            changes: Vec::new(),
384            mapping_ends: Vec::new(),
385            errors: Vec::new(),
386            elapsed: None,
387            loop_counter: None,
388            element_index: None,
389        }
390    }
391}
392
393/// `elapsed` as whole microseconds.
394fn micros<S: Serializer>(elapsed: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
395    match elapsed {
396        Some(d) => s.serialize_u64(u64::try_from(d.as_micros()).unwrap_or(u64::MAX)),
397        None => s.serialize_none(),
398    }
399}
400
401/// `elapsed` from whole microseconds, as [`micros`] writes it.
402fn from_micros<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
403    Ok(Option::<u64>::deserialize(d)?.map(Duration::from_micros))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use serde_json::json;
410
411    fn value(v: serde_json::Value) -> OwnedDataValue {
412        OwnedDataValue::from(&v)
413    }
414
415    fn write(path: &str, old: serde_json::Value, new: serde_json::Value) -> Change {
416        Change::write(path, value(old), value(new))
417    }
418
419    /// `initial` with an empty `data`, one earlier step writing `data.z`,
420    /// then a `map` step of four mappings: a plain write, a skipped `null`,
421    /// an `append` creating its array, and a root merge of two keys, ending
422    /// at `[1, 1, 3, 4]`, followed by the recorder's progress write, which no
423    /// mapping end covers.
424    fn trace() -> Trace {
425        let id: Arc<str> = Arc::from("w");
426        let mut earlier = TraceStep::new(WorkflowIx::new(0), &id, StepResult::Executed);
427        earlier.changes = vec![write("data.z", json!(null), json!(0))];
428        let mut map = TraceStep::new(WorkflowIx::new(0), &id, StepResult::Executed);
429        map.changes = vec![
430            write("data.a", json!(null), json!(1)),
431            write("data.list.0", json!(null), json!("x")),
432            write("data.list.1", json!(null), json!("y")),
433            write("data", json!({}), json!({"b": 2, "c": 3})),
434            write(
435                "metadata.progress",
436                json!(null),
437                json!({"workflow_id": "w", "task_id": "m", "status_code": 200}),
438            ),
439        ];
440        map.mapping_ends = vec![1, 1, 3, 4];
441        Trace {
442            initial: Context::from_root(value(json!({
443                "data": {}, "metadata": {}, "temp_data": {}
444            }))),
445            steps: vec![earlier, map],
446            truncated: false,
447        }
448    }
449
450    #[test]
451    fn a_map_step_replays_mapping_by_mapping_from_the_steps_before_it() {
452        let trace = trace();
453        let contexts = trace.mapping_contexts(1).expect("a map step");
454        let data: Vec<serde_json::Value> = contexts
455            .iter()
456            .map(|c| serde_json::to_value(c.data()).unwrap())
457            .collect();
458        assert_eq!(
459            data,
460            vec![
461                json!({"z": 0, "a": 1}),
462                json!({"z": 0, "a": 1}),
463                json!({"z": 0, "a": 1, "list": ["x", "y"]}),
464                json!({"z": 0, "a": 1, "list": ["x", "y"], "b": 2, "c": 3}),
465            ]
466        );
467        // The last mapping context is the step's context without the
468        // trailing progress write, which `contexts` includes.
469        let after = trace.contexts().nth(1).unwrap();
470        let last = contexts.last().unwrap();
471        assert_eq!(last.data(), after.data());
472        assert!(last.metadata().as_object().is_some_and(|m| m.is_empty()));
473        assert_eq!(
474            serde_json::to_value(after.metadata()).unwrap()["progress"]["status_code"],
475            json!(200)
476        );
477    }
478
479    #[test]
480    fn a_step_without_mapping_ends_and_one_out_of_range_give_none() {
481        let trace = trace();
482        assert!(trace.mapping_contexts(0).is_none());
483        assert!(trace.mapping_contexts(2).is_none());
484    }
485
486    #[test]
487    fn a_malformed_mapping_end_gives_none() {
488        let mut past = trace();
489        past.steps[1].mapping_ends = vec![1, 6];
490        assert!(past.mapping_contexts(1).is_none());
491        let mut backwards = trace();
492        backwards.steps[1].mapping_ends = vec![3, 1];
493        assert!(backwards.mapping_contexts(1).is_none());
494    }
495
496    #[test]
497    fn validate_refuses_what_a_replay_cannot_stand() {
498        assert!(trace().validate().is_ok());
499
500        // A root takes only an object in a run; a scalar or an array would
501        // make the replayed context one no run produced.
502        let mut scalar = trace();
503        scalar.steps[1]
504            .changes
505            .push(Change::write("metadata", value(json!({})), value(json!(5))));
506        assert!(scalar.validate().is_err());
507
508        // A root cannot be removed, and `Path::set` says so.
509        let mut removed = trace();
510        removed.steps[0].changes = vec![Change::removal("data", value(json!({})))];
511        assert!(removed.validate().is_err());
512
513        // An unparsable path is refused, not silently skipped.
514        let mut bad_path = trace();
515        bad_path.steps[0].changes = vec![Change::write(
516            "outside",
517            value(json!(null)),
518            value(json!(1)),
519        )];
520        assert!(bad_path.validate().is_err());
521
522        // The initial context's roots are checked too.
523        let mut bad_initial = trace();
524        bad_initial.initial = Context::from_root(value(json!({
525            "data": 5, "metadata": {}, "temp_data": {}
526        })));
527        assert!(bad_initial.validate().is_err());
528
529        // A redacted root's `null` is legitimate.
530        let mut redacted = trace();
531        redacted.steps[0].changes =
532            vec![Change::write("data", value(json!({})), value(json!(null)))];
533        assert!(redacted.validate().is_ok());
534    }
535}