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;
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 {
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    // Overlay/Portal + Anchor diagnostics (layout investigation helpers)
179    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    // Text input auto-scroll diagnostics
215    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    // General scrolling diagnostics
230    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/// Summary statistics for a completed frame, attached to [`DiagEventKind::FrameEnd`].
267#[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/// Configuration for the diagnostics system.
277///
278/// Controls which categories and levels are emitted, the output sink, and
279/// the sampling rate.
280#[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// --------- Sinks ---------
300
301/// Output destination for diagnostic events.
302#[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        // JSONL for stable tooling integration
318        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    // very simple ring buffer of JSON strings for now
337    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
352// --------- Global Diagnostics ---------
353
354struct 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
375/// Initialize the diagnostics system from environment variables.
376///
377/// Reads `FISSION_DIAG` (categories), `FISSION_DIAG_LEVEL`, `FISSION_DIAG_SINK`,
378/// and `FISSION_DIAG_SAMPLING`. See the crate-level documentation for details.
379pub fn init_from_env() {
380    // Categories
381    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, // handled below
397            _ => None,
398        })
399        .collect();
400
401    // Level
402    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    // Sink
416    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        // Not implemented v1; fallback to stdout
421        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    // Handle wildcard * for categories (enable all)
441    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
462/// Initialize the diagnostics system with the given configuration.
463///
464/// Can only be called once (uses `OnceCell`). Subsequent calls are silently ignored.
465pub 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), // won't be used
483    };
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
501/// Mark the start of a new frame. Increments the frame counter and emits a
502/// [`DiagEventKind::FrameStart`] event.
503pub fn begin_frame(root: Option<u128>) {
504    let _ = with_diag_mut(|d| {
505        let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; // ~60fps increment
506        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
521/// Mark the end of the current frame, attaching the given [`FrameStats`].
522pub 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
540/// Emit a diagnostic event if the given category and level pass the current filter.
541///
542/// This is the primary entry point for all diagnostic events. The event is
543/// automatically timestamped and tagged with the current frame number.
544pub 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
563/// Convenience re-exports for common diagnostic operations.
564pub mod prelude {
565    pub use super::{
566        begin_frame, emit, end_frame, init_from_env, DiagCategory, DiagEventKind, DiagLevel,
567        FrameStats,
568    };
569}
570
571// --------- Snapshot Provider (v1 minimal) ---------
572
573/// The type of snapshot that a [`SnapshotProvider`] can produce.
574#[derive(Debug, Clone, Copy)]
575pub enum SnapshotKind {
576    Layout,
577}
578
579/// A serialized snapshot blob containing JSON data.
580#[derive(Debug, Clone)]
581pub struct SnapshotBlob {
582    pub kind: SnapshotKind,
583    pub json: String,
584}
585
586/// Trait for components that can produce a JSON snapshot of their internal state.
587pub trait SnapshotProvider {
588    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
589}