1#[cfg(feature = "frame-trace")]
4use std::cell::RefCell;
5#[cfg(feature = "frame-trace")]
6use std::collections::VecDeque;
7use std::time::Duration;
8#[cfg(feature = "frame-trace")]
9use web_time::Instant;
10
11#[derive(Clone, Debug)]
12pub struct Section {
13 pub name: &'static str,
14 pub elapsed: Duration,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct FrameTrace {
19 pub sections: Vec<Section>,
20 pub heap_delta_bytes: Option<i64>,
22 pub allocs: Option<crate::alloc::AllocDelta>,
24}
25
26impl FrameTrace {
27 pub fn total(&self) -> Duration {
29 self.sections.iter().map(|s| s.elapsed).sum()
30 }
31}
32
33pub const DEFAULT_CAPACITY: usize = 120;
34
35#[cfg(feature = "frame-trace")]
36struct Tracer {
37 history: VecDeque<FrameTrace>,
38 capacity: usize,
39}
40
41#[cfg(feature = "frame-trace")]
42impl Tracer {
43 fn new(capacity: usize) -> Self {
44 Self {
45 history: VecDeque::with_capacity(capacity),
46 capacity,
47 }
48 }
49}
50
51pub type HeapSampler = fn() -> Option<u64>;
53
54#[cfg(feature = "frame-trace")]
55thread_local! {
56 static TRACER: RefCell<Tracer> = RefCell::new(Tracer::new(DEFAULT_CAPACITY));
57 static CURRENT_SECTIONS: RefCell<Vec<Section>> = const { RefCell::new(Vec::new()) };
58 static LAST_FRAME_END: std::cell::Cell<Option<Instant>> = const { std::cell::Cell::new(None) };
59 static CURRENT_FRAME_START: std::cell::Cell<Option<Instant>> = const { std::cell::Cell::new(None) };
60 static CURRENT_FRAME_HEAP_START: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
61 static CURRENT_FRAME_ALLOC_START: std::cell::Cell<Option<crate::alloc::AllocSnapshot>> = const { std::cell::Cell::new(None) };
62 static HEAP_SAMPLER: std::cell::Cell<Option<HeapSampler>> = const { std::cell::Cell::new(None) };
63 static MAX_EVER: RefCell<std::collections::HashMap<&'static str, Duration>> =
64 RefCell::new(std::collections::HashMap::new());
65 static SPIKE_THRESHOLD: std::cell::Cell<Duration> =
66 const { std::cell::Cell::new(Duration::from_millis(250)) };
67 static FRAME_COUNTER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
68}
69
70#[cfg(feature = "frame-trace")]
71#[must_use = "Scope records on drop; binding it to `_` would record immediately"]
72pub struct Scope {
73 name: &'static str,
74 start: Instant,
75 recording_thread: std::marker::PhantomData<std::rc::Rc<()>>,
76}
77
78#[cfg(feature = "frame-trace")]
79impl Drop for Scope {
80 fn drop(&mut self) {
81 let elapsed = self.start.elapsed();
82 let name = self.name;
83 CURRENT_SECTIONS.with(|s| {
84 if let Ok(mut s) = s.try_borrow_mut() {
85 s.push(Section { name, elapsed });
86 }
87 });
88 }
89}
90
91#[cfg(feature = "frame-trace")]
92#[inline]
93pub fn scope(name: &'static str) -> Scope {
94 Scope {
95 name,
96 start: Instant::now(),
97 recording_thread: std::marker::PhantomData,
98 }
99}
100
101#[cfg(feature = "frame-trace")]
102pub fn set_heap_sampler(sampler: HeapSampler) {
103 HEAP_SAMPLER.with(|s| s.set(Some(sampler)));
104}
105
106#[cfg(feature = "frame-trace")]
107pub fn begin_frame() {
108 CURRENT_FRAME_START.with(|c| c.set(Some(Instant::now())));
109 let sampler = HEAP_SAMPLER.with(|s| s.get());
110 CURRENT_FRAME_HEAP_START.with(|c| c.set(sampler.and_then(|f| f())));
111 CURRENT_FRAME_ALLOC_START.with(|c| c.set(crate::alloc::current_snapshot()));
112}
113
114#[cfg(feature = "frame-trace")]
116pub fn end_frame() {
117 let now = Instant::now();
118 let last_end = LAST_FRAME_END.with(|cell| {
119 let prev = cell.get();
120 cell.set(Some(now));
121 prev
122 });
123 let frame_start = CURRENT_FRAME_START.with(|c| c.take());
124
125 let heap_start = CURRENT_FRAME_HEAP_START.with(|c| c.take());
126 let sampler = HEAP_SAMPLER.with(|s| s.get());
127 let heap_end = sampler.and_then(|f| f());
128 let heap_delta_bytes: Option<i64> = match (heap_start, heap_end) {
129 (Some(a), Some(b)) => Some((b as i64).saturating_sub(a as i64)),
130 _ => None,
131 };
132
133 let alloc_start = CURRENT_FRAME_ALLOC_START.with(|c| c.take());
134 let alloc_end = crate::alloc::current_snapshot();
135 let alloc_delta: Option<crate::alloc::AllocDelta> = match (alloc_start, alloc_end) {
136 (Some(a), Some(b)) => Some(crate::alloc::delta(a, b)),
137 _ => None,
138 };
139
140 let frame_index = FRAME_COUNTER.with(|c| {
141 let n = c.get();
142 c.set(n.wrapping_add(1));
143 n
144 });
145
146 let threshold = SPIKE_THRESHOLD.with(|c| c.get());
147 let mut sections = CURRENT_SECTIONS.with(|s| std::mem::take(&mut *s.borrow_mut()));
148
149 if let Some(last_end) = last_end {
150 let between_frames = now.saturating_duration_since(last_end);
151 sections.push(Section {
152 name: "between-frames",
153 elapsed: between_frames,
154 });
155 if let Some(frame_start) = frame_start {
156 let idle = frame_start.saturating_duration_since(last_end);
157 sections.push(Section {
158 name: "idle",
159 elapsed: idle,
160 });
161 }
162 }
163
164 let mut over_threshold: Vec<(&'static str, Duration)> = Vec::new();
166 MAX_EVER.with(|m| {
167 let mut m = m.borrow_mut();
168 for section in §ions {
169 let entry = m.entry(section.name).or_insert(Duration::ZERO);
170 if section.elapsed > *entry {
171 *entry = section.elapsed;
172 }
173 if section.elapsed > threshold {
174 over_threshold.push((section.name, section.elapsed));
175 }
176 }
177 });
178
179 let mut reusable = TRACER.with(|t| {
180 let mut t = t.borrow_mut();
181 let reusable = if t.history.len() >= t.capacity {
182 t.history
183 .pop_front()
184 .map(|frame| frame.sections)
185 .unwrap_or_default()
186 } else {
187 Vec::new()
188 };
189 t.history.push_back(FrameTrace {
190 sections,
191 heap_delta_bytes,
192 allocs: alloc_delta,
193 });
194 reusable
195 });
196 reusable.clear();
197 CURRENT_SECTIONS.with(|current| *current.borrow_mut() = reusable);
198
199 for (name, elapsed) in over_threshold {
200 let heap_suffix = heap_delta_bytes
201 .map(|d| format!(" heap_delta={:+.2}MB", d as f64 / (1024.0 * 1024.0)))
202 .unwrap_or_default();
203 let alloc_suffix = alloc_delta
204 .map(|d| {
205 format!(
206 " allocs={} ({:+.2}MB net)",
207 d.alloc_count,
208 d.net_bytes as f64 / (1024.0 * 1024.0),
209 )
210 })
211 .unwrap_or_default();
212 tracing::warn!(
213 "frame_trace spike: section='{name}' elapsed={:.1}ms frame={frame_index}{heap_suffix}{alloc_suffix}",
214 elapsed.as_secs_f32() * 1000.0,
215 );
216 }
217}
218
219#[cfg(feature = "frame-trace")]
220pub fn set_capacity(capacity: usize) {
221 TRACER.with(|t| {
222 let mut t = t.borrow_mut();
223 t.capacity = capacity.max(1);
224 while t.history.len() > t.capacity {
225 t.history.pop_front();
226 }
227 });
228}
229
230#[cfg(feature = "frame-trace")]
232pub fn history() -> Vec<FrameTrace> {
233 TRACER.with(|t| t.borrow().history.iter().cloned().collect())
234}
235
236#[cfg(feature = "frame-trace")]
237pub fn clear_history() {
238 TRACER.with(|t| t.borrow_mut().history.clear());
239}
240
241#[cfg(not(feature = "frame-trace"))]
242pub fn clear_history() {}
243
244#[cfg(feature = "frame-trace")]
246pub fn with_history<R>(f: impl FnOnce(&std::collections::VecDeque<FrameTrace>) -> R) -> R {
247 TRACER.with(|t| f(&t.borrow().history))
248}
249
250#[cfg(feature = "frame-trace")]
251pub fn last_frame() -> Option<FrameTrace> {
252 TRACER.with(|t| t.borrow().history.back().cloned())
253}
254
255#[cfg(feature = "frame-trace")]
257pub fn max_ever(name: &'static str) -> Duration {
258 MAX_EVER.with(|m| m.borrow().get(name).copied().unwrap_or(Duration::ZERO))
259}
260
261#[cfg(feature = "frame-trace")]
263pub fn all_max_ever() -> Vec<(&'static str, Duration)> {
264 MAX_EVER.with(|m| {
265 let mut out: Vec<(&'static str, Duration)> =
266 m.borrow().iter().map(|(k, v)| (*k, *v)).collect();
267 out.sort_by_key(|entry| std::cmp::Reverse(entry.1));
268 out
269 })
270}
271
272#[cfg(feature = "frame-trace")]
273pub fn clear_max_ever() {
274 MAX_EVER.with(|m| m.borrow_mut().clear());
275}
276
277#[cfg(feature = "frame-trace")]
279pub fn set_spike_threshold(threshold: Duration) {
280 SPIKE_THRESHOLD.with(|c| c.set(threshold));
281}
282
283#[cfg(feature = "frame-trace")]
285pub fn record_external(name: &'static str, elapsed: Duration) {
286 CURRENT_SECTIONS.with(|s| {
287 if let Ok(mut s) = s.try_borrow_mut() {
288 s.push(Section { name, elapsed });
289 }
290 });
291}
292
293#[cfg(not(feature = "frame-trace"))]
294pub fn record_external(_name: &'static str, _elapsed: Duration) {}
295
296#[cfg(not(feature = "frame-trace"))]
297pub fn max_ever(_name: &'static str) -> Duration {
298 Duration::ZERO
299}
300
301#[cfg(not(feature = "frame-trace"))]
302pub fn all_max_ever() -> Vec<(&'static str, Duration)> {
303 Vec::new()
304}
305
306#[cfg(not(feature = "frame-trace"))]
307pub fn clear_max_ever() {}
308
309#[cfg(not(feature = "frame-trace"))]
310pub fn set_spike_threshold(_threshold: Duration) {}
311
312#[cfg(not(feature = "frame-trace"))]
313pub fn set_heap_sampler(_sampler: HeapSampler) {}
314
315#[derive(Clone, Debug)]
316pub struct SectionStats {
317 pub name: &'static str,
318 pub samples: usize,
319 pub mean: Duration,
320 pub p50: Duration,
321 pub p95: Duration,
322 pub p99: Duration,
323 pub max: Duration,
324}
325
326pub fn percentile(sorted: &[Duration], percent: u8) -> Duration {
328 if sorted.is_empty() {
329 return Duration::ZERO;
330 }
331 let rank = (sorted.len() * usize::from(percent)).div_ceil(100);
332 sorted[rank.saturating_sub(1).min(sorted.len() - 1)]
333}
334
335#[cfg(feature = "frame-trace")]
337pub fn aggregate() -> Vec<SectionStats> {
338 use std::collections::HashMap;
339 let mut buckets: HashMap<&'static str, Vec<Duration>> = HashMap::new();
340 with_history(|frames| {
341 for frame in frames {
342 for section in &frame.sections {
343 buckets
344 .entry(section.name)
345 .or_default()
346 .push(section.elapsed);
347 }
348 }
349 });
350
351 let mut stats: Vec<SectionStats> = buckets
352 .into_iter()
353 .map(|(name, mut samples)| {
354 samples.sort();
355 let n = samples.len();
356 let mean = samples.iter().sum::<Duration>() / (n as u32).max(1);
357 SectionStats {
358 name,
359 samples: n,
360 mean,
361 p50: percentile(&samples, 50),
362 p95: percentile(&samples, 95),
363 p99: percentile(&samples, 99),
364 max: *samples.last().unwrap_or(&Duration::ZERO),
365 }
366 })
367 .collect();
368
369 stats.sort_by_key(|s| std::cmp::Reverse(s.p95));
370 stats
371}
372
373#[cfg(not(feature = "frame-trace"))]
374#[must_use]
375pub struct Scope;
376
377#[cfg(not(feature = "frame-trace"))]
378#[inline]
379pub fn scope(_name: &'static str) -> Scope {
380 Scope
381}
382
383#[cfg(not(feature = "frame-trace"))]
384pub fn end_frame() {}
385
386#[cfg(not(feature = "frame-trace"))]
387pub fn begin_frame() {}
388
389#[cfg(not(feature = "frame-trace"))]
390pub fn set_capacity(_capacity: usize) {}
391
392#[cfg(not(feature = "frame-trace"))]
393pub fn history() -> Vec<FrameTrace> {
394 Vec::new()
395}
396
397#[cfg(not(feature = "frame-trace"))]
398pub fn with_history<R>(f: impl FnOnce(&std::collections::VecDeque<FrameTrace>) -> R) -> R {
399 let empty = std::collections::VecDeque::new();
400 f(&empty)
401}
402
403#[cfg(not(feature = "frame-trace"))]
404pub fn last_frame() -> Option<FrameTrace> {
405 None
406}
407
408#[cfg(not(feature = "frame-trace"))]
409pub fn aggregate() -> Vec<SectionStats> {
410 Vec::new()
411}
412
413#[cfg(all(test, feature = "frame-trace"))]
414mod tests {
415 use super::*;
416 use std::thread::sleep;
417
418 #[test]
419 fn clearing_history_preserves_retention_capacity() {
420 set_capacity(2);
421 end_frame();
422 clear_history();
423 assert!(history().is_empty());
424 for _ in 0..3 {
425 end_frame();
426 }
427 assert_eq!(history().len(), 2);
428 }
429
430 #[test]
431 fn scope_records_elapsed_on_drop() {
432 end_frame();
433
434 {
435 let _s = scope("test-a");
436 sleep(Duration::from_millis(1));
437 }
438 end_frame();
439 let post_frame = last_frame().expect("end_frame should produce a frame");
440
441 let sections = &post_frame.sections;
442 let test_a = sections
443 .iter()
444 .find(|s| s.name == "test-a")
445 .unwrap_or_else(|| panic!("expected 'test-a' in {sections:?}"));
446 assert!(
447 test_a.elapsed >= Duration::from_millis(1),
448 "scope elapsed should be >= sleep duration, got {:?}",
449 test_a.elapsed
450 );
451 }
452
453 #[test]
454 fn heap_sampler_populates_delta_on_completed_frame() {
455 use std::sync::atomic::{AtomicU64, Ordering};
456 static FAKE_HEAP: AtomicU64 = AtomicU64::new(1_000_000);
457 fn fake_sampler() -> Option<u64> {
458 Some(FAKE_HEAP.fetch_add(4096, Ordering::SeqCst) + 4096)
459 }
460 FAKE_HEAP.store(1_000_000, Ordering::SeqCst);
461 set_heap_sampler(fake_sampler);
462 end_frame();
463 begin_frame();
464 end_frame();
465 let frame = last_frame().expect("end_frame should produce a frame");
466 let delta = frame
467 .heap_delta_bytes
468 .expect("sampler is registered; delta should be Some");
469 assert_eq!(delta, 4096, "expected one-increment delta, got {delta}");
470 }
471
472 #[test]
473 fn aggregate_uses_retained_frames_and_sorts_percentiles() {
474 set_capacity(2);
475 record_external("discarded", Duration::from_secs(10));
476 end_frame();
477 for elapsed in [10, 20] {
478 record_external("slow", Duration::from_millis(elapsed));
479 record_external("fast", Duration::from_millis(1));
480 end_frame();
481 }
482 let stats = aggregate();
483 assert!(!stats.iter().any(|row| row.name == "discarded"));
484 let slow = stats.iter().position(|row| row.name == "slow").unwrap();
485 let fast = stats.iter().position(|row| row.name == "fast").unwrap();
486 assert!(slow < fast);
487 assert_eq!(stats[slow].samples, 2);
488 assert_eq!(stats[slow].mean, Duration::from_millis(15));
489 assert_eq!(stats[slow].p50, Duration::from_millis(10));
490 assert_eq!(stats[slow].p95, Duration::from_millis(20));
491 }
492}