1use once_cell::sync::OnceCell;
20use parking_lot::RwLock;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeSet;
23use std::fs::{File, OpenOptions};
24use std::io::Write as _;
25use std::path::PathBuf;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
36#[serde(rename_all = "lowercase")]
37pub enum DiagLevel {
38 Error,
39 Warn,
40 Info,
41 Debug,
42 Trace,
43}
44
45impl DiagLevel {
46 pub fn allows(self, level: DiagLevel) -> bool {
47 use DiagLevel::*;
48 let a = match self {
49 Error => 0,
50 Warn => 1,
51 Info => 2,
52 Debug => 3,
53 Trace => 4,
54 };
55 let b = match level {
56 Error => 0,
57 Warn => 1,
58 Info => 2,
59 Debug => 3,
60 Trace => 4,
61 };
62 b <= a
63 }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Ord, PartialOrd)]
71#[serde(rename_all = "lowercase")]
72pub enum DiagCategory {
73 Frame,
74 Diff,
75 Layout,
76 Paint,
77 Raster,
78 Input,
79 Semantics,
80 Animation,
81 Media,
82 Invariants,
83 Test,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DiagEvent {
93 pub schema_version: u16, pub timestamp_ns: u64,
95 pub frame_no: u64,
96 pub category: DiagCategory,
97 pub level: DiagLevel,
98 #[serde(flatten)]
99 pub event: DiagEventKind,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "kind", content = "payload")]
108pub enum DiagEventKind {
109 FrameStart {
110 root: Option<u128>,
111 },
112 FrameEnd {
113 stats: FrameStats,
114 },
115
116 DiffSummary {
117 nodes_total: u32,
118 nodes_created: u32,
119 nodes_removed: u32,
120 nodes_changed: u32,
121 dirty_layout: u32,
122 dirty_paint: u32,
123 },
124
125 LayoutSummary {
126 nodes: u32,
127 dirty_count: u32,
128 full_rebuild: bool,
129 duration_ns: u64,
130 },
131
132 PaintSummary {
133 segments_reused: u32,
134 segments_regenerated: u32,
135 paint_ops_total: u32,
136 },
137 PaintNode {
138 node: u128,
139 note: Option<String>,
140 },
141 PaintNodeRect {
142 node: u128,
143 x: f32,
144 y: f32,
145 w: f32,
146 h: f32,
147 note: Option<String>,
148 },
149
150 NodeProps {
151 node: u128,
152 op_tag: String,
153 flex_grow: f32,
154 flex_shrink: f32,
155 width: Option<f32>,
156 height: Option<f32>,
157 },
158
159 RasterSummary {
160 cache_hits: u32,
161 cache_misses: u32,
162 tiles_rasterized: u32,
163 },
164 ImageCacheSummary {
165 renderer: String,
166 entries: u64,
167 weighted_bytes: u64,
168 max_bytes: u64,
169 pending: u64,
170 hits: u64,
171 misses: u64,
172 loads_started: u64,
173 loads_completed: u64,
174 loads_failed: u64,
175 evictions: u64,
176 offscreen_skips: u64,
177 },
178 RendererSelected {
179 active: String,
180 requested: String,
181 backend: Option<String>,
182 adapter: Option<String>,
183 fallback_reason: Option<String>,
184 width: u32,
185 height: u32,
186 scale_factor: f64,
187 },
188 FramePerformance {
189 renderer: String,
190 total_ms: f64,
191 },
192 InputLatency {
193 renderer: String,
194 latency_ms: f64,
195 },
196
197 AnimationSummary {
198 active_count: u32,
199 started: u32,
200 replaced: u32,
201 ended: u32,
202 },
203
204 MediaSummary {
205 video_nodes: u32,
206 audio_nodes: u32,
207 embeds_total: u32,
208 },
209
210 PortalsComposed {
212 portal_count: u32,
213 },
214 AnchorPlacement {
215 widget: u128,
216 node: u128,
217 rect_x: f32,
218 rect_y: f32,
219 rect_w: f32,
220 rect_h: f32,
221 place_left: f32,
222 place_top: f32,
223 note: Option<String>,
224 },
225
226 InvariantViolation {
227 kind: String,
228 node: Option<u128>,
229 details: String,
230 dump_ref: Option<String>,
231 },
232
233 InputEvent {
234 kind: String,
235 target: Option<u128>,
236 position: Option<(f32, f32)>,
237 },
238
239 MediaEvent {
240 kind: String,
241 id: Option<u128>,
242 duration_ms: Option<u64>,
243 position_ms: Option<u64>,
244 },
245
246 TextInputAutoScroll {
248 scroll_id: u128,
249 text_id: u128,
250 text_len: u32,
251 measured_w: f32,
252 line_h: f32,
253 viewport_x: f32,
254 viewport_w: f32,
255 content_w: f32,
256 caret_abs_x: f32,
257 offset_before: f32,
258 offset_after: f32,
259 },
260
261 ScrollExtent {
263 node: u128,
264 viewport_w: f32,
265 viewport_h: f32,
266 content_w: f32,
267 content_h: f32,
268 note: Option<String>,
269 },
270 ScrollUpdate {
271 node: u128,
272 axis: String,
273 point_x: f32,
274 point_y: f32,
275 delta: f32,
276 old_offset: f32,
277 new_offset: f32,
278 max_offset: f32,
279 viewport_w: f32,
280 viewport_h: f32,
281 content_w: f32,
282 content_h: f32,
283 },
284 ScrollPaintTranslate {
285 node: u128,
286 axis: String,
287 offset: f32,
288 translate_x: f32,
289 translate_y: f32,
290 },
291 TextLayoutPerformance {
292 text_len: u32,
293 is_rich: bool,
294 duration_ns: u64,
295 },
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct FrameStats {
301 pub dirty_nodes: u32,
302 pub layout_updates: u32,
303 pub paint_misses: u32,
304 pub paint_hits: u32,
305 pub video_surfaces: u32,
306}
307
308#[derive(Debug, Clone)]
313pub struct DiagnosticsConfig {
314 pub enabled_categories: BTreeSet<DiagCategory>,
315 pub min_level: DiagLevel,
316 pub sink: DiagSink,
317 pub sampling: f32,
318}
319
320impl Default for DiagnosticsConfig {
321 fn default() -> Self {
322 Self {
323 enabled_categories: BTreeSet::new(),
324 min_level: DiagLevel::Error,
325 sink: DiagSink::Stdout,
326 sampling: 1.0,
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
335pub enum DiagSink {
336 Stdout,
337 File(PathBuf),
338 RingBuffer(usize),
339 Disabled,
340}
341
342trait SinkImpl: Send + Sync {
343 fn write(&self, event: &DiagEvent);
344}
345
346struct StdoutSinkImpl;
347impl SinkImpl for StdoutSinkImpl {
348 fn write(&self, event: &DiagEvent) {
349 let _ = serde_json::to_string(event).map(|line| println!("{}", line));
351 }
352}
353
354struct FileSinkImpl {
355 file: RwLock<File>,
356}
357impl SinkImpl for FileSinkImpl {
358 fn write(&self, event: &DiagEvent) {
359 if let Ok(s) = serde_json::to_string(event) {
360 let mut f = self.file.write();
361 let _ = f.write_all(s.as_bytes());
362 let _ = f.write_all(b"\n");
363 }
364 }
365}
366
367struct RingBufferSinkImpl {
368 buf: RwLock<Vec<String>>,
370 cap: usize,
371}
372impl SinkImpl for RingBufferSinkImpl {
373 fn write(&self, event: &DiagEvent) {
374 if let Ok(s) = serde_json::to_string(event) {
375 let mut w = self.buf.write();
376 if w.len() >= self.cap {
377 w.remove(0);
378 }
379 w.push(s);
380 }
381 }
382}
383
384struct DiagnosticsInner {
387 config: DiagnosticsConfig,
388 sink_impl: Box<dyn SinkImpl>,
389 frame_no: AtomicU64,
390 timestamp_ns: AtomicU64,
391}
392
393impl DiagnosticsInner {
394 fn should_emit(&self, cat: &DiagCategory, level: DiagLevel) -> bool {
395 if matches!(self.config.sink, DiagSink::Disabled) {
396 return false;
397 }
398 if !self.config.enabled_categories.contains(cat) {
399 return false;
400 }
401 self.config.min_level.allows(level)
402 }
403}
404
405static DIAGNOSTICS: OnceCell<RwLock<DiagnosticsInner>> = OnceCell::new();
406
407pub fn init_from_env() {
412 let cats = std::env::var("FISSION_DIAG").unwrap_or_default();
414 let enabled_categories: BTreeSet<DiagCategory> = cats
415 .split(',')
416 .filter_map(|s| match s.trim().to_lowercase().as_str() {
417 "frame" => Some(DiagCategory::Frame),
418 "diff" => Some(DiagCategory::Diff),
419 "layout" => Some(DiagCategory::Layout),
420 "paint" => Some(DiagCategory::Paint),
421 "raster" => Some(DiagCategory::Raster),
422 "input" => Some(DiagCategory::Input),
423 "semantics" => Some(DiagCategory::Semantics),
424 "animation" => Some(DiagCategory::Animation),
425 "media" => Some(DiagCategory::Media),
426 "invariants" => Some(DiagCategory::Invariants),
427 "test" => Some(DiagCategory::Test),
428 "*" => None, _ => None,
430 })
431 .collect();
432
433 let min_level = match std::env::var("FISSION_DIAG_LEVEL")
435 .unwrap_or_default()
436 .to_lowercase()
437 .as_str()
438 {
439 "error" => DiagLevel::Error,
440 "warn" => DiagLevel::Warn,
441 "info" => DiagLevel::Info,
442 "debug" => DiagLevel::Debug,
443 "trace" => DiagLevel::Trace,
444 _ => DiagLevel::Warn,
445 };
446
447 let sink_env = std::env::var("FISSION_DIAG_SINK").unwrap_or_default();
449 let sink = if sink_env.starts_with("file:") {
450 DiagSink::File(PathBuf::from(sink_env.trim_start_matches("file:")))
451 } else if sink_env.starts_with("ipc:") {
452 DiagSink::Stdout
454 } else if sink_env == "stdout" || sink_env.is_empty() {
455 DiagSink::Stdout
456 } else {
457 DiagSink::Disabled
458 };
459
460 let sampling = std::env::var("FISSION_DIAG_SAMPLING")
461 .ok()
462 .and_then(|s| s.parse::<f32>().ok())
463 .unwrap_or(1.0);
464
465 let mut cfg = DiagnosticsConfig {
466 enabled_categories,
467 min_level,
468 sink,
469 sampling,
470 };
471
472 if cats.split(',').any(|s| s.trim() == "*") {
474 cfg.enabled_categories = [
475 DiagCategory::Frame,
476 DiagCategory::Diff,
477 DiagCategory::Layout,
478 DiagCategory::Paint,
479 DiagCategory::Raster,
480 DiagCategory::Input,
481 DiagCategory::Semantics,
482 DiagCategory::Animation,
483 DiagCategory::Media,
484 DiagCategory::Invariants,
485 DiagCategory::Test,
486 ]
487 .into_iter()
488 .collect();
489 }
490
491 init(cfg);
492}
493
494pub fn init(config: DiagnosticsConfig) {
498 let sink_impl: Box<dyn SinkImpl> = match &config.sink {
499 DiagSink::Stdout => Box::new(StdoutSinkImpl),
500 DiagSink::File(path) => {
501 let file = OpenOptions::new()
502 .create(true)
503 .append(true)
504 .open(path)
505 .unwrap();
506 Box::new(FileSinkImpl {
507 file: RwLock::new(file),
508 })
509 }
510 DiagSink::RingBuffer(cap) => Box::new(RingBufferSinkImpl {
511 buf: RwLock::new(Vec::with_capacity(*cap)),
512 cap: *cap,
513 }),
514 DiagSink::Disabled => Box::new(StdoutSinkImpl), };
516
517 let inner = DiagnosticsInner {
518 config,
519 sink_impl,
520 frame_no: AtomicU64::new(0),
521 timestamp_ns: AtomicU64::new(0),
522 };
523 let _ = DIAGNOSTICS.set(RwLock::new(inner));
524}
525
526fn with_diag_mut<T>(f: impl FnOnce(&mut DiagnosticsInner) -> T) -> Option<T> {
527 DIAGNOSTICS.get().map(|cell| {
528 let mut guard = cell.write();
529 f(&mut *guard)
530 })
531}
532
533pub fn begin_frame(root: Option<u128>) {
536 let _ = with_diag_mut(|d| {
537 let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; let fno = d.frame_no.fetch_add(1, Ordering::Relaxed) + 1;
539 let ev = DiagEvent {
540 schema_version: 1,
541 timestamp_ns: ts,
542 frame_no: fno,
543 category: DiagCategory::Frame,
544 level: DiagLevel::Debug,
545 event: DiagEventKind::FrameStart { root },
546 };
547 if d.should_emit(&ev.category, ev.level) {
548 d.sink_impl.write(&ev);
549 }
550 });
551}
552
553pub fn end_frame(stats: FrameStats) {
555 let _ = with_diag_mut(|d| {
556 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
557 let fno = d.frame_no.load(Ordering::Relaxed);
558 let ev = DiagEvent {
559 schema_version: 1,
560 timestamp_ns: ts,
561 frame_no: fno,
562 category: DiagCategory::Frame,
563 level: DiagLevel::Debug,
564 event: DiagEventKind::FrameEnd { stats },
565 };
566 if d.should_emit(&ev.category, ev.level) {
567 d.sink_impl.write(&ev);
568 }
569 });
570}
571
572pub fn emit(category: DiagCategory, level: DiagLevel, event: DiagEventKind) {
577 let _ = with_diag_mut(|d| {
578 if !d.should_emit(&category, level) {
579 return;
580 }
581 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
582 let fno = d.frame_no.load(Ordering::Relaxed);
583 let ev = DiagEvent {
584 schema_version: 1,
585 timestamp_ns: ts,
586 frame_no: fno,
587 category,
588 level,
589 event,
590 };
591 d.sink_impl.write(&ev);
592 });
593}
594
595pub mod prelude {
597 pub use super::{
598 begin_frame, emit, end_frame, init_from_env, DiagCategory, DiagEventKind, DiagLevel,
599 FrameStats,
600 };
601}
602
603#[derive(Debug, Clone, Copy)]
607pub enum SnapshotKind {
608 Layout,
609}
610
611#[derive(Debug, Clone)]
613pub struct SnapshotBlob {
614 pub kind: SnapshotKind,
615 pub json: String,
616}
617
618pub trait SnapshotProvider {
620 fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
621}
622
623#[cfg(test)]
624mod tests {
625 use super::DiagEventKind;
626
627 #[test]
628 fn renderer_selected_diagnostic_serializes_renderer_identity() {
629 let event = DiagEventKind::RendererSelected {
630 active: "webgpu-vello".to_string(),
631 requested: "auto".to_string(),
632 backend: Some("BrowserWebGpu".to_string()),
633 adapter: Some("Chrome".to_string()),
634 fallback_reason: None,
635 width: 1280,
636 height: 720,
637 scale_factor: 2.0,
638 };
639 let json = serde_json::to_string(&event).expect("serialize renderer diagnostic");
640 assert!(json.contains("RendererSelected"));
641 assert!(json.contains("webgpu-vello"));
642 assert!(json.contains("BrowserWebGpu"));
643 }
644}