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    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    // Overlay/Portal + Anchor diagnostics (layout investigation helpers)
197    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    // Text input auto-scroll diagnostics
233    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    // General scrolling diagnostics
248    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/// Summary statistics for a completed frame, attached to [`DiagEventKind::FrameEnd`].
285#[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/// Configuration for the diagnostics system.
295///
296/// Controls which categories and levels are emitted, the output sink, and
297/// the sampling rate.
298#[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// --------- Sinks ---------
318
319/// Output destination for diagnostic events.
320#[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        // JSONL for stable tooling integration
336        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    // very simple ring buffer of JSON strings for now
355    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
370// --------- Global Diagnostics ---------
371
372struct 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
393/// Initialize the diagnostics system from environment variables.
394///
395/// Reads `FISSION_DIAG` (categories), `FISSION_DIAG_LEVEL`, `FISSION_DIAG_SINK`,
396/// and `FISSION_DIAG_SAMPLING`. See the crate-level documentation for details.
397pub fn init_from_env() {
398    // Categories
399    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, // handled below
415            _ => None,
416        })
417        .collect();
418
419    // Level
420    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    // Sink
434    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        // Not implemented v1; fallback to stdout
439        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    // Handle wildcard * for categories (enable all)
459    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
480/// Initialize the diagnostics system with the given configuration.
481///
482/// Can only be called once (uses `OnceCell`). Subsequent calls are silently ignored.
483pub 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), // won't be used
501    };
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
519/// Mark the start of a new frame. Increments the frame counter and emits a
520/// [`DiagEventKind::FrameStart`] event.
521pub fn begin_frame(root: Option<u128>) {
522    let _ = with_diag_mut(|d| {
523        let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; // ~60fps increment
524        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
539/// Mark the end of the current frame, attaching the given [`FrameStats`].
540pub 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
558/// Emit a diagnostic event if the given category and level pass the current filter.
559///
560/// This is the primary entry point for all diagnostic events. The event is
561/// automatically timestamped and tagged with the current frame number.
562pub 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
581/// Convenience re-exports for common diagnostic operations.
582pub mod prelude {
583    pub use super::{
584        begin_frame, emit, end_frame, init_from_env, DiagCategory, DiagEventKind, DiagLevel,
585        FrameStats,
586    };
587}
588
589// --------- Snapshot Provider (v1 minimal) ---------
590
591/// The type of snapshot that a [`SnapshotProvider`] can produce.
592#[derive(Debug, Clone, Copy)]
593pub enum SnapshotKind {
594    Layout,
595}
596
597/// A serialized snapshot blob containing JSON data.
598#[derive(Debug, Clone)]
599pub struct SnapshotBlob {
600    pub kind: SnapshotKind,
601    pub json: String,
602}
603
604/// Trait for components that can produce a JSON snapshot of their internal state.
605pub 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}