1use std::{cell::RefCell, time::Instant};
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
24#[repr(u8)]
25pub enum Phase {
26 Bootstrap = 0,
27 Digest = 1,
28 Build = 2,
29 Rewrite = 3,
30 MathParse = 4,
31 PostXmlParse = 5,
34 PostScan = 6,
36 Bibliography = 7,
37 Crossref = 8,
38 Graphics = 9,
40 MathImages = 10,
43 MathmlPres = 11,
44 MathmlCont = 12,
45 Split = 13,
47 Xslt = 14,
48 Html5Fixups = 15,
50 Serialize = 16,
51}
52
53impl Phase {
54 pub const COUNT: usize = 17;
55
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Phase::Bootstrap => "bootstrap",
59 Phase::Digest => "digest",
60 Phase::Build => "build",
61 Phase::Rewrite => "rewrite",
62 Phase::MathParse => "math_parse",
63 Phase::PostXmlParse => "post_xml_parse",
64 Phase::PostScan => "post_scan",
65 Phase::Bibliography => "bibliography",
66 Phase::Crossref => "crossref",
67 Phase::Graphics => "graphics",
68 Phase::MathImages => "math_images",
69 Phase::MathmlPres => "mathml_pres",
70 Phase::MathmlCont => "mathml_cont",
71 Phase::Split => "split",
72 Phase::Xslt => "xslt",
73 Phase::Html5Fixups => "html5_fixups",
74 Phase::Serialize => "serialize",
75 }
76 }
77}
78
79const BUCKET_BOUNDS_US: [u64; 8] = [500, 1_000, 2_000, 5_000, 10_000, 20_000, 50_000, 100_000];
82
83fn bucket_for(us: u64) -> usize {
84 for (i, b) in BUCKET_BOUNDS_US.iter().enumerate() {
85 if us < *b {
86 return i;
87 }
88 }
89 8
90}
91
92#[derive(Clone, Debug)]
99pub struct Telemetry {
100 pub paper_id: String,
102 pub git_sha: String,
103 pub cmdline: String,
104 pub host: String,
105 pub timeout_s: u32,
106 pub schema_version: u32,
107
108 pub wall_us: u64,
110 pub phase_us: [u64; Phase::COUNT],
111
112 pub formulae: u32,
114 pub math_parse_attempts: u32,
115 pub math_parse_count: u64,
116 pub math_parse_buckets: [u32; 9],
117 pub graphics_assets: u32,
118 pub graphics_subprocess_count: u32,
119 pub db_objects: u32,
120 pub output_bytes: u64,
121 pub warnings: u32,
122 pub errors: u32,
123 pub fatal_errors: u32,
124 pub external_tool_count: u32,
125
126 pub max_rss_kb: u64,
128 pub child_user_us: u64,
129 pub child_sys_us: u64,
130
131 pub category: String,
133 pub exit_code: i32,
134}
135
136impl Default for Telemetry {
137 fn default() -> Self {
138 Telemetry {
139 paper_id: String::new(),
140 git_sha: option_env!("LATEXML_GIT_SHA").unwrap_or("").to_string(),
141 cmdline: String::new(),
142 host: String::new(),
143 timeout_s: 0,
144 schema_version: 1,
145 wall_us: 0,
146 phase_us: [0; Phase::COUNT],
147 formulae: 0,
148 math_parse_attempts: 0,
149 math_parse_count: 0,
150 math_parse_buckets: [0; 9],
151 graphics_assets: 0,
152 graphics_subprocess_count: 0,
153 db_objects: 0,
154 output_bytes: 0,
155 warnings: 0,
156 errors: 0,
157 fatal_errors: 0,
158 external_tool_count: 0,
159 max_rss_kb: 0,
160 child_user_us: 0,
161 child_sys_us: 0,
162 category: String::new(),
163 exit_code: 0,
164 }
165 }
166}
167
168thread_local! {
169 static STATE: RefCell<Telemetry> = RefCell::new(Telemetry::default());
170 static STACK: RefCell<Vec<(Phase, Instant)>> = const { RefCell::new(Vec::new()) };
173}
174
175pub fn phase_enter(p: Phase) {
179 let now = Instant::now();
180 STACK.with(|s| {
181 let mut stack = s.borrow_mut();
182 if let Some((parent, started)) = stack.last_mut() {
185 let dt = now.saturating_duration_since(*started).as_micros() as u64;
186 let parent = *parent;
187 STATE.with(|st| st.borrow_mut().phase_us[parent as usize] += dt);
188 *started = now;
189 }
190 stack.push((p, now));
191 });
192}
193
194pub fn phase_exit() {
196 let now = Instant::now();
197 STACK.with(|s| {
198 let mut stack = s.borrow_mut();
199 let (p, started) = stack
200 .pop()
201 .expect("telemetry::phase_exit called without matching phase_enter");
202 let dt = now.saturating_duration_since(started).as_micros() as u64;
203 STATE.with(|st| st.borrow_mut().phase_us[p as usize] += dt);
204 if let Some((_, started_parent)) = stack.last_mut() {
206 *started_parent = now;
207 }
208 });
209}
210
211pub struct PhaseGuard {
213 _private: (),
214}
215
216impl Drop for PhaseGuard {
217 fn drop(&mut self) { phase_exit(); }
218}
219
220pub fn phase(p: Phase) -> PhaseGuard {
222 phase_enter(p);
223 PhaseGuard { _private: () }
224}
225
226pub fn incr_formulae() { STATE.with(|s| s.borrow_mut().formulae += 1); }
229
230pub fn set_formulae(n: u32) { STATE.with(|s| s.borrow_mut().formulae = n); }
234
235pub fn add_formulae(n: u32) { STATE.with(|s| s.borrow_mut().formulae += n); }
239
240pub fn record_math_parse(us: u64, parses: u32) {
244 STATE.with(|s| {
245 let mut t = s.borrow_mut();
246 t.math_parse_attempts += 1;
247 t.math_parse_count += parses as u64;
248 t.math_parse_buckets[bucket_for(us)] += 1;
249 });
250}
251
252pub fn incr_graphics_asset() { STATE.with(|s| s.borrow_mut().graphics_assets += 1); }
253pub fn set_graphics_assets(n: u32) { STATE.with(|s| s.borrow_mut().graphics_assets = n); }
254pub fn incr_graphics_subprocess() { STATE.with(|s| s.borrow_mut().graphics_subprocess_count += 1); }
255pub fn add_graphics_subprocess(n: u32) {
260 STATE.with(|s| s.borrow_mut().graphics_subprocess_count += n);
261}
262pub fn incr_external_tool() { STATE.with(|s| s.borrow_mut().external_tool_count += 1); }
263pub fn set_db_objects(n: u32) { STATE.with(|s| s.borrow_mut().db_objects = n); }
264pub fn set_output_bytes(n: u64) { STATE.with(|s| s.borrow_mut().output_bytes = n); }
265pub fn incr_warning() { STATE.with(|s| s.borrow_mut().warnings += 1); }
266pub fn incr_error() { STATE.with(|s| s.borrow_mut().errors += 1); }
267pub fn incr_fatal_error() { STATE.with(|s| s.borrow_mut().fatal_errors += 1); }
268pub fn set_status_counts(warnings: u32, errors: u32, fatal_errors: u32) {
272 STATE.with(|s| {
273 let mut t = s.borrow_mut();
274 t.warnings = warnings;
275 t.errors = errors;
276 t.fatal_errors = fatal_errors;
277 });
278}
279
280pub fn set_paper_id(id: &str) { STATE.with(|s| s.borrow_mut().paper_id = id.to_string()); }
283pub fn set_cmdline(s: &str) { STATE.with(|st| st.borrow_mut().cmdline = s.to_string()); }
284pub fn set_host(h: &str) { STATE.with(|s| s.borrow_mut().host = h.to_string()); }
285pub fn set_timeout_s(t: u32) { STATE.with(|s| s.borrow_mut().timeout_s = t); }
286pub fn set_category(c: &str) { STATE.with(|s| s.borrow_mut().category = c.to_string()); }
287pub fn set_exit_code(e: i32) { STATE.with(|s| s.borrow_mut().exit_code = e); }
288pub fn set_wall_us(w: u64) { STATE.with(|s| s.borrow_mut().wall_us = w); }
289pub fn set_max_rss_kb(r: u64) { STATE.with(|s| s.borrow_mut().max_rss_kb = r); }
290pub fn set_child_rusage_us(user: u64, sys: u64) {
291 STATE.with(|s| {
292 let mut t = s.borrow_mut();
293 t.child_user_us = user;
294 t.child_sys_us = sys;
295 });
296}
297
298pub fn take() -> Telemetry { STATE.with(|s| std::mem::take(&mut *s.borrow_mut())) }
301
302pub fn reset() { STATE.with(|s| *s.borrow_mut() = Telemetry::default()); }
313
314pub fn with<R>(f: impl FnOnce(&Telemetry) -> R) -> R { STATE.with(|s| f(&s.borrow())) }
316
317fn write_json_string(out: &mut String, s: &str) {
320 out.push('"');
321 for c in s.chars() {
322 match c {
323 '"' => out.push_str("\\\""),
324 '\\' => out.push_str("\\\\"),
325 '\n' => out.push_str("\\n"),
326 '\r' => out.push_str("\\r"),
327 '\t' => out.push_str("\\t"),
328 c if (c as u32) < 0x20 => {
329 use std::fmt::Write;
330 write!(out, "\\u{:04x}", c as u32).unwrap();
331 },
332 c => out.push(c),
333 }
334 }
335 out.push('"');
336}
337
338impl Telemetry {
339 #[allow(unused_assignments)]
344 pub fn to_json_line(&self) -> String {
345 use std::fmt::Write;
346 let mut s = String::with_capacity(1024);
347 s.push('{');
348
349 let mut first = true;
350 macro_rules! field {
351 ($name:literal, $val:expr_2021) => {{
352 if !first {
353 s.push(',');
354 }
355 first = false;
356 s.push('"');
357 s.push_str($name);
358 s.push_str("\":");
359 write!(s, "{}", $val).unwrap();
360 }};
361 }
362 macro_rules! field_str {
363 ($name:literal, $val:expr_2021) => {{
364 if !first {
365 s.push(',');
366 }
367 first = false;
368 s.push('"');
369 s.push_str($name);
370 s.push_str("\":");
371 write_json_string(&mut s, $val);
372 }};
373 }
374 macro_rules! field_array_u64 {
375 ($name:literal, $arr:expr_2021) => {{
376 if !first {
377 s.push(',');
378 }
379 first = false;
380 s.push('"');
381 s.push_str($name);
382 s.push_str("\":[");
383 for (i, v) in $arr.iter().enumerate() {
384 if i > 0 {
385 s.push(',');
386 }
387 write!(s, "{}", v).unwrap();
388 }
389 s.push(']');
390 }};
391 }
392 macro_rules! field_array_u32 {
393 ($name:literal, $arr:expr_2021) => {{
394 field_array_u64!($name, $arr);
395 }};
396 }
397
398 field_str!("paper_id", &self.paper_id);
399 field_str!("git_sha", &self.git_sha);
400 field_str!("cmdline", &self.cmdline);
401 field_str!("host", &self.host);
402 field!("timeout_s", self.timeout_s);
403 field!("schema_version", self.schema_version);
404 field!("wall_us", self.wall_us);
405 field_array_u64!("phase_us", &self.phase_us);
406 for (i, val) in self.phase_us.iter().enumerate() {
408 let phase = match i {
409 0 => Phase::Bootstrap,
410 1 => Phase::Digest,
411 2 => Phase::Build,
412 3 => Phase::Rewrite,
413 4 => Phase::MathParse,
414 5 => Phase::PostXmlParse,
415 6 => Phase::PostScan,
416 7 => Phase::Bibliography,
417 8 => Phase::Crossref,
418 9 => Phase::Graphics,
419 10 => Phase::MathImages,
420 11 => Phase::MathmlPres,
421 12 => Phase::MathmlCont,
422 13 => Phase::Split,
423 14 => Phase::Xslt,
424 15 => Phase::Html5Fixups,
425 16 => Phase::Serialize,
426 _ => unreachable!(),
427 };
428 s.push_str(",\"phase_");
429 s.push_str(phase.as_str());
430 s.push_str("_us\":");
431 write!(s, "{}", val).unwrap();
432 }
433 field!("formulae", self.formulae);
434 field!("math_parse_attempts", self.math_parse_attempts);
435 field!("math_parse_count", self.math_parse_count);
436 field_array_u32!("math_parse_buckets", &self.math_parse_buckets);
437 field!("graphics_assets", self.graphics_assets);
438 field!("graphics_subprocess_count", self.graphics_subprocess_count);
439 field!("db_objects", self.db_objects);
440 field!("output_bytes", self.output_bytes);
441 field!("warnings", self.warnings);
442 field!("errors", self.errors);
443 field!("fatal_errors", self.fatal_errors);
444 field!("external_tool_count", self.external_tool_count);
445 field!("max_rss_kb", self.max_rss_kb);
446 field!("child_user_us", self.child_user_us);
447 field!("child_sys_us", self.child_sys_us);
448 field_str!("category", &self.category);
449 field!("exit_code", self.exit_code);
450
451 s.push('}');
452 s
453 }
454}
455
456#[cfg(test)]
459mod tests {
460 use std::{thread::sleep, time::Duration};
461
462 use super::*;
463
464 #[test]
465 fn bucket_boundaries() {
466 assert_eq!(bucket_for(0), 0);
467 assert_eq!(bucket_for(499), 0);
468 assert_eq!(bucket_for(500), 1);
469 assert_eq!(bucket_for(999), 1);
470 assert_eq!(bucket_for(1_000), 2);
471 assert_eq!(bucket_for(99_999), 7);
472 assert_eq!(bucket_for(100_000), 8);
473 assert_eq!(bucket_for(1_000_000), 8);
474 }
475
476 #[test]
477 fn nested_phases_charge_innermost_only() {
478 let _g_outer = phase(Phase::Bootstrap);
479 sleep(Duration::from_micros(200));
480 {
481 let _g_inner = phase(Phase::Digest);
482 sleep(Duration::from_micros(500));
483 }
484 sleep(Duration::from_micros(200));
485 drop(_g_outer);
486
487 let t = take();
488 assert!(
490 t.phase_us[Phase::Bootstrap as usize] >= 300,
491 "bootstrap got {}",
492 t.phase_us[0]
493 );
494 assert!(
496 t.phase_us[Phase::Digest as usize] >= 400,
497 "digest got {}",
498 t.phase_us[Phase::Digest as usize]
499 );
500 }
501
502 #[test]
503 fn json_round_trip_basic_fields() {
504 set_paper_id("0901.0001");
505 set_host("test-host");
506 set_timeout_s(120);
507 set_category("ok");
508 set_exit_code(0);
509 incr_formulae();
510 incr_formulae();
511 record_math_parse(750, 3);
512 record_math_parse(50_000, 1);
513 let t = take();
514 let json = t.to_json_line();
515 assert!(json.starts_with('{'));
516 assert!(json.ends_with('}'));
517 assert!(json.contains("\"paper_id\":\"0901.0001\""));
518 assert!(json.contains("\"formulae\":2"));
519 assert!(json.contains("\"math_parse_attempts\":2"));
520 assert!(json.contains("\"math_parse_count\":4"));
521 assert!(
524 json.contains("\"math_parse_buckets\":[0,1,0,0,0,0,0,1,0]"),
525 "buckets in json: {}",
526 json
527 );
528 }
529
530 #[test]
531 fn json_escapes_string_fields() {
532 set_paper_id("a \"weird\" id\nwith newline");
533 set_cmdline("cmd\twith\ttabs");
534 let t = take();
535 let json = t.to_json_line();
536 assert!(json.contains("\\\"weird\\\""));
537 assert!(json.contains("\\n"));
538 assert!(json.contains("\\t"));
539 }
540
541 #[test]
547 fn reset_clears_accumulating_state() {
548 add_formulae(100);
549 phase_enter(Phase::MathParse);
550 phase_exit();
551 with(|t| {
552 assert_eq!(t.formulae, 100);
553 });
554 reset();
555 with(|t| {
556 assert_eq!(
557 t.formulae, 0,
558 "formulae must not survive a conversion boundary"
559 );
560 assert_eq!(
561 t.phase_us,
562 [0; Phase::COUNT],
563 "phase time must not survive either"
564 );
565 });
566 }
567}