Skip to main content

oopsie_core/erased/
spantrace.rs

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