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