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.
15pub const TRACE_SCHEMA_VERSION: u64 = 1;
16
17/// Value of `manifest.json`'s `format` field, for every version.
18pub const TRACE_FORMAT: &str = "browser-commander-trace";
19
20/// Event families a caller can subscribe the recorder to.
21pub const TRACE_EVENT_SOURCES: [&str; 7] = [
22    "navigation",
23    "interaction",
24    "console",
25    "pageerror",
26    "dialog",
27    "requestfailed",
28    "download",
29];
30
31/// Names every reader looks for inside a bundle.
32pub struct TraceFiles;
33
34impl TraceFiles {
35    /// What the run was and how it ended.
36    pub const MANIFEST: &'static str = "manifest.json";
37    /// The one ordered timeline.
38    pub const EVENTS: &'static str = "events.ndjson";
39    /// Per-checkpoint HTML, control state and screenshots.
40    pub const CHECKPOINTS_DIR: &'static str = "checkpoints";
41    /// DOM mutation batches, one file per checkpoint interval.
42    pub const MUTATIONS_DIR: &'static str = "mutations";
43    /// Content-addressed resources the timeline refers to.
44    pub const ARTIFACTS_DIR: &'static str = "artifacts";
45    /// The offline viewer, when one was written.
46    pub const VIEWER: &'static str = "viewer.html";
47}
48
49/// What the recorder captures.
50pub struct TraceMode;
51
52impl TraceMode {
53    /// Nothing is recorded.
54    pub const OFF: &'static str = "off";
55    /// Only the moments a caller asks for.
56    pub const CHECKPOINTS: &'static str = "checkpoints";
57    /// Checkpoints plus every DOM change between them.
58    pub const CONTINUOUS: &'static str = "continuous";
59    /// Record continuously, keep the bundle only when the run fails.
60    pub const RETAIN_ON_FAILURE: &'static str = "retain-on-failure";
61}
62
63/// Event kinds that share the one ordered timeline.
64pub struct TraceEvent;
65
66impl TraceEvent {
67    /// The recorder started.
68    pub const TRACE_START: &'static str = "trace.start";
69    /// The recorder stopped.
70    pub const TRACE_STOP: &'static str = "trace.stop";
71    /// A named moment was captured.
72    pub const CHECKPOINT: &'static str = "checkpoint";
73    /// A batch of DOM mutations was written.
74    pub const MUTATIONS: &'static str = "mutations";
75    /// The page went somewhere.
76    pub const NAVIGATION: &'static str = "navigation";
77    /// Browser Commander drove the page.
78    pub const INTERACTION: &'static str = "interaction";
79    /// The page logged something.
80    pub const CONSOLE: &'static str = "console";
81    /// The page threw.
82    pub const PAGE_ERROR: &'static str = "pageerror";
83    /// The page asked the user something.
84    pub const DIALOG: &'static str = "dialog";
85    /// A request never reached a server.
86    pub const REQUEST_FAILED: &'static str = "requestfailed";
87    /// A download started, finished or failed.
88    pub const DOWNLOAD: &'static str = "download";
89    /// Something could not be recorded. The run continues; the gap is visible.
90    pub const DROPPED: &'static str = "dropped";
91}
92
93/// Why a record was dropped.
94pub struct TraceDropReason;
95
96impl TraceDropReason {
97    /// The record was larger than a configured ceiling.
98    pub const SIZE_LIMIT: &'static str = "size-limit";
99    /// The bytes could not be written.
100    pub const WRITE_FAILED: &'static str = "write-failed";
101    /// The page could not produce the record.
102    pub const CAPTURE_FAILED: &'static str = "capture-failed";
103    /// The page was gone before the record was taken.
104    pub const PAGE_CLOSED: &'static str = "page-closed";
105    /// The capture took longer than it was allowed to.
106    pub const TIMEOUT: &'static str = "timeout";
107}
108
109/// How a trace ended, recorded in the manifest.
110pub struct TraceOutcome;
111
112impl TraceOutcome {
113    /// `stop()` was called and every record was written.
114    pub const COMPLETE: &'static str = "complete";
115    /// The trace is readable but records are missing.
116    pub const PARTIAL: &'static str = "partial";
117    /// No manifest was ever written; readers repair this on open.
118    pub const TRUNCATED: &'static str = "truncated";
119}
120
121/// How many members of each kind a bundle holds.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct TraceCounts {
125    /// Named moments captured.
126    #[serde(default)]
127    pub checkpoints: u64,
128    /// Records on the timeline.
129    #[serde(default)]
130    pub events: u64,
131    /// DOM mutation batches written.
132    #[serde(default)]
133    pub mutation_batches: u64,
134}
135
136/// What a bundle says about the run that produced it.
137///
138/// Unknown fields are kept rather than dropped, so a bundle written by a newer
139/// minor version of the format survives a round trip through this reader.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct TraceManifest {
143    /// Version of the layout the bundle was written against.
144    #[serde(default)]
145    pub schema_version: u64,
146    /// Always [`TRACE_FORMAT`] for a Browser Commander trace.
147    #[serde(default)]
148    pub format: String,
149    /// One of [`TraceMode`].
150    #[serde(default)]
151    pub mode: Option<String>,
152    /// One of [`TraceOutcome`].
153    #[serde(default = "complete_outcome")]
154    pub outcome: String,
155    /// When the recorder started.
156    #[serde(default)]
157    pub started_at: Option<String>,
158    /// When the recorder stopped.
159    #[serde(default)]
160    pub stopped_at: Option<String>,
161    /// Version of the library that recorded the run.
162    #[serde(default)]
163    pub commander_version: Option<String>,
164    /// Engine the run drove.
165    #[serde(default)]
166    pub engine: Option<String>,
167    /// Browser name and version.
168    #[serde(default)]
169    pub browser: Option<String>,
170    /// Operating system the run happened on.
171    #[serde(default)]
172    pub platform: Option<String>,
173    /// Language runtime that recorded the run.
174    #[serde(default)]
175    pub runtime: Option<String>,
176    /// Event families the recorder subscribed to.
177    #[serde(default)]
178    pub events: Vec<String>,
179    /// DOM capture settings the recorder applied.
180    #[serde(default)]
181    pub dom: Map<String, Value>,
182    /// Redaction settings the recorder applied.
183    #[serde(default)]
184    pub privacy: Map<String, Value>,
185    /// Size ceilings the recorder applied.
186    #[serde(default)]
187    pub limits: Map<String, Value>,
188    /// Members written, by kind.
189    #[serde(default)]
190    pub counts: TraceCounts,
191    /// How many records could not be written.
192    #[serde(default)]
193    pub dropped: u64,
194    /// Anything a newer writer added that this reader does not name.
195    #[serde(flatten)]
196    pub extra: Map<String, Value>,
197}
198
199fn complete_outcome() -> String {
200    TraceOutcome::COMPLETE.to_string()
201}
202
203impl Default for TraceManifest {
204    fn default() -> Self {
205        Self {
206            schema_version: TRACE_SCHEMA_VERSION,
207            format: TRACE_FORMAT.to_string(),
208            mode: None,
209            outcome: complete_outcome(),
210            started_at: None,
211            stopped_at: None,
212            commander_version: None,
213            engine: None,
214            browser: None,
215            platform: Some(format!(
216                "{} {}",
217                std::env::consts::OS,
218                std::env::consts::ARCH
219            )),
220            runtime: Some(format!("rust {}", env!("CARGO_PKG_VERSION"))),
221            events: Vec::new(),
222            dom: Map::new(),
223            privacy: Map::new(),
224            limits: Map::new(),
225            counts: TraceCounts::default(),
226            dropped: 0,
227            extra: Map::new(),
228        }
229    }
230}
231
232impl TraceManifest {
233    /// Whether the bundle holds everything the run produced.
234    pub fn is_complete(&self) -> bool {
235        self.outcome == TraceOutcome::COMPLETE
236    }
237}
238
239/// Format a bundle member's sequence number.
240///
241/// Zero padding keeps `ls` and any reader that sorts lexically in the same
242/// order as the sequence itself.
243pub fn sequence_name(index: u32) -> String {
244    format!("{index:04}")
245}
246
247/// Check that a manifest can be read by this version of the format.
248///
249/// # Errors
250///
251/// Returns an error when the bundle is not a trace, or was written against a
252/// newer schema version than this reader knows.
253pub fn assert_readable_manifest(manifest: &TraceManifest) -> Result<(), crate::traces::TraceError> {
254    if manifest.format != TRACE_FORMAT {
255        return Err(crate::traces::TraceError::NotATrace);
256    }
257    if manifest.schema_version > TRACE_SCHEMA_VERSION {
258        return Err(crate::traces::TraceError::UnsupportedVersion {
259            found: manifest.schema_version,
260        });
261    }
262    Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn manifest(format: &str, schema_version: u64) -> TraceManifest {
270        TraceManifest {
271            format: format.to_string(),
272            schema_version,
273            ..TraceManifest::default()
274        }
275    }
276
277    #[test]
278    fn pads_a_sequence_number_so_it_sorts_in_order() {
279        assert_eq!(sequence_name(1), "0001");
280        assert_eq!(sequence_name(42), "0042");
281        // Beyond four digits the number wins over the padding; the bundle
282        // stays readable, the ordering is the caller's problem.
283        assert_eq!(sequence_name(10_000), "10000");
284    }
285
286    #[test]
287    fn accepts_a_manifest_this_reader_was_written_for() {
288        assert!(assert_readable_manifest(&manifest(TRACE_FORMAT, TRACE_SCHEMA_VERSION)).is_ok());
289    }
290
291    #[test]
292    fn refuses_something_that_is_not_a_trace() {
293        let error = assert_readable_manifest(&manifest("something-else", 1))
294            .expect_err("format does not match");
295
296        assert_eq!(error.to_string(), "not a Browser Commander trace bundle");
297    }
298
299    #[test]
300    fn refuses_a_newer_schema_rather_than_guessing() {
301        let error = assert_readable_manifest(&manifest(TRACE_FORMAT, TRACE_SCHEMA_VERSION + 1))
302            .expect_err("schema is too new");
303
304        assert!(error.to_string().contains("newer than this reader"));
305    }
306
307    #[test]
308    fn declares_the_same_schema_version_as_javascript() {
309        // The three halves of the format are kept in step by hand, so check.
310        let source =
311            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../js/src/traces/schema.js");
312        let Ok(body) = std::fs::read_to_string(&source) else {
313            // Published crates carry no JavaScript package to compare against.
314            return;
315        };
316
317        assert!(body.contains(&format!("TRACE_SCHEMA_VERSION = {TRACE_SCHEMA_VERSION};")));
318        assert!(body.contains(&format!("format: '{TRACE_FORMAT}'")));
319    }
320}