servo-metrics 0.7.0

A component of the servo web-engine.
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

use std::cell::Cell;
use std::cmp::Ordering;
use std::time::Duration;

use malloc_size_of_derive::MallocSizeOf;
use profile_traits::time::{
    ProfilerCategory, ProfilerChan, TimerMetadata, TimerMetadataFrameType, TimerMetadataReflowType,
    send_profile_data,
};
use script_traits::ProgressiveWebMetricType;
use servo_base::cross_process_instant::CrossProcessInstant;
use servo_base::id::LCPCandidateID;
use servo_config::opts::{self, DiagnosticsLoggingOption};
use servo_url::ServoUrl;

/// TODO make this configurable
/// maximum task time is 50ms (in ns)
pub const MAX_TASK_NS: u128 = 50000000;
/// 10 second window
const INTERACTIVE_WINDOW_SECONDS: Duration = Duration::from_secs(10);

pub trait ToMs<T> {
    fn to_ms(&self) -> T;
}

impl ToMs<f64> for u64 {
    fn to_ms(&self) -> f64 {
        *self as f64 / 1000000.
    }
}

fn set_metric(
    pwm: &ProgressiveWebMetrics,
    metadata: Option<TimerMetadata>,
    metric_type: ProgressiveWebMetricType,
    category: ProfilerCategory,
    attr: &Cell<Option<CrossProcessInstant>>,
    metric_time: CrossProcessInstant,
    url: &ServoUrl,
) {
    attr.set(Some(metric_time));

    // Send the metric to the time profiler.
    send_profile_data(
        category,
        metadata,
        pwm.time_profiler_chan(),
        metric_time,
        metric_time,
    );

    if opts::get()
        .debug
        .is_enabled(DiagnosticsLoggingOption::ProgressiveWebMetrics)
    {
        let navigation_start = pwm
            .navigation_start()
            .unwrap_or_else(CrossProcessInstant::epoch);
        let duration = (metric_time - navigation_start).as_seconds_f64();
        println!("{url:?} {metric_type:?} {duration:?}s");
    }
}

/// A data structure to track web metrics dfined in various specifications:
///
///  - <https://w3c.github.io/paint-timing/>
///  - <https://github.com/WICG/time-to-interactive> / <https://github.com/GoogleChrome/lighthouse/issues/27>
///
///  We can look at three different metrics here:
///    - navigation start -> visually ready (dom content loaded)
///    - navigation start -> thread ready (main thread available)
///    - visually ready -> thread ready
#[derive(MallocSizeOf)]
pub struct ProgressiveWebMetrics {
    /// Whether or not this metric is for an `<iframe>` or a top level frame.
    frame_type: TimerMetadataFrameType,
    /// when we navigated to the page
    navigation_start: Option<CrossProcessInstant>,
    /// indicates if the page is visually ready
    dom_content_loaded: Cell<Option<CrossProcessInstant>>,
    /// main thread is available -- there's been a 10s window with no tasks longer than 50ms
    main_thread_available: Cell<Option<CrossProcessInstant>>,
    // max(main_thread_available, dom_content_loaded)
    time_to_interactive: Cell<Option<CrossProcessInstant>>,
    /// The first paint of a particular document.
    /// TODO(mrobinson): It's unclear if this particular metric is reflected in the specification.
    ///
    /// See <https://w3c.github.io/paint-timing/#sec-reporting-paint-timing>.
    first_paint: Cell<Option<CrossProcessInstant>>,
    /// The first "contentful" paint of a particular document.
    ///
    /// See <https://w3c.github.io/paint-timing/#first-contentful-paint>
    first_contentful_paint: Cell<Option<CrossProcessInstant>>,
    /// The time at which the largest contentful paint was rendered.
    ///
    /// See <https://www.w3.org/TR/largest-contentful-paint/>
    largest_contentful_paint: Cell<Option<CrossProcessInstant>>,
    time_profiler_chan: ProfilerChan,
    url: ServoUrl,
}

#[derive(Clone, Copy, Debug, MallocSizeOf)]
pub struct InteractiveWindow {
    start: CrossProcessInstant,
}

impl Default for InteractiveWindow {
    fn default() -> Self {
        Self {
            start: CrossProcessInstant::now(),
        }
    }
}

impl InteractiveWindow {
    // We need to either start or restart the 10s window
    //   start: we've added a new document
    //   restart: there was a task > 50ms
    //   not all documents are interactive
    pub fn start_window(&mut self) {
        self.start = CrossProcessInstant::now();
    }

