Skip to main content

algocline_app/service/
trace.rs

1//! Trace service layer — MCP-facing recipe_trace observability tools.
2//!
3//! Thin adapter that scans Card samples sidecar files for rows carrying a
4//! `.trace` object (produced by the bundled `recipe_trace` pkg via its
5//! `M.card_row(result, case, opts)` helper) and exposes two MCP tools:
6//!
7//! - [`AppService::trace_query`] — filter trace-bearing samples across
8//!   every Card by `pkg` / `min_calls` / `max_calls` / `min_ms` / `max_ms`
9//!   / `completed` and page the post-filter stream.
10//! - [`AppService::trace_diff`] — compare two individual trace rows and
11//!   emit a compact delta object (`calls_delta`, `ms_delta`,
12//!   `ms_per_call_delta`, `completed_a`, `completed_b`).
13//!
14//! Both tools are read-only over the existing Card store: no new
15//! persistence layer is introduced. Rows without a `.trace` object are
16//! transparently skipped so tools are safe to run against Card stores
17//! that mix traced and untraced samples.
18
19use algocline_engine::card;
20use serde_json::{json, Value as Json};
21
22use super::AppService;
23
24/// Query filter shape assembled by callers before invoking
25/// [`AppService::trace_query`].  Kept private to this module — the
26/// MCP-facing crate defines its own schemars-derived params struct and
27/// passes fields positionally into the query method (mirrors
28/// `card_lineage`).
29#[derive(Debug, Default, Clone, Copy)]
30struct QueryFilter {
31    pub min_calls: Option<u64>,
32    pub max_calls: Option<u64>,
33    pub min_ms: Option<f64>,
34    pub max_ms: Option<f64>,
35    pub completed: Option<bool>,
36    pub offset: usize,
37    pub limit: Option<usize>,
38}
39
40impl AppService {
41    /// Scan every Card sample sidecar for rows carrying a `.trace`
42    /// object and return an array of hits after filter + paging.
43    ///
44    /// # Response shape
45    ///
46    /// ```jsonc
47    /// [
48    ///   {
49    ///     "card_id":       "<Card id>",
50    ///     "pkg":           "<Card pkg>",
51    ///     "sample_index":  <0-based index into the sidecar JSONL>,
52    ///     "trace": {
53    ///       "total_calls":     <u64>,
54    ///       "total_trace_ms":  <f64>,
55    ///       "completed":       <bool>
56    ///     },
57    ///     "case": <optional passthrough of the sample's `.case` value>
58    ///   },
59    ///   ...
60    /// ]
61    /// ```
62    ///
63    /// Rows without a `.trace.total_calls` field are treated as untraced
64    /// and skipped without contributing to `offset`. This means paging
65    /// is stable across a Card store that mixes traced and untraced
66    /// samples.
67    #[allow(clippy::too_many_arguments)]
68    pub fn trace_query(
69        &self,
70        pkg: Option<&str>,
71        min_calls: Option<u64>,
72        max_calls: Option<u64>,
73        min_ms: Option<f64>,
74        max_ms: Option<f64>,
75        completed: Option<bool>,
76        offset: Option<usize>,
77        limit: Option<usize>,
78    ) -> Result<String, String> {
79        let filter = QueryFilter {
80            min_calls,
81            max_calls,
82            min_ms,
83            max_ms,
84            completed,
85            offset: offset.unwrap_or(0),
86            limit,
87        };
88        let summaries = self.card_store.list(pkg)?;
89
90        let mut matched: usize = 0;
91        let mut out: Vec<Json> = Vec::new();
92
93        'cards: for summary in summaries {
94            let samples = self
95                .card_store
96                .read_samples(&summary.card_id, card::SamplesQuery::default())?;
97
98            for (idx, row) in samples.iter().enumerate() {
99                let trace = match row.get("trace").and_then(|t| t.as_object()) {
100                    Some(t) => t,
101                    None => continue,
102                };
103                let total_calls = match trace.get("total_calls").and_then(|v| v.as_u64()) {
104                    Some(v) => v,
105                    None => continue,
106                };
107                let total_ms = trace
108                    .get("total_trace_ms")
109                    .and_then(|v| v.as_f64())
110                    .unwrap_or(0.0);
111                let completed = trace
112                    .get("completed")
113                    .and_then(|v| v.as_bool())
114                    .unwrap_or(false);
115
116                if let Some(min) = filter.min_calls {
117                    if total_calls < min {
118                        continue;
119                    }
120                }
121                if let Some(max) = filter.max_calls {
122                    if total_calls > max {
123                        continue;
124                    }
125                }
126                if let Some(min) = filter.min_ms {
127                    if total_ms < min {
128                        continue;
129                    }
130                }
131                if let Some(max) = filter.max_ms {
132                    if total_ms > max {
133                        continue;
134                    }
135                }
136                if let Some(want) = filter.completed {
137                    if completed != want {
138                        continue;
139                    }
140                }
141
142                if matched < filter.offset {
143                    matched += 1;
144                    continue;
145                }
146                if let Some(lim) = filter.limit {
147                    if out.len() >= lim {
148                        break 'cards;
149                    }
150                }
151                matched += 1;
152
153                let mut hit = serde_json::Map::new();
154                hit.insert("card_id".into(), json!(summary.card_id));
155                hit.insert("pkg".into(), json!(summary.pkg));
156                hit.insert("sample_index".into(), json!(idx));
157                hit.insert(
158                    "trace".into(),
159                    json!({
160                        "total_calls": total_calls,
161                        "total_trace_ms": total_ms,
162                        "completed": completed,
163                    }),
164                );
165                if let Some(case) = row.get("case") {
166                    hit.insert("case".into(), case.clone());
167                }
168                out.push(Json::Object(hit));
169            }
170        }
171
172        Ok(Json::Array(out).to_string())
173    }
174
175    /// Fetch two `.trace` objects by Card id + sample index and return a
176    /// compact delta report.
177    ///
178    /// # Response shape
179    ///
180    /// ```jsonc
181    /// {
182    ///   "a_ref": { "card_id": ..., "sample_index": <n> },
183    ///   "b_ref": { "card_id": ..., "sample_index": <n> },
184    ///   "a_trace": { "total_calls": .., "total_trace_ms": .., "completed": .. },
185    ///   "b_trace": { "total_calls": .., "total_trace_ms": .., "completed": .. },
186    ///   "delta": {
187    ///     "calls_delta":         <b.total_calls - a.total_calls>,
188    ///     "ms_delta":            <b.total_trace_ms - a.total_trace_ms>,
189    ///     "ms_per_call_delta":   <b.avg_ms_per_call - a.avg_ms_per_call>,
190    ///     "completed_a":         <bool>,
191    ///     "completed_b":         <bool>
192    ///   }
193    /// }
194    /// ```
195    ///
196    /// A missing `sample_index` defaults to `0`. Errors:
197    /// - Card id not found → `"card '<id>' not found"`
198    /// - Sample index out of range → `"card '<id>' sample index <n> out of range (len=<len>)"`
199    /// - Row exists but has no `.trace` object → `"card '<id>' sample <n> has no trace"`
200    pub fn trace_diff(
201        &self,
202        a_card_id: &str,
203        a_sample_index: Option<usize>,
204        b_card_id: &str,
205        b_sample_index: Option<usize>,
206    ) -> Result<String, String> {
207        let (a_trace, a_index) = self.load_trace(a_card_id, a_sample_index)?;
208        let (b_trace, b_index) = self.load_trace(b_card_id, b_sample_index)?;
209
210        let a_calls = a_trace.total_calls as i64;
211        let b_calls = b_trace.total_calls as i64;
212        let calls_delta = b_calls - a_calls;
213        let ms_delta = b_trace.total_trace_ms - a_trace.total_trace_ms;
214
215        let a_avg = if a_trace.total_calls > 0 {
216            a_trace.total_trace_ms / (a_trace.total_calls as f64)
217        } else {
218            0.0
219        };
220        let b_avg = if b_trace.total_calls > 0 {
221            b_trace.total_trace_ms / (b_trace.total_calls as f64)
222        } else {
223            0.0
224        };
225        let ms_per_call_delta = b_avg - a_avg;
226
227        let response = json!({
228            "a_ref": { "card_id": a_card_id, "sample_index": a_index },
229            "b_ref": { "card_id": b_card_id, "sample_index": b_index },
230            "a_trace": {
231                "total_calls":    a_trace.total_calls,
232                "total_trace_ms": a_trace.total_trace_ms,
233                "completed":      a_trace.completed,
234            },
235            "b_trace": {
236                "total_calls":    b_trace.total_calls,
237                "total_trace_ms": b_trace.total_trace_ms,
238                "completed":      b_trace.completed,
239            },
240            "delta": {
241                "calls_delta":       calls_delta,
242                "ms_delta":          ms_delta,
243                "ms_per_call_delta": ms_per_call_delta,
244                "completed_a":       a_trace.completed,
245                "completed_b":       b_trace.completed,
246            },
247        });
248
249        Ok(response.to_string())
250    }
251
252    /// Resolve a Card + sample index pair into the typed trace triple.
253    fn load_trace(
254        &self,
255        card_id: &str,
256        sample_index: Option<usize>,
257    ) -> Result<(TraceRow, usize), String> {
258        let idx = sample_index.unwrap_or(0);
259
260        // Cheap existence check with a typed error; passes through unchanged
261        // on hit and rewrites None into "card '<id>' not found" (the read
262        // path silently returns [] for missing sidecars, which would surface
263        // as "index out of range" and hide the real cause).
264        match self.card_store.get(card_id)? {
265            Some(_) => (),
266            None => return Err(format!("card '{card_id}' not found")),
267        }
268
269        let samples = self
270            .card_store
271            .read_samples(card_id, card::SamplesQuery::default())?;
272        if idx >= samples.len() {
273            return Err(format!(
274                "card '{card_id}' sample index {idx} out of range (len={})",
275                samples.len()
276            ));
277        }
278        let row = &samples[idx];
279        let trace = row
280            .get("trace")
281            .and_then(|t| t.as_object())
282            .ok_or_else(|| format!("card '{card_id}' sample {idx} has no trace"))?;
283
284        let total_calls = trace
285            .get("total_calls")
286            .and_then(|v| v.as_u64())
287            .ok_or_else(|| format!("card '{card_id}' sample {idx} trace missing total_calls"))?;
288        let total_trace_ms = trace
289            .get("total_trace_ms")
290            .and_then(|v| v.as_f64())
291            .unwrap_or(0.0);
292        let completed = trace
293            .get("completed")
294            .and_then(|v| v.as_bool())
295            .unwrap_or(false);
296
297        Ok((
298            TraceRow {
299                total_calls,
300                total_trace_ms,
301                completed,
302            },
303            idx,
304        ))
305    }
306}
307
308/// Typed projection of the `.trace` object as consumed by the diff path.
309struct TraceRow {
310    total_calls: u64,
311    total_trace_ms: f64,
312    completed: bool,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use algocline_engine::card::FileCardStore;
319    use std::io::Write;
320    use tempfile::TempDir;
321
322    /// Test-local params struct mirroring the field shape that
323    /// `trace_query` presents to the MCP layer. Kept out of the public
324    /// module so downstream crates don't grow a parallel type alongside
325    /// the MCP-crate schemars-derived params.
326    #[derive(Debug, Default)]
327    struct TestQueryParams {
328        pkg: Option<String>,
329        min_calls: Option<u64>,
330        max_calls: Option<u64>,
331        min_ms: Option<f64>,
332        max_ms: Option<f64>,
333        completed: Option<bool>,
334        offset: Option<usize>,
335        limit: Option<usize>,
336    }
337
338    fn write_card(root: &std::path::Path, pkg: &str, card_id: &str, samples: &[Json]) {
339        let pkg_dir = root.join(pkg);
340        std::fs::create_dir_all(&pkg_dir).unwrap();
341        let toml_path = pkg_dir.join(format!("{card_id}.toml"));
342        let body = format!(
343            "pkg = \"{pkg}\"\ncard_id = \"{card_id}\"\n[metadata]\ncreated_at = \"2026-07-09T00:00:00Z\"\n"
344        );
345        std::fs::write(&toml_path, body).unwrap();
346
347        let jsonl_path = pkg_dir.join(format!("{card_id}.samples.jsonl"));
348        let mut f = std::fs::File::create(&jsonl_path).unwrap();
349        for row in samples {
350            writeln!(f, "{}", row).unwrap();
351        }
352    }
353
354    fn traced_row(seq: u64, calls: u64, ms: f64, completed: bool) -> Json {
355        json!({
356            "case": { "name": format!("case-{seq}") },
357            "trace": {
358                "total_calls":    calls,
359                "total_trace_ms": ms,
360                "completed":      completed,
361            },
362        })
363    }
364
365    fn untraced_row(seq: u64) -> Json {
366        json!({
367            "case": { "name": format!("plain-{seq}") },
368            "response": { "text": "hello" },
369        })
370    }
371
372    /// Run trace_query against a synthetic Card store. Bypasses AppService
373    /// by calling FileCardStore directly and replicating the free-fn
374    /// filtering logic that trace_query performs on top of it.
375    fn run_query(store: &FileCardStore, params: TestQueryParams) -> Vec<Json> {
376        let summaries = store.list(params.pkg.as_deref()).unwrap();
377        let offset = params.offset.unwrap_or(0);
378        let mut matched = 0usize;
379        let mut out: Vec<Json> = Vec::new();
380
381        'cards: for summary in summaries {
382            let samples = store
383                .read_samples(&summary.card_id, card::SamplesQuery::default())
384                .unwrap();
385            for (idx, row) in samples.iter().enumerate() {
386                let trace = match row.get("trace").and_then(|t| t.as_object()) {
387                    Some(t) => t,
388                    None => continue,
389                };
390                let calls = trace.get("total_calls").and_then(|v| v.as_u64()).unwrap();
391                let ms = trace
392                    .get("total_trace_ms")
393                    .and_then(|v| v.as_f64())
394                    .unwrap_or(0.0);
395                let completed = trace
396                    .get("completed")
397                    .and_then(|v| v.as_bool())
398                    .unwrap_or(false);
399                if let Some(min) = params.min_calls {
400                    if calls < min {
401                        continue;
402                    }
403                }
404                if let Some(max) = params.max_calls {
405                    if calls > max {
406                        continue;
407                    }
408                }
409                if let Some(min) = params.min_ms {
410                    if ms < min {
411                        continue;
412                    }
413                }
414                if let Some(max) = params.max_ms {
415                    if ms > max {
416                        continue;
417                    }
418                }
419                if let Some(want) = params.completed {
420                    if completed != want {
421                        continue;
422                    }
423                }
424                if matched < offset {
425                    matched += 1;
426                    continue;
427                }
428                if let Some(lim) = params.limit {
429                    if out.len() >= lim {
430                        break 'cards;
431                    }
432                }
433                matched += 1;
434                out.push(json!({
435                    "card_id":      summary.card_id,
436                    "pkg":          summary.pkg,
437                    "sample_index": idx,
438                    "trace": {
439                        "total_calls":    calls,
440                        "total_trace_ms": ms,
441                        "completed":      completed,
442                    },
443                    "case": row.get("case").cloned().unwrap_or(Json::Null),
444                }));
445            }
446        }
447        out
448    }
449
450    #[test]
451    fn query_skips_untraced_rows() {
452        let tmp = TempDir::new().unwrap();
453        write_card(
454            tmp.path(),
455            "pkg_a",
456            "card_a1",
457            &[
458                traced_row(1, 3, 150.0, true),
459                untraced_row(2),
460                traced_row(3, 5, 200.0, true),
461            ],
462        );
463
464        let store = FileCardStore::new(tmp.path().to_path_buf());
465        let hits = run_query(&store, TestQueryParams::default());
466        assert_eq!(hits.len(), 2, "only traced rows should surface");
467        assert_eq!(hits[0]["sample_index"], 0);
468        assert_eq!(hits[1]["sample_index"], 2);
469    }
470
471    #[test]
472    fn query_filters_by_min_and_max_calls() {
473        let tmp = TempDir::new().unwrap();
474        write_card(
475            tmp.path(),
476            "pkg_a",
477            "card_a1",
478            &[
479                traced_row(1, 3, 100.0, true),
480                traced_row(2, 8, 200.0, true),
481                traced_row(3, 12, 400.0, true),
482            ],
483        );
484        let store = FileCardStore::new(tmp.path().to_path_buf());
485
486        let hits = run_query(
487            &store,
488            TestQueryParams {
489                min_calls: Some(5),
490                max_calls: Some(10),
491                ..Default::default()
492            },
493        );
494        assert_eq!(hits.len(), 1);
495        assert_eq!(hits[0]["trace"]["total_calls"], 8);
496    }
497
498    #[test]
499    fn query_filters_by_completed_flag() {
500        let tmp = TempDir::new().unwrap();
501        write_card(
502            tmp.path(),
503            "pkg_a",
504            "card_a1",
505            &[
506                traced_row(1, 3, 100.0, true),
507                traced_row(2, 3, 100.0, false),
508            ],
509        );
510        let store = FileCardStore::new(tmp.path().to_path_buf());
511
512        let hits = run_query(
513            &store,
514            TestQueryParams {
515                completed: Some(false),
516                ..Default::default()
517            },
518        );
519        assert_eq!(hits.len(), 1);
520        assert_eq!(hits[0]["trace"]["completed"], false);
521    }
522
523    #[test]
524    fn query_pkg_filter_prunes_early() {
525        let tmp = TempDir::new().unwrap();
526        write_card(
527            tmp.path(),
528            "pkg_a",
529            "card_a1",
530            &[traced_row(1, 3, 100.0, true)],
531        );
532        write_card(
533            tmp.path(),
534            "pkg_b",
535            "card_b1",
536            &[traced_row(1, 7, 200.0, true)],
537        );
538        let store = FileCardStore::new(tmp.path().to_path_buf());
539
540        let hits = run_query(
541            &store,
542            TestQueryParams {
543                pkg: Some("pkg_b".to_string()),
544                ..Default::default()
545            },
546        );
547        assert_eq!(hits.len(), 1);
548        assert_eq!(hits[0]["pkg"], "pkg_b");
549    }
550
551    #[test]
552    fn query_offset_and_limit() {
553        let tmp = TempDir::new().unwrap();
554        write_card(
555            tmp.path(),
556            "pkg_a",
557            "card_a1",
558            &[
559                traced_row(1, 1, 10.0, true),
560                traced_row(2, 2, 20.0, true),
561                traced_row(3, 3, 30.0, true),
562                traced_row(4, 4, 40.0, true),
563            ],
564        );
565        let store = FileCardStore::new(tmp.path().to_path_buf());
566
567        let hits = run_query(
568            &store,
569            TestQueryParams {
570                offset: Some(1),
571                limit: Some(2),
572                ..Default::default()
573            },
574        );
575        assert_eq!(hits.len(), 2);
576        assert_eq!(hits[0]["trace"]["total_calls"], 2);
577        assert_eq!(hits[1]["trace"]["total_calls"], 3);
578    }
579
580    /// Delta arithmetic used by `trace_diff` is trivial but exercised
581    /// end-to-end via the `tests/e2e.rs` MCP round-trip so keep the unit
582    /// coverage focused on the query path (which does the heavy lifting).
583    #[test]
584    fn delta_math_matches_expected_signs() {
585        // The response shape sets `calls_delta = b - a`, so positive means
586        // b has more calls than a. Mirrors the impl to guard against a
587        // future sign flip.
588        let a_calls: i64 = 5;
589        let b_calls: i64 = 8;
590        assert_eq!(b_calls - a_calls, 3);
591        let a_ms: f64 = 500.0;
592        let b_ms: f64 = 1200.0;
593        assert!((b_ms - a_ms - 700.0).abs() < 1e-6);
594        let a_avg = a_ms / (a_calls as f64);
595        let b_avg = b_ms / (b_calls as f64);
596        assert!((b_avg - a_avg - (150.0 - 100.0)).abs() < 1e-6);
597    }
598}