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::{BTreeMap, 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 = "BTreeMap::is_empty")]
culprit_spans: BTreeMap<String, Vec<&'a str>>,
#[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 culprit_key(trace_id: &str, signature: &str, first: &str, last: &str) -> String {
format!("{trace_id}|{signature}|{first}|{last}")
}
struct CulpritIndex {
serialized_min_sequential: u32,
max_fanout: u32,
published: HashMap<String, String>,
ambiguous: HashSet<String>,
}
impl CulpritIndex {
fn build(report: &Report) -> Option<Self> {
use crate::detect::FindingType;
let cfg = report.detection_config.as_ref()?;
let mut published: HashMap<String, String> = HashMap::new();
let mut ambiguous: HashSet<String> = HashSet::new();
for f in &report.findings {
if !matches!(
f.finding_type,
FindingType::SerializedCalls | FindingType::ExcessiveFanout
) {
continue;
}
let rerun_key = culprit_key(
&f.trace_id,
&crate::acknowledgments::compute_signature(f),
&f.first_timestamp,
&f.last_timestamp,
);
let browser_key = culprit_key(
&f.trace_id,
&f.signature,
&f.first_timestamp,
&f.last_timestamp,
);
if published.insert(rerun_key.clone(), browser_key).is_some() {
ambiguous.insert(rerun_key);
}
}
(!published.is_empty()).then_some(Self {
serialized_min_sequential: cfg.serialized_min_sequential,
max_fanout: cfg.max_fanout,
published,
ambiguous,
})
}
fn for_trace<'a>(&self, trace: &'a Trace) -> BTreeMap<String, Vec<&'a str>> {
let mut out: BTreeMap<String, Vec<&'a str>> = BTreeMap::new();
let mut claimed_twice: HashSet<String> = HashSet::new();
let indices = crate::detect::TraceIndices::build(trace);
let found = crate::detect::serialized::detect_serialized_with_spans(
trace,
&indices,
self.serialized_min_sequential,
)
.into_iter()
.chain(crate::detect::fanout::detect_fanout_with_spans(
trace,
&indices,
self.max_fanout,
));
for (finding, span_ids) in found {
let rerun_key = culprit_key(
&finding.trace_id,
&crate::acknowledgments::compute_signature(&finding),
&finding.first_timestamp,
&finding.last_timestamp,
);
if self.ambiguous.contains(&rerun_key) || claimed_twice.contains(&rerun_key) {
continue;
}
let Some(browser_key) = self.published.get(&rerun_key) else {
continue;
};
if out.insert(browser_key.clone(), span_ids).is_some() {
out.remove(browser_key);
claimed_twice.insert(rerun_key);
}
}
out
}
}
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 index = CulpritIndex::build(report);
let mut culprits_by_trace: HashMap<&str, BTreeMap<String, Vec<&'a str>>> = index
.as_ref()
.map(|idx| {
ordered
.iter()
.map(|t| (t.trace_id.as_str(), idx.for_trace(t)))
.collect()
})
.unwrap_or_default();
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(),
&culprits_by_trace,
)
};
let culprit_spans = kept_refs
.iter()
.filter_map(|t| culprits_by_trace.remove(t.trace_id.as_str()))
.flatten()
.collect();
let embedded_traces = kept_refs.iter().copied().map(embed_trace).collect();
Payload {
version: PAYLOAD_VERSION,
input_label,
report,
embedded_traces,
culprit_spans,
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(),
detection_config: report.detection_config.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>,
culprits_by_trace: &HashMap<&str, BTreeMap<String, Vec<&'a str>>>,
) -> (Vec<&'a Trace>, Option<TrimSummary>) {
let total = ordered.len();
let per_trace_lens: Vec<usize> = ordered
.iter()
.copied()
.map(|t| {
let spans = serde_json::to_string(&embed_trace(t)).map_or(usize::MAX, |s| s.len());
let culprits = culprits_by_trace
.get(t.trace_id.as_str())
.and_then(|m| serde_json::to_string(m).ok())
.map_or(0, |s| s.len());
spans.saturating_add(culprits)
})
.collect();
let envelope = Payload {
version: PAYLOAD_VERSION,
input_label,
report,
embedded_traces: Vec::new(),
culprit_spans: BTreeMap::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",
EventType::Messaging => "messaging",
},
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;