    /// check if 10s has elapsed since start
    pub fn needs_check(&self) -> bool {
        CrossProcessInstant::now() - self.start > INTERACTIVE_WINDOW_SECONDS
    }

    pub fn get_start(&self) -> CrossProcessInstant {
        self.start
    }
}

#[derive(Debug)]
pub enum InteractiveFlag {
    DOMContentLoaded,
    TimeToInteractive(CrossProcessInstant),
}

impl ProgressiveWebMetrics {
    pub fn new(
        time_profiler_chan: ProfilerChan,
        url: ServoUrl,
        frame_type: TimerMetadataFrameType,
    ) -> ProgressiveWebMetrics {
        ProgressiveWebMetrics {
            frame_type,
            navigation_start: None,
            dom_content_loaded: Cell::new(None),
            main_thread_available: Cell::new(None),
            time_to_interactive: Cell::new(None),
            first_paint: Cell::new(None),
            first_contentful_paint: Cell::new(None),
            largest_contentful_paint: Cell::new(None),
            time_profiler_chan,
            url,
        }
    }

    fn make_metadata(&self, first_reflow: bool) -> TimerMetadata {
        TimerMetadata {
            url: self.url.to_string(),
            iframe: self.frame_type.clone(),
            incremental: match first_reflow {
                true => TimerMetadataReflowType::FirstReflow,
                false => TimerMetadataReflowType::Incremental,
            },
        }
    }

    pub fn set_dom_content_loaded(&self) {
        if self.dom_content_loaded.get().is_none() {
            self.dom_content_loaded
                .set(Some(CrossProcessInstant::now()));
        }
    }

    pub fn set_main_thread_available(&self, time: CrossProcessInstant) {
        if self.main_thread_available.get().is_none() {
            self.main_thread_available.set(Some(time));
        }
    }

    pub fn dom_content_loaded(&self) -> Option<CrossProcessInstant> {
        self.dom_content_loaded.get()
    }

    pub fn first_paint(&self) -> Option<CrossProcessInstant> {
        self.first_paint.get()
    }

    pub fn first_contentful_paint(&self) -> Option<CrossProcessInstant> {
        self.first_contentful_paint.get()
    }

    pub fn largest_contentful_paint(&self) -> Option<CrossProcessInstant> {
        self.largest_contentful_paint.get()
    }

    pub fn main_thread_available(&self) -> Option<CrossProcessInstant> {
        self.main_thread_available.get()
    }

    pub fn set_first_paint(&self, paint_time: CrossProcessInstant, first_reflow: bool) {
        set_metric(
            self,
            Some(self.make_metadata(first_reflow)),
            ProgressiveWebMetricType::FirstPaint,
            ProfilerCategory::TimeToFirstPaint,
            &self.first_paint,
            paint_time,
            &self.url,
        );
    }

    pub fn set_first_contentful_paint(&self, paint_time: CrossProcessInstant, first_reflow: bool) {
        set_metric(
            self,
            Some(self.make_metadata(first_reflow)),
            ProgressiveWebMetricType::FirstContentfulPaint,
            ProfilerCategory::TimeToFirstContentfulPaint,
            &self.first_contentful_paint,
            paint_time,
            &self.url,
        );
    }

    pub fn set_largest_contentful_paint(
        &self,
        id: LCPCandidateID,
        paint_time: CrossProcessInstant,
    ) {
        set_metric(
            self,
            Some(self.make_metadata(false)),
            ProgressiveWebMetricType::LargestContentfulPaint { id },
            ProfilerCategory::TimeToLargestContentfulPaint,
            &self.largest_contentful_paint,
            paint_time,
            &self.url,
        );
    }

    // can set either dlc or tti first, but both must be set to actually calc metric
    // when the second is set, set_tti is called with appropriate time
    pub fn maybe_set_tti(&self, metric: InteractiveFlag) {
        if self.get_tti().is_some() {
            return;
        }
        match metric {
            InteractiveFlag::DOMContentLoaded => self.set_dom_content_loaded(),
            InteractiveFlag::TimeToInteractive(time) => self.set_main_thread_available(time),
        }

        let dcl = self.dom_content_loaded.get();
        let mta = self.main_thread_available.get();
        let (dcl, mta) = match (dcl, mta) {
            (Some(dcl), Some(mta)) => (dcl, mta),
            _ => return,
        };
        let metric_time = match dcl.partial_cmp(&mta) {
            Some(Ordering::Less) => mta,
            Some(_) => dcl,
            None => panic!("no ordering possible. something bad happened"),
        };
        set_metric(
            self,
            Some(self.make_metadata(true)),
            ProgressiveWebMetricType::TimeToInteractive,
            ProfilerCategory::TimeToInteractive,
            &self.time_to_interactive,
            metric_time,
            &self.url,
        );
    }

