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}