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}