Skip to main content

fission_diagnostics/
lib.rs

1//! Structured diagnostics and telemetry for the Fission rendering pipeline.
2//!
3//! Provides a global, thread-safe diagnostics system that emits structured JSON
4//! events covering every stage of the frame lifecycle. Configure via environment
5//! variables ([`init_from_env()`]) or programmatically ([`init()`]).
6//!
7//! # Quick start
8//!
9//! ```rust,ignore
10//! use fission_diagnostics::prelude::*;
11//! init_from_env();
12//! begin_frame(None);
13//! emit(DiagCategory::Layout, DiagLevel::Debug, DiagEventKind::LayoutSummary {
14//!     nodes: 100, dirty_count: 2, full_rebuild: false, duration_ns: 500_000,
15//! });
16//! end_frame(FrameStats::default());
17//! ```
18
19use 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// --------- Public Types ---------
29
30/// Severity level for diagnostic events.
31///
32/// Ordered from most to least severe: `Error` > `Warn` > `Info` > `Debug` > `Trace`.
33/// The [`allows()`](DiagLevel::allows) method checks if a given level passes the
34/// configured minimum threshold.
35#[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/// Pipeline subsystem that a diagnostic event belongs to.
67///
68/// Used for category-based filtering. Enable specific categories via the
69/// `FISSION_DIAG` environment variable (comma-separated, or `*` for all).
70#[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/// The top-level diagnostic event envelope.
87///
88/// Contains metadata (schema version, timestamp, frame number, category, level)
89/// and the concrete event payload ([`DiagEventKind`]).
90/// Serialized as a single JSON line (JSONL).
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DiagEvent {
93    pub schema_version: u16, // v1 = 1
94    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/// The concrete payload for a diagnostic event.
103///
104/// Each variant covers a specific pipeline stage or cross-cutting concern.
105/// Serialized with `#[serde(tag = "kind", content = "payload")]`.
106#[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    // Overlay/Portal + Anchor diagnostics (layout investigation helpers)
175    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    // Text input auto-scroll diagnostics
209    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    // General scrolling diagnostics
224    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/// Summary statistics for a completed frame, attached to [`DiagEventKind::FrameEnd`].
261#[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/// Configuration for the diagnostics system.
271///
272/// Controls which categories and levels are emitted, the output sink, and
273/// the sampling rate.
274#[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// --------- Sinks ---------
294
295/// Output destination for diagnostic events.
296#[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        // JSONL for stable tooling integration
312        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    // very simple ring buffer of JSON strings for now
332    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
345// --------- Global Diagnostics ---------
346
347struct 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
364/// Initialize the diagnostics system from environment variables.
365///
366/// Reads `FISSION_DIAG` (categories), `FISSION_DIAG_LEVEL`, `FISSION_DIAG_SINK`,
367/// and `FISSION_DIAG_SAMPLING`. See the crate-level documentation for details.
368pub fn init_from_env() {
369    // Categories
370    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, // handled below
386            _ => None,
387        })
388        .collect();
389
390    // Level
391    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    // Sink
401    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        // Not implemented v1; fallback to stdout
406        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    // Handle wildcard * for categories (enable all)
426    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
447/// Initialize the diagnostics system with the given configuration.
448///
449/// Can only be called once (uses `OnceCell`). Subsequent calls are silently ignored.
450pub 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), // won't be used
459    };
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
484/// Mark the start of a new frame. Increments the frame counter and emits a
485/// [`DiagEventKind::FrameStart`] event.
486pub fn begin_frame(root: Option<u128>) {
487    let _ = with_diag_mut(|d| {
488        let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; // ~60fps increment
489        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
504/// Mark the end of the current frame, attaching the given [`FrameStats`].
505pub 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
523/// Emit a diagnostic event if the given category and level pass the current filter.
524///
525/// This is the primary entry point for all diagnostic events. The event is
526/// automatically timestamped and tagged with the current frame number.
527pub 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
544/// Convenience re-exports for common diagnostic operations.
545pub mod prelude {
546    pub use super::{begin_frame, end_frame, emit, DiagCategory, DiagEventKind, DiagLevel, FrameStats, init_from_env};
547}
548
549// --------- Snapshot Provider (v1 minimal) ---------
550
551/// The type of snapshot that a [`SnapshotProvider`] can produce.
552#[derive(Debug, Clone, Copy)]
553pub enum SnapshotKind { Layout }
554
555/// A serialized snapshot blob containing JSON data.
556#[derive(Debug, Clone)]
557pub struct SnapshotBlob {
558    pub kind: SnapshotKind,
559    pub json: String,
560}
561
562/// Trait for components that can produce a JSON snapshot of their internal state.
563pub trait SnapshotProvider {
564    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
565}