1use once_cell::sync::OnceCell;
20use parking_lot::RwLock;
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeSet, HashSet};
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 { root: Option<u128> },
110 FrameEnd { stats: FrameStats },
111
112 DiffSummary {
113 nodes_total: u32,
114 nodes_created: u32,
115 nodes_removed: u32,
116 nodes_changed: u32,
117 dirty_layout: u32,
118 dirty_paint: u32,
119 },
120
121 LayoutSummary {
122 nodes: u32,
123 dirty_count: u32,
124 full_rebuild: bool,
125 duration_ns: u64,
126 },
127
128 PaintSummary {
129 segments_reused: u32,
130 segments_regenerated: u32,
131 paint_ops_total: u32,
132 },
133 PaintNode {
134 node: u128,
135 note: Option<String>,
136 },
137 PaintNodeRect {
138 node: u128,
139 x: f32,
140 y: f32,
141 w: f32,
142 h: f32,
143 note: Option<String>,
144 },
145
146 NodeProps {
147 node: u128,
148 op_tag: String,
149 flex_grow: f32,
150 flex_shrink: f32,
151 width: Option<f32>,
152 height: Option<f32>,
153 },
154
155 RasterSummary {
156 cache_hits: u32,
157 cache_misses: u32,
158 tiles_rasterized: u32,
159 },
160
161 AnimationSummary {
162 active_count: u32,
163 started: u32,
164 replaced: u32,
165 ended: u32,
166 },
167
168 MediaSummary {
169 video_nodes: u32,
170 audio_nodes: u32,
171 embeds_total: u32,
172 },
173
174 PortalsComposed { portal_count: u32 },
176 AnchorPlacement {
177 widget: u128,
178 node: u128,
179 rect_x: f32,
180 rect_y: f32,
181 rect_w: f32,
182 rect_h: f32,
183 place_left: f32,
184 place_top: f32,
185 note: Option<String>,
186 },
187
188 InvariantViolation {
189 kind: String,
190 node: Option<u128>,
191 details: String,
192 dump_ref: Option<String>,
193 },
194
195 InputEvent {
196 kind: String,
197 target: Option<u128>,
198 position: Option<(f32, f32)>,
199 },
200
201 MediaEvent {
202 kind: String,
203 id: Option<u128>,
204 duration_ms: Option<u64>,
205 position_ms: Option<u64>,
206 },
207
208 TextInputAutoScroll {
210 scroll_id: u128,
211 text_id: u128,
212 text_len: u32,
213 measured_w: f32,
214 line_h: f32,
215 viewport_x: f32,
216 viewport_w: f32,
217 content_w: f32,
218 caret_abs_x: f32,
219 offset_before: f32,
220 offset_after: f32,
221 },
222
223 ScrollExtent {
225 node: u128,
226 viewport_w: f32,
227 viewport_h: f32,
228 content_w: f32,
229 content_h: f32,
230 note: Option<String>,
231 },
232 ScrollUpdate {
233 node: u128,
234 axis: String,
235 point_x: f32,
236 point_y: f32,
237 delta: f32,
238 old_offset: f32,
239 new_offset: f32,
240 max_offset: f32,
241 viewport_w: f32,
242 viewport_h: f32,
243 content_w: f32,
244 content_h: f32,
245 },
246 ScrollPaintTranslate {
247 node: u128,
248 axis: String,
249 offset: f32,
250 translate_x: f32,
251 translate_y: f32,
252 },
253 TextLayoutPerformance {
254 text_len: u32,
255 is_rich: bool,
256 duration_ns: u64,
257 },
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
262pub struct FrameStats {
263 pub dirty_nodes: u32,
264 pub layout_updates: u32,
265 pub paint_misses: u32,
266 pub paint_hits: u32,
267 pub video_surfaces: u32,
268}
269
270#[derive(Debug, Clone)]
275pub struct DiagnosticsConfig {
276 pub enabled_categories: BTreeSet<DiagCategory>,
277 pub min_level: DiagLevel,
278 pub sink: DiagSink,
279 pub sampling: f32,
280}
281
282impl Default for DiagnosticsConfig {
283 fn default() -> Self {
284 Self {
285 enabled_categories: BTreeSet::new(),
286 min_level: DiagLevel::Error,
287 sink: DiagSink::Stdout,
288 sampling: 1.0,
289 }
290 }
291}
292
293#[derive(Debug, Clone)]
297pub enum DiagSink {
298 Stdout,
299 File(PathBuf),
300 RingBuffer(usize),
301 Disabled,
302}
303
304trait SinkImpl: Send + Sync {
305 fn write(&self, event: &DiagEvent);
306}
307
308struct StdoutSinkImpl;
309impl SinkImpl for StdoutSinkImpl {
310 fn write(&self, event: &DiagEvent) {
311 let _ = serde_json::to_string(event)
313 .map(|line| println!("{}", line));
314 }
315}
316
317struct FileSinkImpl {
318 file: RwLock<File>,
319}
320impl SinkImpl for FileSinkImpl {
321 fn write(&self, event: &DiagEvent) {
322 if let Ok(s) = serde_json::to_string(event) {
323 let mut f = self.file.write();
324 let _ = f.write_all(s.as_bytes());
325 let _ = f.write_all(b"\n");
326 }
327 }
328}
329
330struct RingBufferSinkImpl {
331 buf: RwLock<Vec<String>>,
333 cap: usize,
334}
335impl SinkImpl for RingBufferSinkImpl {
336 fn write(&self, event: &DiagEvent) {
337 if let Ok(s) = serde_json::to_string(event) {
338 let mut w = self.buf.write();
339 if w.len() >= self.cap { w.remove(0); }
340 w.push(s);
341 }
342 }
343}
344
345struct DiagnosticsInner {
348 config: DiagnosticsConfig,
349 sink_impl: Box<dyn SinkImpl>,
350 frame_no: AtomicU64,
351 timestamp_ns: AtomicU64,
352}
353
354impl DiagnosticsInner {
355 fn should_emit(&self, cat: &DiagCategory, level: DiagLevel) -> bool {
356 if matches!(self.config.sink, DiagSink::Disabled) { return false; }
357 if !self.config.enabled_categories.contains(cat) { return false; }
358 self.config.min_level.allows(level)
359 }
360}
361
362static DIAGNOSTICS: OnceCell<RwLock<DiagnosticsInner>> = OnceCell::new();
363
364pub fn init_from_env() {
369 let cats = std::env::var("FISSION_DIAG").unwrap_or_default();
371 let enabled_categories: BTreeSet<DiagCategory> = cats
372 .split(',')
373 .filter_map(|s| match s.trim().to_lowercase().as_str() {
374 "frame" => Some(DiagCategory::Frame),
375 "diff" => Some(DiagCategory::Diff),
376 "layout" => Some(DiagCategory::Layout),
377 "paint" => Some(DiagCategory::Paint),
378 "raster" => Some(DiagCategory::Raster),
379 "input" => Some(DiagCategory::Input),
380 "semantics" => Some(DiagCategory::Semantics),
381 "animation" => Some(DiagCategory::Animation),
382 "media" => Some(DiagCategory::Media),
383 "invariants" => Some(DiagCategory::Invariants),
384 "test" => Some(DiagCategory::Test),
385 "*" => None, _ => None,
387 })
388 .collect();
389
390 let min_level = match std::env::var("FISSION_DIAG_LEVEL").unwrap_or_default().to_lowercase().as_str() {
392 "error" => DiagLevel::Error,
393 "warn" => DiagLevel::Warn,
394 "info" => DiagLevel::Info,
395 "debug" => DiagLevel::Debug,
396 "trace" => DiagLevel::Trace,
397 _ => DiagLevel::Warn,
398 };
399
400 let sink_env = std::env::var("FISSION_DIAG_SINK").unwrap_or_default();
402 let sink = if sink_env.starts_with("file:") {
403 DiagSink::File(PathBuf::from(sink_env.trim_start_matches("file:")))
404 } else if sink_env.starts_with("ipc:") {
405 DiagSink::Stdout
407 } else if sink_env == "stdout" || sink_env.is_empty() {
408 DiagSink::Stdout
409 } else {
410 DiagSink::Disabled
411 };
412
413 let sampling = std::env::var("FISSION_DIAG_SAMPLING")
414 .ok()
415 .and_then(|s| s.parse::<f32>().ok())
416 .unwrap_or(1.0);
417
418 let mut cfg = DiagnosticsConfig {
419 enabled_categories,
420 min_level,
421 sink,
422 sampling,
423 };
424
425 if cats.split(',').any(|s| s.trim() == "*") {
427 cfg.enabled_categories = [
428 DiagCategory::Frame,
429 DiagCategory::Diff,
430 DiagCategory::Layout,
431 DiagCategory::Paint,
432 DiagCategory::Raster,
433 DiagCategory::Input,
434 DiagCategory::Semantics,
435 DiagCategory::Animation,
436 DiagCategory::Media,
437 DiagCategory::Invariants,
438 DiagCategory::Test,
439 ]
440 .into_iter()
441 .collect();
442 }
443
444 init(cfg);
445}
446
447pub fn init(config: DiagnosticsConfig) {
451 let sink_impl: Box<dyn SinkImpl> = match &config.sink {
452 DiagSink::Stdout => Box::new(StdoutSinkImpl),
453 DiagSink::File(path) => {
454 let file = OpenOptions::new().create(true).append(true).open(path).unwrap();
455 Box::new(FileSinkImpl { file: RwLock::new(file) })
456 }
457 DiagSink::RingBuffer(cap) => Box::new(RingBufferSinkImpl { buf: RwLock::new(Vec::with_capacity(*cap)), cap: *cap }),
458 DiagSink::Disabled => Box::new(StdoutSinkImpl), };
460
461 let inner = DiagnosticsInner {
462 config,
463 sink_impl,
464 frame_no: AtomicU64::new(0),
465 timestamp_ns: AtomicU64::new(0),
466 };
467 let _ = DIAGNOSTICS.set(RwLock::new(inner));
468}
469
470fn with_diag<T>(f: impl FnOnce(&DiagnosticsInner) -> T) -> Option<T> {
471 DIAGNOSTICS.get().map(|cell| {
472 let guard = cell.read();
473 f(&*guard)
474 })
475}
476
477fn with_diag_mut<T>(f: impl FnOnce(&mut DiagnosticsInner) -> T) -> Option<T> {
478 DIAGNOSTICS.get().map(|cell| {
479 let mut guard = cell.write();
480 f(&mut *guard)
481 })
482}
483
484pub fn begin_frame(root: Option<u128>) {
487 let _ = with_diag_mut(|d| {
488 let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; let fno = d.frame_no.fetch_add(1, Ordering::Relaxed) + 1;
490 let ev = DiagEvent {
491 schema_version: 1,
492 timestamp_ns: ts,
493 frame_no: fno,
494 category: DiagCategory::Frame,
495 level: DiagLevel::Debug,
496 event: DiagEventKind::FrameStart { root },
497 };
498 if d.should_emit(&ev.category, ev.level) {
499 d.sink_impl.write(&ev);
500 }
501 });
502}
503
504pub fn end_frame(stats: FrameStats) {
506 let _ = with_diag_mut(|d| {
507 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
508 let fno = d.frame_no.load(Ordering::Relaxed);
509 let ev = DiagEvent {
510 schema_version: 1,
511 timestamp_ns: ts,
512 frame_no: fno,
513 category: DiagCategory::Frame,
514 level: DiagLevel::Debug,
515 event: DiagEventKind::FrameEnd { stats },
516 };
517 if d.should_emit(&ev.category, ev.level) {
518 d.sink_impl.write(&ev);
519 }
520 });
521}
522
523pub fn emit(category: DiagCategory, level: DiagLevel, event: DiagEventKind) {
528 let _ = with_diag_mut(|d| {
529 if !d.should_emit(&category, level) { return; }
530 let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
531 let fno = d.frame_no.load(Ordering::Relaxed);
532 let ev = DiagEvent {
533 schema_version: 1,
534 timestamp_ns: ts,
535 frame_no: fno,
536 category,
537 level,
538 event,
539 };
540 d.sink_impl.write(&ev);
541 });
542}
543
544pub mod prelude {
546 pub use super::{begin_frame, end_frame, emit, DiagCategory, DiagEventKind, DiagLevel, FrameStats, init_from_env};
547}
548
549#[derive(Debug, Clone, Copy)]
553pub enum SnapshotKind { Layout }
554
555#[derive(Debug, Clone)]
557pub struct SnapshotBlob {
558 pub kind: SnapshotKind,
559 pub json: String,
560}
561
562pub trait SnapshotProvider {
564 fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
565}