Skip to main content

moqtap_trace/
header.rs

1use std::collections::BTreeMap;
2
3use ciborium::Value;
4
5use crate::error::MoqTraceError;
6
7/// Recording perspective — who captured the trace.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Perspective {
10    /// MoQT client (initiator of the QUIC connection).
11    Client,
12    /// MoQT server or relay.
13    Server,
14    /// Passive observer (e.g. DevTools extension, network tap).
15    Observer,
16}
17
18impl Perspective {
19    fn as_str(self) -> &'static str {
20        match self {
21            Perspective::Client => "client",
22            Perspective::Server => "server",
23            Perspective::Observer => "observer",
24        }
25    }
26
27    fn from_str(s: &str) -> Result<Self, MoqTraceError> {
28        match s {
29            "client" => Ok(Perspective::Client),
30            "server" => Ok(Perspective::Server),
31            "observer" => Ok(Perspective::Observer),
32            other => Err(MoqTraceError::InvalidHeader(format!("unknown perspective: {other}"))),
33        }
34    }
35}
36
37/// Detail level — declares what was recorded.
38///
39/// Each level is a strict superset of the one above it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum DetailLevel {
42    /// Control messages only.
43    Control,
44    /// Control messages + data stream headers and object metadata.
45    Headers,
46    /// Headers + payload byte lengths.
47    HeadersSizes,
48    /// Headers + full payload bytes.
49    HeadersData,
50    /// Everything above + raw wire bytes.
51    Full,
52}
53
54impl DetailLevel {
55    fn as_str(self) -> &'static str {
56        match self {
57            DetailLevel::Control => "control",
58            DetailLevel::Headers => "headers",
59            DetailLevel::HeadersSizes => "headers+sizes",
60            DetailLevel::HeadersData => "headers+data",
61            DetailLevel::Full => "full",
62        }
63    }
64
65    fn from_str(s: &str) -> Result<Self, MoqTraceError> {
66        match s {
67            "control" => Ok(DetailLevel::Control),
68            "headers" => Ok(DetailLevel::Headers),
69            "headers+sizes" => Ok(DetailLevel::HeadersSizes),
70            "headers+data" => Ok(DetailLevel::HeadersData),
71            "full" => Ok(DetailLevel::Full),
72            other => Err(MoqTraceError::InvalidHeader(format!("unknown detail level: {other}"))),
73        }
74    }
75}
76
77/// Session metadata header written at the start of a `.moqtrace` file.
78#[derive(Debug, Clone, PartialEq)]
79pub struct TraceHeader {
80    /// MoQT version identifier (e.g. `"moq-transport-14"`).
81    pub protocol: String,
82    /// Recording viewpoint.
83    pub perspective: Perspective,
84    /// Detail level.
85    pub detail: DetailLevel,
86    /// Recording start time (Unix epoch milliseconds).
87    pub start_time: u64,
88    /// Recording end time (Unix epoch milliseconds). Set when trace is
89    /// finalized.
90    pub end_time: Option<u64>,
91    /// Transport type (e.g. `"webtransport"`, `"raw-quic"`).
92    pub transport: Option<String>,
93    /// Software that produced the trace.
94    pub source: Option<String>,
95    /// Remote peer URI.
96    pub endpoint: Option<String>,
97    /// Opaque session correlation identifier.
98    pub session_id: Option<String>,
99    /// User-defined metadata.
100    pub custom: Option<BTreeMap<String, Value>>,
101}
102
103impl From<&TraceHeader> for Value {
104    fn from(h: &TraceHeader) -> Self {
105        let mut pairs: Vec<(Value, Value)> = vec![
106            (Value::Text("protocol".into()), Value::Text(h.protocol.clone())),
107            (Value::Text("perspective".into()), Value::Text(h.perspective.as_str().into())),
108            (Value::Text("detail".into()), Value::Text(h.detail.as_str().into())),
109            (Value::Text("startTime".into()), Value::Integer(h.start_time.into())),
110        ];
111
112        if let Some(end_time) = h.end_time {
113            pairs.push((Value::Text("endTime".into()), Value::Integer(end_time.into())));
114        }
115        if let Some(ref transport) = h.transport {
116            pairs.push((Value::Text("transport".into()), Value::Text(transport.clone())));
117        }
118        if let Some(ref source) = h.source {
119            pairs.push((Value::Text("source".into()), Value::Text(source.clone())));
120        }
121        if let Some(ref endpoint) = h.endpoint {
122            pairs.push((Value::Text("endpoint".into()), Value::Text(endpoint.clone())));
123        }
124        if let Some(ref session_id) = h.session_id {
125            pairs.push((Value::Text("sessionId".into()), Value::Text(session_id.clone())));
126        }
127        if let Some(ref custom) = h.custom {
128            let custom_pairs: Vec<(Value, Value)> =
129                custom.iter().map(|(k, v)| (Value::Text(k.clone()), v.clone())).collect();
130            pairs.push((Value::Text("custom".into()), Value::Map(custom_pairs)));
131        }
132
133        Value::Map(pairs)
134    }
135}
136
137impl TryFrom<Value> for TraceHeader {
138    type Error = MoqTraceError;
139
140    fn try_from(value: Value) -> Result<Self, MoqTraceError> {
141        let pairs = match value {
142            Value::Map(pairs) => pairs,
143            _ => return Err(MoqTraceError::InvalidHeader("header is not a CBOR map".into())),
144        };
145
146        let get_text = |pairs: &[(Value, Value)], key: &str| -> Option<String> {
147            pairs.iter().find_map(|(k, v)| {
148                if k.as_text() == Some(key) {
149                    v.as_text().map(|s| s.to_string())
150                } else {
151                    None
152                }
153            })
154        };
155
156        let get_integer = |pairs: &[(Value, Value)], key: &str| -> Option<u64> {
157            pairs.iter().find_map(|(k, v)| {
158                if k.as_text() == Some(key) {
159                    v.as_integer().and_then(|i| u64::try_from(i).ok())
160                } else {
161                    None
162                }
163            })
164        };
165
166        let protocol = get_text(&pairs, "protocol")
167            .ok_or_else(|| MoqTraceError::InvalidHeader("missing 'protocol'".into()))?;
168        let perspective_str = get_text(&pairs, "perspective")
169            .ok_or_else(|| MoqTraceError::InvalidHeader("missing 'perspective'".into()))?;
170        let detail_str = get_text(&pairs, "detail")
171            .ok_or_else(|| MoqTraceError::InvalidHeader("missing 'detail'".into()))?;
172        let start_time = get_integer(&pairs, "startTime")
173            .ok_or_else(|| MoqTraceError::InvalidHeader("missing 'startTime'".into()))?;
174
175        let custom = pairs.iter().find_map(|(k, v)| {
176            if k.as_text() == Some("custom") {
177                if let Value::Map(custom_pairs) = v {
178                    let map: BTreeMap<String, Value> = custom_pairs
179                        .iter()
180                        .filter_map(|(ck, cv)| ck.as_text().map(|s| (s.to_string(), cv.clone())))
181                        .collect();
182                    Some(map)
183                } else {
184                    None
185                }
186            } else {
187                None
188            }
189        });
190
191        Ok(TraceHeader {
192            protocol,
193            perspective: Perspective::from_str(&perspective_str)?,
194            detail: DetailLevel::from_str(&detail_str)?,
195            start_time,
196            end_time: get_integer(&pairs, "endTime"),
197            transport: get_text(&pairs, "transport"),
198            source: get_text(&pairs, "source"),
199            endpoint: get_text(&pairs, "endpoint"),
200            session_id: get_text(&pairs, "sessionId"),
201            custom,
202        })
203    }
204}