Skip to main content

lean_ctx/gateway_server/
report.rs

1//! `lean-ctx gateway report` (enterprise#50) — the printable CTO/value report.
2//!
3//! Renders a **self-contained HTML file** (no external assets, print-to-PDF
4//! ready) straight from `usage_events`: executive summary, spend/savings
5//! trend (inline SVG), top people/projects/models, routing adoption and the
6//! avoided-cost methodology block. Every number is a real aggregate from the
7//! store — the report never invents or extrapolates beyond the labeled seat
8//! projection (same math as the admin API).
9
10use std::fmt::Write as _;
11
12use super::admin_api::{UsageBreakdownResponse, usage_breakdown};
13use super::admin_timeseries::{TimeseriesResponse, timeseries};
14
15/// Inputs assembled by the CLI layer.
16#[derive(Debug, Clone)]
17pub struct ReportMeta {
18    pub org_label: Option<String>,
19    pub seats: Option<u32>,
20    pub reference_model: Option<String>,
21}
22
23/// Queries the store and renders the report HTML.
24///
25/// # Errors
26/// Propagates store/query errors (report generation requires the database —
27/// there is no fail-open here, a report with missing data would be dishonest).
28pub async fn generate(
29    pool: &deadpool_postgres::Pool,
30    from: chrono::DateTime<chrono::Utc>,
31    to: chrono::DateTime<chrono::Utc>,
32    meta: &ReportMeta,
33) -> anyhow::Result<String> {
34    let usage = usage_breakdown(pool, from, to, meta.seats).await?;
35    let series = timeseries(pool, from, to).await?;
36    let routed = routed_requests(pool, from, to).await?;
37    Ok(render(&usage, &series, routed, meta))
38}
39
40/// Requests that were actively re-routed (routing adoption evidence).
41async fn routed_requests(
42    pool: &deadpool_postgres::Pool,
43    from: chrono::DateTime<chrono::Utc>,
44    to: chrono::DateTime<chrono::Utc>,
45) -> anyhow::Result<i64> {
46    let client = pool.get().await?;
47    let row = client
48        .query_one(
49            "SELECT count(*) AS n FROM usage_events \
50             WHERE ts >= $1 AND ts <= $2 AND routed_from IS NOT NULL",
51            &[&from, &to],
52        )
53        .await?;
54    Ok(row.get("n"))
55}
56
57fn usd(v: f64) -> String {
58    if v.abs() >= 1_000_000.0 {
59        format!("${:.2}M", v / 1_000_000.0)
60    } else if v.abs() >= 10_000.0 {
61        format!("${:.1}k", v / 1_000.0)
62    } else if v.abs() >= 100.0 {
63        format!("${v:.0}")
64    } else {
65        format!("${v:.2}")
66    }
67}
68
69fn n(v: i64) -> String {
70    if v >= 1_000_000 {
71        format!("{:.1}M", v as f64 / 1e6)
72    } else if v >= 10_000 {
73        format!("{:.1}k", v as f64 / 1e3)
74    } else {
75        v.to_string()
76    }
77}
78
79fn esc(s: &str) -> String {
80    s.replace('&', "&amp;")
81        .replace('<', "&lt;")
82        .replace('>', "&gt;")
83}
84
85/// Pure renderer (unit-tested without a database).
86fn render(
87    usage: &UsageBreakdownResponse,
88    series: &TimeseriesResponse,
89    routed_requests: i64,
90    meta: &ReportMeta,
91) -> String {
92    let t = &usage.totals;
93    let org = meta.org_label.as_deref().unwrap_or("Organization");
94    let window = format!("{} → {}", &usage.from[..10], &usage.to[..10]);
95    let avoided = (t.reference_cost_usd - t.cost_usd).max(0.0);
96
97    let top_by = |key: fn(&super::admin_api::UsageBreakdownRow) -> &str| {
98        let mut agg = std::collections::BTreeMap::<String, (i64, f64, f64)>::new();
99        for r in &usage.rows {
100            let e = agg.entry(key(r).to_string()).or_default();
101            e.0 += r.requests;
102            e.1 += r.cost_usd;
103            e.2 += r.saved_usd;
104        }
105        let mut v: Vec<_> = agg.into_iter().collect();
106        v.sort_by(|a, b| b.1.1.total_cmp(&a.1.1));
107        v.truncate(10);
108        v
109    };
110    let people = top_by(|r| &r.person);
111    let projects = top_by(|r| &r.project);
112    let models = top_by(|r| &r.model);
113
114    let table = |title: &str, rows: &[(String, (i64, f64, f64))]| -> String {
115        let mut out = format!(
116            "<h3>{title}</h3><table><thead><tr><th>{title}</th>\
117             <th class=num>Requests</th><th class=num>Saved</th><th class=num>Cost</th></tr></thead><tbody>"
118        );
119        for (name, (req, cost, saved)) in rows {
120            let _ = write!(
121                out,
122                "<tr><td>{}</td><td class=num>{}</td><td class='num saved'>{}</td><td class=num>{}</td></tr>",
123                esc(name),
124                n(*req),
125                usd(*saved),
126                usd(*cost)
127            );
128        }
129        out.push_str("</tbody></table>");
130        out
131    };
132
133    let projection_block = match (t.projection_seats, t.projection_usd_per_month) {
134        (Some(seats), Some(p)) => format!(
135            "<div class=kpi><div class=kpi-label>Projected org savings</div>\
136             <div class=kpi-value>{}/mo</div><div class=kpi-foot>at {seats} seats — extrapolation, not billing</div></div>",
137            usd(p)
138        ),
139        _ => String::new(),
140    };
141
142    let methodology = meta.reference_model.as_deref().map_or_else(
143        || {
144            "<p>No counterfactual reference model is configured; the avoided-cost \
145             column reports 0. Configure <code>[proxy.baseline] reference_model</code> \
146             to enable avoided-cost accounting.</p>"
147                .to_string()
148        },
149        |m| {
150            format!(
151                "<p>The <b>baseline</b> prices every request's <i>uncompressed</i> input \
152                 at the contract-frozen reference model <code>{}</code>. <b>Avoided cost</b> \
153                 is the difference between that counterfactual and the actual spend. Local \
154                 inference is booked at a transparent shadow rate — savings are never \
155                 measured against a free-of-charge fiction.</p>",
156                esc(m)
157            )
158        },
159    );
160
161    format!(
162        r#"<!doctype html>
163<html lang="en"><head><meta charset="utf-8">
164<title>{org_esc} · lean-ctx value report</title>
165<style>
166:root{{--green:#059669;--ink:#111114;--muted:#6b7280;--line:#e5e7eb;--soft:#f5f6f8}}
167*{{margin:0;padding:0;box-sizing:border-box}}
168body{{font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--ink);padding:48px;max-width:960px;margin:0 auto}}
169header{{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:28px}}
170h1{{font-size:22px;letter-spacing:-0.02em}}
171h2{{font-size:15px;margin:32px 0 12px;letter-spacing:-0.01em}}
172h3{{font-size:13px;margin:22px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em}}
173.sub{{color:var(--muted);font-size:12px}}
174.kpis{{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:20px 0}}
175.kpi{{border:1px solid var(--line);border-radius:8px;padding:14px 16px;background:var(--soft)}}
176.kpi-label{{font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted)}}
177.kpi-value{{font-size:24px;font-weight:650;font-variant-numeric:tabular-nums;margin-top:4px}}
178.kpi-value.green{{color:var(--green)}}
179.kpi-foot{{font-size:11px;color:var(--muted);margin-top:2px}}
180table{{width:100%;border-collapse:collapse;font-size:12.5px;margin-bottom:8px}}
181th{{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:6px 8px;border-bottom:1px solid var(--ink)}}
182td{{padding:6px 8px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}}
183.num{{text-align:right}}
184.saved{{color:var(--green)}}
185.chart{{margin:12px 0;border:1px solid var(--line);border-radius:8px;padding:12px;background:#fff}}
186.legend{{font-size:11px;color:var(--muted);margin-top:6px}}
187.legend b{{font-weight:600}}
188footer{{margin-top:40px;padding-top:12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);display:flex;justify-content:space-between}}
189@media print{{body{{padding:24px}}.kpi{{break-inside:avoid}}table{{break-inside:auto}}}}
190</style></head><body>
191<header><div><h1>{org_esc} — AI gateway value report</h1>
192<div class=sub>window {window} · generated by lean-ctx gateway</div></div>
193<div class=sub>lean-ctx v{version}</div></header>
194
195<section class=kpis>
196<div class=kpi><div class=kpi-label>Actual spend</div><div class=kpi-value>{spend}</div><div class=kpi-foot>{requests} requests</div></div>
197<div class=kpi><div class=kpi-label>Verified savings</div><div class="kpi-value green">{saved}</div><div class=kpi-foot>measured per event</div></div>
198<div class=kpi><div class=kpi-label>Baseline (counterfactual)</div><div class=kpi-value>{reference}</div><div class=kpi-foot>uncompressed @ reference model</div></div>
199<div class=kpi><div class=kpi-label>Avoided cost</div><div class="kpi-value green">{avoided}</div><div class=kpi-foot>baseline − actual</div></div>
200{projection_block}
201</section>
202
203<h2>Spend &amp; savings per day</h2>
204<div class=chart>{svg}
205<div class=legend><b>▬</b> spend &nbsp; <b style="color:var(--green)">▬</b> saved &nbsp; <span style="color:#7c3aed"><b>╌</b> baseline</span></div></div>
206
207<h2>Adoption</h2>
208<table><tbody>
209<tr><td>Active people in window</td><td class=num>{persons}</td></tr>
210<tr><td>Requests actively re-routed to cheaper models</td><td class=num>{routed}</td></tr>
211</tbody></table>
212
213<h2>Breakdown</h2>
214{people_table}
215{projects_table}
216{models_table}
217
218<h2>Methodology</h2>
219{methodology}
220
221<footer><span>lean-ctx — SEE · ROUTE · REMEMBER · PROVE</span><span>numbers sourced from usage_events; projection labeled as extrapolation</span></footer>
222</body></html>
223"#,
224        org_esc = esc(org),
225        window = window,
226        version = env!("CARGO_PKG_VERSION"),
227        spend = usd(t.cost_usd),
228        requests = n(t.requests),
229        saved = usd(t.saved_usd),
230        reference = usd(t.reference_cost_usd),
231        avoided = usd(avoided),
232        projection_block = projection_block,
233        svg = trend_svg(series),
234        persons = n(t.active_persons),
235        routed = n(routed_requests),
236        people_table = table("Top people", &people),
237        projects_table = table("Top projects", &projects),
238        models_table = table("Top models", &models),
239        methodology = methodology,
240    )
241}
242
243/// Inline SVG: daily spend bars + saved line + dashed baseline line.
244/// Pure geometry — no JS, prints crisply.
245fn trend_svg(series: &TimeseriesResponse) -> String {
246    const W: f64 = 860.0;
247    const H: f64 = 180.0;
248    const PAD: f64 = 8.0;
249    let points = &series.points;
250    if points.is_empty() {
251        return "<p class=sub>No events in this window.</p>".into();
252    }
253    let max = points
254        .iter()
255        .map(|p| p.cost_usd.max(p.saved_usd).max(p.reference_cost_usd))
256        .fold(0.0_f64, f64::max)
257        .max(1e-9);
258    let count = points.len() as f64;
259    let step = (W - 2.0 * PAD) / count;
260    let bar_w = (step * 0.55).clamp(1.0, 26.0);
261    let y = |v: f64| H - PAD - (v / max) * (H - 2.0 * PAD);
262    let x = |i: usize| PAD + step * (i as f64) + step / 2.0;
263
264    let mut svg = format!(
265        r#"<svg viewBox="0 0 {W} {H}" width="100%" height="{H}" role="img" aria-label="daily spend and savings">"#
266    );
267    // grid lines at 0/50/100%
268    for frac in [0.0_f64, 0.5, 1.0] {
269        let gy = y(max * frac);
270        let _ = write!(
271            svg,
272            r##"<line x1="{PAD}" y1="{gy:.1}" x2="{:.1}" y2="{gy:.1}" stroke="#e5e7eb" stroke-width="1"/>"##,
273            W - PAD
274        );
275    }
276    for (i, p) in points.iter().enumerate() {
277        let _ = write!(
278            svg,
279            r##"<rect x="{:.1}" y="{:.1}" width="{bar_w:.1}" height="{:.1}" fill="#94a3b8" rx="1.5"><title>{}: spend {}</title></rect>"##,
280            x(i) - bar_w / 2.0,
281            y(p.cost_usd),
282            (H - PAD - y(p.cost_usd)).max(0.0),
283            p.day,
284            usd(p.cost_usd),
285        );
286    }
287    let polyline = |vals: Vec<f64>, color: &str, dash: &str| -> String {
288        let pts: Vec<String> = vals
289            .iter()
290            .enumerate()
291            .map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v)))
292            .collect();
293        format!(
294            r#"<polyline points="{}" fill="none" stroke="{color}" stroke-width="2"{dash}/>"#,
295            pts.join(" ")
296        )
297    };
298    svg.push_str(&polyline(
299        points.iter().map(|p| p.saved_usd).collect(),
300        "#059669",
301        "",
302    ));
303    if points.iter().any(|p| p.reference_cost_usd > 0.0) {
304        svg.push_str(&polyline(
305            points.iter().map(|p| p.reference_cost_usd).collect(),
306            "#7c3aed",
307            r#" stroke-dasharray="5 4""#,
308        ));
309    }
310    svg.push_str("</svg>");
311    svg
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::gateway_server::admin_api::{UsageBreakdownRow, UsageTotals};
318    use crate::gateway_server::admin_timeseries::TimeseriesPoint;
319
320    fn fixture() -> (UsageBreakdownResponse, TimeseriesResponse) {
321        let usage = UsageBreakdownResponse {
322            from: "2026-06-01T00:00:00+00:00".into(),
323            to: "2026-07-01T00:00:00+00:00".into(),
324            rows: vec![
325                UsageBreakdownRow {
326                    person: "alice@zuehlke.com".into(),
327                    project: "checkout".into(),
328                    model: "claude-sonnet-4-5".into(),
329                    provider: "anthropic".into(),
330                    requests: 900,
331                    input_tokens: 8_000_000,
332                    output_tokens: 400_000,
333                    cost_usd: 210.0,
334                    saved_tokens: 2_500_000,
335                    saved_usd: 65.0,
336                    measured_requests: 0,
337                    estimated_requests: 0,
338                },
339                UsageBreakdownRow {
340                    person: "bob@zuehlke.com".into(),
341                    project: "platform".into(),
342                    model: "phi-4".into(),
343                    provider: "foundry".into(),
344                    requests: 300,
345                    input_tokens: 1_000_000,
346                    output_tokens: 90_000,
347                    cost_usd: 12.0,
348                    saved_tokens: 400_000,
349                    saved_usd: 4.0,
350                    measured_requests: 0,
351                    estimated_requests: 0,
352                },
353            ],
354            totals: UsageTotals {
355                requests: 1200,
356                cost_usd: 222.0,
357                saved_usd: 69.0,
358                reference_cost_usd: 410.0,
359                active_persons: 2,
360                measured_requests: 0,
361                estimated_requests: 0,
362                projection_seats: Some(800),
363                projection_usd_per_month: Some(27_600.0),
364            },
365        };
366        let series = TimeseriesResponse {
367            from: usage.from.clone(),
368            to: usage.to.clone(),
369            points: vec![
370                TimeseriesPoint {
371                    day: "2026-06-01".into(),
372                    requests: 600,
373                    cost_usd: 111.0,
374                    saved_usd: 30.0,
375                    reference_cost_usd: 205.0,
376                },
377                TimeseriesPoint {
378                    day: "2026-06-02".into(),
379                    requests: 600,
380                    cost_usd: 111.0,
381                    saved_usd: 39.0,
382                    reference_cost_usd: 205.0,
383                },
384            ],
385        };
386        (usage, series)
387    }
388
389    #[test]
390    fn report_contains_real_numbers_and_no_external_assets() {
391        let (usage, series) = fixture();
392        let html = render(
393            &usage,
394            &series,
395            42,
396            &ReportMeta {
397                org_label: Some("Zühlke Engineering AG".into()),
398                seats: Some(800),
399                reference_model: Some("claude-opus-4.5".into()),
400            },
401        );
402        // Executive numbers present.
403        assert!(html.contains("$222"));
404        assert!(html.contains("$69.00"));
405        assert!(html.contains("$410"));
406        assert!(html.contains("$188")); // avoided = 410 - 222
407        assert!(html.contains("$27.6k/mo"));
408        assert!(html.contains("alice@zuehlke.com"));
409        assert!(html.contains("claude-opus-4.5"));
410        assert!(html.contains("42")); // routed requests
411        // Self-contained: no http(s) loads, no scripts.
412        assert!(!html.contains("src=\"http"));
413        assert!(!html.contains("<script"));
414        assert!(html.contains("<svg"));
415        // XSS hygiene on org label.
416        let hostile = render(
417            &usage,
418            &series,
419            0,
420            &ReportMeta {
421                org_label: Some("<script>alert(1)</script>".into()),
422                seats: None,
423                reference_model: None,
424            },
425        );
426        assert!(!hostile.contains("<script>alert"));
427    }
428
429    #[test]
430    fn empty_window_renders_gracefully() {
431        let usage = UsageBreakdownResponse {
432            from: "2026-06-01T00:00:00+00:00".into(),
433            to: "2026-06-02T00:00:00+00:00".into(),
434            rows: vec![],
435            totals: UsageTotals {
436                requests: 0,
437                cost_usd: 0.0,
438                saved_usd: 0.0,
439                reference_cost_usd: 0.0,
440                active_persons: 0,
441                measured_requests: 0,
442                estimated_requests: 0,
443                projection_seats: None,
444                projection_usd_per_month: None,
445            },
446        };
447        let series = TimeseriesResponse {
448            from: usage.from.clone(),
449            to: usage.to.clone(),
450            points: vec![],
451        };
452        let html = render(
453            &usage,
454            &series,
455            0,
456            &ReportMeta {
457                org_label: None,
458                seats: None,
459                reference_model: None,
460            },
461        );
462        assert!(html.contains("No events in this window"));
463        assert!(html.contains("reference_model"));
464    }
465}