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 RendererSelected {
165 active: String,
166 requested: String,
167 backend: Option<String>,
168 adapter: Option<String>,
169 fallback_reason: Option<String>,
170 width: u32,
171 height: u32,
172 scale_factor: f64,
173 },
174 FramePerformance {
175 renderer: String,
176 total_ms: f64,
177 },
178 InputLatency {
179 renderer: String,
180 latency_ms: f64,
181 },
182
183 AnimationSummary {
184 active_count: u32,
185 started: u32,
186 replaced: u32,
187 ended: u32,
188 },
189
190 MediaSummary {
191 video_nodes: u32,
192 audio_nodes: u32,
193 embeds_total: u32,
194 },
195
196 PortalsComposed {
198 portal_count: u32,
199 },
200 AnchorPlacement {
201 widget: u128,
202 node: u128,
203 rect_x: f32,
204 rect_y: f32,
205 rect_w: f32,
206 rect_h: f32,
207 place_left: f32,
208 place_top: f32,
209 note: Option<String>,
210 },
211
212 InvariantViolation {
213 kind: String,
214 node: Option<u128>,
215 details: String,
216 dump_ref: Option<String>,
217 },
218
219 InputEvent {
220 kind: String,
221 target: Option<u128>,
222 position: Option<(f32, f32)>,
223 },
224
225 MediaEvent {
226 kind: String,
227 id: Option<u128>,
228 duration_ms: Option<u64>,
229 position_ms: Option<u64>,
230 },
231
232 TextInputAutoScroll {
234 scroll_id: u128,
235 text_id: u128,
236 text_len: u32,
237 measured_w: f32,
238 line_h: f32,
239 viewport_x: f32,
240 viewport_w: f32,
241 content_w: f32,
242 caret_abs_x: f32,
243 offset_before: f32,
244 offset_after: f32,
245 },
246
247 ScrollExtent {
249 node: u128,
250 viewport_w: f32,
251 viewport_h: f32,
252 content_w: f32,
253 content_h: f32,
254 note: Option<String>,
255 },
256 ScrollUpdate {
257 node: u128,
258 axis: String,
259 point_x: f32,
260 point_y: f32,
261 delta: f32,
262 old_offset: f32,
263 new_offset: f32,
264 max_offset: f32,
265 viewport_w: f32,
266 viewport_h: f32,
267 content_w: f32,
268 content_h: f32,
269 },
270 ScrollPaintTranslate {
271 node: u128,
272 axis: String,
273 offset: f32,
274 translate_x: f32,
275 translate_y: f32,
276 },
277 TextLayoutPerformance {
278 text_len: u32,
279 is_rich: bool,
280 duration_ns: u64,
281 },
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct FrameStats {
287 pub dirty_nodes: u32,
288 pub layout_updates: u32,
289 pub paint_misses: u32,
290 pub paint_hits: u32,
291 pub video_surfaces: u32,
292}
293
294#[derive(Debug, Clone)]
299pub struct DiagnosticsConfig {
300 pub enabled_categories: BTreeSet<DiagCategory>,
301 pub min_level: DiagLevel,
302 pub sink: DiagSink,
303 pub sampling: f32,
304}
305
306impl Default for DiagnosticsConfig {
307 fn default() -> Self {
308 Self {
309 enabled_categories: BTreeSet::new(),
310 min_level: DiagLevel::Error,
311 sink: DiagSink::Stdout,
312 sampling: 1.0,
313 }
314 }
315}
316
317#[derive(Debug, Clone)]
321pub enum DiagSink {
322 Stdout,
323 File(PathBuf),
324 RingBuffer(usize),
325 Disabled,
326}
327
328trait SinkImpl: Send + Sync {
329 fn write(&self, event: &DiagEvent);
330}
331
332struct StdoutSinkImpl;
333impl SinkImpl for StdoutSinkImpl {
334 fn write(&self, event: &DiagEvent) {
335 let _ = serde_json::to_string(event).map(|line| println!("{}", line));
337 }
338}
339
340struct FileSinkImpl {
341 file: RwLock<File>,
342}
343impl SinkImpl for FileSinkImpl {
344 fn write(&self, event: &DiagEvent) {
345 if let Ok(s) = serde_json::to_string(event) {
346 let mut f = self.file.write();
347 let _ = f.write_all(s.as_bytes());
348 let _ = f.write_all(b"\n");
349 }
350 }
351}
352
353struct RingBufferSinkImpl {
354 buf: RwLock<Vec<String>>,
356 cap: usize,
357}
358impl SinkImpl for RingBufferSinkImpl {
359 fn write(&self, event: &DiagEvent) {
360 if let Ok(s) = serde_json::to_string(event) {
361 let mut w = self.buf.write();
362 if w.len() >= self.cap {
363 w.remove(0);
364 }
365 w.push(s);
366 }
367 }
368}
369
370struct DiagnosticsInner {
373 config: DiagnosticsConfig,
374 sink_impl: Box<dyn SinkImpl>,
375 frame_no: AtomicU64,
376 timestamp_ns: AtomicU64,
377}
378
379impl DiagnosticsInner {
380 fn should_emit(&self, cat: &DiagCategory, level: DiagLevel) -> bool {
381 if matches!(self.config.sink, DiagSink::Disabled) {
382 return false;
383 }
384 if !self.config.enabled_categories.contains(cat) {
385 return false;
386 }
387 self.config.min_level.allows(level)
388 }
389}
390
391static DIAGNOSTICS: OnceCell<RwLock<DiagnosticsInner>> = OnceCell::new();
392
393pub fn init_from_env() {
398 let cats = std::env::var("FISSION_DIAG").unwrap_or_default();
400 let enabled_categories: BTreeSet<DiagCategory> = cats
401 .split(',')
402 .filter_map(|s| match s.trim().to_lowercase().as_str() {
403 "frame" => Some(DiagCategory::Frame),
404 "diff" => Some(DiagCategory::Diff),
405 "layout" => Some(DiagCategory::Layout),
406 "paint" => Some(DiagCategory::Paint),
407 "raster" => Some(DiagCategory::Raster),
408 "input" => Some(DiagCategory::Input),
409 "semantics" => Some(DiagCategory::Semantics),
410 "animation" => Some(DiagCategory::Animation),
411 "media" => Some(DiagCategory::Media),
412 "invariants" => Some(DiagCategory::Invariants),
413 "test" => Some(DiagCategory::Test),
414 "*" => None, _ => None,
416 })
417 .collect();
418
419 let min_level = match std::env::var("FISSION_DIAG_LEVEL")
421 .unwrap_or_default()
422 .to_lowercase()
423 .as_str()
424 {
425 "error" => DiagLevel::Error,
426 "warn" => DiagLevel::Warn,
427 "info" => DiagLevel::Info,
428 "debug" => DiagLevel::Debug,
429 "trace" => DiagLevel::Trace,
430 _ => DiagLevel::Warn,
431 };
432
433 let sink_env = std::env::var("FISSION_DIAG_SINK").unwrap_or_default();
435 let sink = if sink_env.starts_with("file:") {
436 DiagSink::File(PathBuf::from(sink_env.trim_start_matches("file:")))
437 } else if sink_env.starts_with("ipc:") {
438 DiagSink::Stdout
440 } else if sink_env == "stdout" || sink_env.is_empty() {
441 DiagSink::Stdout
442 } else {
443 DiagSink::Disabled
444 };
445
446 let sampling = std::env::var("FISSION_DIAG_SAMPLING")
447 .ok()
448 .and_then(|s| s.parse::<f32>().ok())
449 .unwrap_or(1.0);
450
451 let mut cfg = DiagnosticsConfig {
452 enabled_categories,
453 min_level,
454 sink,
455 sampling,
456 };
457
458 if cats.split(',').any(|s| s.trim() == "*") {
460 cfg.enabled_categories = [
461 DiagCategory::Frame,
462 DiagCategory::Diff,
463 DiagCategory::Layout,
464 DiagCategory::Paint,
465 DiagCategory::Raster,
466 DiagCategory::Input,
467 DiagCategory::Semantics,
468 DiagCategory::Animation,
469 DiagCategory::Media,
470 DiagCategory::Invariants,
471 DiagCategory::Test,
472 ]
473 .into_iter()
474 .collect();
475 }
476
477 init(cfg);
478}
479
480pub fn init(config: DiagnosticsConfig) {
484 let sink_impl: Box<dyn SinkImpl> = match &config.sink {
485 DiagSink::Stdout => Box::new(StdoutSinkImpl),
486 DiagSink::File(path) => {
487 let file = OpenOptions::new()
488 .create(true)
489 .append(true)
490 .open(path)
491 .unwrap();
492 Box::new(FileSinkImpl {
493 file: RwLock::new(file),
494 })
495 }
496 DiagSink::RingBuffer(cap) => Box::new(RingBufferSinkImpl {
497 buf: RwLock::new(Vec::with_capacity(*cap)),
498 cap: *cap,
499 }),
500 DiagSink::Disabled => Box::new(StdoutSinkImpl), };
502
503 let inner = DiagnosticsInner {
504 config,
505 sink_impl,
506 frame_no: AtomicU64::new(0),
507 timestamp_ns: AtomicU64::new(0),
508 };
509 let _ = DIAGNOSTICS.set(RwLock::new(inner));
510}
511
512fn with_diag_mut<T>(f: impl FnOnce(&mut DiagnosticsInner) -> T) -> Option<T> {
513 DIAGNOSTICS.get().map(|cell| {
514 let mut guard = cell.write();
515 f(&mut *guard)
516 })
517}
518
519pub fn begin_frame(root: Option<u128>) {
522 let _ = with_diag_mut(|d| {
523 let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; let fno = d.frame_no.fetch_add(1, Ordering::Relaxed) + 1;
525 let ev = DiagEvent {
526 schema_version: 1,
527 timestamp_ns: ts,
528 frame_no: fno,
529 category: DiagCategory::Frame,
530 level: DiagLevel::Debug,
531 event: DiagEventKind::FrameStart { root },
532 };
533 if d.should_emit(&ev.category, ev.level) {
534 d.sink_impl.write(&ev);
535 }
536 });
537}
538
539pub fn end_frame(stats: FrameStats) {
541 let _ = with_diag_mut(|d| {
542 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
543 let fno = d.frame_no.load(Ordering::Relaxed);
544 let ev = DiagEvent {
545 schema_version: 1,
546 timestamp_ns: ts,
547 frame_no: fno,
548 category: DiagCategory::Frame,
549 level: DiagLevel::Debug,
550 event: DiagEventKind::FrameEnd { stats },
551 };
552 if d.should_emit(&ev.category, ev.level) {
553 d.sink_impl.write(&ev);
554 }
555 });
556}
557
558pub fn emit(category: DiagCategory, level: DiagLevel, event: DiagEventKind) {
563 let _ = with_diag_mut(|d| {
564 if !d.should_emit(&category, level) {
565 return;
566 }
567 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
568 let fno = d.frame_no.load(Ordering::Relaxed);
569 let ev = DiagEvent {
570 schema_version: 1,
571 timestamp_ns: ts,
572 frame_no: fno,
573 category,
574 level,
575 event,
576 };
577 d.sink_impl.write(&ev);
578 });
579}
580
581pub mod prelude {
583 pub use super::{
584 begin_frame, emit, end_frame, init_from_env, DiagCategory, DiagEventKind, DiagLevel,
585 FrameStats,
586 };
587}
588
589#[derive(Debug, Clone, Copy)]
593pub enum SnapshotKind {
594 Layout,
595}
596
597#[derive(Debug, Clone)]
599pub struct SnapshotBlob {
600 pub kind: SnapshotKind,
601 pub json: String,
602}
603
604pub trait SnapshotProvider {
606 fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
607}
608
609#[cfg(test)]
610mod tests {
611 use super::DiagEventKind;
612
613 #[test]
614 fn renderer_selected_diagnostic_serializes_renderer_identity() {
615 let event = DiagEventKind::RendererSelected {
616 active: "webgpu-vello".to_string(),
617 requested: "auto".to_string(),
618 backend: Some("BrowserWebGpu".to_string()),
619 adapter: Some("Chrome".to_string()),
620 fallback_reason: None,
621 width: 1280,
622 height: 720,
623 scale_factor: 2.0,
624 };
625 let json = serde_json::to_string(&event).expect("serialize renderer diagnostic");
626 assert!(json.contains("RendererSelected"));
627 assert!(json.contains("webgpu-vello"));
628 assert!(json.contains("BrowserWebGpu"));
629 }
630}