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
346fn maybe_report(log: &Log) {
349 use std::sync::OnceLock;
350 use std::time::Instant;
351
352 static ENABLED: OnceLock<bool> = OnceLock::new();
353 if !*ENABLED.get_or_init(|| {
354 matches!(
355 std::env::var("BLITZ_SCRIPT_STATS").ok().as_deref(),
356 Some("1") | Some("true")
357 )
358 }) {
359 return;
360 }
361
362 static LAST: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
363 let Ok(mut last) = LAST.lock() else { return };
364 let now = Instant::now();
365 if last.is_some_and(|time| now.duration_since(time) < Duration::from_secs(1)) {
366 return;
367 }
368 let elapsed = last.map(|time| now.duration_since(time));
369 *last = Some(now);
370 drop(last);
371
372 let spent_ms = log.spent.as_secs_f64() * 1000.0;
373 static PREV_SPENT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
374 let delta_ms = if let Ok(mut previous) = PREV_SPENT.lock() {
375 let delta = spent_ms - *previous;
376 *previous = spent_ms;
377 delta
378 } else {
379 0.0
380 };
381 let share = elapsed
382 .map(|duration| delta_ms / (duration.as_secs_f64() * 1000.0) * 100.0)
383 .unwrap_or(0.0);
384
385 eprintln!(
386 "[script] polls={} productive={} spent={spent_ms:.0}ms last_second={delta_ms:.1}ms ({share:.1}% of wall clock)",
387 log.total, log.productive,
388 );
389}
390
391#[cfg(feature = "dom-stats")]
392impl Drop for Timed {
393 fn drop(&mut self) {
394 if let Some(started) = self.started {
395 record_static(self.label, started.elapsed());
396 }
397 }
398}
399
400pub fn clear() {
402 if let Ok(mut log) = LOG.lock() {
403 *log = None;
404 }
405 let _ = LOCAL_STATICS.try_with(|local| {
406 if let Ok(mut buckets) = local.try_borrow_mut() {
407 buckets.clear();
408 }
409 });
410}
411
412#[cfg(not(feature = "dom-stats"))]
414pub struct Timed;
415
416#[cfg(not(feature = "dom-stats"))]
417impl Timed {
418 #[must_use]
419 #[inline(always)]
420 pub(crate) fn new(_ctx: &crate::state::DomCtx, _label: &'static str) -> Self {
421 Self
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 static SERIAL: Mutex<()> = Mutex::new(());
433
434 struct TestCapture {
441 _serial: std::sync::MutexGuard<'static, ()>,
442 _sampling: blitz_traits::profiling::DeepProfilingGuard,
443 }
444
445 fn reset() -> TestCapture {
446 let guard = SERIAL
447 .lock()
448 .unwrap_or_else(|poisoned| poisoned.into_inner());
449 *LOG.lock().unwrap() = None;
450 LOCAL_STATICS.with(|local| local.borrow_mut().clear());
453 blitz_traits::profiling::set_deep_profiling_permitted(true);
454 let sampling =
455 blitz_traits::profiling::begin_deep_profiling().expect("permission was just granted");
456 TestCapture {
457 _serial: guard,
458 _sampling: sampling,
459 }
460 }
461
462 #[test]
463 fn static_labels_reach_the_breakdown_without_locking_per_call() {
464 let _serial = reset();
465 for _ in 0..3 {
466 record_static("dom:appendChild", Duration::from_micros(10));
467 }
468 record_static("dom:appendChild", Duration::from_micros(90));
469 let rows = work_breakdown();
470 let row = rows
471 .iter()
472 .find(|(label, ..)| label == "dom:appendChild")
473 .expect("the static bucket is reported");
474 assert_eq!(row.1, 4, "every call counted: {rows:?}");
475 assert!(
476 (row.3 - 0.09).abs() < 0.01,
477 "the worst call survives the total: {rows:?}"
478 );
479 }
480
481 #[test]
482 fn folding_twice_does_not_double_count() {
483 let _serial = reset();
484 record_static("dom:createElement", Duration::from_micros(50));
485 let first = work_breakdown();
486 let second = work_breakdown();
487 assert_eq!(
488 first, second,
489 "a drained bucket must not be added to the shared log again"
490 );
491 }
492
493 #[test]
494 fn nothing_is_reported_before_script_runs() {
495 let _serial = reset();
496 record_poll(Duration::from_millis(5), false);
497 assert!(
498 latest_script_stats().is_none(),
499 "idle polls are not a measurement of script cost"
500 );
501 }
502
503 #[test]
504 fn the_worst_poll_survives_the_mean() {
505 let _serial = reset();
506 for _ in 0..40 {
507 record_poll(Duration::from_millis(1), true);
508 }
509 record_poll(Duration::from_millis(60), true);
510 let stats = latest_script_stats().expect("script ran");
511 assert!(stats.mean_ms < 3.0, "one outlier must not move the mean");
512 assert!(
513 (stats.max_ms - 60.0).abs() < 1.0,
514 "the outlier is the whole point: {stats:?}"
515 );
516 }
517
518 #[test]
519 fn idle_polls_are_counted_without_diluting_the_window() {
520 let _serial = reset();
521 record_poll(Duration::from_millis(2), true);
522 for _ in 0..10 {
523 record_poll(Duration::from_micros(10), false);
524 }
525 let stats = latest_script_stats().expect("script ran");
526 assert_eq!(stats.window_polls, 1);
527 assert_eq!(stats.total_polls, 11);
528 assert_eq!(stats.productive_polls, 1);
529 }
530
531 #[cfg(feature = "dom-stats")]
532 #[test]
533 fn poll_keeps_its_selected_mode_when_the_global_flag_changes_inside_it() {
534 use blitz_dom::{Document, DocumentConfig};
535
536 let _serial = reset();
537 let mut document =
538 crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
539 document.set_poll_hook(|document, _| {
540 blitz_traits::profiling::set_deep_profiling_permitted(false);
543 document.eval("document.body.appendChild(document.createElement('div'))");
544 true
545 });
546
547 assert!(document.poll(None));
548 blitz_traits::profiling::set_deep_profiling_permitted(true);
549
550 assert!(
551 work_breakdown()
552 .iter()
553 .any(|(label, ..)| label == "dom:createElement"),
554 "DOM attribution follows the enclosing poll mode"
555 );
556 assert!(latest_script_stats().is_some());
557 }
558
559 #[cfg(feature = "dom-stats")]
560 #[test]
561 fn disabled_poll_does_not_start_collecting_if_the_global_turns_on_inside_it() {
562 use blitz_dom::{Document, DocumentConfig};
563
564 let _serial = reset();
565 clear();
566 blitz_traits::profiling::set_deep_profiling_permitted(false);
567 let mut document =
568 crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
569 document.set_poll_hook(|document, _| {
570 blitz_traits::profiling::set_deep_profiling_permitted(true);
571 document.eval("document.body.appendChild(document.createElement('div'))");
572 true
573 });
574
575 assert!(document.poll(None));
576
577 assert!(work_breakdown().is_empty());
578 assert!(latest_script_stats().is_none());
579 }
580}