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("&"),
436 '<' => out.push_str("<"),
437 '>' => out.push_str(">"),
438 '"' => out.push_str("""),
439 '\'' => out.push_str("'"),
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;