Skip to main content

mira_core/
json.rs

1//! A JSON writer, because the alternative is 30 crates.
2//!
3//! Mira serializes exactly one shape — query results — and it does so from
4//! hand-written code that already knows the shape statically. serde_json would
5//! buy derive macros for structs that do not exist here (results are built
6//! column-wise straight out of Arrow arrays, never as a tree of Rust values) at
7//! the cost of serde + serde_json + syn + quote + proc-macro2 and a compile-time
8//! hit on every build. This is the whole feature in a hundred lines.
9//!
10//! The nesting API takes a closure per container so brackets cannot be
11//! unbalanced and commas cannot be missed: there is no `end_object` to forget.
12
13/// Incrementally built JSON. `key` before a value inside `obj`, bare values
14/// inside `arr`.
15pub struct Json {
16    buf: String,
17    /// Whether the next value needs a `,` in front of it. Reset on entering a
18    /// container, set after every value.
19    comma: bool,
20}
21
22impl Default for Json {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Json {
29    pub fn new() -> Self {
30        Self {
31            buf: String::with_capacity(64 << 10),
32            comma: false,
33        }
34    }
35
36    pub fn into_string(self) -> String {
37        self.buf
38    }
39
40    fn sep(&mut self) {
41        if self.comma {
42            self.buf.push(',');
43        }
44        self.comma = true;
45    }
46
47    /// Write a `{...}`. Inside `f`, call [`key`](Self::key) then a value.
48    pub fn obj(&mut self, f: impl FnOnce(&mut Self)) {
49        self.sep();
50        self.buf.push('{');
51        self.comma = false;
52        f(self);
53        self.buf.push('}');
54        self.comma = true;
55    }
56
57    /// Write a `[...]`.
58    pub fn arr(&mut self, f: impl FnOnce(&mut Self)) {
59        self.sep();
60        self.buf.push('[');
61        self.comma = false;
62        f(self);
63        self.buf.push(']');
64        self.comma = true;
65    }
66
67    /// A member name. The value that follows attaches to it, so `comma` stays
68    /// as it was rather than being armed here.
69    pub fn key(&mut self, k: &str) {
70        self.sep();
71        escape(&mut self.buf, k);
72        self.buf.push(':');
73        self.comma = false;
74    }
75
76    pub fn str(&mut self, s: &str) {
77        self.sep();
78        escape(&mut self.buf, s);
79    }
80
81    pub fn i64(&mut self, v: i64) {
82        self.sep();
83        self.buf.push_str(itoa(v).as_str());
84    }
85
86    pub fn u64(&mut self, v: u64) {
87        self.sep();
88        self.buf.push_str(&v.to_string());
89    }
90
91    /// A 64-bit integer as a JSON *string*, which is what OTLP/JSON says a
92    /// 64-bit integer is.
93    ///
94    /// Principle 3 decides this: where OTLP specifies an encoding, OTLP wins.
95    /// The spec makes `int64`, `sfixed64` and `uint64` strings on the wire, the
96    /// ingest decoder already reads them that way (section 0), and a response Mira
97    /// cannot feed back to itself as a request body is not a round trip. The
98    /// mechanical reason is the same in both directions: a JSON number is an
99    /// IEEE754 double to a browser and to most parsers, `time_unix_nano` is
100    /// ~1.7e18, and 2^53 is where a double stops counting. Bare-number output
101    /// does not fail there, it silently rounds.
102    ///
103    /// Only the 64-bit columns go through this. A `severity_number`, a
104    /// `status_code` or a `dropped_*` is 32 bits or smaller, arithmetic on it
105    /// is what a reader wants, and no double loses it.
106    pub fn i64_str(&mut self, v: i64) {
107        self.quoted_digits(&v.to_string());
108    }
109
110    pub fn u64_str(&mut self, v: u64) {
111        self.quoted_digits(&v.to_string());
112    }
113
114    /// Quote without escaping: the input is `to_string` of an integer, so it is
115    /// ASCII digits and at most a leading `-`.
116    fn quoted_digits(&mut self, digits: &str) {
117        self.sep();
118        self.buf.push('"');
119        self.buf.push_str(digits);
120        self.buf.push('"');
121    }
122
123    /// Non-finite becomes `null`. JSON has no NaN or Infinity, and both turn up
124    /// in real metrics — a histogram `sum` over no observations, a gauge from a
125    /// division by zero. Emitting the bare token would produce a document that
126    /// every strict parser, including the browser's, rejects outright, taking
127    /// the whole response down with it rather than the one field.
128    pub fn f64(&mut self, v: f64) {
129        self.sep();
130        if v.is_finite() {
131            self.buf.push_str(&ryu_lite(v));
132        } else {
133            self.buf.push_str("null");
134        }
135    }
136
137    pub fn bool(&mut self, v: bool) {
138        self.sep();
139        self.buf.push_str(if v { "true" } else { "false" });
140    }
141
142    pub fn null(&mut self) {
143        self.sep();
144        self.buf.push_str("null");
145    }
146
147    /// Splice in a fragment this writer produced earlier — a rendered value, or
148    /// a run of `"k":v,"k":v` members inside an object.
149    ///
150    /// The escape hatch for the one thing the closure API cannot express:
151    /// caching. A metric's descriptor and its resource attributes are identical
152    /// across every point of a series, and re-rendering them per point is the
153    /// difference between a chart query that costs one pass and one that costs
154    /// two. Empty is a no-op so a cached fragment that turned out to be empty
155    /// cannot emit a stray comma.
156    pub fn raw(&mut self, fragment: &str) {
157        if fragment.is_empty() {
158            return;
159        }
160        self.sep();
161        self.buf.push_str(fragment);
162    }
163
164    /// Lowercase hex of `bytes`, as one string. Trace and span ids are
165    /// `FixedSizeBinary` on disk and hex everywhere a human or a W3C header
166    /// sees them.
167    pub fn hex(&mut self, bytes: &[u8]) {
168        self.sep();
169        self.buf.push('"');
170        for b in bytes {
171            self.buf.push(HEX[(b >> 4) as usize] as char);
172            self.buf.push(HEX[(b & 0xf) as usize] as char);
173        }
174        self.buf.push('"');
175    }
176}
177
178const HEX: &[u8; 16] = b"0123456789abcdef";
179
180fn itoa(v: i64) -> String {
181    v.to_string()
182}
183
184fn ryu_lite(v: f64) -> String {
185    // `{}` on f64 already produces the shortest representation that round-trips
186    // (Rust uses Grisu/Ryū internally), so there is nothing to add here beyond
187    // the name being honest about what it is not. Unlike `{:?}` it never emits
188    // a trailing `.0`, so a whole number needs no trimming — see
189    // `a_whole_double_needs_no_trimming_and_a_control_character_is_never_raw`.
190    format!("{v}")
191}
192
193/// RFC 8259 string escaping, including the control characters below 0x20 that
194/// the spec requires be escaped and that a naive writer silently emits raw.
195fn escape(out: &mut String, s: &str) {
196    out.push('"');
197    for c in s.chars() {
198        match c {
199            '"' => out.push_str("\\\""),
200            '\\' => out.push_str("\\\\"),
201            '\n' => out.push_str("\\n"),
202            '\r' => out.push_str("\\r"),
203            '\t' => out.push_str("\\t"),
204            '\u{08}' => out.push_str("\\b"),
205            '\u{0c}' => out.push_str("\\f"),
206            c if (c as u32) < 0x20 => {
207                out.push_str("\\u00");
208                out.push(HEX[(c as usize >> 4) & 0xf] as char);
209                out.push(HEX[c as usize & 0xf] as char);
210            }
211            c => out.push(c),
212        }
213    }
214    out.push('"');
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    /// Nesting, comma placement and the escapes an attacker-influenced string
222    /// can carry. Any of these wrong is a response body that no strict parser
223    /// will read — the whole query lost, not one field.
224    #[test]
225    fn nesting_commas_and_hostile_strings() {
226        let mut j = Json::new();
227        j.obj(|j| {
228            j.key("rows");
229            j.arr(|j| {
230                j.obj(|j| {
231                    j.key("body");
232                    // A log body is the most attacker-influenced string in the
233                    // system, and a raw newline or a bare 0x01 in it is what
234                    // turns a query response into a parse error.
235                    j.str("line1\nline2\t\"quoted\"\\ \u{1}");
236                    j.key("trace_id");
237                    j.hex(&[0x4b, 0xf9, 0x2f]);
238                });
239                j.obj(|j| {
240                    j.key("n");
241                    j.i64(-17);
242                    // The 64-bit pair. `1e18 + 1` is the whole argument for
243                    // them: as a bare number every browser reads it back as
244                    // 1000000000000000000, one short, with no error anywhere.
245                    j.key("big");
246                    j.i64_str(-1_000_000_000_000_000_001);
247                    j.key("ubig");
248                    j.u64_str(u64::MAX);
249                });
250            });
251            j.key("nan");
252            j.f64(f64::NAN);
253            j.key("ratio");
254            j.f64(0.5);
255            j.key("whole");
256            j.f64(3.0);
257            j.key("empty");
258            j.arr(|_| {});
259        });
260        assert_eq!(
261            j.into_string(),
262            r#"{"rows":[{"body":"line1\nline2\t\"quoted\"\\ \u0001","trace_id":"4bf92f"},{"n":-17,"big":"-1000000000000000001","ubig":"18446744073709551615"}],"nan":null,"ratio":0.5,"whole":3,"empty":[]}"#
263        );
264    }
265
266    /// The rest of RFC 8259's named escapes, and the one thing an empty cached
267    /// fragment must never do: arm a comma. `raw("")` between two members that
268    /// emitted a separator would produce `{"a":1,,"b":2}` — a body a browser
269    /// rejects outright, and only on the runs where the cache happens to miss.
270    #[test]
271    fn an_empty_fragment_is_invisible_and_every_named_escape_is_named() {
272        let mut j = Json::default();
273        j.obj(|j| {
274            j.key("body");
275            // \b and \f have no Rust escape, and 0x1f is the last character the
276            // spec requires escaped, so it is the \u00XX arm's upper boundary.
277            j.str("\u{08}\u{0c}\r\n\t\u{1f} ");
278            j.raw("");
279            j.raw(r#""a":1"#);
280            j.raw("");
281            j.raw(r#""b":2"#);
282        });
283        assert_eq!(
284            j.into_string(),
285            "{\"body\":\"\\b\\f\\r\\n\\t\\u001f \",\"a\":1,\"b\":2}"
286        );
287    }
288
289    /// `Display` for `f64` never writes the trailing `.0` that `Debug` does, so
290    /// a whole double is already the integer a chart wants, across the whole
291    /// range a metric can hold. If that ever changes, every `count` in a query
292    /// response grows a `.0` and the trim this writer used to carry has to come
293    /// back.
294    #[test]
295    fn no_finite_double_is_written_with_a_trailing_point_zero() {
296        for v in [3.0f64, -0.0, 1e20, 1e-7, f64::MAX, f64::MIN_POSITIVE, 0.5] {
297            let mut j = Json::new();
298            j.f64(v);
299            let s = j.into_string();
300            assert!(!s.ends_with(".0"), "{v:?} rendered as {s}");
301            assert_eq!(s.parse::<f64>().expect("round trips"), v);
302        }
303    }
304}