Skip to main content

rustev_contract/
trace.rs

1//! The span line document, `rustev.trace/1` (spec 028, 3.10.1): one compact
2//! JSON object per span per line, keys sorted, at most 64 KiB per line.
3//!
4//! Each attribute value says how it was produced (`allow`, `digest` or
5//! `truncated`); a dropped field is absent and listed in
6//! `rustev.redaction.withheld`. A reader parses a line with
7//! [`TraceLine::parse_line`], which applies [`crate::limits::TRACE_V1`] and
8//! checks the line's own invariants.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14
15use crate::canonical::{CanonicalError, record_canonical_bytes};
16use crate::limits::TRACE_V1;
17use crate::trace_fields::{fixed, span};
18use crate::{Document, DocumentError};
19
20/// The schema string of a span line.
21pub const TRACE: &str = "rustev.trace/1";
22
23/// An attribute value: a string, integer, number, boolean or a homogeneous
24/// array of one of those (3.10.1).
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(untagged)]
27pub enum TraceValue {
28    Str(String),
29    Int(i64),
30    Double(f64),
31    Bool(bool),
32    StrArray(Vec<String>),
33    IntArray(Vec<i64>),
34    DoubleArray(Vec<f64>),
35    BoolArray(Vec<bool>),
36}
37
38impl TraceValue {
39    /// The value's string, when it is one.
40    pub fn as_str(&self) -> Option<&str> {
41        match self {
42            TraceValue::Str(s) => Some(s),
43            _ => None,
44        }
45    }
46
47    /// Every number is finite, and an empty array is written as a string
48    /// array (an empty JSON array reads back as one, so any other empty
49    /// array would not round-trip).
50    pub fn is_well_formed(&self) -> bool {
51        match self {
52            TraceValue::Double(d) => d.is_finite(),
53            TraceValue::DoubleArray(a) => !a.is_empty() && a.iter().all(|d| d.is_finite()),
54            TraceValue::IntArray(a) => !a.is_empty(),
55            TraceValue::BoolArray(a) => !a.is_empty(),
56            _ => true,
57        }
58    }
59}
60
61impl From<&str> for TraceValue {
62    fn from(s: &str) -> Self {
63        TraceValue::Str(s.to_string())
64    }
65}
66
67impl From<String> for TraceValue {
68    fn from(s: String) -> Self {
69        TraceValue::Str(s)
70    }
71}
72
73impl From<i64> for TraceValue {
74    fn from(i: i64) -> Self {
75        TraceValue::Int(i)
76    }
77}
78
79impl From<bool> for TraceValue {
80    fn from(b: bool) -> Self {
81        TraceValue::Bool(b)
82    }
83}
84
85/// How an exported value was produced (3.10.1).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
87#[serde(rename_all = "snake_case")]
88pub enum Mark {
89    Allow,
90    Digest,
91    Truncated,
92}
93
94/// One exported attribute: its value and how it was produced.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
96#[serde(deny_unknown_fields)]
97pub struct TraceAttr {
98    pub v: TraceValue,
99    pub r: Mark,
100}
101
102/// Span kind (3.5.3).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
104#[serde(rename_all = "snake_case")]
105pub enum SpanKind {
106    Internal,
107    Client,
108}
109
110/// Span status (3.5.3). An unresolved, escalated or cancelled decision is an
111/// answer, so its root stays `unset`; only a failed or deadline attempt is
112/// `error`.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
114#[serde(rename_all = "snake_case")]
115pub enum SpanStatus {
116    Unset,
117    Error,
118}
119
120/// A span event (3.5.5).
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
122#[serde(deny_unknown_fields)]
123pub struct TraceEvent {
124    pub name: String,
125    pub time_unix_nanos: u64,
126    pub attributes: BTreeMap<String, TraceAttr>,
127}
128
129/// The instrumentation scope (3.2.4).
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
131#[serde(deny_unknown_fields)]
132pub struct TraceScope {
133    pub name: String,
134    pub version: String,
135    pub schema_url: String,
136}
137
138/// The resource, on the root line only (3.2.4, 3.10.1).
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
140#[serde(deny_unknown_fields)]
141pub struct TraceResource {
142    pub attributes: BTreeMap<String, TraceAttr>,
143    pub schema_url: String,
144    pub scope: TraceScope,
145}
146
147/// One span, one line (3.10.1).
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
149#[serde(deny_unknown_fields)]
150pub struct TraceLine {
151    pub schema: String,
152    /// The span schema version, `1` for `rustev.otel/1`.
153    pub schema_version: u32,
154    /// 32 lowercase hex digits.
155    pub trace_id: String,
156    /// 16 lowercase hex digits.
157    pub span_id: String,
158    /// 16 lowercase hex digits, or null for a root without a host parent.
159    pub parent_span_id: Option<String>,
160    pub name: String,
161    pub kind: SpanKind,
162    pub start_unix_nanos: u64,
163    pub end_unix_nanos: u64,
164    pub status: SpanStatus,
165    pub attributes: BTreeMap<String, TraceAttr>,
166    pub events: Vec<TraceEvent>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub resource: Option<TraceResource>,
169}
170
171crate::document::document!(TraceLine, TRACE, TRACE_V1);
172
173/// Why a line was refused.
174#[derive(Debug, Clone, PartialEq)]
175pub enum TraceLineError {
176    /// Not a `rustev.trace/1` document within `TRACE_V1`.
177    Parse(DocumentError),
178    /// A field breaks a line invariant; `what` names it.
179    Invalid { what: String },
180    /// The line has no canonical bytes (a non-finite number).
181    Canonical(CanonicalError),
182    /// The written line exceeds 64 KiB.
183    TooLong { bytes: usize },
184}
185
186impl fmt::Display for TraceLineError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            TraceLineError::Parse(e) => write!(f, "{e}"),
190            TraceLineError::Invalid { what } => write!(f, "invalid trace line: {what}"),
191            TraceLineError::Canonical(e) => write!(f, "{e}"),
192            TraceLineError::TooLong { bytes } => write!(
193                f,
194                "trace line is {bytes} bytes, at most {}",
195                TRACE_V1.max_bytes
196            ),
197        }
198    }
199}
200
201impl std::error::Error for TraceLineError {}
202
203fn is_hex(s: &str, len: usize) -> bool {
204    s.len() == len
205        && s.bytes()
206            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
207        && s.bytes().any(|b| b != b'0')
208}
209
210fn invalid(what: impl Into<String>) -> TraceLineError {
211    TraceLineError::Invalid { what: what.into() }
212}
213
214impl TraceLine {
215    /// Parse one line (without its newline) under `TRACE_V1` and check it.
216    pub fn parse_line(bytes: &[u8]) -> Result<Self, TraceLineError> {
217        let line = Self::parse(bytes).map_err(TraceLineError::Parse)?;
218        line.check()?;
219        Ok(line)
220    }
221
222    /// The line's invariants: the span schema version is
223    /// `SPAN_SCHEMA_VERSION`, ids are non-zero lowercase hex of their
224    /// length, the name is a span name of 3.5.1, the end is not before the
225    /// start, only a root (a line with `resource`) carries the resource,
226    /// every value is well formed, and the withheld list is present.
227    pub fn check(&self) -> Result<(), TraceLineError> {
228        if self.schema_version != crate::trace_fields::SPAN_SCHEMA_VERSION {
229            return Err(invalid("schema_version"));
230        }
231        if !is_hex(&self.trace_id, 32) {
232            return Err(invalid("trace_id"));
233        }
234        if !is_hex(&self.span_id, 16) {
235            return Err(invalid("span_id"));
236        }
237        if let Some(p) = &self.parent_span_id
238            && !is_hex(p, 16)
239        {
240            return Err(invalid("parent_span_id"));
241        }
242        if !span::ALL.contains(&self.name.as_str()) {
243            return Err(invalid("name"));
244        }
245        if self.end_unix_nanos < self.start_unix_nanos {
246            return Err(invalid("end_unix_nanos"));
247        }
248        if self.resource.is_some() != (self.name == span::DECISION) {
249            return Err(invalid("resource"));
250        }
251        let finite = self.attributes.values().all(|a| a.v.is_well_formed())
252            && self
253                .events
254                .iter()
255                .all(|e| e.attributes.values().all(|a| a.v.is_well_formed()));
256        if !finite {
257            return Err(invalid("a non-finite number or an empty non-string array"));
258        }
259        match self.attributes.get(fixed::WITHHELD) {
260            Some(TraceAttr {
261                v: TraceValue::StrArray(_),
262                r: Mark::Allow,
263            }) => {}
264            // An empty array parses as the first array variant, `StrArray`.
265            _ => return Err(invalid(fixed::WITHHELD)),
266        }
267        Ok(())
268    }
269
270    /// The line's bytes, without the newline: compact, keys sorted, binary64
271    /// numbers in shortest round-trip form. Refused over 64 KiB.
272    pub fn to_line(&self) -> Result<Vec<u8>, TraceLineError> {
273        self.check()?;
274        let bytes = record_canonical_bytes(self).map_err(TraceLineError::Canonical)?;
275        if bytes.len() > TRACE_V1.max_bytes {
276            return Err(TraceLineError::TooLong { bytes: bytes.len() });
277        }
278        Ok(bytes)
279    }
280}