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    ImageCacheSummary {
165        renderer: String,
166        entries: u64,
167        weighted_bytes: u64,
168        max_bytes: u64,
169        pending: u64,
170        hits: u64,
171        misses: u64,
172        loads_started: u64,
173        loads_completed: u64,
174        loads_failed: u64,
175        evictions: u64,
176        offscreen_skips: u64,
177    },
178    RendererSelected {
179        active: String,
180        requested: String,
181        backend: Option<String>,
182        adapter: Option<String>,
183        fallback_reason: Option<String>,
184        width: u32,
185        height: u32,
186        scale_factor: f64,
187    },
188    FramePerformance {
189        renderer: String,
190        total_ms: f64,
191    },
192    InputLatency {
193        renderer: String,
194        latency_ms: f64,
195    },
196
197    AnimationSummary {
198        active_count: u32,
199        started: u32,
200        replaced: u32,
201        ended: u32,
202    },
203
204    MediaSummary {
205        video_nodes: u32,
206        audio_nodes: u32,
207        embeds_total: u32,
208    },
209
210    // Overlay/Portal + Anchor diagnostics (layout investigation helpers)
211    PortalsComposed {
212        portal_count: u32,
213    },
214    AnchorPlacement {
215        widget: u128,
216        node: u128,
217        rect_x: f32,
218        rect_y: f32,
219        rect_w: f32,
220        rect_h: f32,
221        place_left: f32,
222        place_top: f32,
223        note: Option<String>,
224    },
225
226    InvariantViolation {
227        kind: String,
228        node: Option<u128>,
229        details: String,
230        dump_ref: Option<String>,
231    },
232
233    InputEvent {
234        kind: String,
235        target: Option<u128>,
236        position: Option<(f32, f32)>,
237    },
238
239    MediaEvent {
240        kind: String,
241        id: Option<u128>,
242        duration_ms: Option<u64>,
243        position_ms: Option<u64>,
244    },
245
246    // Text input auto-scroll diagnostics
247    TextInputAutoScroll {
248        scroll_id: u128,
249        text_id: u128,
250        text_len: u32,
251        measured_w: f32,
252        line_h: f32,
253        viewport_x: f32,
254        viewport_w: f32,
255        content_w: f32,
256        caret_abs_x: f32,
257        offset_before: f32,
258        offset_after: f32,
259    },
260
261    // General scrolling diagnostics
262    ScrollExtent {
263        node: u128,
264        viewport_w: f32,
265        viewport_h: f32,
266        content_w: f32,
267        content_h: f32,
268        note: Option<String>,
269    },
270    ScrollUpdate {
271        node: u128,
272        axis: String,
273        point_x: f32,
274        point_y: f32,
275        delta: f32,
276        old_offset: f32,
277        new_offset: f32,
278        max_offset: f32,
279        viewport_w: f32,
280        viewport_h: f32,
281        content_w: f32,
282        content_h: f32,
283    },
284    ScrollPaintTranslate {
285        node: u128,
286        axis: String,
287        offset: f32,
288        translate_x: f32,
289        translate_y: f32,
290    },
291    TextLayoutPerformance {
292        text_len: u32,
293        is_rich: bool,
294        duration_ns: u64,
295    },
296}
297
298/// Summary statistics for a completed frame, attached to [`DiagEventKind::FrameEnd`].
299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct FrameStats {
301    pub dirty_nodes: u32,
302    pub layout_updates: u32,
303    pub paint_misses: u32,
304    pub paint_hits: u32,
305    pub video_surfaces: u32,
306}
307
308/// Configuration for the diagnostics system.
309///
310/// Controls which categories and levels are emitted, the output sink, and
311/// the sampling rate.
312#[derive(Debug, Clone)]
313pub struct DiagnosticsConfig {
314    pub enabled_categories: BTreeSet<DiagCategory>,
315    pub min_level: DiagLevel,
316    pub sink: DiagSink,
317    pub sampling: f32,
318}
319
320impl Default for DiagnosticsConfig {
321    fn default() -> Self {
322        Self {
323            enabled_categories: BTreeSet::new(),
324            min_level: DiagLevel::Error,
325            sink: DiagSink::Stdout,
326            sampling: 1.0,
327        }
328    }
329}
330
331// --------- Sinks ---------
332
333/// Output destination for diagnostic events.
334#[derive(Debug, Clone)]
335pub enum DiagSink {
336    Stdout,
337    File(PathBuf),
338    RingBuffer(usize),
339    Disabled,
340}
341
342trait SinkImpl: Send + Sync {
343    fn write(&self, event: &DiagEvent);
344}
345
346struct StdoutSinkImpl;
347impl SinkImpl for StdoutSinkImpl {
348    fn write(&self, event: &DiagEvent) {
349        // JSONL for stable tooling integration
350        let _ = serde_json::to_string(event).map(|line| println!("{}", line));
351    }
352}
353
354struct FileSinkImpl {
355    file: RwLock<File>,
356}
357impl SinkImpl for FileSinkImpl {
358    fn write(&self, event: &DiagEvent) {
359        if let Ok(s) = serde_json::to_string(event) {
360            let mut f = self.file.write();
361            let _ = f.write_all(s.as_bytes());
362            let _ = f.write_all(b"\n");
363        }
364    }
365}
366
367struct RingBufferSinkImpl {
368    // very simple ring buffer of JSON strings for now
369    buf: RwLock<Vec<String>>,
370    cap: usize,
371}
372impl SinkImpl for RingBufferSinkImpl {
373    fn write(&self, event: &DiagEvent) {
374        if let Ok(s) = serde_json::to_string(event) {
375            let mut w = self.buf.write();
376            if w.len() >= self.cap {
377                w.remove(0);
378            }
379            w.push(s);
380        }
381    }
382}
383
384// --------- Global Diagnostics ---------
385
386struct DiagnosticsInner {
387    config: DiagnosticsConfig,
388    sink_impl: Box<dyn SinkImpl>,
389    frame_no: AtomicU64,
390    timestamp_ns: AtomicU64,
391}
392
393impl DiagnosticsInner {
394    fn should_emit(&self, cat: &DiagCategory, level: DiagLevel) -> bool {
395        if matches!(self.config.sink, DiagSink::Disabled) {
396            return false;
397        }
398        if !self.config.enabled_categories.contains(cat) {
399            return false;
400        }
401        self.config.min_level.allows(level)
402    }
403}
404
405static DIAGNOSTICS: OnceCell<RwLock<DiagnosticsInner>> = OnceCell::new();
406
407/// Initialize the diagnostics system from environment variables.
408///
409/// Reads `FISSION_DIAG` (categories), `FISSION_DIAG_LEVEL`, `FISSION_DIAG_SINK`,
410/// and `FISSION_DIAG_SAMPLING`. See the crate-level documentation for details.
411pub fn init_from_env() {
412    // Categories
413    let cats = std::env::var("FISSION_DIAG").unwrap_or_default();
414    let enabled_categories: BTreeSet<DiagCategory> = cats
415        .split(',')
416        .filter_map(|s| match s.trim().to_lowercase().as_str() {
417            "frame" => Some(DiagCategory::Frame),
418            "diff" => Some(DiagCategory::Diff),
419            "layout" => Some(DiagCategory::Layout),
420            "paint" => Some(DiagCategory::Paint),
421            "raster" => Some(DiagCategory::Raster),
422            "input" => Some(DiagCategory::Input),
423            "semantics" => Some(DiagCategory::Semantics),
424            "animation" => Some(DiagCategory::Animation),
425            "media" => Some(DiagCategory::Media),
426            "invariants" => Some(DiagCategory::Invariants),
427            "test" => Some(DiagCategory::Test),
428            "*" => None, // handled below
429            _ => None,
430        })
431        .collect();
432
433    // Level
434    let min_level = match std::env::var("FISSION_DIAG_LEVEL")
435        .unwrap_or_default()
436        .to_lowercase()
437        .as_str()
438    {
439        "error" => DiagLevel::Error,
440        "warn" => DiagLevel::Warn,
441        "info" => DiagLevel::Info,
442        "debug" => DiagLevel::Debug,
443        "trace" => DiagLevel::Trace,
444        _ => DiagLevel::Warn,
445    };
446
447    // Sink
448    let sink_env = std::env::var("FISSION_DIAG_SINK").unwrap_or_default();
449    let sink = if sink_env.starts_with("file:") {
450        DiagSink::File(PathBuf::from(sink_env.trim_start_matches("file:")))
451    } else if sink_env.starts_with("ipc:") {
452        // Not implemented v1; fallback to stdout
453        DiagSink::Stdout
454    } else if sink_env == "stdout" || sink_env.is_empty() {
455        DiagSink::Stdout
456    } else {
457        DiagSink::Disabled
458    };
459
460    let sampling = std::env::var("FISSION_DIAG_SAMPLING")
461        .ok()
462        .and_then(|s| s.parse::<f32>().ok())
463        .unwrap_or(1.0);
464
465    let mut cfg = DiagnosticsConfig {
466        enabled_categories,
467        min_level,
468        sink,
469        sampling,
470    };
471
472    // Handle wildcard * for categories (enable all)
473    if cats.split(',').any(|s| s.trim() == "*") {
474        cfg.enabled_categories = [
475            DiagCategory::Frame,
476            DiagCategory::Diff,
477            DiagCategory::Layout,
478            DiagCategory::Paint,
479            DiagCategory::Raster,
480            DiagCategory::Input,
481            DiagCategory::Semantics,
482            DiagCategory::Animation,
483            DiagCategory::Media,
484            DiagCategory::Invariants,
485            DiagCategory::Test,
486        ]
487        .into_iter()
488        .collect();
489    }
490
491    init(cfg);
492}
493
494/// Initialize the diagnostics system with the given configuration.
495///
496/// Can only be called once (uses `OnceCell`). Subsequent calls are silently ignored.
497pub fn init(config: DiagnosticsConfig) {
498    let sink_impl: Box<dyn SinkImpl> = match &config.sink {
499        DiagSink::Stdout => Box::new(StdoutSinkImpl),
500        DiagSink::File(path) => {
501            let file = OpenOptions::new()
502                .create(true)
503                .append(true)
504                .open(path)
505                .unwrap();
506            Box::new(FileSinkImpl {
507                file: RwLock::new(file),
508            })
509        }
510        DiagSink::RingBuffer(cap) => Box::new(RingBufferSinkImpl {
511            buf: RwLock::new(Vec::with_capacity(*cap)),
512            cap: *cap,
513        }),
514        DiagSink::Disabled => Box::new(StdoutSinkImpl), // won't be used
515    };
516
517    let inner = DiagnosticsInner {
518        config,
519        sink_impl,
520        frame_no: AtomicU64::new(0),
521        timestamp_ns: AtomicU64::new(0),
522    };
523    let _ = DIAGNOSTICS.set(RwLock::new(inner));
524}
525
526fn with_diag_mut<T>(f: impl FnOnce(&mut DiagnosticsInner) -> T) -> Option<T> {
527    DIAGNOSTICS.get().map(|cell| {
528        let mut guard = cell.write();
529        f(&mut *guard)
530    })
531}
532
533/// Mark the start of a new frame. Increments the frame counter and emits a
534/// [`DiagEventKind::FrameStart`] event.
535pub fn begin_frame(root: Option<u128>) {
536    let _ = with_diag_mut(|d| {
537        let ts = d.timestamp_ns.fetch_add(16666666, Ordering::Relaxed) + 1; // ~60fps increment
538        let fno = d.frame_no.fetch_add(1, Ordering::Relaxed) + 1;
539        let ev = DiagEvent {
540            schema_version: 1,
541            timestamp_ns: ts,
542            frame_no: fno,
543            category: DiagCategory::Frame,
544            level: DiagLevel::Debug,
545            event: DiagEventKind::FrameStart { root },
546        };
547        if d.should_emit(&ev.category, ev.level) {
548            d.sink_impl.write(&ev);
549        }
550    });
551}
552
553/// Mark the end of the current frame, attaching the given [`FrameStats`].
554pub fn end_frame(stats: FrameStats) {
555    let _ = with_diag_mut(|d| {
556        let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
557        let fno = d.frame_no.load(Ordering::Relaxed);
558        let ev = DiagEvent {
559            schema_version: 1,
560            timestamp_ns: ts,
561            frame_no: fno,
562            category: DiagCategory::Frame,
563            level: DiagLevel::Debug,
564            event: DiagEventKind::FrameEnd { stats },
565        };
566        if d.should_emit(&ev.category, ev.level) {
567            d.sink_impl.write(&ev);
568        }
569    });
570}
571
572/// Emit a diagnostic event if the given category and level pass the current filter.
573///
574/// This is the primary entry point for all diagnostic events. The event is
575/// automatically timestamped and tagged with the current frame number.
576pub fn emit(category: DiagCategory, level: DiagLevel, event: DiagEventKind) {
577    let _ = with_diag_mut(|d| {
578        if !d.should_emit(&category, level) {
579            return;
580        }
581        let ts = d.timestamp_ns.fetch_add(1, Ordering::Relaxed) + 1;
582        let fno = d.frame_no.load(Ordering::Relaxed);
583        let ev = DiagEvent {
584            schema_version: 1,
585            timestamp_ns: ts,
586            frame_no: fno,
587            category,
588            level,
589            event,
590        };
591        d.sink_impl.write(&ev);
592    });
593}
594
595/// Convenience re-exports for common diagnostic operations.
596pub mod prelude {
597    pub use super::{
598        begin_frame, emit, end_frame, init_from_env, DiagCategory, DiagEventKind, DiagLevel,
599        FrameStats,
600    };
601}
602
603// --------- Snapshot Provider (v1 minimal) ---------
604
605/// The type of snapshot that a [`SnapshotProvider`] can produce.
606#[derive(Debug, Clone, Copy)]
607pub enum SnapshotKind {
608    Layout,
609}
610
611/// A serialized snapshot blob containing JSON data.
612#[derive(Debug, Clone)]
613pub struct SnapshotBlob {
614    pub kind: SnapshotKind,
615    pub json: String,
616}
617
618/// Trait for components that can produce a JSON snapshot of their internal state.
619pub trait SnapshotProvider {
620    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob>;
621}
622
623#[cfg(test)]
624mod tests {
625    use super::DiagEventKind;
626
627    #[test]
628    fn renderer_selected_diagnostic_serializes_renderer_identity() {
629        let event = DiagEventKind::RendererSelected {
630            active: "webgpu-vello".to_string(),
631            requested: "auto".to_string(),
632            backend: Some("BrowserWebGpu".to_string()),
633            adapter: Some("Chrome".to_string()),
634            fallback_reason: None,
635            width: 1280,
636            height: 720,
637            scale_factor: 2.0,
638        };
639        let json = serde_json::to_string(&event).expect("serialize renderer diagnostic");
640        assert!(json.contains("RendererSelected"));
641        assert!(json.contains("webgpu-vello"));
642        assert!(json.contains("BrowserWebGpu"));
643    }
644}