1use std::sync::Mutex;
15use std::time::Duration;
16
17const CAPACITY: usize = 256;
20
21#[derive(Debug, Clone, Copy)]
25struct Poll {
26 duration: Duration,
27}
28
29#[derive(Debug, Default, Clone, Copy)]
33struct Bucket {
34 calls: u64,
35 spent: Duration,
36 worst: Duration,
37}
38
39impl Bucket {
40 fn record(&mut self, duration: Duration) {
41 self.calls += 1;
42 self.spent += duration;
43 if duration > self.worst {
44 self.worst = duration;
45 }
46 }
47
48 fn absorb(&mut self, other: &Bucket) {
49 self.calls += other.calls;
50 self.spent += other.spent;
51 if other.worst > self.worst {
52 self.worst = other.worst;
53 }
54 }
55}
56
57#[derive(Debug, Default)]
58struct Log {
59 statics: std::collections::BTreeMap<&'static str, Bucket>,
63 dynamic: std::collections::BTreeMap<String, Bucket>,
66 polls: Vec<Poll>,
67 total: u64,
69 productive: u64,
71 spent: Duration,
73}
74
75static LOG: Mutex<Option<Log>> = Mutex::new(None);
76
77thread_local! {
78 static LOCAL_STATICS: std::cell::RefCell<Vec<(&'static str, Bucket)>> =
88 const { std::cell::RefCell::new(Vec::new()) };
89}
90
91pub fn record_static(label: &'static str, duration: Duration) {
96 let _ = LOCAL_STATICS.try_with(|local| {
100 let Ok(mut buckets) = local.try_borrow_mut() else {
101 return;
102 };
103 if let Some((_, bucket)) = buckets
107 .iter_mut()
108 .find(|(seen, _)| std::ptr::eq(*seen, label) || *seen == label)
109 {
110 bucket.record(duration);
111 return;
112 }
113 let mut bucket = Bucket::default();
114 bucket.record(duration);
115 buckets.push((label, bucket));
116 });
117}
118
119fn drain_local_statics(log: &mut Log) {
126 let _ = LOCAL_STATICS.try_with(|local| {
127 let Ok(mut buckets) = local.try_borrow_mut() else {
128 return;
129 };
130 for (label, bucket) in buckets.iter_mut() {
131 log.statics.entry(*label).or_default().absorb(bucket);
132 *bucket = Bucket::default();
133 }
134 });
135}
136
137#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
150pub struct BoundaryCounters {
151 pub strings_crossed: u64,
153 pub bytes_copied: u64,
155}
156
157thread_local! {
158 static BOUNDARY: std::cell::Cell<BoundaryCounters> =
161 const { std::cell::Cell::new(BoundaryCounters { strings_crossed: 0, bytes_copied: 0 }) };
162}
163
164pub(crate) fn record_boundary_string(bytes: usize) {
167 if !blitz_traits::profiling::deep_profiling_enabled() {
168 return;
169 }
170 let _ = BOUNDARY.try_with(|cell| {
171 let mut counters = cell.get();
172 counters.strings_crossed += 1;
173 counters.bytes_copied += bytes as u64;
174 cell.set(counters);
175 });
176}
177
178#[must_use]
180pub fn boundary_counters() -> BoundaryCounters {
181 BOUNDARY.try_with(std::cell::Cell::get).unwrap_or_default()
182}
183
184pub fn reset_boundary_counters() {
186 let _ = BOUNDARY.try_with(|cell| cell.set(BoundaryCounters::default()));
187}
188
189pub fn record_work(label: &str, duration: Duration) {
195 let Ok(mut guard) = LOG.lock() else {
196 return;
197 };
198 let log = guard.get_or_insert_with(Log::default);
199 log.dynamic
200 .entry(label.to_string())
201 .or_default()
202 .record(duration);
203}
204
205#[must_use]
207pub fn work_breakdown() -> Vec<(String, u64, f64, f64)> {
208 let Ok(mut guard) = LOG.lock() else {
209 return Vec::new();
210 };
211 let log = guard.get_or_insert_with(Log::default);
214 drain_local_statics(log);
215 let mut rows: Vec<(String, u64, f64, f64)> = log
216 .statics
217 .iter()
218 .map(|(label, bucket)| {
219 (
220 (*label).to_string(),
221 bucket.calls,
222 bucket.spent.as_secs_f64() * 1_000.0,
223 bucket.worst.as_secs_f64() * 1_000.0,
224 )
225 })
226 .chain(log.dynamic.iter().map(|(label, bucket)| {
227 (
228 label.clone(),
229 bucket.calls,
230 bucket.spent.as_secs_f64() * 1_000.0,
231 bucket.worst.as_secs_f64() * 1_000.0,
232 )
233 }))
234 .collect();
235 rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
236 rows
237}
238
239pub fn record_poll(duration: Duration, ran_script: bool) {
241 let Ok(mut guard) = LOG.lock() else {
242 return;
243 };
244 let log = guard.get_or_insert_with(Log::default);
245 drain_local_statics(log);
248 log.total += 1;
249 log.spent += duration;
250 maybe_report(log);
251 if !ran_script {
252 return;
253 }
254 log.productive += 1;
255 if log.polls.len() == CAPACITY {
256 log.polls.remove(0);
257 }
258 log.polls.push(Poll { duration });
259}
260
261#[derive(Debug, Clone, Copy, PartialEq)]
264pub struct ScriptStatsSnapshot {
265 pub mean_ms: f64,
266 pub p95_ms: f64,
267 pub max_ms: f64,
268 pub window_polls: u64,
270 pub total_polls: u64,
272 pub productive_polls: u64,
274 pub spent_ms: f64,
276}
277
278#[must_use]
281pub fn latest_script_stats() -> Option<ScriptStatsSnapshot> {
282 if !blitz_traits::profiling::deep_profiling_permitted() {
289 return None;
290 }
291 let guard = LOG.lock().ok()?;
292 let log = guard.as_ref()?;
293 if log.polls.is_empty() {
294 return None;
295 }
296 let mut millis: Vec<f64> = log
297 .polls
298 .iter()
299 .map(|poll| poll.duration.as_secs_f64() * 1_000.0)
300 .collect();
301 let sum: f64 = millis.iter().sum();
302 let mean = sum / millis.len() as f64;
303 millis.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
304 let rank = ((millis.len() as f64) * 0.95).ceil() as usize;
307 let p95 = millis[rank.saturating_sub(1).min(millis.len() - 1)];
308 Some(ScriptStatsSnapshot {
309 mean_ms: mean,
310 p95_ms: p95,
311 max_ms: *millis.last().unwrap_or(&0.0),
312 window_polls: millis.len() as u64,
313 total_polls: log.total,
314 productive_polls: log.productive,
315 spent_ms: log.spent.as_secs_f64() * 1_000.0,
316 })
317}
318
319#[cfg(feature = "dom-stats")]
330pub struct Timed {
331 label: &'static str,
332 started: Option<std::time::Instant>,
333}
334
335#[cfg(feature = "dom-stats")]
336impl Timed {
337 #[must_use]
338 pub(crate) fn new(ctx: &crate::state::DomCtx, label: &'static str) -> Self {
339 Self {
340 label,
341 started: ctx.deep_profiling_enabled().then(std::time::Instant::now),
342 }
343 }
344}
345
346#[cfg(feature = "dom-stats")]
347impl Drop for Timed {
348 fn drop(&mut self) {
349 if let Some(started) = self.started {
350 record_static(self.label, started.elapsed());
351 }
352 }
353}
354
355pub fn clear() {
357 if let Ok(mut log) = LOG.lock() {
358 *log = None;
359 }
360 let _ = LOCAL_STATICS.try_with(|local| {
361 if let Ok(mut buckets) = local.try_borrow_mut() {
362 buckets.clear();
363 }
364 });
365}
366
367#[cfg(not(feature = "dom-stats"))]
369pub struct Timed;
370
371#[cfg(not(feature = "dom-stats"))]
372impl Timed {
373 #[must_use]
374 #[inline(always)]
375 pub(crate) fn new(_ctx: &crate::state::DomCtx, _label: &'static str) -> Self {
376 Self
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 static SERIAL: Mutex<()> = Mutex::new(());
388
389 struct TestCapture {
396 _serial: std::sync::MutexGuard<'static, ()>,
397 _sampling: blitz_traits::profiling::DeepProfilingGuard,
398 }
399
400 fn reset() -> TestCapture {
401 let guard = SERIAL
402 .lock()
403 .unwrap_or_else(|poisoned| poisoned.into_inner());
404 *LOG.lock().unwrap() = None;
405 LOCAL_STATICS.with(|local| local.borrow_mut().clear());
408 blitz_traits::profiling::set_deep_profiling_permitted(true);
409 let sampling =
410 blitz_traits::profiling::begin_deep_profiling().expect("permission was just granted");
411 TestCapture {
412 _serial: guard,
413 _sampling: sampling,
414 }
415 }
416
417 #[test]
418 fn static_labels_reach_the_breakdown_without_locking_per_call() {
419 let _serial = reset();
420 for _ in 0..3 {
421 record_static("dom:appendChild", Duration::from_micros(10));
422 }
423 record_static("dom:appendChild", Duration::from_micros(90));
424 let rows = work_breakdown();
425 let row = rows
426 .iter()
427 .find(|(label, ..)| label == "dom:appendChild")
428 .expect("the static bucket is reported");
429 assert_eq!(row.1, 4, "every call counted: {rows:?}");
430 assert!(
431 (row.3 - 0.09).abs() < 0.01,
432 "the worst call survives the total: {rows:?}"
433 );
434 }
435
436 #[test]
437 fn folding_twice_does_not_double_count() {
438 let _serial = reset();
439 record_static("dom:createElement", Duration::from_micros(50));
440 let first = work_breakdown();
441 let second = work_breakdown();
442 assert_eq!(
443 first, second,
444 "a drained bucket must not be added to the shared log again"
445 );
446 }
447
448 #[test]
449 fn nothing_is_reported_before_script_runs() {
450 let _serial = reset();
451 record_poll(Duration::from_millis(5), false);
452 assert!(
453 latest_script_stats().is_none(),
454 "idle polls are not a measurement of script cost"
455 );
456 }
457
458 #[test]
459 fn the_worst_poll_survives_the_mean() {
460 let _serial = reset();
461 for _ in 0..40 {
462 record_poll(Duration::from_millis(1), true);
463 }
464 record_poll(Duration::from_millis(60), true);
465 let stats = latest_script_stats().expect("script ran");
466 assert!(stats.mean_ms < 3.0, "one outlier must not move the mean");
467 assert!(
468 (stats.max_ms - 60.0).abs() < 1.0,
469 "the outlier is the whole point: {stats:?}"
470 );
471 }
472
473 #[test]
474 fn idle_polls_are_counted_without_diluting_the_window() {
475 let _serial = reset();
476 record_poll(Duration::from_millis(2), true);
477 for _ in 0..10 {
478 record_poll(Duration::from_micros(10), false);
479 }
480 let stats = latest_script_stats().expect("script ran");
481 assert_eq!(stats.window_polls, 1);
482 assert_eq!(stats.total_polls, 11);
483 assert_eq!(stats.productive_polls, 1);
484 }
485
486 #[cfg(feature = "dom-stats")]
487 #[test]
488 fn poll_keeps_its_selected_mode_when_the_global_flag_changes_inside_it() {
489 use blitz_dom::{Document, DocumentConfig};
490
491 let _serial = reset();
492 let mut document =
493 crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
494 document.set_poll_hook(|document, _| {
495 blitz_traits::profiling::set_deep_profiling_permitted(false);
498 document.eval("document.body.appendChild(document.createElement('div'))");
499 true
500 });
501
502 assert!(document.poll(None));
503 blitz_traits::profiling::set_deep_profiling_permitted(true);
504
505 assert!(
506 work_breakdown()
507 .iter()
508 .any(|(label, ..)| label == "dom:createElement"),
509 "DOM attribution follows the enclosing poll mode"
510 );
511 assert!(latest_script_stats().is_some());
512 }
513
514 #[cfg(feature = "dom-stats")]
515 #[test]
516 fn disabled_poll_does_not_start_collecting_if_the_global_turns_on_inside_it() {
517 use blitz_dom::{Document, DocumentConfig};
518
519 let _serial = reset();
520 clear();
521 blitz_traits::profiling::set_deep_profiling_permitted(false);
522 let mut document =
523 crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
524 document.set_poll_hook(|document, _| {
525 blitz_traits::profiling::set_deep_profiling_permitted(true);
526 document.eval("document.body.appendChild(document.createElement('div'))");
527 true
528 });
529
530 assert!(document.poll(None));
531
532 assert!(work_breakdown().is_empty());
533 assert!(latest_script_stats().is_none());
534 }
535}
536
537fn maybe_report(log: &Log) {
546 use std::sync::OnceLock;
547 use std::time::Instant;
548
549 static ENABLED: OnceLock<bool> = OnceLock::new();
550 if !*ENABLED.get_or_init(|| {
551 matches!(
552 std::env::var("BLITZ_SCRIPT_STATS").ok().as_deref(),
553 Some("1") | Some("true")
554 )
555 }) {
556 return;
557 }
558
559 static LAST: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
560 let Ok(mut last) = LAST.lock() else { return };
561 let now = Instant::now();
562 if last.is_some_and(|t| now.duration_since(t) < Duration::from_secs(1)) {
563 return;
564 }
565 let elapsed = last.map(|t| now.duration_since(t));
566 *last = Some(now);
567 drop(last);
568
569 let spent_ms = log.spent.as_secs_f64() * 1000.0;
573 static PREV_SPENT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
574 let delta_ms = if let Ok(mut prev) = PREV_SPENT.lock() {
575 let d = spent_ms - *prev;
576 *prev = spent_ms;
577 d
578 } else {
579 0.0
580 };
581 let share = elapsed
582 .map(|e| delta_ms / (e.as_secs_f64() * 1000.0) * 100.0)
583 .unwrap_or(0.0);
584
585 eprintln!(
586 "[script] polls={} productive={} spent={spent_ms:.0}ms last_second={delta_ms:.1}ms ({share:.1}% of wall clock)",
587 log.total, log.productive,
588 );
589}