Skip to main content

zenkey_fleet/report/
tape.rs

1//! The tape plane (RFC 09 §5.2): the `.zrec` header, the row dialect a
2//! capture is made of, and what a capture or a replay reports afterwards.
3//!
4//! [`ZrecHeader`] — with the two version-2 blocks it may carry,
5//! [`PreambleInfo`] and [`PreRollInfo`] — is read as well as written: a
6//! `.zrec` on disk outlives the process that wrote it, so the header is a
7//! contract in both directions and derives `Deserialize`. (The trigger
8//! record a version-2 file interleaves is [`super::Transition`], the
9//! watchdog's own shape, which reads back for the same reason.)
10
11use serde::{Deserialize, Serialize};
12
13/// The first line of a `.zrec` file: what was asked, under which base, and
14/// when (RFC 09 §5.1 O4 — a capture names its question). The `base` is the
15/// operator's *stated* deployment base at capture time; recorded keys are
16/// full wire keys and are never re-derived from it (O3).
17///
18/// `PartialEq` only, since v1.34: the version-2 blocks carry measured
19/// spans as `f64`.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct ZrecHeader {
22    /// Format version ([`ZREC_VERSION`](crate::tape::record::ZREC_VERSION)).
23    pub zrec: u32,
24    /// The full wire selectors the capture watched. A wildcard selector
25    /// never crosses an `@`-chunk, so a `**` capture excludes the verbatim
26    /// planes by construction (O5) — the reader states that rather than
27    /// letting the file claim "everything".
28    pub selectors: Vec<String>,
29    /// The deployment base the operator resolved at capture time
30    /// (may be empty: the base-less bus-root deployment).
31    pub base: String,
32    /// Capture start, RFC 3339 wall clock — provenance, not a pacing clock
33    /// (pacing rides each row's `t`).
34    pub captured_at: String,
35    /// Version 2 (RFC 13 §4.1, v1.34; #218): the state preamble this
36    /// capture carries — the bounded fetch that produced the `preamble`
37    /// rows and what it could not fetch. Absent on a version-1 file and on
38    /// a capture that asked for none.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub preamble: Option<PreambleInfo>,
41    /// Version 2: the retained window this capture's pre-roll came from —
42    /// what was asked, what the ring could give, and the ring's two
43    /// eviction kinds kept apart (O6). Absent on a live capture.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub pre_roll: Option<PreRollInfo>,
46}
47
48/// What a version-2 preamble is a snapshot *of* (RFC 13 §4.3's pre-roll
49/// bullet): the values at the moment the ring began are not recoverable,
50/// so the honest substitute has to be named rather than implied.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum PreambleSemantics {
54    /// The current state of what the ring cannot show: only keys **absent**
55    /// from the retained window were fetched at trigger time. A key the
56    /// ring holds already has its story in the pre-roll rows.
57    AbsentFromWindow,
58    /// The full current state under the watched selectors at trigger time,
59    /// ring or no ring — every fetched key, so a reader that seeds from the
60    /// preamble alone has the whole base.
61    Full,
62}
63
64/// The state preamble's account of itself (RFC 13 §4.1, version 2).
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct PreambleInfo {
67    /// Preamble rows written — counted apart from observed rows (O6).
68    pub count: u64,
69    /// How long the fan-in GET took: a preamble is collected *over* a span,
70    /// never at an instant (RFC 13 §4.4's obligation, inherited).
71    pub collected_over_s: f64,
72    /// The selectors actually fetched — the state-class projection of the
73    /// watched set, so a watch that reaches no `state` key fetched nothing.
74    pub selectors: Vec<String>,
75    pub semantics: PreambleSemantics,
76    /// Replies the bounded fetch could not keep or could not read: error
77    /// envelopes plus replies elided past the reply bound (O6).
78    #[serde(default, skip_serializing_if = "is_zero")]
79    pub incomplete: u64,
80    /// Watched selectors whose state could not be fetched at all — a GET
81    /// that could not be issued, or a selector that reaches no `state`
82    /// key — named rather than folded into a count (O5).
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub failed: Vec<String>,
85}
86
87/// The retained window's account of itself at trigger time (RFC 13 §4.3's
88/// pre-roll bullet): what `--pre` asked for and what the ring could give.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct PreRollInfo {
91    /// The pre-roll the operator asked for, seconds.
92    pub asked_s: f64,
93    /// The span the ring actually held when the trigger fired — shorter
94    /// than `asked_s` while the ring was still filling, or when the byte
95    /// budget bit (`evicted`).
96    pub covered_s: f64,
97    /// The selectors the ring was fed under: the pre-roll covers these and
98    /// nothing wider (O5).
99    pub watched: Vec<String>,
100    /// Samples the ring dropped because its **byte** budget bit — the
101    /// window is then narrower than `asked_s` claims (O6).
102    #[serde(default, skip_serializing_if = "is_zero")]
103    pub evicted: u64,
104    /// Samples that aged past the pre-roll — the window sliding as
105    /// declared, counted apart from `evicted` (v1.18 R1 forbids the fold).
106    #[serde(default, skip_serializing_if = "is_zero")]
107    pub expired: u64,
108}
109
110fn is_zero(n: &u64) -> bool {
111    *n == 0
112}
113
114/// What a capture did — the shared report shape both frontends render.
115#[derive(Debug, Clone, Serialize)]
116pub struct RecordReport {
117    /// The header as written: a capture names its question (O4).
118    pub header: ZrecHeader,
119    /// Where the capture went, when it went to a file.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub out: Option<String>,
122    /// Samples written.
123    pub samples: u64,
124    /// Samples the capture missed while behind — stored in the file as
125    /// interleaved drop records *and* totalled here (O6).
126    pub dropped: u64,
127    /// Wall-clock capture length.
128    pub duration_ms: u64,
129    /// The transition that fired a triggered capture (#218) — absent on a
130    /// plain capture, and on a triggered run that never fired.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub trigger: Option<super::Transition>,
133    /// The state preamble written ahead of the pre-roll, as the header
134    /// states it.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub preamble: Option<PreambleInfo>,
137    /// The retained window the pre-roll came from, as the header states it.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub pre_roll: Option<PreRollInfo>,
140    /// Preamble rows written — never added to `samples` (O6, applied to
141    /// rows: the kinds are counted apart).
142    #[serde(skip_serializing_if = "is_zero")]
143    pub preamble_rows: u64,
144}
145
146/// What a replay did — the shared report shape both frontends render.
147#[derive(Debug, Clone, Serialize)]
148pub struct ReplayReport {
149    /// The capture header, echoed: a replay names what it replayed.
150    pub header: ZrecHeader,
151    pub dry_run: bool,
152    pub speed: f64,
153    /// Rows published (dry run: rows that would have been).
154    pub published: u64,
155    /// Tombstones sent (dry run: would have been).
156    pub tombstones: u64,
157    /// Rows that could not be parsed — counted, never skipped.
158    pub malformed: u64,
159    /// Delete rows the retire gate refused.
160    pub refused: u64,
161    /// Samples the *capture* missed (summed from the file's drop records):
162    /// this replay is a partial view and says so (O6).
163    pub capture_dropped: u64,
164    /// The first few malformed/refused reasons, for the human render.
165    #[serde(skip_serializing_if = "Vec::is_empty")]
166    pub first_errors: Vec<String>,
167    /// Version-2 preamble rows **not** published — the default: re-stamping
168    /// state-at-capture-start republishes a snapshot over the live fleet
169    /// (RFC 13 §4.2), so the replayer skips them unless told `--seed-state`
170    /// and says how many.
171    #[serde(skip_serializing_if = "is_zero")]
172    pub preamble_skipped: u64,
173    /// Preamble rows published (dry run: would have been) under
174    /// `--seed-state`, through the same retire gate as any row.
175    #[serde(skip_serializing_if = "is_zero")]
176    pub preamble_seeded: u64,
177    /// Trigger records met in the file — never published; a marker, not a
178    /// row.
179    #[serde(skip_serializing_if = "is_zero")]
180    pub triggers: u64,
181}
182
183/// One sample, as the explorers write it (#235).
184///
185/// The write side of this module's dialect: `.zrec` rows (RFC 09 §5.2),
186/// `zenctl echo --format ndjson`, and zengui's echo export are all
187/// this struct, so [`parse_row`](crate::tape::ingest::parse_row) reads back what any of them wrote. Every
188/// optional field is `skip_serializing_if`: a writer that does not hold a
189/// fact omits it rather than nulling it, because a `null` here would claim
190/// a question was asked and answered negatively (RFC 09 §5.1 O4). That is
191/// also what keeps the three writers' rows a *subset* relationship rather
192/// than three shapes — a `.zrec` row carries no `origin`, an echo row
193/// carries no pacing offset, and neither is lying about the other.
194#[derive(Debug, Clone, Default, PartialEq, Serialize)]
195pub struct SampleRow {
196    /// Full wire key, as received — explorers run un-namespaced (RFC 09 §5).
197    pub key: String,
198    /// The convention-parsed origin chunk, when the key parses at all.
199    ///
200    /// Absent means the key did not parse under the observer's base, which
201    /// is a fact about the key and not a claim about the fleet (O1/O3).
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub origin: Option<String>,
204    /// The parsed subject tail, joined — absent on the same terms as
205    /// [`SampleRow::origin`].
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub subject: Option<String>,
208    /// Microseconds since the capture epoch: the **observer's arrival
209    /// clock**, and the only thing replay paces by (RFC 09 §5.2). A live
210    /// stream has no epoch to be relative to, so it omits this.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub t: Option<u64>,
213    /// The registry-declared type name, when a decode was asked for and
214    /// resolved one. `--no-decode` never asks, so it omits this rather than
215    /// nulling it (O4).
216    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
217    pub type_name: Option<String>,
218    /// Whether [`SampleRow::value`] is a schema decode rather than a
219    /// structural rendering. Absent when nothing was decoded.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub typed: Option<bool>,
222    /// The sample's declared encoding, verbatim (RFC 08 §7: sample beats
223    /// registry beats sniff).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub encoding: Option<String>,
226    /// The sample's HLC, when one rode it. Whose clock it is depends on who
227    /// stamped it (RFC 09 §5.1 O7); this field says only that it exists.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub timestamp: Option<String>,
230    /// The RFC 04 §3 QoS profile **name**, and only when the wire's actual
231    /// axes match one.
232    ///
233    /// This is the field [`parse_row`](crate::tape::ingest::parse_row) resolves through
234    /// `zenkey::qos::QosProfile::from_name`, so it must never carry
235    /// anything else — axes matching no profile are not approximated, they
236    /// ride [`SampleRow::qos_axes`] instead. Writing the axes here is
237    /// exactly the bug in #235.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub qos: Option<String>,
240    /// The wire's actual QoS axes as one token,
241    /// `priority/congestion/reliability[+express]` (#120).
242    ///
243    /// A fact worth carrying and *not* a profile name: a fleet is free to
244    /// publish axes no profile declares, and the declared-vs-observed
245    /// comparison is the point.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub qos_axes: Option<String>,
248    /// A tombstone: authoritative retirement, never an empty put
249    /// (RFC 04 §1.2). Always written — every sample is one or the other,
250    /// and that is a fact rather than an unanswered question.
251    pub delete: bool,
252    /// Base64 of the exact wire payload — lossless and round-trippable,
253    /// which is why [`parse_row`](crate::tape::ingest::parse_row) prefers it over [`SampleRow::value`].
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub bytes: Option<String>,
256    /// The payload as a *rendering*: a schema decode when one was asked
257    /// for and succeeded, else the structural degradation. It does not
258    /// round-trip a binary payload, and RFC 09 §5.2 says so.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub value: Option<serde_json::Value>,
261    /// True payload size, whatever the rendering above shows.
262    ///
263    /// Deliberately not spelled `bytes`: that key has meant the base64
264    /// payload since RFC 09 §5.2, and a byte count under it makes the row
265    /// unreadable rather than merely lossy (#235).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub payload_bytes: Option<usize>,
268    /// The publishing entity, `zid:eid#sn`, when `SourceInfo` rode the
269    /// sample. Usually absent: zenoh 1.9 and 1.10 deliver none to a
270    /// subscriber — 1.10 even dropped setting it through the advanced API
271    /// (eclipse-zenoh/zenoh#2563) — RFC 09 §5.1 O7's practical note.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub source: Option<String>,
274    /// The attachment as a rendering (#117), on the same terms as
275    /// [`SampleRow::value`].
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub attachment: Option<serde_json::Value>,
278    /// Base64 of the exact attachment bytes; wins over the rendering.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub attachment_b64: Option<String>,
281    /// True attachment size, whatever the rendering above shows.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub attachment_bytes: Option<usize>,
284    /// The RFC 08 §7 validation verdict, present only when the pipeline
285    /// was asked — and then always, so "valid" and "not checked" cannot be
286    /// confused by a shared absence (#159).
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub verdict: Option<String>,
289    /// The failed constraints, when the verdict is `invalid`.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub violations: Option<Vec<String>>,
292    /// Why a decode that was asked for did not happen.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub decode_error: Option<String>,
295    /// A version-2 preamble row (RFC 13 §4.1, #218): state fetched at
296    /// trigger time and written ahead of the first observed row, at
297    /// `t: 0`. Only ever written as `true`; an observed row omits it
298    /// rather than saying `false`, on the same terms as every other
299    /// optional field here.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub preamble: Option<bool>,
302}
303
304/// The wire's QoS axes as one stable token: `priority/congestion/reliability`,
305/// `+express` when set — lowercase, cut/awk-friendly, never `Debug`.
306///
307/// **The engine's word, not a frontend's.** This is the spelling
308/// [`SampleRow::qos_axes`] carries, which
309/// [`parse_row`](crate::tape::ingest::parse_row) reads back for
310/// `pub --from ndjson` and `.zrec` replay (RFC 09 §5.2) — so it is a
311/// round-trip contract, not a rendering choice. Both frontends used to spell
312/// it independently, fifteen literals each, agreeing by a comment that said
313/// they agreed (#353). The precedent is `LatencyReport::caveat`, worded here
314/// in #213 for exactly this reason: two frontends must not be able to
315/// describe one fact differently.
316pub fn qos_axes_token(
317    priority: zenoh::qos::Priority,
318    congestion_control: zenoh::qos::CongestionControl,
319    reliability: zenoh::qos::Reliability,
320    express: bool,
321) -> String {
322    use zenoh::qos::{CongestionControl as Cc, Priority as P, Reliability as R};
323    let p = match priority {
324        P::RealTime => "real_time",
325        P::InteractiveHigh => "interactive_high",
326        P::InteractiveLow => "interactive_low",
327        P::DataHigh => "data_high",
328        P::Data => "data",
329        P::DataLow => "data_low",
330        P::Background => "background",
331    };
332    let c = match congestion_control {
333        Cc::Drop => "drop",
334        Cc::Block => "block",
335        // `CongestionControl` is `#[non_exhaustive]` upstream: a variant this
336        // build has never heard of renders as `other` rather than as one of
337        // the two it knows.
338        _ => "other",
339    };
340    let r = match reliability {
341        R::BestEffort => "best_effort",
342        R::Reliable => "reliable",
343    };
344    format!("{p}/{c}/{r}{}", if express { "+express" } else { "" })
345}
346
347#[cfg(test)]
348mod qos_axes_tests {
349    use super::*;
350    use zenoh::qos::{CongestionControl as Cc, Priority as P, Reliability as R};
351
352    /// The token is a round-trip contract, not a rendering: `.zrec` replay
353    /// and `pub --from ndjson` read it back (RFC 09 §5.2). Pinned here rather
354    /// than in either frontend, because it is neither frontend's (#353).
355    #[test]
356    fn the_axes_token_is_stable() {
357        assert_eq!(
358            qos_axes_token(P::Data, Cc::Drop, R::BestEffort, false),
359            "data/drop/best_effort"
360        );
361        assert_eq!(
362            qos_axes_token(P::RealTime, Cc::Block, R::Reliable, true),
363            "real_time/block/reliable+express"
364        );
365        assert_eq!(
366            qos_axes_token(P::InteractiveHigh, Cc::Block, R::Reliable, false),
367            "interactive_high/block/reliable"
368        );
369        assert_eq!(
370            qos_axes_token(P::Background, Cc::Drop, R::BestEffort, true),
371            "background/drop/best_effort+express"
372        );
373    }
374
375    /// Every declared profile renders a token, and the five are distinct —
376    /// which is what makes declared-vs-observed a comparison at all (#120).
377    #[test]
378    fn every_qos_profile_has_a_distinct_axes_token() {
379        let tokens: Vec<String> = zenkey::QosProfile::ALL
380            .iter()
381            .map(|p| {
382                qos_axes_token(
383                    p.priority(),
384                    p.congestion_control(),
385                    p.reliability(),
386                    p.express(),
387                )
388            })
389            .collect();
390        let mut unique = tokens.clone();
391        unique.sort();
392        unique.dedup();
393        assert_eq!(unique.len(), tokens.len(), "{tokens:?}");
394    }
395}