Skip to main content

oopsie_core/erased/
spantrace.rs

1//! Serializable span trace representation.
2
3use alloc::boxed::Box;
4use alloc::vec::Vec;
5use core::fmt;
6
7use serde::{Deserialize, Serialize};
8
9// ─────────────────────────────────────────────────────────────────────────────
10// TracingLevel
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// The verbosity level of a transported span, mirroring `tracing::Level`.
14///
15/// Unrecognized or unparseable level strings map to [`UNKNOWN`](Self::UNKNOWN)
16/// instead of rejecting the payload, whether parsed or deserialized.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[repr(u8)]
19pub enum TracingLevel {
20    /// The most verbose level.
21    TRACE = 0,
22    /// Debug-level diagnostics.
23    DEBUG = 1,
24    /// Informational messages.
25    INFO = 2,
26    /// Warnings about recoverable conditions.
27    WARN = 3,
28    /// Errors and failures.
29    ERROR = 4,
30    /// A level that could not be recognized when deserializing.
31    UNKNOWN = 5,
32}
33
34impl fmt::Display for TracingLevel {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(match self {
37            Self::TRACE => "TRACE",
38            Self::DEBUG => "DEBUG",
39            Self::INFO => "INFO",
40            Self::WARN => "WARN",
41            Self::ERROR => "ERROR",
42            Self::UNKNOWN => "UNKNOWN",
43        })
44    }
45}
46
47impl core::str::FromStr for TracingLevel {
48    type Err = core::convert::Infallible;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        let level = if s.eq_ignore_ascii_case("TRACE") {
52            Self::TRACE
53        } else if s.eq_ignore_ascii_case("DEBUG") {
54            Self::DEBUG
55        } else if s.eq_ignore_ascii_case("INFO") {
56            Self::INFO
57        } else if s.eq_ignore_ascii_case("WARN") {
58            Self::WARN
59        } else if s.eq_ignore_ascii_case("ERROR") {
60            Self::ERROR
61        } else {
62            Self::UNKNOWN
63        };
64        Ok(level)
65    }
66}
67
68impl<'de> Deserialize<'de> for TracingLevel {
69    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
70    where
71        D: serde::Deserializer<'de>,
72    {
73        // A level is cosmetic metadata; a transport for error reports must
74        // degrade on a bad value rather than drop the whole error.
75        let s = alloc::borrow::Cow::<str>::deserialize(deserializer)?;
76        Ok(s.parse().unwrap_or(Self::UNKNOWN))
77    }
78}
79
80#[cfg(feature = "tracing")]
81impl From<TracingLevel> for tracing::Level {
82    #[inline]
83    fn from(level: TracingLevel) -> Self {
84        match level {
85            TracingLevel::ERROR | TracingLevel::UNKNOWN => Self::ERROR,
86            TracingLevel::WARN => Self::WARN,
87            TracingLevel::INFO => Self::INFO,
88            TracingLevel::DEBUG => Self::DEBUG,
89            TracingLevel::TRACE => Self::TRACE,
90        }
91    }
92}
93
94#[cfg(feature = "tracing")]
95impl From<&tracing::Level> for TracingLevel {
96    #[inline]
97    fn from(level: &tracing::Level) -> Self {
98        match *level {
99            tracing::Level::ERROR => Self::ERROR,
100            tracing::Level::WARN => Self::WARN,
101            tracing::Level::INFO => Self::INFO,
102            tracing::Level::DEBUG => Self::DEBUG,
103            tracing::Level::TRACE => Self::TRACE,
104        }
105    }
106}
107
108// ─────────────────────────────────────────────────────────────────────────────
109// Erased span trace types
110// ─────────────────────────────────────────────────────────────────────────────
111
112/// A serializable, type-erased representation of a span trace.
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
114#[non_exhaustive]
115pub struct ErasedSpanTrace {
116    spans: Vec<ErasedSpan>,
117}
118
119/// A single span in an erased span trace.
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121#[non_exhaustive]
122pub struct ErasedSpan {
123    metadata: ErasedMetadata,
124    fields: Box<str>,
125}
126
127impl ErasedSpan {
128    /// The span's metadata.
129    #[must_use]
130    #[inline]
131    pub const fn metadata(&self) -> &ErasedMetadata {
132        &self.metadata
133    }
134
135    /// The span's recorded fields, formatted as a single string.
136    #[must_use]
137    #[inline]
138    pub fn fields(&self) -> &str {
139        &self.fields
140    }
141}
142
143/// Serializable metadata from a tracing span.
144#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
145#[non_exhaustive]
146pub struct ErasedMetadata {
147    name: Box<str>,
148    target: Box<str>,
149    level: TracingLevel,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    module_path: Option<Box<str>>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    file: Option<Box<str>>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    line: Option<u32>,
156}
157
158impl ErasedMetadata {
159    /// The span's name.
160    #[must_use]
161    #[inline]
162    pub fn name(&self) -> &str {
163        &self.name
164    }
165
166    /// The span's target (typically the module path it was created in).
167    #[must_use]
168    #[inline]
169    pub fn target(&self) -> &str {
170        &self.target
171    }
172
173    /// The span's verbosity level.
174    #[must_use]
175    #[inline]
176    pub const fn level(&self) -> TracingLevel {
177        self.level
178    }
179
180    /// The module path the span was created in, if recorded.
181    #[must_use]
182    #[inline]
183    pub fn module_path(&self) -> Option<&str> {
184        self.module_path.as_deref()
185    }
186
187    /// The source file the span was created in, if recorded.
188    #[must_use]
189    #[inline]
190    pub fn file(&self) -> Option<&str> {
191        self.file.as_deref()
192    }
193
194    /// The line within the source file, if recorded.
195    #[must_use]
196    #[inline]
197    pub const fn line(&self) -> Option<u32> {
198        self.line
199    }
200}
201
202#[cfg(feature = "tracing")]
203impl<'a> From<&'a tracing::Metadata<'_>> for ErasedMetadata {
204    fn from(meta: &'a tracing::Metadata<'_>) -> Self {
205        Self {
206            name: meta.name().into(),
207            target: meta.target().into(),
208            level: meta.level().into(),
209            module_path: meta.module_path().map(Box::from),
210            file: meta.file().map(Box::from),
211            line: meta.line(),
212        }
213    }
214}
215
216impl ErasedSpanTrace {
217    /// Create an `ErasedSpanTrace` from a live `SpanTrace`.
218    #[cfg(feature = "tracing")]
219    #[must_use]
220    pub fn from_spantrace_ref(st: &crate::SpanTrace) -> Self {
221        let mut spans = Vec::new();
222        st.as_span_trace().with_spans(|metadata, fields| {
223            spans.push(ErasedSpan {
224                metadata: ErasedMetadata::from(metadata),
225                fields: fields.into(),
226            });
227            true
228        });
229        Self { spans }
230    }
231
232    /// Returns `true` when the trace contains no spans.
233    #[must_use]
234    #[inline]
235    pub const fn is_empty(&self) -> bool {
236        self.spans.is_empty()
237    }
238
239    /// Iterate over spans with their metadata and fields.
240    pub fn with_spans<F>(&self, mut f: F)
241    where
242        F: FnMut(&ErasedMetadata, &str) -> bool,
243    {
244        for ErasedSpan { metadata, fields } in &self.spans {
245            if !f(metadata, fields) {
246                break;
247            }
248        }
249    }
250}
251
252#[cfg(feature = "tracing")]
253impl From<&crate::SpanTrace> for ErasedSpanTrace {
254    #[inline]
255    fn from(st: &crate::SpanTrace) -> Self {
256        Self::from_spantrace_ref(st)
257    }
258}
259
260impl fmt::Display for ErasedSpanTrace {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        let mut err = Ok(());
263        let mut span = 0;
264
265        macro_rules! try_bool {
266            ($e:expr, $dest:ident) => {{
267                let ret = $e.unwrap_or_else(|e| $dest = Err(e));
268
269                if $dest.is_err() {
270                    return false;
271                }
272
273                ret
274            }};
275        }
276
277        self.with_spans(|metadata, fields| {
278            if span > 0 {
279                try_bool!(write!(f, "\n",), err);
280            }
281
282            try_bool!(
283                write!(
284                    f,
285                    "{:>3}: {}::{}",
286                    span + 1,
287                    metadata.target(),
288                    metadata.name()
289                ),
290                err
291            );
292
293            if !fields.is_empty() {
294                try_bool!(write!(f, "\n           with {}", fields), err);
295            }
296
297            if let Some((file, line)) = metadata.file().zip(metadata.line()) {
298                try_bool!(write!(f, "\n             at {}:{}", file, line), err);
299            }
300
301            span += 1;
302            true
303        });
304
305        err
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_tracing_level_tolerant_deserialization() {
315        let level: TracingLevel = serde_json::from_value(serde_json::json!("info")).unwrap();
316        assert_eq!(level, TracingLevel::INFO, "case-insensitive known level");
317
318        let level: TracingLevel = serde_json::from_value(serde_json::json!("FATAL")).unwrap();
319        assert_eq!(
320            level,
321            TracingLevel::UNKNOWN,
322            "unknown level degrades to UNKNOWN"
323        );
324
325        // Canonical uppercase still round-trips byte-stably.
326        let json = serde_json::to_string(&TracingLevel::WARN).unwrap();
327        assert_eq!(json, r#""WARN""#);
328        let back: TracingLevel = serde_json::from_str(&json).unwrap();
329        assert_eq!(back, TracingLevel::WARN);
330
331        // UNKNOWN itself round-trips.
332        let json = serde_json::to_string(&TracingLevel::UNKNOWN).unwrap();
333        assert_eq!(json, r#""UNKNOWN""#);
334        let back: TracingLevel = serde_json::from_str(&json).unwrap();
335        assert_eq!(back, TracingLevel::UNKNOWN);
336    }
337
338    #[test]
339    fn test_erased_spantrace_deserialization() {
340        let json = serde_json::json!({
341            "spans": [
342                {
343                    "metadata": {
344                        "name": "inner_span",
345                        "target": "my_crate::module",
346                        "level": "INFO",
347                        "module_path": "my_crate::module",
348                        "file": "src/lib.rs",
349                        "line": 42
350                    },
351                    "fields": "request_id=123"
352                },
353                {
354                    "metadata": {
355                        "name": "outer_span",
356                        "target": "my_crate",
357                        "level": "DEBUG"
358                    },
359                    "fields": ""
360                }
361            ]
362        });
363
364        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
365        assert_eq!(spantrace.spans.len(), 2);
366    }
367
368    #[test]
369    fn test_erased_spantrace_serialization_roundtrip() {
370        let json = serde_json::json!({
371            "spans": [
372                {
373                    "metadata": {
374                        "name": "test_span",
375                        "target": "test_target",
376                        "level": "WARN",
377                        "file": "test.rs",
378                        "line": 100
379                    },
380                    "fields": "key=value"
381                }
382            ]
383        });
384
385        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
386        let serialized = serde_json::to_string(&spantrace).unwrap();
387        let roundtrip: ErasedSpanTrace = serde_json::from_str(&serialized).unwrap();
388
389        // Verify roundtrip by serializing again
390        let serialized2 = serde_json::to_string(&roundtrip).unwrap();
391        assert_eq!(serialized, serialized2);
392    }
393
394    #[cfg(feature = "test-utils")]
395    #[test]
396    fn test_erased_spantrace_display() {
397        let json = serde_json::json!({
398            "spans": [
399                {
400                    "metadata": {
401                        "name": "inner_function",
402                        "target": "my_module",
403                        "level": "INFO",
404                        "file": "src/lib.rs",
405                        "line": 42
406                    },
407                    "fields": ""
408                },
409                {
410                    "metadata": {
411                        "name": "outer_function",
412                        "target": "my_crate",
413                        "level": "DEBUG"
414                    },
415                    "fields": ""
416                }
417            ]
418        });
419
420        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
421        let display = spantrace.to_string();
422
423        insta::assert_snapshot!(display, @"
424        1: my_module::inner_function
425                   at src/lib.rs:42
426        2: my_crate::outer_function
427        ");
428    }
429
430    #[cfg(feature = "test-utils")]
431    #[test]
432    fn test_erased_spantrace_display_with_fields() {
433        let json = serde_json::json!({
434            "spans": [
435                {
436                    "metadata": {
437                        "name": "process_request",
438                        "target": "server",
439                        "level": "INFO",
440                        "file": "src/server.rs",
441                        "line": 100
442                    },
443                    "fields": "user_id=42, method=GET"
444                }
445            ]
446        });
447
448        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
449        let display = spantrace.to_string();
450
451        insta::assert_snapshot!(display, @"
452        1: server::process_request
453                 with user_id=42, method=GET
454                   at src/server.rs:100
455        ");
456    }
457
458    #[test]
459    fn test_erased_spantrace_with_spans_iteration() {
460        let json = serde_json::json!({
461            "spans": [
462                {"metadata": {"name": "span_a", "target": "target_a", "level": "INFO"}, "fields": ""},
463                {"metadata": {"name": "span_b", "target": "target_b", "level": "DEBUG"}, "fields": ""},
464                {"metadata": {"name": "span_c", "target": "target_c", "level": "TRACE"}, "fields": ""}
465            ]
466        });
467
468        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
469
470        let mut collected = Vec::new();
471        spantrace.with_spans(|metadata, _fields| {
472            collected.push(format!("{}::{}", metadata.target(), metadata.name()));
473            true // continue
474        });
475
476        assert_eq!(
477            collected,
478            vec!["target_a::span_a", "target_b::span_b", "target_c::span_c"]
479        );
480    }
481
482    #[test]
483    fn test_erased_spantrace_with_spans_early_exit() {
484        let json = serde_json::json!({
485            "spans": [
486                {"metadata": {"name": "a", "target": "t", "level": "INFO"}, "fields": ""},
487                {"metadata": {"name": "b", "target": "t", "level": "INFO"}, "fields": ""},
488                {"metadata": {"name": "c", "target": "t", "level": "INFO"}, "fields": ""}
489            ]
490        });
491
492        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
493
494        let mut count = 0;
495        spantrace.with_spans(|_metadata, _fields| {
496            count += 1;
497            count < 2 // stop after second span
498        });
499
500        assert_eq!(count, 2);
501    }
502
503    // ── ErasedMetadata getters ──────────────────────────────────────────
504
505    #[test]
506    fn test_erased_metadata_getters_with_optional_fields() {
507        let json = serde_json::json!({
508            "spans": [{
509                "metadata": {
510                    "name": "my_span",
511                    "target": "my_crate::module",
512                    "level": "INFO",
513                    "module_path": "my_crate::module",
514                    "file": "src/lib.rs",
515                    "line": 42
516                },
517                "fields": ""
518            }]
519        });
520
521        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
522        spantrace.with_spans(|metadata, _fields| {
523            assert_eq!(metadata.module_path(), Some("my_crate::module"));
524            assert_eq!(metadata.file(), Some("src/lib.rs"));
525            assert_eq!(metadata.line(), Some(42));
526            false
527        });
528    }
529
530    #[test]
531    fn test_erased_metadata_getters_without_optional_fields() {
532        let json = serde_json::json!({
533            "spans": [{
534                "metadata": {
535                    "name": "bare_span",
536                    "target": "some_target",
537                    "level": "DEBUG"
538                },
539                "fields": ""
540            }]
541        });
542
543        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
544        spantrace.with_spans(|metadata, _fields| {
545            assert_eq!(metadata.module_path(), None);
546            assert_eq!(metadata.file(), None);
547            assert_eq!(metadata.line(), None);
548            false
549        });
550    }
551
552    // ── ErasedSpanTrace Display edge case ─────────────────────────────
553
554    #[test]
555    fn test_erased_spantrace_display_span_zero_has_no_leading_newline() {
556        let json = serde_json::json!({
557            "spans": [{
558                "metadata": {
559                    "name": "only_span",
560                    "target": "t",
561                    "level": "INFO"
562                },
563                "fields": ""
564            }]
565        });
566        let spantrace: ErasedSpanTrace = serde_json::from_value(json).unwrap();
567        let display = spantrace.to_string();
568        // The first span must NOT start with a newline
569        assert!(!display.starts_with('\n'));
570        assert!(display.starts_with("  1:"));
571    }
572}