    pub fn get_tti(&self) -> Option<CrossProcessInstant> {
        self.time_to_interactive.get()
    }

    pub fn needs_tti(&self) -> bool {
        self.get_tti().is_none()
    }

    pub fn navigation_start(&self) -> Option<CrossProcessInstant> {
        self.navigation_start
    }

    pub fn set_navigation_start(&mut self, time: CrossProcessInstant) {
        self.navigation_start = Some(time);
    }

    pub fn time_profiler_chan(&self) -> &ProfilerChan {
        &self.time_profiler_chan
    }
}

#[cfg(test)]
mod test {
    use servo_base::generic_channel;

    use super::*;

    fn test_metrics() -> ProgressiveWebMetrics {
        let (sender, _) = generic_channel::channel().unwrap();
        let profiler_chan = ProfilerChan(Some(sender));
        let mut metrics = ProgressiveWebMetrics::new(
            profiler_chan,
            ServoUrl::parse("about:blank").unwrap(),
            TimerMetadataFrameType::RootWindow,
        );

        assert!((&metrics).navigation_start().is_none());
        assert!(metrics.get_tti().is_none());
        assert!(metrics.first_contentful_paint().is_none());
        assert!(metrics.first_paint().is_none());

        metrics.set_navigation_start(CrossProcessInstant::now());

        metrics
    }

    #[test]
    fn test_set_dcl() {
        let metrics = test_metrics();
        metrics.maybe_set_tti(InteractiveFlag::DOMContentLoaded);
        let dcl = metrics.dom_content_loaded();
        assert!(dcl.is_some());

        // try to overwrite
        metrics.maybe_set_tti(InteractiveFlag::DOMContentLoaded);
        assert_eq!(metrics.dom_content_loaded(), dcl);
        assert_eq!(metrics.get_tti(), None);
    }

    #[test]
    fn test_set_mta() {
        let metrics = test_metrics();
        let now = CrossProcessInstant::now();
        metrics.maybe_set_tti(InteractiveFlag::TimeToInteractive(now));
        let main_thread_available_time = metrics.main_thread_available();
        assert!(main_thread_available_time.is_some());
        assert_eq!(main_thread_available_time, Some(now));

        // try to overwrite
        metrics.maybe_set_tti(InteractiveFlag::TimeToInteractive(
            CrossProcessInstant::now(),
        ));
        assert_eq!(metrics.main_thread_available(), main_thread_available_time);
        assert_eq!(metrics.get_tti(), None);
    }

    #[test]
    fn test_set_tti_dcl() {
        let metrics = test_metrics();
        let now = CrossProcessInstant::now();
        metrics.maybe_set_tti(InteractiveFlag::TimeToInteractive(now));
        let main_thread_available_time = metrics.main_thread_available();
        assert!(main_thread_available_time.is_some());

        metrics.maybe_set_tti(InteractiveFlag::DOMContentLoaded);
        let dom_content_loaded_time = metrics.dom_content_loaded();
        assert!(dom_content_loaded_time.is_some());

        assert_eq!(metrics.get_tti(), dom_content_loaded_time);
    }

    #[test]
    fn test_set_tti_mta() {
        let metrics = test_metrics();
        metrics.maybe_set_tti(InteractiveFlag::DOMContentLoaded);
        let dcl = metrics.dom_content_loaded();
        assert!(dcl.is_some());

        let time = CrossProcessInstant::now();
        metrics.maybe_set_tti(InteractiveFlag::TimeToInteractive(time));
        let mta = metrics.main_thread_available();
        assert!(mta.is_some());

        assert_eq!(metrics.get_tti(), mta);
    }

    #[test]
    fn test_first_paint_setter() {
        let metrics = test_metrics();
        metrics.set_first_paint(CrossProcessInstant::now(), false);
        assert!(metrics.first_paint().is_some());
    }

    #[test]
    fn test_first_contentful_paint_setter() {
        let metrics = test_metrics();
        metrics.set_first_contentful_paint(CrossProcessInstant::now(), false);
        assert!(metrics.first_contentful_paint().is_some());
    }
}