1use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use rocket::http::Status;
27use rocket::serde::json::Json;
28use rocket::{Route, State};
29use rocket_dyn_templates::{Template, context};
30
31use crate::backend::DbPool;
32use crate::models::{Corpus, Service, Task};
33use crate::telemetry::TelemetrySummary;
34
35const TELEMETRY_TTL: Duration = Duration::from_secs(300);
38
39type TelemetryCacheMap = HashMap<(i32, i32), (Instant, Arc<TelemetrySummary>)>;
41
42#[derive(Default)]
48pub struct TelemetryCache(Mutex<TelemetryCacheMap>);
49
50fn resolve(
52 corpus: &str,
53 service: &str,
54 connection: &mut diesel::PgConnection,
55) -> Result<(Corpus, Service), Status> {
56 let corpus = Corpus::find_by_name(corpus, connection).map_err(|_| Status::NotFound)?;
57 let service = Service::find_by_name(service, connection).map_err(|_| Status::NotFound)?;
58 Ok((corpus, service))
59}
60
61fn cached_summary(
66 corpus: &str,
67 service: &str,
68 pool: &State<DbPool>,
69 cache: &State<TelemetryCache>,
70) -> Result<Arc<TelemetrySummary>, Status> {
71 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
72 let (corpus, service) = resolve(corpus, service, &mut connection)?;
73 let key = (corpus.id, service.id);
74 {
77 let cached = cache.0.lock().map_err(|_| Status::InternalServerError)?;
78 if let Some((computed_at, summary)) = cached.get(&key)
79 && computed_at.elapsed() < TELEMETRY_TTL
80 {
81 return Ok(summary.clone());
82 }
83 }
84 let entries = Task::completed_entries(corpus.id, service.id, &mut connection)
87 .map_err(|_| Status::InternalServerError)?;
88 drop(connection);
89 let summary = Arc::new(crate::telemetry::aggregate(
90 &corpus.name,
91 &service.name,
92 corpus.sandbox_id(),
93 entries,
94 ));
95 cache
98 .0
99 .lock()
100 .map_err(|_| Status::InternalServerError)?
101 .insert(key, (Instant::now(), summary.clone()));
102 Ok(summary)
103}
104
105#[get("/api/telemetry/<corpus>/<service>")]
110pub fn api_telemetry(
111 corpus: &str,
112 service: &str,
113 pool: &State<DbPool>,
114 cache: &State<TelemetryCache>,
115) -> Result<Json<TelemetrySummary>, Status> {
116 let summary = cached_summary(corpus, service, pool, cache)?;
119 Ok(Json((*summary).clone()))
120}
121
122#[get("/telemetry/<corpus>/<service>")]
127pub fn telemetry_report_page(
128 corpus: &str,
129 service: &str,
130 pool: &State<DbPool>,
131 cache: &State<TelemetryCache>,
132) -> Result<Template, Status> {
133 let summary = cached_summary(corpus, service, pool, cache)?;
134 let global = serde_json::json!({
135 "title": format!("Telemetry · {}/{}", summary.corpus, summary.service),
136 "description": "Per-run conversion telemetry: latency, memory, and per-phase profile",
137 });
138 let summary = (*summary).clone();
141 Ok(Template::render(
142 "telemetry-report",
143 context! { global, summary },
144 ))
145}
146
147pub fn routes() -> Vec<Route> { routes![api_telemetry, telemetry_report_page] }
149
150#[cfg(test)]
151mod tests {
152 use crate::telemetry::{
153 MathStats, OutcomeWall, PHASES, Percentiles, RssBuckets, TailStats, TelemetrySummary,
154 };
155 use rocket_dyn_templates::tera::{Context, Tera, Value};
156 use std::collections::HashMap;
157
158 #[test]
163 fn telemetry_report_template_renders() {
164 let layout = std::fs::read_to_string("templates/layout.html.tera")
165 .expect("layout template present (run tests from the repo root)");
166 let page = std::fs::read_to_string("templates/telemetry-report.html.tera")
167 .expect("telemetry-report template present");
168 let mut tera = Tera::default();
169 tera
170 .add_raw_templates(vec![
171 ("layout", layout.as_str()),
172 ("telemetry-report", page.as_str()),
173 ])
174 .expect("templates parse and their inheritance resolves");
175 tera.register_filter(
178 "group_thousands",
179 |value: &Value, _: &HashMap<String, Value>| Ok(value.clone()),
180 );
181
182 let summary = TelemetrySummary {
183 corpus: "arxiv".to_string(),
184 service: "tex_to_html".to_string(),
185 sample_count: 3,
186 skipped: 1,
187 outcome_counts: vec![
188 ("no_problem".to_string(), 2),
189 ("warning".to_string(), 1),
190 ("error".to_string(), 0),
191 ("fatal".to_string(), 0),
192 ],
193 wall_ms: Percentiles {
194 p50: 100,
195 p90: 200,
196 p99: 300,
197 max: 400,
198 },
199 rss_mib: Percentiles {
200 p50: 10,
201 p90: 20,
202 p99: 30,
203 max: 40,
204 },
205 phase_p99_ms: PHASES.iter().map(|phase| (phase.to_string(), 5)).collect(),
206 phase_wall_pct: PHASES
207 .iter()
208 .map(|phase| (phase.to_string(), 100.0 / 17.0))
209 .collect(),
210 tail: TailStats {
211 top1pct_wall_share: 10.0,
212 top5pct_wall_share: 27.0,
213 over_30s: 5,
214 over_60s: 1,
215 over_120s: 0,
216 over_180s: 0,
217 },
218 rss_buckets: RssBuckets {
219 over_2gib: 3,
220 over_3gib: 1,
221 over_4gib: 0,
222 },
223 math: MathStats {
224 formulae: 100,
225 parse_invocations: 90,
226 parse_count: 120,
227 parses_per_formula: 1.33,
228 },
229 slow_tail_dominant: vec![("math_parse".to_string(), 22), ("digest".to_string(), 17)],
230 fatal_profile: OutcomeWall {
231 n: 1,
232 median_ms: 3000,
233 mean_ms: 13000,
234 p99_ms: 98000,
235 },
236 no_problem_profile: OutcomeWall {
237 n: 2,
238 median_ms: 100,
239 mean_ms: 150,
240 p99_ms: 300,
241 },
242 slowest: Some(("1234.5678".to_string(), 400)),
243 highest_rss: Some(("2345.6789".to_string(), 40)),
244 total_formulae: 100,
245 total_graphics_assets: 5,
246 total_output_bytes: 999,
247 generated_unix: 0,
248 };
249 let mut context = Context::new();
250 context.insert(
251 "global",
252 &serde_json::json!({ "title": "t", "description": "d" }),
253 );
254 context.insert("summary", &summary);
255 context.insert("is_admin", &false);
257 context.insert("message", &"");
258 context.insert("history", &false);
259
260 let html = tera
261 .render("telemetry-report", &context)
262 .expect("the telemetry-report template renders with a representative summary");
263 assert!(html.contains("Outcome mix"), "renders the outcome section");
264 assert!(
265 html.contains("Per-phase profile"),
266 "renders the phase profile"
267 );
268 assert!(
269 html.contains("/document/arxiv/tex_to_html/1234.5678"),
270 "the slowest witness links to its per-article forensics"
271 );
272 assert!(
273 html.contains("2345.6789"),
274 "renders the highest-RSS witness"
275 );
276 assert!(
277 html.contains("Where wall time goes"),
278 "renders the phase budget"
279 );
280 assert!(
281 html.contains("Wall by outcome"),
282 "renders the outcome wall profiles"
283 );
284 assert!(
285 html.contains("candidate parses/formula"),
286 "renders the math over-parse multiplier"
287 );
288 assert!(
289 html.contains("Slowest-50 dominated by"),
290 "renders the slow-tail driver"
291 );
292 assert!(
293 html.contains("alloc wall"),
294 "renders the RSS pressure buckets"
295 );
296 }
297}