use crate::correlate::Trace;
use crate::diff::DiffReport;
use crate::event::EventType;
use crate::ingest::mysql_stat::MySqlStatReport;
use crate::ingest::pg_stat::PgStatReport;
use crate::normalize::NormalizedEvent;
use crate::report::Report;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::Path;
const TEMPLATE: &str = include_str!("html_template.html");
const JSON_PLACEHOLDER: &str = "{{REPORT_JSON}}";
const TITLE_PLACEHOLDER: &str = "{{PAGE_TITLE}}";
const CSP_PLACEHOLDER: &str = "{{CONTENT_SECURITY_POLICY}}";
const BRAND_LOGO_PLACEHOLDER: &str = "{{BRAND_LOGO}}";
const FONT_FACES_PLACEHOLDER: &str = "{{FONT_FACES}}";
const DEFAULT_TITLE: &str = "perf-sentinel report";
const FONT_FACES: &str = include_str!("fonts.css");
const BRAND_LOGO_LIGHT_SVG: &str = include_str!("logo-horiz-light.svg");
const BRAND_LOGO_DARK_SVG: &str = include_str!("logo-horiz-dark.svg");
const DEFAULT_SIZE_TARGET_BYTES: usize = 5 * 1024 * 1024;
const STATIC_CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; \
style-src 'unsafe-inline'; img-src data:; \
font-src data:; base-uri 'none'; form-action 'none'";
const fn assert_no_double_brace(s: &str) {
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
assert!(
!(bytes[i] == b'{' && bytes[i + 1] == b'{'),
"embedded asset must not contain `{{{{`, it would shadow placeholder substitution"
);
i += 1;
}
}
const _: () = {
assert_no_double_brace(STATIC_CSP);
assert_no_double_brace(BRAND_LOGO_LIGHT_SVG);
assert_no_double_brace(BRAND_LOGO_DARK_SVG);
assert_no_double_brace(FONT_FACES);
};
const PAYLOAD_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RenderOptions {
pub input_label: String,
pub max_traces_embedded: Option<usize>,
pub pg_stat: Option<PgStatReport>,
pub mysql_stat: Option<MySqlStatReport>,
pub diff: Option<DiffReport>,
pub daemon_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderStats {
pub kept: usize,
pub total: usize,
}
#[must_use]
pub fn render(report: &Report, traces: &[Trace], options: &RenderOptions) -> (String, RenderStats) {
if let Some(url) = options.daemon_url.as_deref()
&& let Some(rest) = url.strip_prefix("http://")
{
let host_only = rest.split(['/', ':']).next().unwrap_or("");
let is_loopback =
host_only == "localhost" || host_only == "127.0.0.1" || host_only == "[::1]";
if !is_loopback {
tracing::warn!(
daemon_url = url,
"http:// daemon URL on a non-loopback host: ack/revoke fetches will be blocked when the report is served over https://"
);
}
}
let sanitized_label = sanitize_input_label(&options.input_label);
let (report_embed, trimmed_findings) = slim_report_for_embed(report, options);
let payload = build_payload_with_label(
&report_embed,
&report.green_summary.top_offenders,
traces,
options,
&sanitized_label,
trimmed_findings,
);
let kept = payload.embedded_traces.len();
let total = payload.trimmed_traces.as_ref().map_or(kept, |s| s.total);
let json = serde_json::to_string(&payload).expect("payload always serializes");
let title = derive_page_title(&sanitized_label);
let csp = build_csp(options.daemon_url.as_deref());
let html = inject(&json, &title, &csp);
(html, RenderStats { kept, total })
}
pub fn write(
report: &Report,
traces: &[Trace],
options: &RenderOptions,
output: &Path,
) -> std::io::Result<()> {
let (html, _stats) = render(report, traces, options);
std::fs::write(output, html)
}
#[derive(Debug, Serialize)]
struct Payload<'a> {
version: &'static str,
input_label: &'a str,
report: &'a Report,
embedded_traces: Vec<EmbeddedTrace<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
trimmed_traces: Option<TrimSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
trimmed_findings: Option<TrimSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pg_stat: Option<&'a PgStatReport>,
#[serde(skip_serializing_if = "Option::is_none")]
mysql_stat: Option<&'a MySqlStatReport>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<&'a DiffReport>,
#[serde(skip_serializing_if = "Option::is_none")]
daemon: Option<DaemonHandle<'a>>,
}
#[derive(Debug, Serialize)]
struct DaemonHandle<'a> {
url: &'a str,
}
#[derive(Debug, Serialize)]
struct EmbeddedTrace<'a> {
trace_id: &'a str,
spans: Vec<EmbeddedSpan<'a>>,
}
#[derive(Debug, Serialize)]
struct EmbeddedSpan<'a> {
span_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
parent_span_id: Option<&'a str>,
service: &'a str,
endpoint: &'a str,
event_type: &'static str,
operation: &'a str,
template: &'a str,
duration_us: u64,
#[serde(skip_serializing_if = "Option::is_none")]
status_code: Option<u16>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct TrimSummary {
kept: usize,
total: usize,
}
fn inject(json: &str, title: &str, csp: &str) -> String {
assert!(
!csp.contains("{{"),
"CSP must not contain `{{{{` placeholder bytes, got: {csp}"
);
let safe = json.replace("</", "<\\/");
let brand_logo = format!(
"<span class=\"ps-logo ps-logo-light\">{BRAND_LOGO_LIGHT_SVG}</span>\
<span class=\"ps-logo ps-logo-dark\">{BRAND_LOGO_DARK_SVG}</span>"
);
TEMPLATE
.replacen(FONT_FACES_PLACEHOLDER, FONT_FACES, 1)
.replacen(BRAND_LOGO_PLACEHOLDER, &brand_logo, 1)
.replacen(JSON_PLACEHOLDER, &safe, 1)
.replacen(CSP_PLACEHOLDER, csp, 1)
.replacen(TITLE_PLACEHOLDER, title, 1)
}
#[must_use]
fn build_csp(daemon_url: Option<&str>) -> String {
match daemon_url {
Some(url) => format!("{STATIC_CSP}; connect-src 'self' {url}"),
None => STATIC_CSP.to_string(),
}
}
fn derive_page_title(input_label: &str) -> String {
let trimmed = input_label.trim();
if trimmed.is_empty() || trimmed == "-" {
return DEFAULT_TITLE.to_string();
}
let filename = Path::new(trimmed)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(trimmed);
format!("perf-sentinel: {}", html_escape_text(filename))
}
fn sanitize_input_label(input_label: &str) -> String {
input_label
.chars()
.filter(|c| !c.is_control() && !is_unsafe_format_char(*c))
.collect()
}
fn html_escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if c.is_control() || is_unsafe_format_char(c) => {}
_ => out.push(c),
}
}
out
}
fn is_unsafe_format_char(c: char) -> bool {
matches!(
c,
'\u{200E}' | '\u{200F}' | '\u{2028}' | '\u{2029}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' )
}
fn build_payload_with_label<'a>(
report: &'a Report,
full_top_offenders: &[crate::report::TopOffender],
traces: &'a [Trace],
options: &'a RenderOptions,
input_label: &'a str,
trimmed_findings: Option<TrimSummary>,
) -> Payload<'a> {
let ordered = order_candidates_by_iis(&report.findings, full_top_offenders, traces);
let total = ordered.len();
let (kept_refs, trimmed) = if let Some(cap) = options.max_traces_embedded {
let take = cap.min(total);
let summary = if take < total {
Some(TrimSummary { kept: take, total })
} else {
None
};
(ordered.into_iter().take(take).collect::<Vec<_>>(), summary)
} else {
trim_to_size_target(
ordered,
report,
options,
input_label,
trimmed_findings.clone(),
)
};
let embedded_traces = kept_refs.iter().copied().map(embed_trace).collect();
Payload {
version: PAYLOAD_VERSION,
input_label,
report,
embedded_traces,
trimmed_traces: trimmed,
trimmed_findings,
pg_stat: options.pg_stat.as_ref(),
mysql_stat: options.mysql_stat.as_ref(),
diff: options.diff.as_ref(),
daemon: options
.daemon_url
.as_deref()
.map(|url| DaemonHandle { url }),
}
}
fn order_candidates_by_iis<'a>(
findings: &[crate::detect::Finding],
top_offenders: &[crate::report::TopOffender],
traces: &'a [Trace],
) -> Vec<&'a Trace> {
let finding_trace_ids: HashSet<&str> = findings.iter().map(|f| f.trace_id.as_str()).collect();
let mut rank: HashMap<(&str, &str), usize> = HashMap::new();
for (i, off) in top_offenders.iter().enumerate() {
rank.insert((off.service.as_str(), off.endpoint.as_str()), i);
}
let mut scored: Vec<(usize, &'a Trace)> = traces
.iter()
.filter(|t| finding_trace_ids.contains(t.trace_id.as_str()))
.map(|t| (trace_rank(t, &rank), t))
.collect();
scored.sort_by_key(|(score, _)| *score);
scored.into_iter().map(|(_, t)| t).collect()
}
fn trace_rank(trace: &Trace, rank: &HashMap<(&str, &str), usize>) -> usize {
trace
.spans
.iter()
.map(|s| {
rank.get(&(s.event.service.as_ref(), s.event.source.endpoint.as_str()))
.copied()
.unwrap_or(usize::MAX)
})
.min()
.unwrap_or(usize::MAX)
}
const FINDINGS_BUDGET_SHARE_PCT: usize = 70;
const TOP_OFFENDERS_EMBED_CAP: usize = 25;
fn slim_report_for_embed(
report: &Report,
options: &RenderOptions,
) -> (Report, Option<TrimSummary>) {
let (findings, trimmed_findings) = select_embedded_findings(report, options);
let mut green_summary = report.green_summary.clone();
green_summary
.top_offenders
.truncate(TOP_OFFENDERS_EMBED_CAP);
let embed = Report {
analysis: report.analysis.clone(),
findings,
green_summary,
quality_gate: report.quality_gate.clone(),
per_endpoint_io_ops: Vec::new(),
correlations: report.correlations.clone(),
warnings: report.warnings.clone(),
warning_details: report.warning_details.clone(),
acknowledged_findings: report.acknowledged_findings.clone(),
binary_version: report.binary_version.clone(),
disclosure_waste: report.disclosure_waste.clone(),
};
(embed, trimmed_findings)
}
fn select_embedded_findings(
report: &Report,
options: &RenderOptions,
) -> (Vec<crate::detect::Finding>, Option<TrimSummary>) {
if options.max_traces_embedded.is_some() {
return (report.findings.clone(), None);
}
let json_budget = DEFAULT_SIZE_TARGET_BYTES.saturating_sub(TEMPLATE.len());
let findings_budget = json_budget * FINDINGS_BUDGET_SHARE_PCT / 100;
let sizes: Vec<usize> = report
.findings
.iter()
.map(|f| serde_json::to_string(f).map_or(usize::MAX, |s| s.len()))
.collect();
let total_len = sizes
.iter()
.fold(2usize, |acc, len| acc.saturating_add(len.saturating_add(1)));
if total_len <= findings_budget {
return (report.findings.clone(), None);
}
let mut order: Vec<usize> = (0..report.findings.len()).collect();
order.sort_by_key(|&i| &report.findings[i].severity);
let mut running = 2usize; let mut keep: Vec<usize> = Vec::new();
for &i in &order {
let next = running.saturating_add(sizes[i].saturating_add(1));
if next > findings_budget {
break;
}
running = next;
keep.push(i);
}
keep.sort_unstable();
let summary = TrimSummary {
kept: keep.len(),
total: report.findings.len(),
};
let kept = keep
.into_iter()
.map(|i| report.findings[i].clone())
.collect();
(kept, Some(summary))
}
fn trim_to_size_target<'a>(
ordered: Vec<&'a Trace>,
report: &Report,
options: &'a RenderOptions,
input_label: &'a str,
trimmed_findings: Option<TrimSummary>,
) -> (Vec<&'a Trace>, Option<TrimSummary>) {
let total = ordered.len();
let per_trace_lens: Vec<usize> = ordered
.iter()
.copied()
.map(|t| serde_json::to_string(&embed_trace(t)).map_or(usize::MAX, |s| s.len()))
.collect();
let envelope = Payload {
version: PAYLOAD_VERSION,
input_label,
report,
embedded_traces: Vec::new(),
trimmed_traces: Some(TrimSummary { kept: 0, total }),
trimmed_findings,
pg_stat: options.pg_stat.as_ref(),
mysql_stat: options.mysql_stat.as_ref(),
diff: options.diff.as_ref(),
daemon: options
.daemon_url
.as_deref()
.map(|url| DaemonHandle { url }),
};
let envelope_len = serde_json::to_string(&envelope).map_or(usize::MAX, |s| s.len());
let json_budget = DEFAULT_SIZE_TARGET_BYTES.saturating_sub(TEMPLATE.len());
let mut running = envelope_len;
let mut keep_count: usize = 0;
for &len in &per_trace_lens {
let delta = len.saturating_add(1);
let next = running.saturating_add(delta);
if next > json_budget {
break;
}
running = next;
keep_count += 1;
}
let kept: Vec<&'a Trace> = ordered.into_iter().take(keep_count).collect();
let trimmed = if kept.len() < total {
Some(TrimSummary {
kept: kept.len(),
total,
})
} else {
None
};
(kept, trimmed)
}
fn embed_trace(t: &Trace) -> EmbeddedTrace<'_> {
EmbeddedTrace {
trace_id: t.trace_id.as_str(),
spans: t.spans.iter().map(embed_span).collect(),
}
}
fn embed_span(e: &NormalizedEvent) -> EmbeddedSpan<'_> {
EmbeddedSpan {
span_id: e.event.span_id.as_str(),
parent_span_id: e.event.parent_span_id.as_deref(),
service: e.event.service.as_ref(),
endpoint: e.event.source.endpoint.as_str(),
event_type: match e.event.event_type {
EventType::Sql => "sql",
EventType::HttpOut => "http_out",
},
operation: e.event.operation.as_str(),
template: e.template.as_ref(),
duration_us: e.event.duration_us,
status_code: e.event.status_code,
}
}
#[cfg(test)]
mod tests;