use crate::correlate::Trace;
use crate::diff::DiffReport;
use crate::ingest::mysql_stat::MySqlStatReport;
use crate::ingest::pg_stat::PgStatReport;
use crate::report::{EmbeddedTrace, 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 mut payload = build_payload_with_label(
&report_embed,
&report.green_summary.top_offenders,
traces,
options,
&sanitized_label,
trimmed_findings,
);
if payload.embedded_traces.is_empty() && !report.embedded_traces.is_empty() {
let (selected, trimmed) = select_report_carried_traces(&report_embed, report, options);
payload.embedded_traces = selected;
payload.trimmed_traces = trimmed;
}
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>,
#[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, 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,
grouping: Option<&crate::event::GroupingAttribute>,
signature: &str,
first: &str,
last: &str,
) -> String {
let mut key = format!("{trace_id}|{signature}|{first}|{last}");
if let Some(grouping) = grouping {
use std::fmt::Write;
write!(
&mut key,
"|{}:{}|{}:{}",
grouping.key.len(),
grouping.key,
grouping.value.len(),
grouping.value
)
.expect("writing to a String cannot fail");
}
key
}
type SlowCulprit = (
String,
String,
crate::detect::FindingType,
String,
Option<(String, String)>,
);
type DirectCulprit = (
String,
String,
crate::detect::FindingType,
String,
Option<(String, String)>,
);
fn grouping_matches(event: &crate::event::SpanEvent, expected: Option<&(String, String)>) -> bool {
event.grouping_identity() == expected.map(|(key, value)| (key.as_str(), value.as_str()))
}
struct CulpritIndex {
serialized_min_sequential: u32,
max_fanout: u32,
pool_saturation_concurrent_threshold: u32,
slow_threshold_us: u64,
published: HashMap<String, String>,
ambiguous: HashSet<String>,
slow: Vec<SlowCulprit>,
direct: Vec<DirectCulprit>,
}
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();
let mut slow = Vec::new();
let mut direct = Vec::new();
for f in &report.findings {
if matches!(
f.finding_type,
FindingType::NPlusOneSql
| FindingType::NPlusOneHttp
| FindingType::NPlusOneMessaging
| FindingType::ChattyService
) {
direct.push((
f.trace_id.clone(),
culprit_key(
&f.trace_id,
f.effective_grouping(),
&f.signature,
&f.first_timestamp,
&f.last_timestamp,
),
f.finding_type.clone(),
f.pattern.template.clone(),
f.effective_grouping()
.map(|g| (g.key.to_string(), g.value.to_string())),
));
continue;
}
if matches!(
f.finding_type,
FindingType::SlowSql | FindingType::SlowHttp | FindingType::SlowMessaging
) {
slow.push((
f.trace_id.clone(),
culprit_key(
&f.trace_id,
f.effective_grouping(),
&f.signature,
&f.first_timestamp,
&f.last_timestamp,
),
f.finding_type.clone(),
f.pattern.template.clone(),
f.effective_grouping()
.map(|g| (g.key.to_string(), g.value.to_string())),
));
continue;
}
if !matches!(
f.finding_type,
FindingType::SerializedCalls
| FindingType::ExcessiveFanout
| FindingType::RedundantSql
| FindingType::RedundantHttp
| FindingType::PoolSaturation
) {
continue;
}
let rerun_key = culprit_key(
&f.trace_id,
f.effective_grouping(),
&crate::acknowledgments::compute_signature(f),
&f.first_timestamp,
&f.last_timestamp,
);
let browser_key = culprit_key(
&f.trace_id,
f.effective_grouping(),
&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() || !slow.is_empty() || !direct.is_empty()).then_some(Self {
serialized_min_sequential: cfg.serialized_min_sequential,
max_fanout: cfg.max_fanout,
pool_saturation_concurrent_threshold: cfg.pool_saturation_concurrent_threshold,
slow_threshold_us: cfg.slow_threshold_ms.saturating_mul(1000),
published,
ambiguous,
slow,
direct,
})
}
fn for_trace<'a>(&self, trace: &'a Trace) -> BTreeMap<String, Vec<&'a str>> {
let mut out: BTreeMap<String, Vec<&'a str>> = BTreeMap::new();
self.add_template_matched(trace, &mut out);
self.add_slow_matched(trace, &mut out);
self.add_rerun_matched(trace, &mut out);
out
}
fn add_template_matched<'a>(&self, trace: &'a Trace, out: &mut BTreeMap<String, Vec<&'a str>>) {
for (trace_id, browser_key, finding_type, template, grouping) in &self.direct {
if trace_id != &trace.trace_id {
continue;
}
let ids = trace
.spans
.iter()
.filter(|span| match finding_type {
crate::detect::FindingType::NPlusOneSql
| crate::detect::FindingType::NPlusOneHttp
| crate::detect::FindingType::NPlusOneMessaging => {
finding_type
== &crate::detect::FindingType::from_event_type_n_plus_one(
&span.event.event_type,
)
&& span.template.as_ref() == template
&& grouping_matches(&span.event, grouping.as_ref())
}
crate::detect::FindingType::ChattyService => {
span.event.event_type == crate::event::EventType::HttpOut
&& grouping_matches(&span.event, grouping.as_ref())
}
_ => false,
})
.map(|span| span.event.span_id.as_str())
.collect();
out.insert(browser_key.clone(), ids);
}
}
fn add_slow_matched<'a>(&self, trace: &'a Trace, out: &mut BTreeMap<String, Vec<&'a str>>) {
for (trace_id, browser_key, finding_type, template, grouping) in &self.slow {
if trace_id != &trace.trace_id {
continue;
}
let ids = trace
.spans
.iter()
.filter(|span| {
finding_type
== &crate::detect::FindingType::from_event_type_slow(&span.event.event_type)
&& span.template.as_ref() == template
&& span.event.duration_us > self.slow_threshold_us
&& grouping_matches(&span.event, grouping.as_ref())
})
.map(|span| span.event.span_id.as_str())
.collect();
out.insert(browser_key.clone(), ids);
}
}
fn add_rerun_matched<'a>(&self, trace: &'a Trace, out: &mut BTreeMap<String, Vec<&'a str>>) {
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,
))
.chain(crate::detect::redundant::detect_redundant_with_spans(
trace,
&[],
))
.chain(
crate::detect::pool_saturation::detect_pool_saturation_with_spans(
trace,
self.pool_saturation_concurrent_threshold,
),
);
for (finding, span_ids) in found {
let rerun_key = culprit_key(
&finding.trace_id,
finding.effective_grouping(),
&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);
}
}
}
}
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(EmbeddedTrace::from_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(),
embedded_traces: Vec::new(),
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))
}
#[derive(Serialize)]
struct EmbeddedTraceRef<'a> {
trace_id: &'a str,
spans: Vec<EmbeddedSpanRef<'a>>,
}
#[derive(Serialize)]
struct EmbeddedSpanRef<'a> {
span_id: &'a str,
#[serde(skip_serializing_if = "str::is_empty")]
timestamp: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
parent_span_id: Option<&'a str>,
service: &'a str,
endpoint: &'a str,
event_type: &'a crate::event::EventType,
operation: &'a str,
template: &'a str,
duration_us: u64,
#[serde(skip_serializing_if = "Option::is_none")]
status_code: Option<u16>,
}
fn embed_trace_ref(t: &Trace) -> EmbeddedTraceRef<'_> {
EmbeddedTraceRef {
trace_id: t.trace_id.as_str(),
spans: t
.spans
.iter()
.map(|e| EmbeddedSpanRef {
span_id: e.event.span_id.as_str(),
timestamp: e.event.timestamp.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: &e.event.event_type,
operation: e.event.operation.as_str(),
template: e.template.as_ref(),
duration_us: e.event.duration_us,
status_code: e.event.status_code,
})
.collect(),
}
}
fn select_report_carried_traces(
report_embed: &Report,
report: &Report,
options: &RenderOptions,
) -> (Vec<EmbeddedTrace>, Option<TrimSummary>) {
let mut severity_by_trace: HashMap<&str, crate::detect::Severity> = HashMap::new();
for f in &report_embed.findings {
severity_by_trace
.entry(f.trace_id.as_str())
.and_modify(|s| {
if f.severity < *s {
s.clone_from(&f.severity);
}
})
.or_insert_with(|| f.severity.clone());
}
let mut ranked: Vec<(crate::detect::Severity, usize, &EmbeddedTrace)> = report
.embedded_traces
.iter()
.enumerate()
.filter_map(|(pos, t)| {
severity_by_trace
.get(t.trace_id.as_str())
.map(|sev| (sev.clone(), pos, t))
})
.collect();
let total = ranked.len();
ranked.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
let kept: Vec<EmbeddedTrace> = if let Some(cap) = options.max_traces_embedded {
ranked
.into_iter()
.take(cap)
.map(|(_, _, t)| t.clone())
.collect()
} else {
let json_budget = DEFAULT_SIZE_TARGET_BYTES.saturating_sub(TEMPLATE.len());
let used = serde_json::to_string(report_embed).map_or(usize::MAX, |s| s.len());
let budget = json_budget.saturating_sub(used);
let mut spent = 0usize;
ranked
.into_iter()
.filter(|(_, _, t)| {
let size = serde_json::to_string(t).map_or(usize::MAX, |s| s.len());
if spent.saturating_add(size) > budget {
return false;
}
spent += size;
true
})
.map(|(_, _, t)| t.clone())
.collect()
};
let trimmed = (kept.len() < total).then_some(TrimSummary {
kept: kept.len(),
total,
});
(kept, trimmed)
}
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_ref(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)
}
#[cfg(test)]
mod tests;