Skip to main content

mira/
api.rs

1//! The query API.
2//!
3//! Shares the OTLP/HTTP listener rather than taking a port of its own: `/v1/*`
4//! is OTLP, `/api/v1/*` is query, `/` is the UI. One port is one thing to
5//! expose, one thing to firewall and one thing to get wrong, which is the whole
6//! argument for a single binary applied one level down.
7//!
8//! Queries are KYAML documents, and the parser is the one `config.rs` already
9//! uses. That is not a coincidence or a saving — it is the point of the
10//! KYAML-first principle. KYAML is a strict YAML 1.2 subset with explicit `{}`
11//! and `[]`, quoted strings and permitted trailing commas, which makes valid
12//! JSON valid KYAML: a browser can `JSON.stringify` a query and an agent can
13//! write one with comments in it, and both arrive at the same parser. There is
14//! no second query language and no JSON dependency.
15//!
16//! Every handler hops to `spawn_blocking` before touching a block. Reads are
17//! mmap reads, and a cold page fault stalls the OS thread it lands on with no
18//! yield point and no signal to the scheduler; run one on a tokio worker and it
19//! blocks every other connection that worker owns.
20
21use std::path::PathBuf;
22use std::sync::{Arc, LazyLock};
23
24use axum::Router;
25use axum::extract::State;
26use axum::http::{StatusCode, header};
27use axum::response::{IntoResponse, Response};
28use axum::routing::post;
29use tokio::sync::Semaphore;
30use yaml_rust2::parser::{Event, EventReceiver, Parser};
31use yaml_rust2::{Yaml, YamlLoader};
32
33use mira_core::frame::{self, Expand};
34use mira_core::query::{self, Op, Search, Signal, Target, Term, Value};
35use mira_core::series::{self, SeriesQuery};
36
37use crate::pipeline;
38
39#[derive(Clone, Default)]
40pub struct Api {
41    pub data_dir: Arc<PathBuf>,
42    /// The three flushers' open blocks, in [`pipeline::SIGNALS`] order.
43    ///
44    /// Default slots have no flusher behind them and always read empty, which is
45    /// what a unit test wanting only an `Api` gets. See
46    /// [`mira_core::signal::Open`].
47    pub open: pipeline::OpenSlots,
48    /// The alert evaluator's rules and their current state.
49    ///
50    /// Here rather than in a state of its own because three surfaces read it —
51    /// `/api/v1/alerts`, the MCP tool and the TUI pane — and the whole point of
52    /// [`crate::alert`] is that they are looking at one object. The default is
53    /// an engine with no rules, which answers "alerting is off".
54    pub alerts: Arc<crate::alert::Engine>,
55}
56
57impl Api {
58    /// The open block for one signal, as the read path wants it — everything
59    /// acknowledged before this call, published or not.
60    ///
61    /// Awaited before the query rather than inside it: this is where
62    /// read-your-writes is bought, and paying for it here keeps the scan itself
63    /// synchronous and off the runtime. One entry per flusher shard, which is
64    /// what `search_open` takes — the plumbing was already the general one
65    /// before a signal had more than one open block at a time.
66    pub(crate) async fn open(&self, signal: &str) -> Vec<Arc<mira_core::signal::Open>> {
67        let Some(i) = pipeline::SIGNALS.iter().position(|s| *s == signal) else {
68            return Vec::new();
69        };
70        self.open[i].fresh().await
71    }
72
73    /// Every signal's open block, in [`pipeline::SIGNALS`] order.
74    ///
75    /// For the two frame walks that cross signals. Parallel rather than
76    /// concatenated because sequence numbers are per-signal: a flat list would
77    /// let the logs snapshot collide with a traces block on `(node, seq)`.
78    pub(crate) async fn open_all(&self) -> Vec<Vec<Arc<mira_core::signal::Open>>> {
79        let mut out = Vec::with_capacity(pipeline::SIGNALS.len());
80        for s in pipeline::SIGNALS {
81            out.push(self.open(s).await);
82        }
83        out
84    }
85}
86
87pub fn router(api: Api) -> Router {
88    Router::new()
89        .route("/api/v1/query", post(query_handler))
90        .route("/api/v1/metrics/query", post(series_handler))
91        .route("/api/v1/metrics/names", post(names_handler))
92        .route("/api/v1/correlate", post(correlate_handler))
93        .route("/api/v1/map", post(map_handler))
94        .route("/api/v1/entities", post(entities_handler))
95        .with_state(api)
96}
97
98/// Largest `limit` a caller can ask for.
99///
100/// Results are materialized into one JSON string in memory, so this is a real
101/// memory bound and not a policy. An agent asking for everything gets a lot,
102/// but not the process.
103const MAX_LIMIT: usize = 10_000;
104
105/// Log records or spans matching a filter, newest first.
106///
107/// The one read every other read is defined in terms of: an alert rule's
108/// `query`, a UI list and an agent's `query_records` are all this document.
109/// `next` in the response is the cursor to hand back as `after` for the page
110/// behind it, and `stats` says how much was scanned to answer.
111async fn query_handler(State(api): State<Api>, body: String) -> Response {
112    let q = match parse_search(&body, now_nanos()) {
113        Ok(q) => q,
114        Err(e) => return bad_request(&e),
115    };
116    let dir = api.data_dir.clone();
117    let open = api.open(q.signal.dir()).await;
118    run("rows", move || query::search_open(&dir, &q, &open)).await
119}
120
121/// One metric's series over a window, with the exemplars naming the traces
122/// behind the points.
123async fn series_handler(State(api): State<Api>, body: String) -> Response {
124    let q = match parse_series(&body, now_nanos()) {
125        Ok(q) => q,
126        Err(e) => return bad_request(&e),
127    };
128    let dir = api.data_dir.clone();
129    let open = api.open("metrics").await;
130    run("series", move || series::series_open(&dir, &q, &open)).await
131}
132
133/// Which metric names a window holds. An empty body means *right now*.
134///
135/// ```yaml
136/// { "from": "-1h", "to": "now" }
137/// ```
138async fn names_handler(State(api): State<Api>, body: String) -> Response {
139    let now = now_nanos();
140    // An empty body is a valid request for "what is there right now", which is
141    // the first thing a UI or an agent asks.
142    let doc = match window(if body.trim().is_empty() { "{}" } else { &body }, now) {
143        Ok(w) => w,
144        Err(e) => return bad_request(&e),
145    };
146    let dir = api.data_dir.clone();
147    let open = api.open("metrics").await;
148    run("names", move || {
149        series::names_open(&dir, doc.0, doc.1, &open)
150    })
151    .await
152}
153
154/// The frame around a filter: its time extent, the traces it touches and the
155/// services that took part — one round trip where a client makes three.
156///
157/// ```yaml
158/// {
159///   "signal": "logs",
160///   "from": "-15m",
161///   "where": [ { "field": "severity_number", "gte": 17 } ],
162///   "expand": [ "traces", "around:2s", "peers" ],
163/// }
164/// ```
165///
166/// One call is one investigation step: *what was going on around the thing I
167/// searched for*. The answer is a frame — a window, the traces it covers and
168/// the services that took part — and every field of it is an input to an
169/// ordinary `/api/v1/query`, which is what keeps the algebra closed.
170async fn correlate_handler(State(api): State<Api>, body: String) -> Response {
171    let now = now_nanos();
172    let (q, ops) = match parse_correlate(&body, now) {
173        Ok(v) => v,
174        Err(e) => return bad_request(&e),
175    };
176    let dir = api.data_dir.clone();
177    // Both signals, because `anchor` reads the one the search names and the
178    // span-side expanders always read traces.
179    let anchored = api.open(q.signal.dir()).await;
180    let all = api.open_all().await;
181    run("frame", move || correlate(&dir, &q, &ops, &anchored, &all)).await
182}
183
184/// Anchor, walk, label. Shared with the MCP tool of the same name, so an agent
185/// and the UI are reading one implementation rather than two.
186pub(crate) fn correlate(
187    dir: &std::path::Path,
188    q: &Search,
189    ops: &[Expand],
190    anchored: &[Arc<mira_core::signal::Open>],
191    all: &[Vec<Arc<mira_core::signal::Open>>],
192) -> mira_core::error::Result<query::Results> {
193    let (f, a) = frame::anchor(dir, q, anchored)?;
194    // The span-side expanders read traces and nothing else, so they get the
195    // traces slot rather than the whole set.
196    let traces = all.get(1).map_or(&[][..], Vec::as_slice);
197    let (f, w) = frame::expand(dir, &f, ops, traces)?;
198    let names = frame::names_of(dir, &f, all)?;
199    let mut j = mira_core::json::Json::new();
200    f.write_json(&mut j, &names);
201    Ok(query::Results {
202        json: j.into_string(),
203        stats: query::Stats {
204            blocks_total: a.blocks_total + w.blocks_total,
205            blocks_scanned: a.blocks_scanned + w.blocks_scanned,
206            rows_scanned: a.rows_scanned + w.rows_scanned,
207            rows_matched: a.rows_matched + w.rows_matched,
208            ..Default::default()
209        },
210        next: None,
211    })
212}
213
214/// The service map over a window.
215///
216/// ```yaml
217/// { "from": "-15m", "to": "now", "max_spans": "50000" }
218/// ```
219///
220/// `max_spans` bounds the walk rather than the answer: a map is built by
221/// reading spans and joining them by parent, so the honest limit is on how many
222/// are read, not on how many edges come back.
223async fn map_handler(State(api): State<Api>, body: String) -> Response {
224    let now = now_nanos();
225    let doc = match parse(if body.trim().is_empty() { "{}" } else { &body }) {
226        Ok(d) => d,
227        Err(e) => return bad_request(&e),
228    };
229    let (from, to, max_spans) = match map_doc(&doc, now) {
230        Ok(v) => v,
231        Err(e) => return bad_request(&e),
232    };
233    let dir = api.data_dir.clone();
234    let open = api.open("traces").await;
235    run("map", move || frame::map(&dir, from, to, max_spans, &open)).await
236}
237
238/// Every service that produced anything in a window.
239///
240/// ```yaml
241/// { "from": "-15m", "to": "now" }
242/// ```
243async fn entities_handler(State(api): State<Api>, body: String) -> Response {
244    let now = now_nanos();
245    let (from, to) = match window(if body.trim().is_empty() { "{}" } else { &body }, now) {
246        Ok(w) => w,
247        Err(e) => return bad_request(&e),
248    };
249    let dir = api.data_dir.clone();
250    let open = api.open_all().await;
251    run("entities", move || frame::entities(&dir, from, to, &open)).await
252}
253
254/// How many searches may be on the blocking pool at once.
255///
256/// The bound is the point, not the number. Every read hops to that pool — and
257/// so does every `publish` fsync, which is the ingest path's durability
258/// barrier. Unbounded, a burst of wide scans takes every thread tokio will hand
259/// out and the flusher queues behind them, so a slow query becomes an ingest
260/// stall. A permit keeps the two apart without a second runtime.
261///
262/// One per core, because a search already fans out across cores by itself
263/// (`mira_core::query::search_open`), so the (n+1)th finishes sooner waiting
264/// for a permit than time-slicing against n others. Four when the count is
265/// unavailable: enough that the UI's three panes never serialise.
266static SCANS: LazyLock<Semaphore> =
267    LazyLock::new(|| Semaphore::new(std::thread::available_parallelism().map_or(4, |n| n.get())));
268
269/// Run a search on the blocking pool, holding a permit for as long as it takes.
270///
271/// `spawn_blocking` is not optional here: reads are mmap reads, and a cold page
272/// fault stalls the OS thread with no yield point, taking every other connection
273/// that tokio worker owns down with it.
274pub(crate) async fn scan<T: Send + 'static>(
275    f: impl FnOnce() -> T + Send + 'static,
276) -> Result<T, tokio::task::JoinError> {
277    // Dropped when this returns, which is after the read has finished rather
278    // than after it has started — a permit released at `spawn_blocking` would
279    // bound nothing.
280    let _permit = SCANS.acquire().await.ok();
281    tokio::task::spawn_blocking(f).await
282}
283
284async fn run(
285    field: &'static str,
286    f: impl FnOnce() -> mira_core::error::Result<query::Results> + Send + 'static,
287) -> Response {
288    let t = std::time::Instant::now();
289    match scan(f).await {
290        Ok(Ok(r)) => json_ok(envelope(field, &r, t.elapsed())),
291        Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
292        Err(e) => (
293            StatusCode::INTERNAL_SERVER_ERROR,
294            format!("query task panicked: {e}"),
295        )
296            .into_response(),
297    }
298}
299
300/// The response body every read returns: the rows under their own key, and what
301/// the query cost. The cost is not decoration — it is what tells a caller its
302/// filter was too broad, and an agent has no other way to find that out.
303///
304/// `elapsed` is wall time around the read, queueing for a scan permit included.
305/// That is the number the caller actually waited, and it is the one worth
306/// showing: server time excluding the queue is a figure only the server can
307/// enjoy. Microseconds because a hot query here is single-digit milliseconds
308/// and "0 ms" is not a measurement.
309pub fn envelope(field: &str, r: &query::Results, elapsed: std::time::Duration) -> String {
310    format!(
311        "{{\"{field}\":{},\"stats\":{{\"blocks_total\":{},\"blocks_scanned\":{},\
312         \"rows_scanned\":{},\"rows_matched\":{},\"elapsed_us\":{}}}{}}}",
313        r.json,
314        r.stats.blocks_total,
315        r.stats.blocks_scanned,
316        r.stats.rows_scanned,
317        r.stats.rows_matched,
318        elapsed.as_micros(),
319        // Absent rather than null on the last page, so `if (doc.next)` is the
320        // whole of a reader's paging logic.
321        r.next
322            .map(|c| format!(",\"next\":\"{c}\""))
323            .unwrap_or_default()
324    )
325}
326
327fn json_ok(body: String) -> Response {
328    (
329        StatusCode::OK,
330        [(header::CONTENT_TYPE, "application/json")],
331        body,
332    )
333        .into_response()
334}
335
336/// Errors come back as JSON too, so a caller has one thing to parse. An agent
337/// that has to distinguish a JSON success from a text/plain failure will
338/// eventually feed the failure to a JSON parser and report the parse error
339/// instead of the actual problem.
340fn bad_request(msg: &str) -> Response {
341    let mut j = mira_core::json::Json::new();
342    j.obj(|j| {
343        j.key("error");
344        j.str(msg);
345    });
346    (
347        StatusCode::BAD_REQUEST,
348        [(header::CONTENT_TYPE, "application/json")],
349        j.into_string(),
350    )
351        .into_response()
352}
353
354pub fn now_nanos() -> i64 {
355    std::time::SystemTime::now()
356        .duration_since(std::time::UNIX_EPOCH)
357        .unwrap_or_default()
358        .as_nanos() as i64
359}
360
361/// Parse a query document.
362///
363/// ```yaml
364/// {
365///   "signal": "logs",
366///   "from": "-15m",
367///   "to": "now",
368///   "where": [
369///     { "attr": "service.name", "eq": "checkout" },
370///     { "field": "severity_number", "gte": 17 },
371///     { "attr": "http.route", "contains": "/api" },
372///   ],
373///   "limit": 100,
374///   "after": "1757241600000000000.2718281828.7.41",
375/// }
376/// ```
377///
378/// `after` is the `next` field of a previous response, passed back verbatim, and
379/// is how you read past `limit`. There is no `offset`: on a store still being
380/// written to, a batch arriving between two pages shifts every row down, so an
381/// offset reader sees a row twice or never — and it costs the engine the whole
382/// prefix on every page.
383///
384/// A term names its target with `attr` or `field` and its operator with the
385/// other key, so `{"attr": "x", "eq": 1}` rather than
386/// `{"target": ..., "op": ..., "value": ...}`. Two keys instead of three, and
387/// the shape reads as the thing it means — which matters more than usual here,
388/// because a large share of these documents will be written by a model that has
389/// seen the schema once.
390pub fn parse_search(text: &str, now: i64) -> Result<Search, String> {
391    search_doc(&parse(text)?, now)
392}
393
394/// [`parse_search`] on an already-parsed document, for callers that received one
395/// nested inside something else — an MCP tool call, say.
396pub fn search_doc(doc: &Yaml, now: i64) -> Result<Search, String> {
397    known(doc, &["signal", "from", "to", "where", "limit", "after"])?;
398    let signal = match doc["signal"].as_str() {
399        Some(s) => Signal::parse(s).ok_or(format!("unknown signal {s:?}"))?,
400        None => Signal::Logs,
401    };
402    let (from, to) = bounds(doc, now)?;
403    let limit = positive(doc, "limit", 100, MAX_LIMIT)?;
404    let after = match &doc["after"] {
405        Yaml::BadValue | Yaml::Null => None,
406        // A cursor is a string even though it is all digits and dots: KYAML
407        // quotes every scalar, and `1757241600000000000.2718281828.7.41` read
408        // as a float would come back as a different cursor entirely.
409        y => Some(
410            y.as_str()
411                .ok_or("after: quote the cursor; it is a string")?
412                .parse()?,
413        ),
414    };
415    Ok(Search {
416        signal,
417        from,
418        to,
419        terms: terms(doc)?,
420        limit,
421        after,
422    })
423}
424
425/// Parse a metrics query document.
426///
427/// ```yaml
428/// {
429///   "name": "http.server.request.duration",
430///   "from": "-1h",
431///   "where": [ { "attr": "service.name", "eq": "checkout" } ],
432///   "max_series": 50,
433/// }
434/// ```
435///
436/// Same `where` grammar as [`parse_search`], because a caller who has learned
437/// one filter syntax should not have to learn a second one to look at a chart.
438pub fn parse_series(text: &str, now: i64) -> Result<SeriesQuery, String> {
439    series_doc(&parse(text)?, now)
440}
441
442pub fn series_doc(doc: &Yaml, now: i64) -> Result<SeriesQuery, String> {
443    known(
444        doc,
445        &["name", "from", "to", "where", "max_series", "max_points"],
446    )?;
447    let (from, to) = bounds(doc, now)?;
448    Ok(SeriesQuery {
449        name: doc["name"].as_str().map(str::to_owned),
450        from,
451        to,
452        terms: terms(doc)?,
453        max_series: positive(doc, "max_series", 200, 2_000)?,
454        max_points: positive(doc, "max_points", 5_000, 100_000)?,
455    })
456}
457
458/// A correlate document: a search, plus the walk to apply to the frame it
459/// anchors.
460pub fn parse_correlate(text: &str, now: i64) -> Result<(Search, Vec<Expand>), String> {
461    correlate_doc(&parse(text)?, now)
462}
463
464/// [`parse_correlate`] on an already-parsed document, for the MCP side.
465pub fn correlate_doc(doc: &Yaml, now: i64) -> Result<(Search, Vec<Expand>), String> {
466    known(
467        doc,
468        &["signal", "from", "to", "where", "limit", "after", "expand"],
469    )?;
470    Ok((correlate_search(doc, now)?, expands(&doc["expand"])?))
471}
472
473/// The window and the span budget of a service-map request.
474pub fn map_doc(doc: &Yaml, now: i64) -> Result<(i64, i64, usize), String> {
475    known(doc, &["from", "to", "max_spans"])?;
476    let (from, to) = bounds(doc, now)?;
477    Ok((from, to, positive(doc, "max_spans", 1_000_000, 20_000_000)?))
478}
479
480/// The search half of a correlate document.
481///
482/// `search_doc` would refuse `expand` as an unknown key, and relaxing it there
483/// would let a misspelled key through on `/api/v1/query` — which is the one
484/// mistake the strictness exists to catch. Stripping the key here costs a clone
485/// of a document that is a handful of scalars.
486fn correlate_search(doc: &Yaml, now: i64) -> Result<Search, String> {
487    let mut map = doc.as_hash().cloned().unwrap_or_default();
488    map.remove(&Yaml::String("expand".into()));
489    search_doc(&Yaml::Hash(map), now)
490}
491
492/// `["traces", "around:2s", "peers"]`.
493///
494/// A list and not a set: the operations do not commute, and `around` before
495/// `traces` is overwritten by the extent `traces` measures.
496fn expands(y: &Yaml) -> Result<Vec<Expand>, String> {
497    let items = match y {
498        Yaml::BadValue | Yaml::Null => return Ok(Vec::new()),
499        Yaml::Array(a) => a,
500        _ => return Err("`expand` must be a list of steps".into()),
501    };
502    items
503        .iter()
504        .map(|s| {
505            let s = s.as_str().ok_or("each `expand` step must be a string")?;
506            match s.split_once(':') {
507                Some(("around", d)) => {
508                    Ok(Expand::Around(crate::config::duration(d)?.as_nanos() as i64))
509                }
510                None if s == "traces" => Ok(Expand::Traces),
511                None if s == "peers" => Ok(Expand::Peers),
512                _ => Err(format!(
513                    "unknown expand step {s:?}; expected traces, peers, or around:<duration>"
514                )),
515            }
516        })
517        .collect()
518}
519
520/// The `from`/`to` pair of any query document.
521pub fn window(text: &str, now: i64) -> Result<(i64, i64), String> {
522    window_doc(&parse(text)?, now)
523}
524
525/// [`window`] on an already-parsed document, for the MCP side.
526pub fn window_doc(doc: &Yaml, now: i64) -> Result<(i64, i64), String> {
527    known(doc, &["from", "to"])?;
528    bounds(doc, now)
529}
530
531/// Refuse a document that is not a mapping, or that carries a key this endpoint
532/// does not implement.
533///
534/// Every other reader here indexes by key and defaults what is missing, so a
535/// misspelled `where` is indistinguishable from no `where` at all and the answer
536/// is a confident 200 over the whole window — the one failure a caller cannot
537/// see in the response it gets. `parse_term` has always been this strict one
538/// level down; this is the same rule at the top of the document.
539pub fn known(doc: &Yaml, keys: &[&str]) -> Result<(), String> {
540    // A tool call that carries no `arguments` at all is a legal MCP request and
541    // means the same thing as an empty document.
542    if doc.is_badvalue() || doc.is_null() {
543        return Ok(());
544    }
545    let map = doc.as_hash().ok_or("a query must be a mapping")?;
546    for k in map.keys() {
547        let k = k.as_str().ok_or("query keys must be strings")?;
548        if !keys.contains(&k) {
549            return Err(format!(
550                "unknown query key {k:?}; expected one of {}",
551                keys.join(" ")
552            ));
553        }
554    }
555    Ok(())
556}
557
558/// One KYAML document, or a readable reason it is not one.
559pub fn parse(text: &str) -> Result<Yaml, String> {
560    if has_alias(text) {
561        return Err(
562            "KYAML has no anchors or aliases: write the value out instead of \
563             referring to an anchor with `*`"
564                .into(),
565        );
566    }
567    let docs = match YamlLoader::load_from_str(text) {
568        Ok(docs) => docs,
569        // JSON spells a non-BMP character as a surrogate pair and `json.dumps`
570        // does so by default, but YAML 1.2 has no surrogates and the loader
571        // refuses one — which would make an ASCII-escaping JSON client the one
572        // client principle 5 does not get for free. Retrying only a document
573        // that has already failed keeps the rewrite away from every valid one:
574        // a single-quoted `'\ud83d\ude00'` is twelve literal characters, and
575        // it parses on the first attempt.
576        Err(e) => YamlLoader::load_from_str(&fold_surrogates(text))
577            .map_err(|_| format!("not valid KYAML: {e}"))?,
578    };
579    docs.into_iter().next().ok_or("empty query".into())
580}
581
582/// Does this document use a YAML alias?
583///
584/// It has to be answered before the loader sees the text, because yaml-rust2
585/// resolves an alias by deep-cloning the anchored node into the tree. Seven
586/// levels of nine-way reuse is 356 bytes on the wire and a gigabyte of `Yaml` in
587/// the loader, nine levels is under 400 bytes and the OOM killer \u2014 and with
588/// `panic = "abort"` that takes every open block and in-flight export with it.
589/// The endpoints that route through here are unauthenticated on 4318, and
590/// neither `ingest.max_request_bytes` nor the inflate cap sees anything wrong
591/// with a body this small.
592///
593/// Bounding the expansion would be the answer if aliases were a feature Mira
594/// owed anyone. KYAML has no anchors and no aliases, so the whole mechanism is
595/// refused instead \u2014 which is also the diagnosis, rather than a limit the caller
596/// has to reverse-engineer from a truncated document.
597///
598/// The scan is a second pass over the token stream, so it is skipped for the
599/// bodies that cannot contain an alias: the token always starts with `*`, and
600/// `memchr` over the body costs a fraction of what parsing it does. A `*` inside
601/// a quoted string \u2014 a log body, a wildcard in a filter \u2014 pays for the pass and
602/// changes nothing else.
603fn has_alias(text: &str) -> bool {
604    if !text.contains('*') {
605        return false;
606    }
607    #[derive(Default)]
608    struct Spy(bool);
609    impl EventReceiver for Spy {
610        fn on_event(&mut self, ev: Event) {
611            self.0 |= matches!(ev, Event::Alias(_));
612        }
613    }
614    let mut spy = Spy::default();
615    // A syntax error is not this function's to report: the loader hits the same
616    // one and says where it is.
617    let _ = Parser::new_from_str(text).load(&mut spy, true);
618    spy.0
619}
620
621/// `\ud83d\ude00` becomes the character it encodes. Everything else is copied
622/// through untouched, an unpaired surrogate included: that is not a character,
623/// and folding it into a replacement one would turn a rejected query into a
624/// silently different one.
625fn fold_surrogates(text: &str) -> String {
626    fn hex4(s: &str) -> Option<u32> {
627        u32::from_str_radix(s.strip_prefix("\\u")?.get(..4)?, 16).ok()
628    }
629    let mut out = String::with_capacity(text.len());
630    let mut rest = text;
631    while let Some(i) = rest.find("\\u") {
632        out.push_str(&rest[..i]);
633        let folded = match (hex4(&rest[i..]), rest.get(i + 6..).and_then(hex4)) {
634            (Some(h @ 0xD800..=0xDBFF), Some(l @ 0xDC00..=0xDFFF)) => {
635                char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00))
636            }
637            _ => None,
638        };
639        match folded {
640            Some(c) => {
641                out.push(c);
642                rest = &rest[i + 12..];
643            }
644            None => {
645                out.push_str("\\u");
646                rest = &rest[i + 2..];
647            }
648        }
649    }
650    out.push_str(rest);
651    out
652}
653
654/// Default window is the last hour. A query with no bounds would scan the whole
655/// retention period, which is the one mistake that turns a fast engine into a
656/// slow one, and it is the mistake an agent makes first.
657pub fn bounds(doc: &Yaml, now: i64) -> Result<(i64, i64), String> {
658    let from = time_field(&doc["from"], now, now - 3_600_000_000_000)?;
659    let to = time_field(&doc["to"], now, now)?;
660    if from > to {
661        return Err(format!("from ({from}) is after to ({to})"));
662    }
663    Ok((from, to))
664}
665
666fn terms(doc: &Yaml) -> Result<Vec<Term>, String> {
667    match &doc["where"] {
668        Yaml::BadValue | Yaml::Null => Ok(Vec::new()),
669        Yaml::Array(a) => a.iter().map(parse_term).collect(),
670        _ => Err("`where` must be a list of terms".into()),
671    }
672}
673
674/// A positive integer bound, defaulted and capped rather than refused. Every
675/// one of these caps a materialization that happens in memory.
676fn positive(doc: &Yaml, key: &str, default: usize, max: usize) -> Result<usize, String> {
677    match &doc[key] {
678        Yaml::BadValue | Yaml::Null => Ok(default),
679        Yaml::Integer(n) if *n > 0 => Ok((*n as usize).min(max)),
680        other => Err(format!("{key} must be a positive integer, got {other:?}")),
681    }
682}
683
684fn parse_term(y: &Yaml) -> Result<Term, String> {
685    let map = y.as_hash().ok_or("each `where` term must be a mapping")?;
686    let mut target = None;
687    let mut opval = None;
688    for (k, v) in map {
689        let k = k.as_str().ok_or("term keys must be strings")?;
690        match k {
691            "attr" => {
692                target = Some(Target::Attr(
693                    v.as_str().ok_or("`attr` must be a string")?.to_owned(),
694                ));
695            }
696            "field" => {
697                target = Some(Target::Field(
698                    v.as_str().ok_or("`field` must be a string")?.to_owned(),
699                ));
700            }
701            other => {
702                let op = Op::parse(other).ok_or(format!(
703                    "unknown term key {other:?}; expected attr, field, or one of \
704                     eq ne lt lte gt gte contains"
705                ))?;
706                opval = Some((op, scalar(v)?));
707            }
708        }
709    }
710    let target = target.ok_or("a term needs `attr` or `field`")?;
711    let (op, value) = opval.ok_or("a term needs an operator, such as `eq`")?;
712    Ok(Term { target, op, value })
713}
714
715/// A query scalar.
716///
717/// Unlike `config.rs::scalar`, which refuses anything YAML guessed at, this
718/// keeps the guesses. The two are answering different questions: a config value
719/// of `0x1f` almost certainly meant the string, whereas a query comparing
720/// against `31` means the number, and JSON — which is where these documents come
721/// from — has already committed to that distinction in its syntax.
722fn scalar(y: &Yaml) -> Result<Value, String> {
723    Ok(match y {
724        Yaml::String(s) => Value::Str(s.clone()),
725        Yaml::Integer(i) => Value::Int(*i),
726        Yaml::Boolean(b) => Value::Bool(*b),
727        Yaml::Real(r) => Value::Double(r.parse().map_err(|_| format!("{r:?} is not a number"))?),
728        other => return Err(format!("{other:?} is not a comparable value")),
729    })
730}
731
732/// `now`, `-15m`, or absolute nanoseconds.
733///
734/// Relative is the form a UI and an agent both reach for, and it is the form
735/// that survives being pasted into a chat and run an hour later. Absolute
736/// nanoseconds are the form the API returns, so a value from a result can be
737/// fed straight back in.
738fn time_field(y: &Yaml, now: i64, default: i64) -> Result<i64, String> {
739    match y {
740        Yaml::BadValue | Yaml::Null => Ok(default),
741        Yaml::Integer(n) => Ok(*n),
742        Yaml::String(s) if s == "now" => Ok(now),
743        Yaml::String(s) => {
744            let (sign, rest) = match s.strip_prefix('-') {
745                Some(r) => (-1i64, r),
746                None => (1, s.strip_prefix('+').unwrap_or(s)),
747            };
748            let d = crate::config::duration(rest)?;
749            Ok(now + sign * (d.as_nanos() as i64))
750        }
751        other => Err(format!("{other:?} is not a time")),
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    /// The query document is written here as a browser would send it — bare
760    /// JSON — because "KYAML is a superset of JSON" is load-bearing for the API
761    /// and not something to take on faith from a spec.
762    #[test]
763    fn json_from_a_browser_parses_as_kyaml() {
764        let now = 1_000_000_000_000_000_000;
765        let q = parse_search(
766            r#"{"signal":"traces","from":"-15m","to":"now","limit":50,
767                "where":[{"attr":"service.name","eq":"checkout"},
768                         {"field":"duration_nano","gte":500000000},
769                         {"field":"name","contains":"GET"}]}"#,
770            now,
771        )
772        .unwrap();
773        assert_eq!(q.signal, Signal::Traces);
774        assert_eq!(q.to, now);
775        assert_eq!(q.from, now - 900_000_000_000);
776        assert_eq!(q.limit, 50);
777        assert_eq!(q.terms.len(), 3);
778        assert!(matches!(&q.terms[0].target, Target::Attr(k) if k == "service.name"));
779        assert_eq!(q.terms[1].op, Op::Gte);
780        assert_eq!(q.terms[1].value, Value::Int(500_000_000));
781        assert_eq!(q.terms[2].op, Op::Contains);
782    }
783
784    /// ...and the same query from a JSON client that escapes non-ASCII, which
785    /// `json.dumps` does by default. A character outside the BMP arrives as a
786    /// surrogate pair, which YAML 1.2 has no notion of, so without the fold the
787    /// one client that does not work for free is a JSON one.
788    #[test]
789    fn json_surrogate_escapes_are_folded_into_the_character() {
790        let q = parse_search(
791            r#"{"where":[{"field":"body","contains":"caf\u00e9 \ud83d\ude00"}]}"#,
792            0,
793        )
794        .unwrap();
795        assert_eq!(q.terms[0].value, Value::Str("café 😀".into()));
796        // Half a pair is not a character, so it stays the loader's error rather
797        // than becoming a replacement character in a filter that then silently
798        // matches nothing.
799        let err =
800            parse_search(r#"{"where":[{"field":"body","contains":"\ud83d"}]}"#, 0).unwrap_err();
801        assert!(err.contains("not valid KYAML"), "{err}");
802    }
803
804    /// ...and the same query in house style, with the trailing commas and the
805    /// comment that JSON cannot carry. This is what makes the format worth
806    /// having: an agent can annotate its own query.
807    #[test]
808    fn kyaml_with_comments_and_trailing_commas_parses_the_same() {
809        let q = parse_search(
810            r#"{
811              "signal": "logs",
812              # only the errors
813              "where": [
814                { "field": "severity_number", "gte": 17 },
815              ],
816            }"#,
817            0,
818        )
819        .unwrap();
820        assert_eq!(q.signal, Signal::Logs);
821        assert_eq!(q.terms.len(), 1);
822        assert_eq!(q.limit, 100);
823        // An unbounded query would scan all of retention, so the default window
824        // is an hour rather than everything.
825        assert_eq!(q.from, -3_600_000_000_000);
826    }
827
828    #[test]
829    fn malformed_queries_say_what_is_wrong() {
830        let err = |s: &str| parse_search(s, 0).unwrap_err();
831        assert!(err(r#"{"signal":"jaeger"}"#).contains("unknown signal"));
832        assert!(err(r#"{"where":[{"attr":"a","like":"b"}]}"#).contains("unknown term key"));
833        assert!(err(r#"{"where":[{"eq":"b"}]}"#).contains("needs `attr` or `field`"));
834        assert!(err(r#"{"where":[{"attr":"a"}]}"#).contains("needs an operator"));
835        assert!(err(r#"{"from":"now","to":"-1h"}"#).contains("is after"));
836        assert!(err(r#"{"limit":0}"#).contains("positive integer"));
837        // A key nothing implements is a typo, and answering a typo with a page
838        // of unfiltered rows is worse than answering it with an error: the
839        // caller has no way to tell that its filter was dropped.
840        assert!(err(r#"{"signal":"logs","filters":[]}"#).contains("unknown query key"));
841        assert!(err(r#"{"query":{"signal":"logs"}}"#).contains("unknown query key"));
842        // A whole document that is not a mapping is not a query either.
843        assert!(err("[1,2,3]").contains("must be a mapping"));
844        let step = parse_series(r#"{"name":"m","step":"1m"}"#, 0).unwrap_err();
845        assert!(step.contains("step"), "{step}");
846        let w = window(r#"{"from":0,"limit":5}"#, 0).unwrap_err();
847        assert!(w.contains("limit"), "{w}");
848        // A container is not a scalar and not a time. Both readers default what
849        // is missing, so the alternative to refusing these is comparing against
850        // whatever `unwrap_or_default` produced — a filter that matches nothing
851        // and a window that says it was honoured.
852        assert!(err(r#"{"where":[{"attr":"a","eq":[1,2]}]}"#).contains("not a comparable value"));
853        assert!(err(r#"{"where":[{"attr":"a","eq":{}}]}"#).contains("not a comparable value"));
854        assert!(err(r#"{"from":[1]}"#).contains("is not a time"));
855        assert!(err(r#"{"to":{"at":1}}"#).contains("is not a time"));
856    }
857
858    /// Unknown keys are refused, so the known set has to be exactly what the
859    /// shipped clients send — a false rejection breaks the browser UI, the
860    /// terminal UI and every MCP tool at once. The terminal UI's documents are
861    /// checked in `tui.rs` against the code that builds them; these are the
862    /// browser's and the agent's.
863    #[test]
864    fn every_document_the_clients_send_is_accepted() {
865        for d in [
866            r#"{"signal":"logs","from":"-1h","to":"now","where":[],"limit":200}"#,
867            r#"{"signal":"traces","from":0,"to":"now","limit":2000,
868                "where":[{"field":"trace_id","eq":"ab"}]}"#,
869            r#"{"signal":"logs","limit":100,"after":"1757241600000000000.2718281828.7.41"}"#,
870        ] {
871            assert!(parse_search(d, 0).is_ok(), "{d}");
872        }
873        for d in [
874            r#"{"name":"m","from":"-1h","to":"now","where":[]}"#,
875            r#"{"name":"m","from":"-1h","to":"now","max_series":64,"max_points":400,"where":[]}"#,
876        ] {
877            assert!(parse_series(d, 0).is_ok(), "{d}");
878        }
879        for d in [
880            r#"{"signal":"logs","from":"-1h","to":"now","where":[],"limit":200,
881                "expand":["traces","peers"]}"#,
882            r#"{"expand":[]}"#,
883            "{}",
884        ] {
885            assert!(parse_correlate(d, 0).is_ok(), "{d}");
886        }
887        assert!(window(r#"{"from":"-1h","to":"now"}"#, 0).is_ok());
888        assert!(window("{}", 0).is_ok());
889        // An MCP tool call is allowed to carry no `arguments` member at all.
890        assert!(search_doc(&Yaml::BadValue, 0).is_ok());
891        assert!(correlate_doc(&Yaml::BadValue, 0).is_ok());
892        assert!(map_doc(&Yaml::BadValue, 0).is_ok());
893    }
894
895    /// The walk is a list because the steps do not commute, and the wire
896    /// spelling of a step is the only place the algebra meets a string — so
897    /// both the parse and every way of getting it wrong are checked here.
898    #[test]
899    fn an_expansion_walk_parses_in_order_and_says_what_it_does_not_know() {
900        let (q, ops) = parse_correlate(
901            r#"{"signal":"traces","expand":["around:2s","traces","peers"]}"#,
902            0,
903        )
904        .unwrap();
905        assert_eq!(q.signal.dir(), "traces");
906        assert_eq!(
907            ops,
908            [Expand::Around(2_000_000_000), Expand::Traces, Expand::Peers]
909        );
910        for (d, want) in [
911            (r#"{"expand":"traces"}"#, "list of steps"),
912            (r#"{"expand":[7]}"#, "must be a string"),
913            (r#"{"expand":["sideways"]}"#, "sideways"),
914            (r#"{"expand":["around:soon"]}"#, "soon"),
915            (r#"{"expand":["peers:1"]}"#, "peers:1"),
916            (r#"{"expanded":[]}"#, "unknown query key"),
917        ] {
918            assert!(parse_correlate(d, 0).unwrap_err().contains(want), "{d}");
919        }
920    }
921
922    /// A YAML alias bomb: six levels of nine-way reuse, 250 bytes on the wire,
923    /// 531,441 leaves once expanded. It is short of the seven levels that took a
924    /// live server to 1.02 GB on purpose — if this guard ever regresses the test
925    /// should fail, not take the runner's memory with it.
926    ///
927    /// Every unauthenticated endpoint on 4318 routes through `parse`, so the
928    /// refusal is checked here rather than once per handler.
929    #[test]
930    fn an_alias_bomb_is_refused_before_it_is_expanded() {
931        let bomb = r#"{"a":&a "lol",
932          "b":&b [*a,*a,*a,*a,*a,*a,*a,*a,*a],
933          "c":&c [*b,*b,*b,*b,*b,*b,*b,*b,*b],
934          "d":&d [*c,*c,*c,*c,*c,*c,*c,*c,*c],
935          "e":&e [*d,*d,*d,*d,*d,*d,*d,*d,*d],
936          "f":&f [*e,*e,*e,*e,*e,*e,*e,*e,*e],
937          "g":[*f,*f,*f,*f,*f,*f,*f,*f,*f]}"#;
938        let err = parse(bomb).unwrap_err();
939        // The reader of this is a model as often as a person, and "invalid
940        // document" would leave both of them editing at random.
941        assert!(err.contains("alias"), "{err}");
942        assert!(err.contains("KYAML"), "{err}");
943        // Every entry point that takes a body reaches the same guard.
944        assert!(parse_search(bomb, 0).is_err());
945        assert!(parse_series(bomb, 0).is_err());
946        assert!(window(bomb, 0).is_err());
947        // An anchor nothing refers to expands to nothing, so it is only the
948        // alias that is refused.
949        assert!(parse(r#"{"signal":&s "logs"}"#).is_ok());
950
951        // The mechanism being guarded against, at a depth that is safe to load:
952        // the loader materialises each alias as a full copy rather than sharing
953        // it, so every level multiplies the tree by nine.
954        let three = r#"{"a":&a "lol","b":&b [*a,*a,*a,*a,*a,*a,*a,*a,*a],
955          "c":&c [*b,*b,*b,*b,*b,*b,*b,*b,*b],"d":[*c,*c,*c,*c,*c,*c,*c,*c,*c]}"#;
956        let expanded = YamlLoader::load_from_str(three).unwrap().remove(0);
957        assert_eq!(expanded["d"].as_vec().unwrap().len(), 9);
958        assert_eq!(expanded["d"][8][8].as_vec().unwrap().len(), 9);
959        assert_eq!(expanded["d"][8][8][8].as_str(), Some("lol"));
960    }
961
962    /// The scan is skipped unless the body contains a `*`, and a `*` that is
963    /// part of a string is not an alias — a filter looking for a wildcard in a
964    /// log line has to keep working, and it is the one input that pays for the
965    /// pass.
966    #[test]
967    fn a_star_inside_a_string_is_not_an_alias() {
968        let q = parse_search(r#"{"where":[{"field":"body","contains":"rate *"}]}"#, 0).unwrap();
969        assert_eq!(q.terms[0].value, Value::Str("rate *".into()));
970    }
971
972    #[test]
973    fn limit_is_capped_rather_than_refused() {
974        let q = parse_search(r#"{"limit":9999999}"#, 0).unwrap();
975        assert_eq!(q.limit, MAX_LIMIT);
976    }
977
978    /// Status, content type and body of one handler's answer.
979    async fn answer(res: Response) -> (StatusCode, String, String) {
980        let status = res.status();
981        let ct = res
982            .headers()
983            .get(header::CONTENT_TYPE)
984            .and_then(|v| v.to_str().ok())
985            .unwrap_or_default()
986            .to_owned();
987        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
988            .await
989            .unwrap();
990        (status, ct, String::from_utf8(body.to_vec()).unwrap())
991    }
992
993    /// Each of the six endpoints validates its own document, and each one
994    /// refuses with a JSON error object under a 400.
995    ///
996    /// Both halves are load-bearing. Skipping the check is not a crash — it is
997    /// a confident 200 over the whole window with the caller's filter silently
998    /// dropped, which is the one failure that does not appear in the response.
999    /// And an error that came back as `text/plain` would be fed to a JSON
1000    /// parser by every client here, which then reports the parse error instead
1001    /// of the reason.
1002    #[tokio::test]
1003    async fn every_endpoint_refuses_a_document_it_cannot_honour_with_a_json_400() {
1004        let api = Api::default();
1005        let st = || State(api.clone());
1006        for (res, want) in [
1007            (
1008                query_handler(st(), r#"{"signal":"jaeger"}"#.into()).await,
1009                "unknown signal",
1010            ),
1011            (
1012                series_handler(st(), r#"{"max_series":0}"#.into()).await,
1013                "max_series must be a positive integer",
1014            ),
1015            (
1016                names_handler(st(), r#"{"from":"yesterday"}"#.into()).await,
1017                "yesterday",
1018            ),
1019            (
1020                correlate_handler(st(), r#"{"expand":"traces"}"#.into()).await,
1021                "`expand` must be a list of steps",
1022            ),
1023            // The service map is the one endpoint that parses and validates in
1024            // two steps, so it has two ways to answer 400.
1025            (
1026                map_handler(st(), "{not: [kyaml".into()).await,
1027                "not valid KYAML",
1028            ),
1029            (
1030                map_handler(st(), r#"{"max_spans":0}"#.into()).await,
1031                "max_spans must be a positive integer",
1032            ),
1033            (
1034                entities_handler(st(), r#"{"limit":5}"#.into()).await,
1035                "unknown query key \\\"limit\\\"",
1036            ),
1037        ] {
1038            let (status, ct, body) = answer(res).await;
1039            assert_eq!(status, StatusCode::BAD_REQUEST, "{want}: {body}");
1040            assert_eq!(ct, "application/json", "{want}");
1041            assert!(body.starts_with(r#"{"error":""#), "{want}: {body}");
1042            assert!(body.contains(want), "{body}");
1043        }
1044
1045        // The three endpoints whose document is optional treat an empty body as
1046        // "everything, now" rather than as a malformed request — it is the
1047        // first call the UI and an agent both make.
1048        let dir = std::env::temp_dir().join(format!("mira-api-empty-{}", std::process::id()));
1049        std::fs::create_dir_all(&dir).unwrap();
1050        let api = Api {
1051            data_dir: Arc::new(dir.clone()),
1052            ..Default::default()
1053        };
1054        let st = || State(api.clone());
1055        for res in [
1056            names_handler(st(), String::new()).await,
1057            map_handler(st(), "  ".into()).await,
1058            entities_handler(st(), String::new()).await,
1059        ] {
1060            let (status, ct, body) = answer(res).await;
1061            assert_eq!(status, StatusCode::OK, "{body}");
1062            assert_eq!(ct, "application/json");
1063            assert!(body.contains("\"stats\":"), "{body}");
1064        }
1065        let _ = std::fs::remove_dir_all(&dir);
1066    }
1067
1068    /// A read that fails, and a read that panics, are both a 500 with the
1069    /// reason in the body — not a dropped connection and not a dead process.
1070    ///
1071    /// `spawn_blocking` catches the unwind, so the alternative to reporting the
1072    /// `JoinError` is a request that never answers while the server carries on
1073    /// as if nothing happened. The store is broken the way a half-written data
1074    /// directory is: a plain file where a signal's directory belongs.
1075    #[tokio::test]
1076    async fn a_failed_or_panicking_scan_answers_500_with_the_reason() {
1077        let dir = std::env::temp_dir().join(format!("mira-api-broken-{}", std::process::id()));
1078        let _ = std::fs::remove_dir_all(&dir);
1079        std::fs::create_dir_all(&dir).unwrap();
1080        std::fs::write(dir.join("logs"), b"not a directory").unwrap();
1081        let api = Api {
1082            data_dir: Arc::new(dir.clone()),
1083            ..Default::default()
1084        };
1085        let (status, _, body) = answer(query_handler(State(api), "{}".into()).await).await;
1086        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}");
1087        assert!(!body.is_empty(), "a 500 with no reason is not a report");
1088        // The read returned an error; it did not take the process near a panic.
1089        assert!(!body.contains("panicked"), "{body}");
1090
1091        // The panic is deliberate: this is the arm that exists because a read
1092        // runs on a pool thread whose unwind tokio hands back as a `JoinError`.
1093        let (status, _, body) = answer(run("rows", || panic!("a page fault, say")).await).await;
1094        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
1095        assert!(body.contains("query task panicked"), "{body}");
1096        let _ = std::fs::remove_dir_all(&dir);
1097    }
1098
1099    /// A signal name nothing implements has no open block, rather than an index
1100    /// into an array of three.
1101    ///
1102    /// Every handler reaches this with a string, and `correlate` reaches it
1103    /// with one the caller chose. Out of range would be a panic on the request
1104    /// path, and with `panic = "abort"` in the release profile that is the
1105    /// process and every in-flight export with it.
1106    ///
1107    /// Exactly one slot is served, because three empty ones cannot tell an
1108    /// unknown name apart from a known one: a lookup that answered slot 0 for
1109    /// everything it did not recognise would read identically, and it would
1110    /// serve logs to a caller asking about profiles.
1111    #[tokio::test]
1112    async fn an_unknown_signal_has_no_open_block_rather_than_an_index_out_of_range() {
1113        let dir = std::env::temp_dir().join(format!("mira-api-slots-{}", std::process::id()));
1114        let _ = std::fs::remove_dir_all(&dir);
1115        std::fs::create_dir_all(&dir).unwrap();
1116        let pcfg = Arc::new(pipeline::Config {
1117            data_dir: dir.clone(),
1118            node: 0x51,
1119            // With the log on, the acknowledgement is the frame and not the
1120            // seal — which is the only way to hold an export that is both
1121            // acknowledged and still in the builder, which is what an open
1122            // block *is*. Without it `submit` would wait out `max_block_age`
1123            // below and there would be nothing open to find.
1124            wal: Some(Arc::new(mira_core::wal::Wal::open(&dir, 0x51).unwrap())),
1125            // Long enough that nothing seals mid-test: the block has to still
1126            // be open for the slot to have anything in it.
1127            max_block_age: std::time::Duration::from_secs(3_600),
1128            ..Default::default()
1129        });
1130        let (ingest, logs_slot, flusher) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1131        assert!(
1132            ingest
1133                .submit(crate::e2e::logs_export("checkout", 1_000, 3))
1134                .await
1135                .is_ok()
1136        );
1137        let api = Api {
1138            data_dir: Arc::new(dir.clone()),
1139            open: [logs_slot, Default::default(), Default::default()],
1140            ..Default::default()
1141        };
1142
1143        let logs = api.open("logs").await;
1144        assert_eq!(logs.len(), 1, "the served slot answers with its open block");
1145        assert_eq!(logs[0].sealed.num_rows, 3);
1146        // Neither an unknown name nor another signal reaches into it.
1147        for absent in ["profiles", "", "traces", "metrics"] {
1148            assert!(api.open(absent).await.is_empty(), "{absent}");
1149        }
1150
1151        // One slot per signal and in signal order, not a flat concatenation:
1152        // sequence numbers are per-signal and a flat list would let logs
1153        // collide with traces on `(node, seq)`.
1154        let all = api.open_all().await;
1155        assert_eq!(all.len(), pipeline::SIGNALS.len());
1156        let i = pipeline::SIGNALS.iter().position(|s| *s == "logs").unwrap();
1157        for (j, blocks) in all.iter().enumerate() {
1158            assert_eq!(blocks.len(), usize::from(j == i), "slot {j}");
1159        }
1160
1161        drop(ingest);
1162        flusher.await.unwrap();
1163        let _ = std::fs::remove_dir_all(&dir);
1164    }
1165}