Skip to main content

relux_runtime/observe/structured/
mod.rs

1//! Structured logging schema and accumulator.
2//!
3//! The `StructuredLog` produced here is the canonical artifact of a test run:
4//! a spans glossary, a flat list of execution events, a parallel list of
5//! buffer events, a shells glossary, and an optional failure record. Each
6//! type derives `serde` (JSON-on-disk) and `ts-rs` (TypeScript declarations
7//! consumed by the SPA viewer).
8//!
9//! TypeScript bindings are produced by enabling the `ts-export` cargo
10//! feature on this crate and running the auto-injected
11//! `export_bindings_*` tests; `just build-viewer` drives both.
12
13pub mod artifact;
14pub mod buffer;
15pub mod builder;
16pub mod event;
17pub mod failure;
18pub mod log_sink;
19pub mod match_context;
20pub mod shell;
21pub mod skip;
22pub mod span;
23pub mod utf8_stream;
24
25use std::collections::HashMap;
26
27use serde::Deserialize;
28use serde::Serialize;
29use ts_rs::TS;
30
31pub use artifact::ArtifactEntry;
32pub use buffer::BufferEvent;
33pub use buffer::BufferEventKind;
34pub use builder::StructuredLogBuilder;
35pub use event::CancelReasonRecord;
36pub use event::Event;
37pub use event::EventKind;
38pub use event::EventSeq;
39pub use event::MultiMatchPattern;
40pub use failure::CancellationRecord;
41pub use failure::FailureRecord;
42pub use failure::StackFrame;
43pub use match_context::MatchContext;
44pub use shell::ShellRecord;
45pub use skip::SkipRecord;
46pub use span::FnCallKind;
47pub use span::MarkerEvalDecision;
48pub use span::MarkerEvalDetail;
49pub use span::MarkerEvalKind;
50pub use span::MarkerEvalModifier;
51pub use span::Span;
52pub use span::SpanId;
53pub use span::SpanKind;
54pub use utf8_stream::Utf8Stream;
55
56/// Source-file location resolved from an `IrSpan`. Lives on spans and stack
57/// frames; events resolve against their span if needed.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
59#[cfg_attr(
60    feature = "ts-export",
61    ts(export, export_to = "../../../viewer/src/types/")
62)]
63pub struct SourceLocation {
64    pub file: String,
65    pub line: usize,
66    pub start: usize,
67    pub end: usize,
68}
69
70impl std::fmt::Display for SourceLocation {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(f, "{}:{}", self.file, self.line)
73    }
74}
75
76/// `events.json` schema version. Bump on any change to the on-disk
77/// shape (fields added, removed, or renamed; new tagged-enum variants;
78/// a narrowed field meaning). External consumers should verify this
79/// matches the version they expect.
80pub const SCHEMA_VERSION: u32 = 3;
81
82/// Top-level structured log for a single test run. Produced by
83/// `StructuredLogBuilder::build`.
84#[derive(Debug, Clone, Serialize, Deserialize, TS)]
85#[cfg_attr(
86    feature = "ts-export",
87    ts(export, export_to = "../../../viewer/src/types/")
88)]
89pub struct StructuredLog {
90    /// Schema version of this artifact. See `SCHEMA_VERSION`.
91    pub schema_version: u32,
92    pub info: TestInfo,
93    pub outcome: TestOutcome,
94    pub env: EnvInfo,
95    pub shells: HashMap<String, ShellRecord>,
96    /// JSON-serializes `SpanId` keys as strings (per JSON object-key rules),
97    /// so the TS type uses a string-keyed record rather than `bigint`-keyed.
98    #[ts(as = "HashMap<String, Span>")]
99    pub spans: HashMap<SpanId, Span>,
100    pub events: Vec<Event>,
101    pub buffer_events: Vec<BufferEvent>,
102    /// `.relux` file contents referenced by any span's `location` or any
103    /// event's `source`. Keys are relative paths matching `SourceLocation.file`.
104    pub sources: HashMap<String, String>,
105    /// Files written under the test's artifacts directory, sorted with
106    /// `cmp_artifact_paths` (files before subdirs within each directory).
107    pub artifacts: Vec<ArtifactEntry>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, TS)]
111#[cfg_attr(
112    feature = "ts-export",
113    ts(export, export_to = "../../../viewer/src/types/")
114)]
115pub struct TestInfo {
116    pub name: String,
117    pub path: String,
118    pub duration_ms: u64,
119}
120
121/// Tagged verdict carried by `StructuredLog`. Replaces the older pair of
122/// `TestInfo.outcome: String` + `StructuredLog.failure: Option<_>` so the
123/// schema cannot represent contradictory states.
124#[derive(Debug, Clone, Serialize, Deserialize, TS)]
125#[cfg_attr(
126    feature = "ts-export",
127    ts(export, export_to = "../../../viewer/src/types/")
128)]
129// Tag is `kind` (not `type`) because `FailureRecord` is itself a tagged
130// enum on `type`; flattening with `tag = "type"` here would collide and
131// collapse the TS-side narrowing to `never`.
132#[serde(tag = "kind", rename_all = "kebab-case")]
133pub enum TestOutcome {
134    Pass,
135    Fail(FailureRecord),
136    Cancelled(CancellationRecord),
137    Skip(SkipRecord),
138}
139
140/// Serializable mirror of `relux_core::pure::LayeredEnvSource` for the
141/// structured log. The core type carries a `PathBuf` and derives neither
142/// `serde` nor `ts-rs`, so the schema keeps its own tagged mirror; a
143/// `DotEnv` path is lossily stringified.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
145#[cfg_attr(
146    feature = "ts-export",
147    ts(export, export_to = "../../../viewer/src/types/")
148)]
149#[serde(tag = "kind", rename_all = "kebab-case")]
150pub enum EnvSourceRecord {
151    Base,
152    DotEnv { path: String },
153    ReluxInternal,
154    EffectOverlay { mnemonic: String },
155}
156
157impl From<&relux_core::pure::LayeredEnvSource> for EnvSourceRecord {
158    fn from(s: &relux_core::pure::LayeredEnvSource) -> Self {
159        use relux_core::pure::LayeredEnvSource as S;
160        match s {
161            S::Base => Self::Base,
162            S::DotEnv(p) => Self::DotEnv {
163                path: p.to_string_lossy().into_owned(),
164            },
165            S::ReluxInternal => Self::ReluxInternal,
166            S::EffectOverlay(m) => Self::EffectOverlay {
167                mnemonic: m.clone(),
168            },
169        }
170    }
171}
172
173/// One resolved environment entry in the bootstrap dump, tagged with the
174/// provenance of the layer that supplied the winning value.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
176#[cfg_attr(
177    feature = "ts-export",
178    ts(export, export_to = "../../../viewer/src/types/")
179)]
180pub struct EnvValue {
181    pub key: String,
182    pub value: String,
183    pub source: EnvSourceRecord,
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
187#[cfg_attr(
188    feature = "ts-export",
189    ts(export, export_to = "../../../viewer/src/types/")
190)]
191pub struct EnvInfo {
192    pub bootstrap: Vec<EnvValue>,
193}
194
195/// Serde helper that encodes `Duration` as fractional milliseconds (`f64`).
196/// Matches what the viewer expects (`number` of milliseconds since test start).
197pub(crate) mod ts_duration_ms {
198    use std::time::Duration;
199
200    use serde::Deserialize;
201    use serde::Deserializer;
202    use serde::Serializer;
203
204    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
205        s.serialize_f64(d.as_secs_f64() * 1000.0)
206    }
207
208    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
209        let ms = f64::deserialize(d)?;
210        Ok(Duration::from_secs_f64(ms / 1000.0))
211    }
212}
213
214/// Same as `ts_duration_ms` but for `Option<Duration>`.
215pub(crate) mod ts_duration_ms_opt {
216    use std::time::Duration;
217
218    use serde::Deserialize;
219    use serde::Deserializer;
220    use serde::Serializer;
221
222    pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
223        match d {
224            Some(d) => s.serialize_some(&(d.as_secs_f64() * 1000.0)),
225            None => s.serialize_none(),
226        }
227    }
228
229    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
230        let opt = Option::<f64>::deserialize(d)?;
231        Ok(opt.map(|ms| Duration::from_secs_f64(ms / 1000.0)))
232    }
233}
234
235#[cfg(test)]
236mod env_provenance_tests {
237    use super::*;
238
239    #[test]
240    fn env_source_record_serialises_dot_env() {
241        let r = EnvSourceRecord::DotEnv {
242            path: "/p/.env".into(),
243        };
244        let v = serde_json::to_value(&r).unwrap();
245        assert_eq!(v, serde_json::json!({"kind": "dot-env", "path": "/p/.env"}));
246    }
247
248    #[test]
249    fn env_source_record_variants_serialise() {
250        assert_eq!(
251            serde_json::to_value(EnvSourceRecord::Base).unwrap(),
252            serde_json::json!({"kind": "base"})
253        );
254        assert_eq!(
255            serde_json::to_value(EnvSourceRecord::ReluxInternal).unwrap(),
256            serde_json::json!({"kind": "relux-internal"})
257        );
258        assert_eq!(
259            serde_json::to_value(EnvSourceRecord::EffectOverlay {
260                mnemonic: "brave-yak-0001".into()
261            })
262            .unwrap(),
263            serde_json::json!({"kind": "effect-overlay", "mnemonic": "brave-yak-0001"})
264        );
265    }
266
267    #[test]
268    fn env_source_record_from_core() {
269        use relux_core::pure::LayeredEnvSource;
270        let r: EnvSourceRecord = (&LayeredEnvSource::ReluxInternal).into();
271        assert_eq!(r, EnvSourceRecord::ReluxInternal);
272        let r: EnvSourceRecord = (&LayeredEnvSource::DotEnv("/a/.env".into())).into();
273        assert_eq!(
274            r,
275            EnvSourceRecord::DotEnv {
276                path: "/a/.env".into()
277            }
278        );
279        let r: EnvSourceRecord = (&LayeredEnvSource::EffectOverlay("m".into())).into();
280        assert_eq!(
281            r,
282            EnvSourceRecord::EffectOverlay {
283                mnemonic: "m".into()
284            }
285        );
286    }
287
288    #[test]
289    fn env_value_serialises() {
290        let ev = EnvValue {
291            key: "PORT".into(),
292            value: "5432".into(),
293            source: EnvSourceRecord::Base,
294        };
295        let v = serde_json::to_value(&ev).unwrap();
296        assert_eq!(v["key"], "PORT");
297        assert_eq!(v["value"], "5432");
298        assert_eq!(v["source"]["kind"], "base");
299    }
300
301    #[test]
302    fn schema_version_is_three() {
303        assert_eq!(SCHEMA_VERSION, 3);
304    }
305}