Skip to main content

browser_commander/traces/
schema.rs

1//! The portable browser trace format, as Rust knows it (issue #87).
2//!
3//! A trace is written by the JavaScript recorder and read by JavaScript,
4//! Python and Rust. The layout is the contract between them, so every name and
5//! value a reader has to agree on lives here, mirroring
6//! `js/src/traces/schema.js`.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11/// Schema version of the bundle.
12///
13/// Readers refuse a major version they were not written for rather than
14/// guessing at the meaning of unknown records.
15///
16/// Version 2 (issue #93) is what makes continuous replay complete: mutation
17/// batches name the frame they came from, child-list records carry the
18/// position a node was inserted at or removed from, and live control state -
19/// typing, checking, selecting, focus and scroll - is recorded as it happens
20/// instead of only at checkpoints. A version 1 reader given a version 2 bundle
21/// would quietly replay a different page, so it refuses instead.
22pub const TRACE_SCHEMA_VERSION: u64 = 2;
23
24/// Value of `manifest.json`'s `format` field, for every version.
25pub const TRACE_FORMAT: &str = "browser-commander-trace";
26
27/// Event families a caller can subscribe the recorder to.
28pub const TRACE_EVENT_SOURCES: [&str; 7] = [
29    "navigation",
30    "interaction",
31    "console",
32    "pageerror",
33    "dialog",
34    "requestfailed",
35    "download",
36];
37
38/// Names every reader looks for inside a bundle.
39pub struct TraceFiles;
40
41impl TraceFiles {
42    /// What the run was and how it ended.
43    pub const MANIFEST: &'static str = "manifest.json";
44    /// The one ordered timeline.
45    pub const EVENTS: &'static str = "events.ndjson";
46    /// Per-checkpoint HTML, control state and screenshots.
47    pub const CHECKPOINTS_DIR: &'static str = "checkpoints";
48    /// DOM mutation batches, one file per checkpoint interval.
49    pub const MUTATIONS_DIR: &'static str = "mutations";
50    /// Content-addressed resources the timeline refers to.
51    pub const ARTIFACTS_DIR: &'static str = "artifacts";
52    /// The offline viewer, when one was written.
53    pub const VIEWER: &'static str = "viewer.html";
54}
55
56/// What the recorder captures.
57pub struct TraceMode;
58
59impl TraceMode {
60    /// Nothing is recorded.
61    pub const OFF: &'static str = "off";
62    /// Only the moments a caller asks for.
63    pub const CHECKPOINTS: &'static str = "checkpoints";
64    /// Checkpoints plus every DOM change between them.
65    pub const CONTINUOUS: &'static str = "continuous";
66    /// Record continuously, keep the bundle only when the run fails.
67    pub const RETAIN_ON_FAILURE: &'static str = "retain-on-failure";
68}
69
70/// Event kinds that share the one ordered timeline.
71pub struct TraceEvent;
72
73impl TraceEvent {
74    /// The recorder started.
75    pub const TRACE_START: &'static str = "trace.start";
76    /// The recorder stopped.
77    pub const TRACE_STOP: &'static str = "trace.stop";
78    /// A named moment was captured.
79    pub const CHECKPOINT: &'static str = "checkpoint";
80    /// A batch of DOM mutations was written.
81    pub const MUTATIONS: &'static str = "mutations";
82    /// The page went somewhere.
83    pub const NAVIGATION: &'static str = "navigation";
84    /// Browser Commander drove the page.
85    pub const INTERACTION: &'static str = "interaction";
86    /// The page logged something.
87    pub const CONSOLE: &'static str = "console";
88    /// The page threw.
89    pub const PAGE_ERROR: &'static str = "pageerror";
90    /// The page asked the user something.
91    pub const DIALOG: &'static str = "dialog";
92    /// A request never reached a server.
93    pub const REQUEST_FAILED: &'static str = "requestfailed";
94    /// A download started, finished or failed.
95    pub const DOWNLOAD: &'static str = "download";
96    /// Something could not be recorded. The run continues; the gap is visible.
97    pub const DROPPED: &'static str = "dropped";
98}
99
100/// Record kinds inside a mutation batch.
101pub struct TraceMutationKind;
102
103impl TraceMutationKind {
104    /// An attribute was set, changed or removed.
105    pub const ATTRIBUTES: &'static str = "attributes";
106    /// Text inside a node changed.
107    pub const CHARACTER_DATA: &'static str = "characterData";
108    /// Children were added, moved or removed.
109    pub const CHILD_LIST: &'static str = "childList";
110    /// A change to what an element holds rather than to the document.
111    ///
112    /// Typing into an input, checking a box, choosing an option, moving focus
113    /// and scrolling all change what the user sees and none of them mutate the
114    /// DOM, so no observer reports them and replay skipped them (issue #93).
115    pub const LIVE_STATE: &'static str = "live-state";
116}
117
118/// What a `live-state` record changed.
119pub struct TraceLiveState;
120
121impl TraceLiveState {
122    /// What an input, textarea or editable element holds.
123    pub const VALUE: &'static str = "value";
124    /// Whether a checkbox or radio is ticked.
125    pub const CHECKED: &'static str = "checked";
126    /// Which options of a select are chosen.
127    pub const SELECTED: &'static str = "selected";
128    /// Which element has focus.
129    pub const FOCUS: &'static str = "focus";
130    /// How far an element or the document is scrolled.
131    pub const SCROLL: &'static str = "scroll";
132}
133
134/// Why a checkpoint was taken.
135pub struct TraceCheckpointReason;
136
137impl TraceCheckpointReason {
138    /// The base snapshot every later record is a change against.
139    pub const INITIAL: &'static str = "initial";
140    /// A caller named this moment.
141    pub const CHECKPOINT: &'static str = "checkpoint";
142    /// The run failed here.
143    pub const FAILURE: &'static str = "failure";
144}
145
146/// Why a record was dropped.
147pub struct TraceDropReason;
148
149impl TraceDropReason {
150    /// The record was larger than a configured ceiling.
151    pub const SIZE_LIMIT: &'static str = "size-limit";
152    /// The bytes could not be written.
153    pub const WRITE_FAILED: &'static str = "write-failed";
154    /// The page could not produce the record.
155    pub const CAPTURE_FAILED: &'static str = "capture-failed";
156    /// The page was gone before the record was taken.
157    pub const PAGE_CLOSED: &'static str = "page-closed";
158    /// The capture took longer than it was allowed to.
159    pub const TIMEOUT: &'static str = "timeout";
160}
161
162/// How a trace ended, recorded in the manifest.
163pub struct TraceOutcome;
164
165impl TraceOutcome {
166    /// `stop()` was called and every record was written.
167    pub const COMPLETE: &'static str = "complete";
168    /// The trace is readable but records are missing.
169    pub const PARTIAL: &'static str = "partial";
170    /// No manifest was ever written; readers repair this on open.
171    pub const TRUNCATED: &'static str = "truncated";
172}
173
174/// How many members of each kind a bundle holds.
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct TraceCounts {
178    /// Named moments captured.
179    #[serde(default)]
180    pub checkpoints: u64,
181    /// Records on the timeline.
182    #[serde(default)]
183    pub events: u64,
184    /// DOM mutation batches written.
185    #[serde(default)]
186    pub mutation_batches: u64,
187}
188
189/// What a bundle's records let a viewer reproduce.
190///
191/// Issue #93 asked for the viewer to stop claiming more than it can do. Saying
192/// it in the manifest is better than saying it in the viewer's markup: a bundle
193/// recorded by an older version is honestly described by its own manifest, and
194/// any of the three readers can tell a caller what they are looking at.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct TraceReplaySupport {
198    /// Checkpoints are snapshots, always replayable.
199    #[serde(default)]
200    pub checkpoints: bool,
201    /// DOM mutations are recorded between checkpoints.
202    #[serde(default)]
203    pub mutations: bool,
204    /// Child-list records say where a node went, so removals and moves replay.
205    #[serde(default)]
206    pub child_list_positions: bool,
207    /// Typing, checking, selecting, focus and scroll are recorded live.
208    #[serde(default)]
209    pub live_state: bool,
210    /// Every record names the context, page, navigation and frame it is from.
211    #[serde(default)]
212    pub identifiers: bool,
213}
214
215impl Default for TraceReplaySupport {
216    fn default() -> Self {
217        Self {
218            checkpoints: true,
219            mutations: false,
220            child_list_positions: false,
221            live_state: false,
222            identifiers: false,
223        }
224    }
225}
226
227/// What a bundle says about the run that produced it.
228///
229/// Unknown fields are kept rather than dropped, so a bundle written by a newer
230/// minor version of the format survives a round trip through this reader.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct TraceManifest {
234    /// Version of the layout the bundle was written against.
235    #[serde(default)]
236    pub schema_version: u64,
237    /// Always [`TRACE_FORMAT`] for a Browser Commander trace.
238    #[serde(default)]
239    pub format: String,
240    /// One of [`TraceMode`].
241    #[serde(default)]
242    pub mode: Option<String>,
243    /// One of [`TraceOutcome`].
244    #[serde(default = "complete_outcome")]
245    pub outcome: String,
246    /// When the recorder started.
247    #[serde(default)]
248    pub started_at: Option<String>,
249    /// When the recorder stopped.
250    #[serde(default)]
251    pub stopped_at: Option<String>,
252    /// Version of the library that recorded the run.
253    #[serde(default)]
254    pub commander_version: Option<String>,
255    /// Engine the run drove.
256    #[serde(default)]
257    pub engine: Option<String>,
258    /// Browser name and version.
259    #[serde(default)]
260    pub browser: Option<String>,
261    /// Operating system the run happened on.
262    #[serde(default)]
263    pub platform: Option<String>,
264    /// Language runtime that recorded the run.
265    #[serde(default)]
266    pub runtime: Option<String>,
267    /// Event families the recorder subscribed to.
268    #[serde(default)]
269    pub events: Vec<String>,
270    /// DOM capture settings the recorder applied.
271    #[serde(default)]
272    pub dom: Map<String, Value>,
273    /// What the bundle's records let a viewer reproduce.
274    #[serde(default)]
275    pub replay: TraceReplaySupport,
276    /// Redaction settings the recorder applied.
277    #[serde(default)]
278    pub privacy: Map<String, Value>,
279    /// Size ceilings the recorder applied.
280    #[serde(default)]
281    pub limits: Map<String, Value>,
282    /// Members written, by kind.
283    #[serde(default)]
284    pub counts: TraceCounts,
285    /// How many records could not be written.
286    #[serde(default)]
287    pub dropped: u64,
288    /// Anything a newer writer added that this reader does not name.
289    #[serde(flatten)]
290    pub extra: Map<String, Value>,
291}
292
293fn complete_outcome() -> String {
294    TraceOutcome::COMPLETE.to_string()
295}
296
297impl Default for TraceManifest {
298    fn default() -> Self {
299        Self {
300            schema_version: TRACE_SCHEMA_VERSION,
301            format: TRACE_FORMAT.to_string(),
302            mode: None,
303            outcome: complete_outcome(),
304            started_at: None,
305            stopped_at: None,
306            commander_version: None,
307            engine: None,
308            browser: None,
309            platform: Some(format!(
310                "{} {}",
311                std::env::consts::OS,
312                std::env::consts::ARCH
313            )),
314            runtime: Some(format!("rust {}", env!("CARGO_PKG_VERSION"))),
315            events: Vec::new(),
316            dom: Map::new(),
317            replay: TraceReplaySupport::default(),
318            privacy: Map::new(),
319            limits: Map::new(),
320            counts: TraceCounts::default(),
321            dropped: 0,
322            extra: Map::new(),
323        }
324    }
325}
326
327impl TraceManifest {
328    /// Whether the bundle holds everything the run produced.
329    pub fn is_complete(&self) -> bool {
330        self.outcome == TraceOutcome::COMPLETE
331    }
332}
333
334/// Format a bundle member's sequence number.
335///
336/// Zero padding keeps `ls` and any reader that sorts lexically in the same
337/// order as the sequence itself.
338pub fn sequence_name(index: u32) -> String {
339    format!("{index:04}")
340}
341
342/// Check that a manifest can be read by this version of the format.
343///
344/// # Errors
345///
346/// Returns an error when the bundle is not a trace, or was written against a
347/// newer schema version than this reader knows.
348pub fn assert_readable_manifest(manifest: &TraceManifest) -> Result<(), crate::traces::TraceError> {
349    if manifest.format != TRACE_FORMAT {
350        return Err(crate::traces::TraceError::NotATrace);
351    }
352    if manifest.schema_version > TRACE_SCHEMA_VERSION {
353        return Err(crate::traces::TraceError::UnsupportedVersion {
354            found: manifest.schema_version,
355        });
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn manifest(format: &str, schema_version: u64) -> TraceManifest {
365        TraceManifest {
366            format: format.to_string(),
367            schema_version,
368            ..TraceManifest::default()
369        }
370    }
371
372    #[test]
373    fn pads_a_sequence_number_so_it_sorts_in_order() {
374        assert_eq!(sequence_name(1), "0001");
375        assert_eq!(sequence_name(42), "0042");
376        // Beyond four digits the number wins over the padding; the bundle
377        // stays readable, the ordering is the caller's problem.
378        assert_eq!(sequence_name(10_000), "10000");
379    }
380
381    #[test]
382    fn accepts_a_manifest_this_reader_was_written_for() {
383        assert!(assert_readable_manifest(&manifest(TRACE_FORMAT, TRACE_SCHEMA_VERSION)).is_ok());
384    }
385
386    #[test]
387    fn refuses_something_that_is_not_a_trace() {
388        let error = assert_readable_manifest(&manifest("something-else", 1))
389            .expect_err("format does not match");
390
391        assert_eq!(error.to_string(), "not a Browser Commander trace bundle");
392    }
393
394    #[test]
395    fn refuses_a_newer_schema_rather_than_guessing() {
396        let error = assert_readable_manifest(&manifest(TRACE_FORMAT, TRACE_SCHEMA_VERSION + 1))
397            .expect_err("schema is too new");
398
399        assert!(error.to_string().contains("newer than this reader"));
400    }
401
402    #[test]
403    fn declares_the_same_schema_version_as_javascript() {
404        // The three halves of the format are kept in step by hand, so check.
405        let source =
406            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../js/src/traces/schema.js");
407        let Ok(body) = std::fs::read_to_string(&source) else {
408            // Published crates carry no JavaScript package to compare against.
409            return;
410        };
411
412        assert!(body.contains(&format!("TRACE_SCHEMA_VERSION = {TRACE_SCHEMA_VERSION};")));
413        assert!(body.contains(&format!("format: '{TRACE_FORMAT}'")));
414    }
415}