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}