Skip to main content

sentinel_core/report/html/
mod.rs

1//! HTML dashboard sink (single-file output, vanilla JS, `textContent`-only).
2//!
3//! Emits a self-contained HTML file that renders a completed [`Report`]
4//! as an interactive dashboard with Findings, Explain and (when green
5//! scoring is enabled) `GreenOps` tabs.
6//!
7//! # Security model
8//!
9//! All user-controlled data is injected inside a
10//! `<script id="report-data" type="application/json">` block and read
11//! once at load time via `Element.textContent`. The bundled JS uses
12//! `textContent` and `document.createElement()` exclusively and never
13//! calls `innerHTML`, `insertAdjacentHTML`, `document.write`, `eval()`
14//! or `new Function()`. The unit test
15//! [`tests::no_forbidden_apis_in_template`] greps the template on every
16//! build to enforce the rule.
17//!
18//! Additional defense: [`inject`] escapes the substring `</` in the
19//! serialized JSON payload to `<\/` so a user-controlled value
20//! (SQL template, HTTP URL, service name) cannot close the `<script>`
21//! block early. `\/` is a permitted JSON string escape, so
22//! `JSON.parse` recovers the original value unchanged.
23//!
24//! # Trace embedding
25//!
26//! Only traces that contain at least one finding are embedded (the empty
27//! state in the Explain tab makes free navigation pointless). When
28//! `max_traces_embedded` is `None`, the sink targets a ~5 MB HTML file
29//! size by trimming the lowest-IIS traces first (top-waste fallback
30//! reusing the `top_offenders` ordering). When the user sets
31//! `max_traces_embedded` explicitly, that cap is honored exactly,
32//! regardless of the size target.
33//!
34//! See `docs/design/07-CLI-CONFIG-RELEASE.md` for the full design
35//! rationale.
36
37use crate::correlate::Trace;
38use crate::diff::DiffReport;
39use crate::event::EventType;
40use crate::ingest::mysql_stat::MySqlStatReport;
41use crate::ingest::pg_stat::PgStatReport;
42use crate::normalize::NormalizedEvent;
43use crate::report::Report;
44use serde::Serialize;
45use std::collections::{HashMap, HashSet};
46use std::path::Path;
47
48const TEMPLATE: &str = include_str!("html_template.html");
49const JSON_PLACEHOLDER: &str = "{{REPORT_JSON}}";
50const TITLE_PLACEHOLDER: &str = "{{PAGE_TITLE}}";
51const CSP_PLACEHOLDER: &str = "{{CONTENT_SECURITY_POLICY}}";
52const BRAND_LOGO_PLACEHOLDER: &str = "{{BRAND_LOGO}}";
53const FONT_FACES_PLACEHOLDER: &str = "{{FONT_FACES}}";
54const DEFAULT_TITLE: &str = "perf-sentinel report";
55// DM Sans + JetBrains Mono (OFL-1.1) Latin subset, embedded as base64 woff2 so
56// the self-contained report renders the brand typefaces offline, with no network
57// fetch. Generated from the @fontsource woff2 subsets; the license text lives
58// in `fonts-LICENSE.txt` beside it. Base64 alphabet contains no `{` so the
59// double-brace guard below holds.
60const FONT_FACES: &str = include_str!("fonts.css");
61// Brand wordmark (horizontal lockup), embedded so the self-contained report
62// needs no network fetch. `logo-horiz-light.svg` is the dark wordmark for
63// light backgrounds; `logo-horiz-dark.svg` is the light wordmark for dark
64// backgrounds. The template swaps them by `data-theme` in pure CSS. Kept
65// inside this crate (not referenced from the repo-root `logo/`) so
66// `cargo publish` packages them; an out-of-package `include_str!` would break
67// the published crate's compile.
68const BRAND_LOGO_LIGHT_SVG: &str = include_str!("logo-horiz-light.svg");
69const BRAND_LOGO_DARK_SVG: &str = include_str!("logo-horiz-dark.svg");
70const DEFAULT_SIZE_TARGET_BYTES: usize = 5 * 1024 * 1024;
71/// Static-mode Content-Security-Policy. See `docs/design/07-CLI-CONFIG-RELEASE.md`
72/// ยง "`STATIC_CSP` compile-time invariant" for the substitution-shadowing
73/// guarantee enforced by the const block below.
74const STATIC_CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; \
75                          style-src 'unsafe-inline'; img-src data:; \
76                          font-src data:; base-uri 'none'; form-action 'none'";
77
78/// Compile-time guard: a value substituted into the document before the JSON
79/// marker (the CSP, the brand SVGs) must not contain `{{`, which would shadow
80/// a later `{{...}}` placeholder during [`inject`].
81const fn assert_no_double_brace(s: &str) {
82    let bytes = s.as_bytes();
83    let mut i = 0;
84    while i + 1 < bytes.len() {
85        assert!(
86            !(bytes[i] == b'{' && bytes[i + 1] == b'{'),
87            "embedded asset must not contain `{{{{`, it would shadow placeholder substitution"
88        );
89        i += 1;
90    }
91}
92const _: () = {
93    assert_no_double_brace(STATIC_CSP);
94    assert_no_double_brace(BRAND_LOGO_LIGHT_SVG);
95    assert_no_double_brace(BRAND_LOGO_DARK_SVG);
96    // FONT_FACES is substituted first in `inject`, ahead of the JSON/CSP/
97    // TITLE markers, so a stray `{{` in the embedded font CSS would shadow a
98    // real placeholder. base64 has no `{`, but guard it like the siblings.
99    assert_no_double_brace(FONT_FACES);
100};
101/// Embedded in every payload as the `version` field. Extracted from the
102/// environment at compile time via `env!`, kept as a single constant so
103/// the size-trim pass and the final build path cannot drift.
104const PAYLOAD_VERSION: &str = env!("CARGO_PKG_VERSION");
105
106/// Options controlling HTML rendering.
107///
108/// `#[non_exhaustive]` for SemVer-minor field additions (0.9.5 added
109/// `mysql_stat`); struct literals (including functional record update)
110/// do not compile outside this crate, so external crates start from
111/// `RenderOptions::default()` and set the public fields one by one.
112#[derive(Debug, Clone, Default)]
113#[non_exhaustive]
114pub struct RenderOptions {
115    /// Label shown in the top bar (filename, `-` for stdin, etc.).
116    pub input_label: String,
117    /// Explicit cap on embedded traces. When `None`, the sink trims to
118    /// fit [`DEFAULT_SIZE_TARGET_BYTES`] using the top-waste fallback.
119    pub max_traces_embedded: Option<usize>,
120    /// Optional `pg_stat_statements` report embedded alongside the
121    /// analysis. When `Some`, the HTML dashboard exposes a `pg_stat` tab
122    /// plus the Explain-to-`pg_stat` cross-navigation for matching SQL
123    /// templates.
124    pub pg_stat: Option<PgStatReport>,
125    /// Optional `MySQL` Performance Schema digest report embedded
126    /// alongside the analysis. When `Some`, the HTML dashboard exposes
127    /// a `mysql_stat` tab with the same ranking sub-switcher as
128    /// `pg_stat`.
129    pub mysql_stat: Option<MySqlStatReport>,
130    /// Optional diff against a baseline run embedded alongside the
131    /// analysis. When `Some`, the HTML dashboard exposes a Diff tab
132    /// with new/resolved findings, severity changes, and per-endpoint
133    /// deltas.
134    pub diff: Option<DiffReport>,
135    /// When `Some`, the generated HTML enables live mode: the in-page
136    /// JavaScript connects to the daemon at this URL for ack/revoke
137    /// interactions, fetches the daemon-side acks listing, and shows a
138    /// connection-status indicator. Reveals the auth-key prompt modal
139    /// on a 401 response. The daemon must have CORS configured (see
140    /// `[daemon.cors]` in CONFIGURATION.md) and the document origin
141    /// allowed.
142    ///
143    /// When `None`, the HTML is purely static: no badge, no
144    /// ack/revoke buttons, no acknowledgments panel, strict CSP with
145    /// no `connect-src` directive.
146    ///
147    /// The URL is expected to have been validated by the caller. The
148    /// renderer trusts it as-is and concatenates it into the
149    /// Content-Security-Policy `connect-src` directive. Validation
150    /// rejects userinfo, paths, query strings and ASCII control
151    /// characters via `crates/sentinel-cli/src/ack.rs::validate_url`.
152    /// The browser-side handlers (auth-key prompt, ack/revoke modal,
153    /// fetch retry) live in the live-mode IIFE block at the bottom
154    /// of `crates/sentinel-core/src/report/html_template.html`.
155    pub daemon_url: Option<String>,
156}
157
158/// Counters describing how many candidate traces ended up embedded in
159/// the rendered HTML. Returned by [`render`] so callers can surface a
160/// trim notice to the user when `kept < total`. Field naming mirrors
161/// the private `TrimSummary` struct used inside the JSON payload.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct RenderStats {
164    /// Number of traces actually embedded in the rendered HTML.
165    pub kept: usize,
166    /// Total candidate traces before the trace-level size or cap trim.
167    /// Candidates come from the findings kept in the embed, so when the
168    /// findings trim fires this is already conservative versus the full
169    /// JSON report (deliberate: every embedded trace has its finding
170    /// visible in the dashboard).
171    pub total: usize,
172}
173
174/// Render a report to a self-contained HTML string and return how many
175/// traces were embedded vs. how many were candidates.
176///
177/// # Panics
178///
179/// Panics if `serde_json` fails to serialize the payload. The payload
180/// is built from `Serialize` types with only string and number keys,
181/// so this can only happen on serde internal errors (out-of-memory and
182/// similar system-level failures), not on user input.
183///
184/// # Examples
185///
186/// ```no_run
187/// use sentinel_core::report::html::{render, RenderOptions};
188/// use sentinel_core::pipeline::analyze_with_traces;
189/// # fn load_events() -> Vec<sentinel_core::event::SpanEvent> { vec![] }
190/// let events = load_events();
191/// let cfg = sentinel_core::config::Config::default();
192/// let (report, traces) = analyze_with_traces(events, &cfg);
193/// // RenderOptions is #[non_exhaustive]: start from Default and set fields.
194/// let mut options = RenderOptions::default();
195/// options.input_label = "traces.json".to_string();
196/// let (html, _stats) = render(&report, &traces, &options);
197/// assert!(html.starts_with("<!DOCTYPE html>"));
198/// ```
199#[must_use]
200pub fn render(report: &Report, traces: &[Trace], options: &RenderOptions) -> (String, RenderStats) {
201    // Mixed-content guard: an http:// daemon URL on a non-loopback host
202    // breaks ack/revoke fetches if the report is later served over https.
203    if let Some(url) = options.daemon_url.as_deref()
204        && let Some(rest) = url.strip_prefix("http://")
205    {
206        let host_only = rest.split(['/', ':']).next().unwrap_or("");
207        let is_loopback =
208            host_only == "localhost" || host_only == "127.0.0.1" || host_only == "[::1]";
209        if !is_loopback {
210            tracing::warn!(
211                daemon_url = url,
212                "http:// daemon URL on a non-loopback host: ack/revoke fetches will be blocked when the report is served over https://"
213            );
214        }
215    }
216    let sanitized_label = sanitize_input_label(&options.input_label);
217    let (report_embed, trimmed_findings) = slim_report_for_embed(report, options);
218    // Trace ranking reads the un-slimmed `top_offenders` so the ordering
219    // is accurate even past the embed cap; the payload serializes the
220    // slim report.
221    let payload = build_payload_with_label(
222        &report_embed,
223        &report.green_summary.top_offenders,
224        traces,
225        options,
226        &sanitized_label,
227        trimmed_findings,
228    );
229    let kept = payload.embedded_traces.len();
230    let total = payload.trimmed_traces.as_ref().map_or(kept, |s| s.total);
231    // Serialization of our fixed-shape payload cannot fail: all nested
232    // types are `Serialize`, every map key is `&'static str`, and there
233    // are no non-string map keys anywhere in the tree. If a future
234    // refactor introduces a `HashMap<NonStringKey, _>` anywhere under
235    // `Payload`, `serde_json` will fail here at runtime. Keep the
236    // payload's map keys `&'static str` or `String` only.
237    let json = serde_json::to_string(&payload).expect("payload always serializes");
238    let title = derive_page_title(&sanitized_label);
239    let csp = build_csp(options.daemon_url.as_deref());
240    let html = inject(&json, &title, &csp);
241    (html, RenderStats { kept, total })
242}
243
244/// Render and write a rendered HTML dashboard to `output`.
245///
246/// # Errors
247///
248/// Returns the underlying [`std::io::Error`] if the file cannot be
249/// created or written.
250///
251/// # Panics
252///
253/// Panics if serialization fails for the same reason as [`render`].
254pub fn write(
255    report: &Report,
256    traces: &[Trace],
257    options: &RenderOptions,
258    output: &Path,
259) -> std::io::Result<()> {
260    let (html, _stats) = render(report, traces, options);
261    std::fs::write(output, html)
262}
263
264// --- internal ---
265
266#[derive(Debug, Serialize)]
267struct Payload<'a> {
268    version: &'static str,
269    input_label: &'a str,
270    report: &'a Report,
271    embedded_traces: Vec<EmbeddedTrace<'a>>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    trimmed_traces: Option<TrimSummary>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    trimmed_findings: Option<TrimSummary>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pg_stat: Option<&'a PgStatReport>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    mysql_stat: Option<&'a MySqlStatReport>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    diff: Option<&'a DiffReport>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    daemon: Option<DaemonHandle<'a>>,
284}
285
286/// Live-mode handle embedded in the JSON payload. Presence flips the JS
287/// boot path from "static" to "live": fetch ack data, reveal the
288/// daemon-status badge, attach Ack/Revoke handlers. Field naming kept
289/// short on purpose, the JSON is read at boot every time.
290#[derive(Debug, Serialize)]
291struct DaemonHandle<'a> {
292    url: &'a str,
293}
294
295#[derive(Debug, Serialize)]
296struct EmbeddedTrace<'a> {
297    trace_id: &'a str,
298    spans: Vec<EmbeddedSpan<'a>>,
299}
300
301#[derive(Debug, Serialize)]
302struct EmbeddedSpan<'a> {
303    span_id: &'a str,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    parent_span_id: Option<&'a str>,
306    service: &'a str,
307    endpoint: &'a str,
308    event_type: &'static str,
309    operation: &'a str,
310    template: &'a str,
311    duration_us: u64,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    status_code: Option<u16>,
314}
315
316#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
317struct TrimSummary {
318    kept: usize,
319    total: usize,
320}
321
322/// Inject the CSP, page title and JSON payload into the template.
323///
324/// Escapes `</` to `<\/` in the JSON payload so a user-controlled
325/// string cannot close the `<script>` block early. `\/` is a permitted
326/// JSON string escape, so round-tripping through `JSON.parse` recovers
327/// the original value. The title is already HTML-escaped by
328/// [`derive_page_title`]. The CSP string is built by [`build_csp`] from
329/// a static prefix and the validated daemon URL, no untrusted bytes
330/// reach the meta tag.
331///
332/// Substitution order is critical and verified by
333/// `hostile_input_label_with_json_placeholder_does_not_double_substitute`
334/// and friends:
335/// - the brand SVG is substituted first; it is trusted compile-time content
336///   guaranteed `{{`-free (see [`assert_no_double_brace`]), so it cannot lay
337///   down a fake placeholder for the later passes to match;
338/// - the JSON payload is substituted before the title, so a hostile
339///   `input_label` carrying `{{REPORT_JSON}}` (injected only at the title
340///   pass) cannot trigger a second JSON substitution;
341/// - the CSP and title markers sit in `<head>`, ahead of both the JSON block
342///   and the brand marker, so a hostile title or JSON payload cannot shadow
343///   the static `replacen(..., 1)` matches.
344fn inject(json: &str, title: &str, csp: &str) -> String {
345    // Defense-in-depth: a `{{` byte sequence in the CSP would shadow a
346    // template placeholder during the title substitution. `validate_url`
347    // rejects bytes `hyper::Uri` does not accept in a host so the check
348    // holds today; plain `assert!` keeps the safety net in release.
349    assert!(
350        !csp.contains("{{"),
351        "CSP must not contain `{{{{` placeholder bytes, got: {csp}"
352    );
353    let safe = json.replace("</", "<\\/");
354    // Brand wordmark as raw inline SVG (light + dark variants), substituted
355    // into the static <span> in the topbar. Server-side substitution, not a
356    // runtime `innerHTML`, so the template keeps its textContent-only XSS
357    // invariant. The SVG is trusted compile-time content and lives in the
358    // body (not a <script>), so no `</` escaping is needed. Substituted
359    // before the report JSON so a hostile `{{BRAND_LOGO}}` inside report
360    // content cannot shadow this one.
361    let brand_logo = format!(
362        "<span class=\"ps-logo ps-logo-light\">{BRAND_LOGO_LIGHT_SVG}</span>\
363         <span class=\"ps-logo ps-logo-dark\">{BRAND_LOGO_DARK_SVG}</span>"
364    );
365    TEMPLATE
366        // Font faces first: trusted base64 in the <head> <style>, substituted
367        // before the report JSON so a hostile `{{FONT_FACES}}` inside report
368        // content cannot shadow it. The base64 alphabet has no `{`.
369        .replacen(FONT_FACES_PLACEHOLDER, FONT_FACES, 1)
370        .replacen(BRAND_LOGO_PLACEHOLDER, &brand_logo, 1)
371        .replacen(JSON_PLACEHOLDER, &safe, 1)
372        .replacen(CSP_PLACEHOLDER, csp, 1)
373        .replacen(TITLE_PLACEHOLDER, title, 1)
374}
375
376/// Build the Content-Security-Policy string for a render call. In
377/// static mode, returns the historical strict policy verbatim. In live
378/// mode, appends `connect-src 'self' <daemon_url>` so the in-page
379/// JavaScript can `fetch()` the daemon AND any same-origin asset (a
380/// future template change adding a same-origin fetch will not silently
381/// break under the strict CSP). The caller validates the URL upstream
382/// (the CLI runs it through `validate_url` and rejects userinfo, paths,
383/// query strings, ASCII control characters), so no CSP-breaking byte
384/// (single quote, semicolon, whitespace, curly braces) can land in the
385/// directive value. The `inject` `debug_assert!(!csp.contains("{{"))`
386/// is the load-bearing fallback in case `validate_url` is ever
387/// relaxed.
388#[must_use]
389fn build_csp(daemon_url: Option<&str>) -> String {
390    match daemon_url {
391        Some(url) => format!("{STATIC_CSP}; connect-src 'self' {url}"),
392        None => STATIC_CSP.to_string(),
393    }
394}
395
396/// Derive the `<title>` text from the user-supplied `input_label`.
397///
398/// Strips any path components, HTML-escapes the filename, and formats
399/// as `perf-sentinel: <filename>`. Falls back to a fixed string when
400/// the label is empty or `-` (stdin).
401fn derive_page_title(input_label: &str) -> String {
402    let trimmed = input_label.trim();
403    if trimmed.is_empty() || trimmed == "-" {
404        return DEFAULT_TITLE.to_string();
405    }
406    let filename = Path::new(trimmed)
407        .file_name()
408        .and_then(|n| n.to_str())
409        .unwrap_or(trimmed);
410    format!("perf-sentinel: {}", html_escape_text(filename))
411}
412
413/// Strip control and unsafe-format characters from `input_label`
414/// before it lands in the JSON payload. The topbar renders the value
415/// via `textContent`, so there is no XSS risk, but a leaked `BiDi`
416/// override would still flip the visible order of surrounding text.
417fn sanitize_input_label(input_label: &str) -> String {
418    input_label
419        .chars()
420        .filter(|c| !c.is_control() && !is_unsafe_format_char(*c))
421        .collect()
422}
423
424/// Minimal HTML escape for the title text. `<title>` is a raw-text
425/// element, so only `&` and `<` strictly need escaping, but we also
426/// escape `>` and the two quote characters for belt-and-braces safety.
427/// Control characters (Unicode Cc, plus the known `BiDi` and
428/// line/paragraph-separator format codes that some terminals and
429/// browsers honor) are dropped so a hostile filename cannot inject
430/// cosmetic payloads into the browser tab.
431fn html_escape_text(s: &str) -> String {
432    let mut out = String::with_capacity(s.len());
433    for c in s.chars() {
434        match c {
435            '&' => out.push_str("&amp;"),
436            '<' => out.push_str("&lt;"),
437            '>' => out.push_str("&gt;"),
438            '"' => out.push_str("&quot;"),
439            '\'' => out.push_str("&#39;"),
440            c if c.is_control() || is_unsafe_format_char(c) => {}
441            _ => out.push(c),
442        }
443    }
444    out
445}
446
447/// Unicode format characters that carry cosmetic payloads: `BiDi`
448/// override and isolate marks, line/paragraph separators, and the
449/// byte-order mark. `char::is_control` only catches the `Cc` category,
450/// so we filter these `Cf` entries by hand.
451fn is_unsafe_format_char(c: char) -> bool {
452    matches!(
453        c,
454        '\u{200E}' // LEFT-TO-RIGHT MARK
455        | '\u{200F}' // RIGHT-TO-LEFT MARK
456        | '\u{2028}' // LINE SEPARATOR
457        | '\u{2029}' // PARAGRAPH SEPARATOR
458        | '\u{202A}'..='\u{202E}' // LRE / RLE / PDF / LRO / RLO
459        | '\u{2066}'..='\u{2069}' // LRI / RLI / FSI / PDI
460        | '\u{FEFF}' // BYTE ORDER MARK
461    )
462}
463
464fn build_payload_with_label<'a>(
465    report: &'a Report,
466    full_top_offenders: &[crate::report::TopOffender],
467    traces: &'a [Trace],
468    options: &'a RenderOptions,
469    input_label: &'a str,
470    trimmed_findings: Option<TrimSummary>,
471) -> Payload<'a> {
472    // Candidate set from the embedded findings (so every embedded trace
473    // has its finding shown), ranked by the full offender list (so the
474    // ordering does not degrade past the embed cap).
475    let ordered = order_candidates_by_iis(&report.findings, full_top_offenders, traces);
476    let total = ordered.len();
477
478    let (kept_refs, trimmed) = if let Some(cap) = options.max_traces_embedded {
479        let take = cap.min(total);
480        let summary = if take < total {
481            Some(TrimSummary { kept: take, total })
482        } else {
483            None
484        };
485        (ordered.into_iter().take(take).collect::<Vec<_>>(), summary)
486    } else {
487        trim_to_size_target(
488            ordered,
489            report,
490            options,
491            input_label,
492            trimmed_findings.clone(),
493        )
494    };
495
496    let embedded_traces = kept_refs.iter().copied().map(embed_trace).collect();
497
498    Payload {
499        version: PAYLOAD_VERSION,
500        input_label,
501        report,
502        embedded_traces,
503        trimmed_traces: trimmed,
504        trimmed_findings,
505        pg_stat: options.pg_stat.as_ref(),
506        mysql_stat: options.mysql_stat.as_ref(),
507        diff: options.diff.as_ref(),
508        daemon: options
509            .daemon_url
510            .as_deref()
511            .map(|url| DaemonHandle { url }),
512    }
513}
514
515/// Filter traces to those referenced by a finding and sort by
516/// per-trace IIS (highest first). Lower `top_offenders` index means
517/// higher IIS. Traces whose `(service, endpoint)` pairs are absent from
518/// `top_offenders` rank as `usize::MAX` and sort last.
519fn order_candidates_by_iis<'a>(
520    findings: &[crate::detect::Finding],
521    top_offenders: &[crate::report::TopOffender],
522    traces: &'a [Trace],
523) -> Vec<&'a Trace> {
524    let finding_trace_ids: HashSet<&str> = findings.iter().map(|f| f.trace_id.as_str()).collect();
525
526    let mut rank: HashMap<(&str, &str), usize> = HashMap::new();
527    for (i, off) in top_offenders.iter().enumerate() {
528        rank.insert((off.service.as_str(), off.endpoint.as_str()), i);
529    }
530
531    let mut scored: Vec<(usize, &'a Trace)> = traces
532        .iter()
533        .filter(|t| finding_trace_ids.contains(t.trace_id.as_str()))
534        .map(|t| (trace_rank(t, &rank), t))
535        .collect();
536    scored.sort_by_key(|(score, _)| *score);
537    scored.into_iter().map(|(_, t)| t).collect()
538}
539
540fn trace_rank(trace: &Trace, rank: &HashMap<(&str, &str), usize>) -> usize {
541    trace
542        .spans
543        .iter()
544        .map(|s| {
545            rank.get(&(s.event.service.as_ref(), s.event.source.endpoint.as_str()))
546                .copied()
547                .unwrap_or(usize::MAX)
548        })
549        .min()
550        .unwrap_or(usize::MAX)
551}
552
553/// Findings share of the JSON budget when the sink targets a file size.
554/// Traces get whatever remains; without this bound a large batch (tens of
555/// thousands of findings) ships a multi-MB envelope no matter how many
556/// traces are trimmed.
557const FINDINGS_BUDGET_SHARE_PCT: usize = 70;
558
559/// Cap on `green_summary.top_offenders` embedded in the HTML payload. The
560/// dashboard only ever reads `top_offenders[0]` (the "Top offender" card),
561/// so a high-endpoint-cardinality report would otherwise embed thousands
562/// of rows nothing renders. The full ranking still drives trace ordering
563/// (from the un-slimmed report) and stays in `analyze --format json`. The
564/// cap leaves headroom for a future top-N table without re-bloating.
565const TOP_OFFENDERS_EMBED_CAP: usize = 25;
566
567/// Build the slimmed `Report` embedded in the HTML payload. Three sections
568/// the dashboard does not fully render are bounded so a high-volume report
569/// does not bloat the self-contained file, while `analyze --format json`
570/// keeps every one of them in full:
571///   - `findings`: trimmed critical-first when over the size budget
572///     (surfaced as a banner), full otherwise;
573///   - `per_endpoint_io_ops`: dropped entirely (no dashboard view reads it);
574///   - `green_summary.top_offenders`: capped to [`TOP_OFFENDERS_EMBED_CAP`].
575fn slim_report_for_embed(
576    report: &Report,
577    options: &RenderOptions,
578) -> (Report, Option<TrimSummary>) {
579    let (findings, trimmed_findings) = select_embedded_findings(report, options);
580    // Clone-then-truncate: the transient full clone is freed immediately,
581    // and a one-shot HTML render is not a hot path. What matters is that
582    // the serialized payload carries at most the cap.
583    let mut green_summary = report.green_summary.clone();
584    green_summary
585        .top_offenders
586        .truncate(TOP_OFFENDERS_EMBED_CAP);
587    // Exhaustive literal, not `report.clone()`: it makes the dropped
588    // `per_endpoint_io_ops` explicit (never cloning the big vec) and turns
589    // a future new `Report` field into a compile error here.
590    let embed = Report {
591        analysis: report.analysis.clone(),
592        findings,
593        green_summary,
594        quality_gate: report.quality_gate.clone(),
595        per_endpoint_io_ops: Vec::new(),
596        correlations: report.correlations.clone(),
597        warnings: report.warnings.clone(),
598        warning_details: report.warning_details.clone(),
599        acknowledged_findings: report.acknowledged_findings.clone(),
600        binary_version: report.binary_version.clone(),
601        disclosure_waste: report.disclosure_waste.clone(),
602    };
603    (embed, trimmed_findings)
604}
605
606/// Select the findings to embed. Critical findings are kept first, then
607/// warning, then info, preserving the canonical report order inside each
608/// band. Returns the full set (and no summary) when `--max-traces-embedded`
609/// opts out of size targeting or the set already fits; otherwise the
610/// critical-first prefix that fits, with a [`TrimSummary`].
611fn select_embedded_findings(
612    report: &Report,
613    options: &RenderOptions,
614) -> (Vec<crate::detect::Finding>, Option<TrimSummary>) {
615    if options.max_traces_embedded.is_some() {
616        return (report.findings.clone(), None);
617    }
618    let json_budget = DEFAULT_SIZE_TARGET_BYTES.saturating_sub(TEMPLATE.len());
619    let findings_budget = json_budget * FINDINGS_BUDGET_SHARE_PCT / 100;
620    // Serialize each finding exactly once: the same sizes serve the
621    // whole-array early exit (sum + commas + brackets) and the budget
622    // loop below, instead of serializing the array a second time.
623    let sizes: Vec<usize> = report
624        .findings
625        .iter()
626        .map(|f| serde_json::to_string(f).map_or(usize::MAX, |s| s.len()))
627        .collect();
628    let total_len = sizes
629        .iter()
630        .fold(2usize, |acc, len| acc.saturating_add(len.saturating_add(1)));
631    if total_len <= findings_budget {
632        return (report.findings.clone(), None);
633    }
634
635    let mut order: Vec<usize> = (0..report.findings.len()).collect();
636    // Stable sort on the derived Severity ordering (Critical < Warning
637    // < Info): severity bands first, canonical order within a band.
638    order.sort_by_key(|&i| &report.findings[i].severity);
639
640    let mut running = 2usize; // the [] array brackets
641    let mut keep: Vec<usize> = Vec::new();
642    for &i in &order {
643        let next = running.saturating_add(sizes[i].saturating_add(1));
644        if next > findings_budget {
645            break;
646        }
647        running = next;
648        keep.push(i);
649    }
650    keep.sort_unstable();
651
652    let summary = TrimSummary {
653        kept: keep.len(),
654        total: report.findings.len(),
655    };
656    let kept = keep
657        .into_iter()
658        .map(|i| report.findings[i].clone())
659        .collect();
660    (kept, Some(summary))
661}
662
663/// Greedy trim-to-size loop: serialize, measure, drop the lowest-ranked
664/// trace if over budget. Bounded by the number of input traces. On
665/// realistic inputs (few dozen traces, report JSON under ~200 KB) the
666/// first iteration usually fits and no trimming happens.
667fn trim_to_size_target<'a>(
668    ordered: Vec<&'a Trace>,
669    report: &Report,
670    options: &'a RenderOptions,
671    input_label: &'a str,
672    trimmed_findings: Option<TrimSummary>,
673) -> (Vec<&'a Trace>, Option<TrimSummary>) {
674    let total = ordered.len();
675
676    // Serialize each embedded trace once and the non-trace envelope
677    // once, then prefix-sum scan for the longest trace prefix that
678    // fits under the size target: O(N * avg_trace_size) total, unlike
679    // re-serializing the whole payload per shed trace, which is O(N^2).
680
681    // Step 1: per-trace JSON sizes. We account for the surrounding
682    // comma and the 2 literal bracket bytes of the JSON array via
683    // `separator_overhead` below.
684    let per_trace_lens: Vec<usize> = ordered
685        .iter()
686        .copied()
687        .map(|t| serde_json::to_string(&embed_trace(t)).map_or(usize::MAX, |s| s.len()))
688        .collect();
689
690    // Step 2: envelope size. Build a payload whose `embedded_traces`
691    // is empty, serialize it once, and use its length as the fixed
692    // overhead that every kept-trace count shares. `trimmed_traces`
693    // is set to a placeholder with realistic digits so its JSON
694    // length is not under-reported (the actual value is written back
695    // in `build_payload_with_label` after trimming), and the real
696    // `trimmed_findings` rides along for the same reason.
697    let envelope = Payload {
698        version: PAYLOAD_VERSION,
699        input_label,
700        report,
701        embedded_traces: Vec::new(),
702        trimmed_traces: Some(TrimSummary { kept: 0, total }),
703        trimmed_findings,
704        pg_stat: options.pg_stat.as_ref(),
705        mysql_stat: options.mysql_stat.as_ref(),
706        diff: options.diff.as_ref(),
707        daemon: options
708            .daemon_url
709            .as_deref()
710            .map(|url| DaemonHandle { url }),
711    };
712    let envelope_len = serde_json::to_string(&envelope).map_or(usize::MAX, |s| s.len());
713
714    // Budget for the serialized JSON payload: the template is a fixed
715    // cost on every output. If TEMPLATE.len() already exceeds the
716    // target (implausible), we return an empty set rather than
717    // underflow.
718    let json_budget = DEFAULT_SIZE_TARGET_BYTES.saturating_sub(TEMPLATE.len());
719
720    // Find the largest prefix of `ordered` whose combined size fits
721    // under the budget. Each trace contributes `len + 1` (for the
722    // comma separator); the two empty-array bytes `[]` are already
723    // included in `envelope_len`.
724    let mut running = envelope_len;
725    let mut keep_count: usize = 0;
726    for &len in &per_trace_lens {
727        let delta = len.saturating_add(1);
728        let next = running.saturating_add(delta);
729        if next > json_budget {
730            break;
731        }
732        running = next;
733        keep_count += 1;
734    }
735
736    let kept: Vec<&'a Trace> = ordered.into_iter().take(keep_count).collect();
737    let trimmed = if kept.len() < total {
738        Some(TrimSummary {
739            kept: kept.len(),
740            total,
741        })
742    } else {
743        None
744    };
745    (kept, trimmed)
746}
747
748fn embed_trace(t: &Trace) -> EmbeddedTrace<'_> {
749    EmbeddedTrace {
750        trace_id: t.trace_id.as_str(),
751        spans: t.spans.iter().map(embed_span).collect(),
752    }
753}
754
755fn embed_span(e: &NormalizedEvent) -> EmbeddedSpan<'_> {
756    EmbeddedSpan {
757        span_id: e.event.span_id.as_str(),
758        parent_span_id: e.event.parent_span_id.as_deref(),
759        service: e.event.service.as_ref(),
760        endpoint: e.event.source.endpoint.as_str(),
761        event_type: match e.event.event_type {
762            EventType::Sql => "sql",
763            EventType::HttpOut => "http_out",
764        },
765        operation: e.event.operation.as_str(),
766        // Only the masked template is embedded. The raw event.target (the
767        // original db.statement / URL) carries literals and must never reach
768        // the HTML payload. The JS falls back template-first, so dropping it
769        // loses nothing observable.
770        template: e.template.as_ref(),
771        duration_us: e.event.duration_us,
772        status_code: e.event.status_code,
773    }
774}
775
776#[cfg(test)]
777mod tests;