Skip to main content

relux_runtime/observe/structured/
span.rs

1use std::time::Duration;
2
3use serde::Deserialize;
4use serde::Serialize;
5use ts_rs::TS;
6
7use super::SourceLocation;
8
9pub type SpanId = u64;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
12#[cfg_attr(
13    feature = "ts-export",
14    ts(export, export_to = "../../../viewer/src/types/")
15)]
16#[serde(rename_all = "kebab-case")]
17pub enum FnCallKind {
18    User,
19    Bif,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
23#[cfg_attr(
24    feature = "ts-export",
25    ts(export, export_to = "../../../viewer/src/types/")
26)]
27#[serde(rename_all = "kebab-case")]
28pub enum MarkerEvalKind {
29    Skip,
30    Run,
31    Flaky,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
35#[cfg_attr(
36    feature = "ts-export",
37    ts(export, export_to = "../../../viewer/src/types/")
38)]
39#[serde(rename_all = "kebab-case")]
40pub enum MarkerEvalModifier {
41    If,
42    Unless,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
46#[cfg_attr(
47    feature = "ts-export",
48    ts(export, export_to = "../../../viewer/src/types/")
49)]
50#[serde(rename_all = "kebab-case")]
51pub enum MarkerEvalDecision {
52    /// Marker's action did not apply.
53    Pass,
54    /// Marker's action applied - the kind tells which (skip / run /
55    /// flaky).
56    Mark,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
60#[cfg_attr(
61    feature = "ts-export",
62    ts(export, export_to = "../../../viewer/src/types/")
63)]
64#[serde(tag = "shape", rename_all = "kebab-case")]
65pub enum MarkerEvalDetail {
66    Unconditional,
67    Bare {
68        value: String,
69        met: bool,
70    },
71    PureMatch {
72        value: String,
73        pattern: String,
74        is_regex: bool,
75        met: bool,
76    },
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, TS)]
80#[cfg_attr(
81    feature = "ts-export",
82    ts(export, export_to = "../../../viewer/src/types/")
83)]
84#[serde(tag = "kind", rename_all = "kebab-case")]
85pub enum SpanKind {
86    Test {
87        name: String,
88    },
89    EffectSetup {
90        effect: String,
91        overlay: Vec<(String, String)>,
92        alias: Option<String>,
93        /// Overlay keys whose value was sourced from a sibling start's
94        /// exposed variable, each paired with that sibling's alias
95        /// (`DB_PORT` sourced from `Db`). Empty when the start has no
96        /// implicit deps. R015 provenance for the viewer.
97        dep_sources: Vec<(String, String)>,
98        /// Identity marker computed from the effect-instance dedup key.
99        /// Same value on every `EffectSetup` for the same instance -
100        /// the bootstrap span plus every dedup'd reuse share it.
101        marker: String,
102        /// `false` on the bootstrap span that runs the setup body.
103        /// `true` on zero-duration spans emitted by dedup'd acquires.
104        is_reuse: bool,
105    },
106    EffectCleanup {
107        effect: String,
108        alias: Option<String>,
109        /// `EffectSetup` span this cleanup releases. Cleanups are parented
110        /// directly under the test span (not the long-closed `EffectSetup`)
111        /// so they stay well-ordered and reachable in the viewer; this
112        /// back-reference preserves the link so consumers can resolve a
113        /// cleanup shell's scope to the owning effect's vars.
114        setup_span: SpanId,
115        /// Identity marker, identical to the paired `EffectSetup`'s.
116        marker: String,
117        /// `false` on the final-release span that runs the cleanup body.
118        /// `true` on zero-duration spans emitted by non-last releases.
119        is_deferred: bool,
120    },
121    ShellBlock {
122        shell: String,
123    },
124    /// Block opened by the runtime when entering `<{ ... }` (and timed
125    /// variants). Spans the multipattern scan; closes at
126    /// `MultiMatchDone` or `MultiMatchTimeout` (or via the failure path
127    /// on fail-pattern abort). Mirrors `ShellBlock`'s shape; the viewer
128    /// keys on this kind to apply the observation-vs-drain rule for
129    /// per-pattern `Matched` buffer events.
130    MultiMatch {
131        shell: String,
132    },
133    CleanupBlock,
134    FnCall {
135        name: String,
136        args: Vec<(String, String)>,
137        result: Option<String>,
138        callee_kind: FnCallKind,
139        is_pure: bool,
140    },
141    /// Synthetic root span grouping per-test marker evaluations.
142    /// Opened before the test root; carries no payload of its own.
143    Markers,
144    /// One marker evaluation. Child of `Markers`. Inner sink-op
145    /// events (`var-read`, `interpolation`, `fn-call`, `pure-match`)
146    /// describe how the condition was computed; a final `bool-check`
147    /// event carries the truthy/falsy outcome that the `decision`
148    /// summarises. `marker_kind` avoids collision with serde tag `kind`.
149    MarkerEval {
150        marker_kind: MarkerEvalKind,
151        modifier: MarkerEvalModifier,
152        decision: MarkerEvalDecision,
153    },
154}
155
156impl SpanKind {
157    /// Discriminator string matching the `serde(tag = "kind")` representation.
158    /// Used by stack-frame rendering so that consumers see the same string
159    /// they'd see in the JSON `spans` glossary.
160    pub fn kind_str(&self) -> &'static str {
161        match self {
162            SpanKind::Test { .. } => "test",
163            SpanKind::EffectSetup { .. } => "effect-setup",
164            SpanKind::EffectCleanup { .. } => "effect-cleanup",
165            SpanKind::ShellBlock { .. } => "shell-block",
166            SpanKind::MultiMatch { .. } => "multi-match",
167            SpanKind::CleanupBlock => "cleanup-block",
168            SpanKind::FnCall { .. } => "fn-call",
169            SpanKind::Markers => "markers",
170            SpanKind::MarkerEval { .. } => "marker-eval",
171        }
172    }
173
174    /// Frame name and args used in stack-frame rendering. `name` is the
175    /// test, effect, or function name; `args` is the call args or effect overlay.
176    pub fn frame_data(&self) -> (Option<String>, Vec<(String, String)>) {
177        match self {
178            SpanKind::CleanupBlock => (None, Vec::new()),
179            SpanKind::Test { name } => (Some(name.clone()), Vec::new()),
180            SpanKind::EffectSetup {
181                effect, overlay, ..
182            } => (Some(effect.clone()), overlay.clone()),
183            SpanKind::EffectCleanup { effect, .. } => (Some(effect.clone()), Vec::new()),
184            SpanKind::ShellBlock { shell } => (Some(shell.clone()), Vec::new()),
185            SpanKind::MultiMatch { shell } => (Some(shell.clone()), Vec::new()),
186            SpanKind::FnCall { name, args, .. } => (Some(name.clone()), args.clone()),
187            SpanKind::Markers => (None, Vec::new()),
188            SpanKind::MarkerEval { .. } => (None, Vec::new()),
189        }
190    }
191
192    /// User-supplied alias bound at start time, if any (`start FX as Alias`).
193    /// Only effect-setup / effect-cleanup frames carry one today.
194    pub fn frame_alias(&self) -> Option<String> {
195        match self {
196            SpanKind::EffectSetup { alias, .. } => alias.clone(),
197            SpanKind::EffectCleanup { alias, .. } => alias.clone(),
198            _ => None,
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn markers_span_kind_serialises_as_kebab_kind() {
209        let kind = SpanKind::Markers;
210        let v = serde_json::to_value(&kind).unwrap();
211        assert_eq!(v, serde_json::json!({ "kind": "markers" }));
212    }
213
214    #[test]
215    fn marker_eval_span_kind_serialises_payload() {
216        let kind = SpanKind::MarkerEval {
217            marker_kind: MarkerEvalKind::Skip,
218            modifier: MarkerEvalModifier::If,
219            decision: MarkerEvalDecision::Mark,
220        };
221        let v = serde_json::to_value(&kind).unwrap();
222        assert_eq!(v["kind"], serde_json::json!("marker-eval"));
223        assert_eq!(v["marker_kind"], serde_json::json!("skip"));
224        assert_eq!(v["modifier"], serde_json::json!("if"));
225        assert_eq!(v["decision"], serde_json::json!("mark"));
226    }
227
228    #[test]
229    fn fn_call_span_serializes_callee_kind_and_is_pure() {
230        let span = SpanKind::FnCall {
231            name: "trim".into(),
232            args: vec![("$0".into(), "  hi  ".into())],
233            result: Some("hi".into()),
234            callee_kind: FnCallKind::Bif,
235            is_pure: true,
236        };
237        let json = serde_json::to_value(&span).unwrap();
238        assert_eq!(json["kind"], "fn-call");
239        assert_eq!(json["name"], "trim");
240        assert_eq!(json["callee_kind"], "bif");
241        assert_eq!(json["is_pure"], true);
242    }
243
244    #[test]
245    fn multi_match_span_kind_serialises() {
246        let kind = SpanKind::MultiMatch {
247            shell: "default".into(),
248        };
249        let v = serde_json::to_value(&kind).unwrap();
250        assert_eq!(v["kind"], serde_json::json!("multi-match"));
251        assert_eq!(v["shell"], serde_json::json!("default"));
252    }
253
254    #[test]
255    fn effect_setup_span_kind_serialises_dep_sources() {
256        let kind = SpanKind::EffectSetup {
257            effect: "Api".into(),
258            overlay: vec![("DB_PORT".into(), "5432".into())],
259            alias: Some("Api".into()),
260            dep_sources: vec![("DB_PORT".into(), "Db".into())],
261            marker: "marker-1".into(),
262            is_reuse: false,
263        };
264        let json = serde_json::to_value(&kind).unwrap();
265        assert_eq!(json["kind"], "effect-setup");
266        assert_eq!(json["dep_sources"][0][0], "DB_PORT");
267        assert_eq!(json["dep_sources"][0][1], "Db");
268    }
269
270    #[test]
271    fn multi_match_span_kind_str_and_frame_data() {
272        let kind = SpanKind::MultiMatch {
273            shell: "default".into(),
274        };
275        assert_eq!(kind.kind_str(), "multi-match");
276        let (name, args) = kind.frame_data();
277        assert_eq!(name.as_deref(), Some("default"));
278        assert!(args.is_empty());
279        assert_eq!(kind.frame_alias(), None);
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, TS)]
284#[cfg_attr(
285    feature = "ts-export",
286    ts(export, export_to = "../../../viewer/src/types/")
287)]
288pub struct Span {
289    pub id: SpanId,
290    #[serde(flatten)]
291    pub kind: SpanKind,
292    pub parent: Option<SpanId>,
293    #[serde(with = "super::ts_duration_ms")]
294    #[ts(as = "f64")]
295    pub start_ts: Duration,
296    #[serde(with = "super::ts_duration_ms_opt")]
297    #[ts(as = "Option<f64>")]
298    pub end_ts: Option<Duration>,
299    pub location: Option<SourceLocation>,
300}