diff --git a/.cargo/config.toml b/.cargo/config.toml index 2ea92d5062..690508a55d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,5 @@ [alias] +generate-rust-dashboards = ["run", "-p", "generate-rust-dashboards"] regen-protobufs = ["run", "--bin", "protobuf-gen", "tools/protobuf_files.toml"] uniffi-bindgen = ["run", "--package", "embedded-uniffi-bindgen", "--"] uniffi-bindgen-library-mode = ["run", "--package", "uniffi-bindgen-library-mode", "--"] diff --git a/Cargo.lock b/Cargo.lock index 15d328eda9..cb64f5daa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,6 +1696,17 @@ dependencies = [ "viaduct-dev", ] +[[package]] +name = "generate-rust-dashboards" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "clap", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.5" diff --git a/Cargo.toml b/Cargo.toml index d57acc0783..b5e3771e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "megazords/ios-rust", "megazords/ios-rust/focus", "tools/embedded-uniffi-bindgen", + "tools/generate-rust-dashboards", "tools/start-bindings", "tools/uniffi-bindgen-library-mode", "automation/swift-components-docs", diff --git a/tools/generate-rust-dashboards/Cargo.toml b/tools/generate-rust-dashboards/Cargo.toml new file mode 100644 index 0000000000..840c464347 --- /dev/null +++ b/tools/generate-rust-dashboards/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "generate-rust-dashboards" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +camino = "1" +clap = {version = "4.2", default-features = false, features = ["std", "derive"]} +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/tools/generate-rust-dashboards/README.md b/tools/generate-rust-dashboards/README.md new file mode 100644 index 0000000000..8f41b37d31 --- /dev/null +++ b/tools/generate-rust-dashboards/README.md @@ -0,0 +1,20 @@ +# Dashboard Generator + +Use this tool to create Grafana dashboards for your team's components. + +## Setup + +Ensure you have a yardstick account by going to https://yardstick.mozilla.org/ and logging in using Mozilla SSO. +You should have “editor” access and can create, edit, and delete dashboards and alerts. If not, go +to https://mozilla-hub.atlassian.net/wiki/spaces/SRE/pages/886866077/Yardstick+Grafana+Service+User+Guide +for help. + +## Configuration + +Edit `src/component_config.rs` add a `Component` variant for each of your team's components. +Edit `src/team_config.rs` and add an entry for your team. +Feel free to copy the and paste other team's configurations to get started. + +## Running + +Run `cargo generate-rust-dashboards [team-name] [output-directory]` and follow the instructions. diff --git a/tools/generate-rust-dashboards/src/component_config.rs b/tools/generate-rust-dashboards/src/component_config.rs new file mode 100644 index 0000000000..f9dc2a0b29 --- /dev/null +++ b/tools/generate-rust-dashboards/src/component_config.rs @@ -0,0 +1,79 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::config::{Application, Application::*}; + +/// Enumeration containing all Rust components. +/// When adding new variants, make sure to also update the impl block below +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Component { + Autofill, + Fxa, + Logins, + Places, + RemoteSettings, + Suggest, + Tabs, +} + +impl Component { + /// Unique name for the component in slug format (lower-case letters + dashes). + pub fn slug(&self) -> &'static str { + match self { + Self::Autofill => "autofill", + Self::Fxa => "fxa", + Self::Logins => "logins", + Self::Places => "places", + Self::RemoteSettings => "remote-settings", + Self::Suggest => "suggest", + Self::Tabs => "tabs", + } + } + + /// Applications your component ships on + pub fn applications(&self) -> &[Application] { + match self { + Self::Autofill => &[Android, Ios], + Self::Fxa => &[Android, Ios], + Self::Logins => &[Desktop, Android, Ios], + Self::Places => &[Android, Ios], + Self::RemoteSettings => &[Desktop, Android, Ios], + Self::Suggest => &[Desktop, Android, Ios], + Self::Tabs => &[Desktop, Android, Ios], + } + } + + /// Prefix for error strings. + /// + /// This is the common prefix for strings sent to the `error_support`. You can usually find it + /// by going to `error.rs` for your component and looking at the `report_error` calls. + pub fn error_prefix(&self) -> &'static str { + match self { + Self::Autofill => "autofill-", + Self::Fxa => "fxa-client-", + Self::Logins => "logins-", + Self::Places => "places-", + Self::RemoteSettings => "remote-settings-", + Self::Suggest => "suggest-", + Self::Tabs => "tabs-", + } + } + + /// Sync engine names + /// + /// These represent 2 things: + /// - The Glean pings for the component without the `-sync` suffix. + /// - The `engine.name` value for the legacy `telemetry.sync` table. + pub fn sync_engines(&self) -> &[&'static str] { + match self { + Self::Autofill => &["addresses", "creditcards"], + Self::Fxa => &[], + Self::Logins => &["logins"], + Self::Places => &["bookmarks", "history"], + Self::RemoteSettings => &[], + Self::Suggest => &[], + Self::Tabs => &["tabs"], + } + } +} diff --git a/tools/generate-rust-dashboards/src/config.rs b/tools/generate-rust-dashboards/src/config.rs new file mode 100644 index 0000000000..3df9bb5348 --- /dev/null +++ b/tools/generate-rust-dashboards/src/config.rs @@ -0,0 +1,250 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use std::{collections::BTreeSet, fmt}; + +pub use crate::component_config::Component; +use crate::util::slug; + +/// Dashboard configuration for a team +pub struct TeamConfig { + /// Display name for the team. + /// + /// This is what shows up on your dashboard titles. Spell it out however you want. + pub team_name: &'static str, + + /// Components that your team manages. + pub components: Vec, + + /// Track component errors + /// + /// This adds a panel to the main dashboard as well as creates a extra dashboard for error + /// details. + pub component_errors: bool, + + /// Track sync metrics + /// + /// This adds a panel to the main dashboard as well as creates a extra dashboard for sync + /// errors. + pub sync_metrics: bool, + + /// Metric to include on your main dashboard + pub main_dashboard_metrics: Vec, + + /// Extra dashboards to generate + pub extra_dashboards: Vec, +} + +/// Extra dashboard to generate for a team +pub struct ExtraDashboard { + pub name: &'static str, + /// Metrics to include in the dashboard + pub metrics: Vec, +} + +/// Metric to add to your team dashboard +/// +/// Metrics will add panels to your overview dashboard and/or create secondary dashboards. +pub enum Metric { + Counter(CounterMetric), + LabeledCounter(LabeledCounterMetric), + Distribution(DistributionMetric), + LabeledDistribution(LabeledDistributionMetric), +} + +/// Glean counter +/// +/// This will create time-series panels for the counter +pub struct CounterMetric { + /// Name to display on the dashboard + pub display_name: &'static str, + /// Name of the ping ("metrics" by default) + pub ping: &'static str, + /// Category name (top-level key in metrics.yaml) + pub category: &'static str, + /// Metric name (key for the metric) + pub metric: &'static str, + // Which applications report this metric + pub applications: Vec, +} + +/// Glean labeled counter +/// +/// This will create time-series panels for the counter, partitioned by the label +pub struct LabeledCounterMetric { + /// Name to display on the dashboard + pub display_name: &'static str, + /// Name of the ping ("metrics" by default) + pub ping: &'static str, + /// Category name (top-level key in metrics.yaml) + pub category: &'static str, + /// Metric name (key for the metric) + pub metric: &'static str, + // Which applications report this metric + pub applications: Vec, +} + +/// Glean timing/memory distribution +/// +/// This will create time-series panels for the 5th, 50th and 95th percentile. +pub struct DistributionMetric { + pub kind: DistributionMetricKind, + /// Name to display on the dashboard + pub display_name: &'static str, + /// Label describing what we're measure, including units + pub axis_label: &'static str, + /// Name of the ping ("metrics" by default) + pub ping: &'static str, + /// Category name (top-level key in metrics.yaml) + pub category: &'static str, + /// Metric name (key for the metric) + pub metric: &'static str, + // Which applications report this metric + pub applications: Vec, + // Divide each value by this amount + // + // Note: + // * Timing distributions are always stored in nanoseconds, regardless of the unit listed in + // `metrics.yaml` + // * Memory distributions are always stored in bytes, regardless of the unit listed in + // `metrics.yaml` + pub value_divisor: Option, + // Filter out values lower than this amount (takes effect before the divisor) + pub value_filter: Option, + // Link to an extra dashboard, the inner value is the name of the dashboard + pub link_to: Option<&'static str>, +} + +/// Glean labeled timing/memory distribution +/// +/// This will create time-series panels for the 5th, 50th and 95th percentile. +/// Percentiles will be partitioned by the metric label. +pub struct LabeledDistributionMetric { + pub kind: DistributionMetricKind, + /// Name to display on the dashboard + pub display_name: &'static str, + /// Label describing what we're measure, including units + pub axis_label: &'static str, + /// Name of the ping ("metrics" by default) + pub ping: &'static str, + /// Category name (top-level key in metrics.yaml) + pub category: &'static str, + /// Metric name (key for the metric) + pub metric: &'static str, + // Which applications report this metric + pub applications: Vec, + // Divide each value by this amount + // + // Note: + // * Timing distributions are always stored in nanoseconds, regardless of the unit listed in + // `metrics.yaml` + // * Memory distributions are always stored in bytes, regardless of the unit listed in + // `metrics.yaml` + pub value_divisor: Option, + // Filter out values lower than this amount (takes effect before the divisor) + pub value_filter: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DistributionMetricKind { + Memory, + Timing, + Custom, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Application { + Android, + Ios, + Desktop, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ReleaseChannel { + Nightly, + Beta, + Release, +} + +impl TeamConfig { + pub fn applications(&self) -> BTreeSet { + self.components + .iter() + .flat_map(Component::applications) + .cloned() + .collect() + } + + pub fn team_slug(&self) -> String { + slug(self.team_name) + } +} + +impl Application { + pub fn slug(&self) -> &'static str { + match self { + Self::Android => "android", + Self::Ios => "ios", + Self::Desktop => "desktop", + } + } + + pub fn bigquery_dataset(&self) -> &'static str { + // There's a few datasets we can use, these were chosen because they seem to include + // release, beta, and nightly data + match self { + Self::Android => "fenix", + Self::Ios => "firefox_ios", + Self::Desktop => "firefox_desktop", + } + } + + pub fn display_name(&self, channel: ReleaseChannel) -> String { + format!("{self} ({channel})") + } +} + +impl fmt::Display for Application { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Android => write!(f, "Android"), + Self::Ios => write!(f, "iOS"), + Self::Desktop => write!(f, "Desktop"), + } + } +} + +impl fmt::Display for ReleaseChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nightly => write!(f, "nightly"), + Self::Beta => write!(f, "beta"), + Self::Release => write!(f, "release"), + } + } +} + +impl From for Metric { + fn from(m: CounterMetric) -> Self { + Self::Counter(m) + } +} + +impl From for Metric { + fn from(m: LabeledCounterMetric) -> Self { + Self::LabeledCounter(m) + } +} + +impl From for Metric { + fn from(m: DistributionMetric) -> Self { + Self::Distribution(m) + } +} + +impl From for Metric { + fn from(m: LabeledDistributionMetric) -> Self { + Self::LabeledDistribution(m) + } +} diff --git a/tools/generate-rust-dashboards/src/main.rs b/tools/generate-rust-dashboards/src/main.rs new file mode 100644 index 0000000000..254aaf4766 --- /dev/null +++ b/tools/generate-rust-dashboards/src/main.rs @@ -0,0 +1,91 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use std::fs; + +use anyhow::{anyhow, Result}; +use camino::Utf8PathBuf; +use clap::Parser; + +pub mod component_config; +pub mod config; +mod main_dashboard; +pub mod metrics; +pub mod schema; +mod sql; +mod team_config; +pub mod util; + +#[derive(Parser, Debug)] +#[clap(name = "generate-rust-dashboard")] +struct Cli { + /// Your team name + team_name: String, + + /// Directory to write JSON files to + output_dir: Utf8PathBuf, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let config = team_config::all_dashboards() + .into_iter() + .find(|d| { + d.team_name + .to_ascii_lowercase() + .contains(&cli.team_name.to_ascii_lowercase()) + }) + .ok_or_else(|| anyhow!("Dashboard not found: {}", cli.team_name))?; + + let mut main_dashboard_builder = main_dashboard::start_dashboard(&config); + let mut extra_dashboards: Vec = vec![]; + + if config.component_errors { + metrics::rust_component_errors::add_to_dashboard(&mut main_dashboard_builder, &config)?; + extra_dashboards.push(metrics::rust_component_errors::extra_dashboard(&config)?); + } + + if config.sync_metrics { + metrics::sync::add_to_main_dashboard(&mut main_dashboard_builder, &config)?; + extra_dashboards.push(metrics::sync::extra_dashboard(&config)?); + } + + for metric in config.main_dashboard_metrics.iter() { + metric.add_to_dashboard(&mut main_dashboard_builder, &config)?; + } + + for extra_dash_config in config.extra_dashboards.iter() { + let mut builder = schema::DashboardBuilder::new( + extra_dash_config.name, + util::slug(extra_dash_config.name), + ); + for metric in extra_dash_config.metrics.iter() { + metric.add_to_dashboard(&mut builder, &config)?; + } + extra_dashboards.push(builder.dashboard); + } + + if !cli.output_dir.exists() { + fs::create_dir_all(&cli.output_dir)?; + } + + println!(); + println!("Generating Dashboards:"); + let dashboards = std::iter::once(main_dashboard_builder.dashboard).chain(extra_dashboards); + for dashboard in dashboards { + let mut value = serde_json::to_value(&dashboard)?; + value.sort_all_objects(); + let content = serde_json::to_string_pretty(&value)?; + let path = cli.output_dir.join(format!("{}.json", dashboard.uid)); + fs::write(&path, content)?; + println!("{path}"); + } + + println!(); + println!("* Go to https://yardstick.mozilla.org/dashboards"); + println!("* Create/navigate to a folder for your team"); + println!("* Click New -> Import and import each generated JSON file"); + + Ok(()) +} diff --git a/tools/generate-rust-dashboards/src/main_dashboard.rs b/tools/generate-rust-dashboards/src/main_dashboard.rs new file mode 100644 index 0000000000..173414bff5 --- /dev/null +++ b/tools/generate-rust-dashboards/src/main_dashboard.rs @@ -0,0 +1,28 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Component, TeamConfig}, + schema::{CustomVariable, CustomVariableSelection, DashboardBuilder}, + util::Join, +}; + +pub fn start_dashboard(config: &TeamConfig) -> DashboardBuilder { + let mut builder = DashboardBuilder::new( + config.team_name.to_string(), + format!("{}-main", config.team_slug()), + ); + + // Components variable + builder.add_variable(CustomVariable { + label: "Components".into(), + name: "components".into(), + multi: true, + query: config.components.iter().map(Component::slug).join(","), + current: CustomVariableSelection::multi(config.components.iter().map(Component::slug)), + ..CustomVariable::default() + }); + + builder +} diff --git a/tools/generate-rust-dashboards/src/metrics/counter.rs b/tools/generate-rust-dashboards/src/metrics/counter.rs new file mode 100644 index 0000000000..973090d85f --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/counter.rs @@ -0,0 +1,70 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Application, CounterMetric, ReleaseChannel, TeamConfig}, + schema::{ + DashboardBuilder, Datasource, FieldConfig, FieldConfigCustom, FieldConfigDefaults, GridPos, + Panel, Target, TimeSeriesPanel, + }, + sql::Query, + Result, +}; + +pub fn add_to_dashboard( + builder: &mut DashboardBuilder, + _config: &TeamConfig, + metric: &CounterMetric, +) -> Result<()> { + builder.add_panel_title(metric.display_name); + for app in metric.applications.iter().cloned() { + builder.add_panel_third(count_panel(app, ReleaseChannel::Nightly, metric)); + builder.add_panel_third(count_panel(app, ReleaseChannel::Beta, metric)); + builder.add_panel_third(count_panel(app, ReleaseChannel::Release, metric)); + } + Ok(()) +} + +fn count_panel(application: Application, channel: ReleaseChannel, metric: &CounterMetric) -> Panel { + let CounterMetric { + ping, + category, + metric, + .. + } = *metric; + + let mut query = Query { + select: vec!["$__timeGroup(submission_timestamp, $__interval) as time".into()], + from: format!("`mozdata.{}.{ping}`", application.bigquery_dataset()), + where_: vec![ + "$__timeFilter(submission_timestamp)".into(), + format!("{ping}.counter.{category}_{metric} IS NOT NULL"), + format!("normalized_channel = '{channel}'"), + ], + group_by: Some("1".into()), + order_by: Some("time asc".into()), + ..Query::default() + }; + query.add_count_per_day_column(format!("SUM({ping}.counter.{category}_{metric})"), metric); + + TimeSeriesPanel { + title: application.display_name(channel), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + interval: "1h".into(), + targets: vec![Target::table(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links: vec![], + custom: FieldConfigCustom { + axis_label: "count / day".into(), + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![], + ..TimeSeriesPanel::default() + } + .into() +} diff --git a/tools/generate-rust-dashboards/src/metrics/distribution.rs b/tools/generate-rust-dashboards/src/metrics/distribution.rs new file mode 100644 index 0000000000..464a999303 --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/distribution.rs @@ -0,0 +1,142 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Application, DistributionMetric, DistributionMetricKind, TeamConfig}, + schema::{ + DashboardBuilder, DataLink, Datasource, FieldConfig, FieldConfigCustom, + FieldConfigDefaults, GridPos, Panel, Target, TimeSeriesPanel, Transformation, + }, + sql::Query, + util::{slug, UrlBuilder}, + Result, +}; + +pub fn add_to_dashboard( + builder: &mut DashboardBuilder, + _config: &TeamConfig, + metric: &DistributionMetric, +) -> Result<()> { + builder.add_panel_title(metric.display_name); + for app in metric.applications.iter().cloned() { + builder.add_panel_third(count_panel(app, metric, 5)); + builder.add_panel_third(count_panel(app, metric, 50)); + builder.add_panel_third(count_panel(app, metric, 95)); + } + Ok(()) +} + +fn count_panel(application: Application, metric: &DistributionMetric, percentile: u32) -> Panel { + let DistributionMetric { + kind, + ping, + category, + metric, + value_divisor, + value_filter, + axis_label, + link_to, + .. + } = *metric; + let dataset = application.bigquery_dataset(); + let metric_table = match kind { + DistributionMetricKind::Memory => "memory_distribution", + DistributionMetricKind::Timing => "timing_distribution", + DistributionMetricKind::Custom => "custom_distribution", + }; + + // Group metrics and calculate quantiles. + // q is a 20-quantile array (0%, 5%, ..., 95%, 100%) + let mut subquery = Query { + select: vec![ + "$__timeGroup(submission_timestamp, $__interval) as time".into(), + "normalized_channel".into(), + "APPROX_QUANTILES(CAST(values.key AS INT64), 20) as q".into(), + ], + from: format!("`mozdata.{dataset}.{ping}`"), + joins: vec![ + format!("CROSS JOIN UNNEST(metrics.{metric_table}.{category}_{metric}.values) as values"), + // Cross join with an array with length=values.value to make the APPROX_QUANTILES statement above work. + // Histogram metrics are stored in bigquery as a struct of key/value pairs. + // The key is the measurement value, while the value is the count. + // APPROX_QUANTILES expects to count single values, + // so use this CROSS JOIN to repeat each key `value` times. + "CROSS JOIN UNNEST(GENERATE_ARRAY(1, values.value)) AS repeat_number".into(), + ], + where_: vec![ + "$__timeFilter(submission_timestamp)".into(), + "(normalized_channel = 'nightly' OR normalized_channel = 'beta' OR normalized_channel = 'release')".into(), + ], + group_by: Some("1, 2".into()), + ..Query::default() + }; + + if let Some(amount) = value_filter { + subquery + .where_ + .push(format!("CAST(values.key AS INT64) >= {amount}")); + } + let mut query = Query { + select: vec!["time".into(), "normalized_channel".into()], + from: subquery.as_subquery(), + order_by: Some("time desc, normalized_channel asc".into()), + ..Query::default() + }; + + let quantile_index = percentile / 5; + match value_divisor { + None => { + query + .select + .extend([format!("q[OFFSET({quantile_index})] as amount")]); + } + Some(amount) => { + query + .select + .extend([format!("q[OFFSET({quantile_index})] / {amount} as amount")]); + } + } + + let mut links = vec![]; + if let Some(link_to) = link_to { + links.push(DataLink { + url: UrlBuilder::new_dashboard(slug(link_to)) + .with_time_range_param() + .build(), + target_blank: true, + one_click: true, + title: "Details".into(), + }); + } + + TimeSeriesPanel { + title: format!("{application} ({percentile}th percentile)"), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + interval: "1d".into(), + targets: vec![Target::table(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links, + custom: FieldConfigCustom { + axis_label: axis_label.into(), + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![ + Transformation::PartitionByValues { + fields: vec!["normalized_channel".into()], + keep_fields: true, + }, + // Fixup the field names for better legend labels + Transformation::RenameByRegex { + regex: "amount (.*)".into(), + rename_pattern: "$1".into(), + }, + ], + ..TimeSeriesPanel::default() + } + .into() +} diff --git a/tools/generate-rust-dashboards/src/metrics/labeled_counter.rs b/tools/generate-rust-dashboards/src/metrics/labeled_counter.rs new file mode 100644 index 0000000000..af7e89d645 --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/labeled_counter.rs @@ -0,0 +1,83 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Application, LabeledCounterMetric, ReleaseChannel, TeamConfig}, + schema::{ + DashboardBuilder, Datasource, FieldConfig, FieldConfigCustom, FieldConfigDefaults, GridPos, + Panel, Target, TimeSeriesPanel, Transformation, + }, + sql::Query, + Result, +}; + +pub fn add_to_dashboard( + builder: &mut DashboardBuilder, + _config: &TeamConfig, + metric: &LabeledCounterMetric, +) -> Result<()> { + builder.add_panel_title(metric.display_name); + for app in metric.applications.iter().cloned() { + builder.add_panel_third(count_panel(app, ReleaseChannel::Nightly, metric)); + builder.add_panel_third(count_panel(app, ReleaseChannel::Beta, metric)); + builder.add_panel_third(count_panel(app, ReleaseChannel::Release, metric)); + } + Ok(()) +} + +fn count_panel( + application: Application, + channel: ReleaseChannel, + metric: &LabeledCounterMetric, +) -> Panel { + let LabeledCounterMetric { + ping, + category, + metric, + .. + } = *metric; + let dataset = application.bigquery_dataset(); + + let mut query = Query { + select: vec![ + "$__timeGroup(submission_timestamp, $__interval) as time".into(), + "counter.key as label".into(), + ], + from: format!("`mozdata.{dataset}.{ping}`"), + joins: vec![format!( + "CROSS JOIN UNNEST(metrics.labeled_counter.{category}_{metric}) as counter" + )], + where_: vec![ + "$__timeFilter(submission_timestamp)".into(), + format!("normalized_channel = '{channel}'"), + ], + group_by: Some("1, 2".into()), + order_by: Some("time asc, label asc".into()), + ..Query::default() + }; + query.add_count_per_day_column("SUM(counter.value)", metric); + + TimeSeriesPanel { + title: application.display_name(channel), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + interval: "1h".into(), + targets: vec![Target::table(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links: vec![], + custom: FieldConfigCustom { + axis_label: "count / day".into(), + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![Transformation::PartitionByValues { + fields: vec!["label".into()], + keep_fields: true, + }], + ..TimeSeriesPanel::default() + } + .into() +} diff --git a/tools/generate-rust-dashboards/src/metrics/labeled_distribution.rs b/tools/generate-rust-dashboards/src/metrics/labeled_distribution.rs new file mode 100644 index 0000000000..f6c42a87c7 --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/labeled_distribution.rs @@ -0,0 +1,133 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Application, DistributionMetricKind, LabeledDistributionMetric, TeamConfig}, + schema::{ + DashboardBuilder, Datasource, FieldConfig, FieldConfigCustom, FieldConfigDefaults, GridPos, + Panel, Target, TimeSeriesPanel, Transformation, + }, + sql::Query, + Result, +}; + +pub fn add_to_dashboard( + builder: &mut DashboardBuilder, + _config: &TeamConfig, + metric: &LabeledDistributionMetric, +) -> Result<()> { + builder.add_panel_title(metric.display_name); + for app in metric.applications.iter().cloned() { + builder.add_panel_third(count_panel(app, metric, 5)); + builder.add_panel_third(count_panel(app, metric, 50)); + builder.add_panel_third(count_panel(app, metric, 95)); + } + Ok(()) +} + +fn count_panel( + application: Application, + metric: &LabeledDistributionMetric, + percentile: u32, +) -> Panel { + let LabeledDistributionMetric { + kind, + ping, + category, + metric, + value_divisor, + value_filter, + axis_label, + .. + } = *metric; + let dataset = application.bigquery_dataset(); + let metric_table = match kind { + DistributionMetricKind::Memory => "labeled_memory_distribution", + DistributionMetricKind::Timing => "labeled_timing_distribution", + DistributionMetricKind::Custom => "labeled_custom_distribution", + }; + + // Group metrics and calculate quantiles. + // q is a 20-quantile array (0%, 5%, ..., 95%, 100%) + let mut subquery = Query { + select: vec![ + "$__timeGroup(submission_timestamp, $__interval) as time".into(), + "CONCAT(metric.key, ' ', normalized_channel) as group_name".into(), + "APPROX_QUANTILES(CAST(values.key AS INT64), 20) as q".into(), + ], + from: format!("`mozdata.{dataset}.{ping}`"), + joins: vec![ + format!("CROSS JOIN UNNEST(metrics.{metric_table}.{category}_{metric}) as metric"), + "CROSS JOIN UNNEST(metric.value.values) as values".into(), + // Cross join with an array with length=values.value to make the APPROX_QUANTILES statement above work. + // Histogram metrics are stored in bigquery as a struct of key/value pairs. + // The key is the measurement value, while the value is the count. + // APPROX_QUANTILES expects to count single values, + // so use this CROSS JOIN to repeat each key `value` times. + "CROSS JOIN UNNEST(GENERATE_ARRAY(1, values.value)) AS repeat_number".into(), + ], + where_: vec![ + "$__timeFilter(submission_timestamp)".into(), + "(normalized_channel = 'nightly' OR normalized_channel = 'beta' OR normalized_channel = 'release')".into(), + ], + group_by: Some("1, 2".into()), + ..Query::default() + }; + + if let Some(amount) = value_filter { + subquery + .where_ + .push(format!("CAST(values.key AS INT64) >= {amount}")); + } + let mut query = Query { + select: vec!["time".into(), "group_name".into()], + from: subquery.as_subquery(), + order_by: Some("time desc, group_name asc".into()), + ..Query::default() + }; + + let quantile_index = percentile / 5; + match value_divisor { + None => { + query + .select + .extend([format!("q[OFFSET({quantile_index})] as amount")]); + } + Some(amount) => { + query + .select + .extend([format!("q[OFFSET({quantile_index})] / {amount} as amount")]); + } + } + + TimeSeriesPanel { + title: format!("{application} ({percentile}th percentile)"), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + interval: "1d".into(), + targets: vec![Target::table(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links: vec![], + custom: FieldConfigCustom { + axis_label: axis_label.into(), + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![ + Transformation::PartitionByValues { + fields: vec!["group_name".into()], + keep_fields: true, + }, + // Fixup the field names for better legend labels + Transformation::RenameByRegex { + regex: "amount (.*)".into(), + rename_pattern: "$1".into(), + }, + ], + ..TimeSeriesPanel::default() + } + .into() +} diff --git a/tools/generate-rust-dashboards/src/metrics/mod.rs b/tools/generate-rust-dashboards/src/metrics/mod.rs new file mode 100644 index 0000000000..451a775502 --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/mod.rs @@ -0,0 +1,35 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +pub mod counter; +pub mod distribution; +pub mod labeled_counter; +pub mod labeled_distribution; +pub mod rust_component_errors; +pub mod sync; + +use crate::{ + config::{Metric, TeamConfig}, + schema::DashboardBuilder, + Result, +}; + +impl Metric { + pub fn add_to_dashboard( + &self, + builder: &mut DashboardBuilder, + config: &TeamConfig, + ) -> Result<()> { + match self { + Self::Counter(metric) => counter::add_to_dashboard(builder, config, metric), + Self::LabeledCounter(metric) => { + labeled_counter::add_to_dashboard(builder, config, metric) + } + Self::Distribution(metric) => distribution::add_to_dashboard(builder, config, metric), + Self::LabeledDistribution(metric) => { + labeled_distribution::add_to_dashboard(builder, config, metric) + } + } + } +} diff --git a/tools/generate-rust-dashboards/src/metrics/rust_component_errors.rs b/tools/generate-rust-dashboards/src/metrics/rust_component_errors.rs new file mode 100644 index 0000000000..88d82345eb --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/rust_component_errors.rs @@ -0,0 +1,238 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +//! Handles the `rust_component_errors` ping. + +use crate::{ + config::{Application, ReleaseChannel, TeamConfig}, + schema::{ + Dashboard, DashboardBuilder, DataLink, Datasource, FieldConfig, FieldConfigCustom, + FieldConfigDefaults, GridPos, LogPanel, Panel, QueryVariable, QueryVariableQuery, Target, + TimeSeriesPanel, Transformation, + }, + sql::Query, + util::UrlBuilder, + Result, +}; + +pub fn add_to_dashboard(builder: &mut DashboardBuilder, config: &TeamConfig) -> Result<()> { + builder.add_panel_title("Component Errors"); + + for app in config.applications().iter() { + builder.add_panel_third(count_panel(config, *app, ReleaseChannel::Nightly)); + builder.add_panel_third(count_panel(config, *app, ReleaseChannel::Beta)); + builder.add_panel_third(count_panel(config, *app, ReleaseChannel::Release)); + } + + Ok(()) +} + +fn count_panel(config: &TeamConfig, application: Application, channel: ReleaseChannel) -> Panel { + let mut query = Query { + prep_statements: error_type_re_prep_statements(config), + select: vec![ + "$__timeGroup(submission_timestamp, $__interval) as time".into(), + "metrics.string.rust_component_errors_error_type as error_type".into(), + ], + from: format!( + "mozdata.{}.rust_component_errors", + application.bigquery_dataset() + ), + where_: vec![ + format!("normalized_channel = '{channel}'"), + "$__timeFilter(submission_timestamp)".into(), + "metrics.string.rust_component_errors_error_type IS NOT NULL".into(), + "REGEXP_CONTAINS(metrics.string.rust_component_errors_error_type, error_type_re)" + .into(), + ], + group_by: Some("1, 2".into()), + order_by: Some("error_type, time".into()), + ..Query::default() + }; + query.add_count_per_day_column("COUNT(*)", "errors"); + + TimeSeriesPanel { + title: application.display_name(channel), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + interval: "1h".into(), + targets: vec![Target::table(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links: vec![DataLink { + url: UrlBuilder::new_dashboard(format!("{}-errors", config.team_slug())) + .with_time_range_param() + .with_param("var-application", application.slug()) + .with_param("var-channel", channel.to_string()) + .with_param("var-error_type", "${__data.fields[\"error_type\"]}") + .build(), + target_blank: true, + one_click: true, + title: "Error list".into(), + }], + custom: FieldConfigCustom { + axis_label: "errors / day".into(), + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![ + Transformation::PartitionByValues { + fields: vec!["error_type".into()], + keep_fields: true, + }, + // Fixup the field names for better legend labels + Transformation::RenameByRegex { + regex: "errors (.*)".into(), + rename_pattern: "$1".into(), + }, + ], + ..TimeSeriesPanel::default() + } + .into() +} + +pub fn extra_dashboard(config: &TeamConfig) -> Result { + let mut builder = DashboardBuilder::new( + format!("{} - Error List", config.team_name), + format!("{}-errors", config.team_slug()), + ); + builder.add_application_variable(config)?; + builder.add_channel_variable(); + builder.add_variable(error_type_variable()); + builder.add_filter_sql_variable(); + + builder.add_panel_full(error_list_count_panel()); + builder.add_panel_full(error_list_log_panel()); + + Ok(builder.dashboard) +} + +pub fn error_type_variable() -> QueryVariable { + let query = QueryVariableQuery::from_sql( + "\ +SELECT DISTINCT metrics.string.rust_component_errors_error_type +FROM mozdata.fenix.rust_component_errors +WHERE submission_timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 day) + AND metrics.string.rust_component_errors_error_type IS NOT NULL + AND metrics.string.rust_component_errors_error_type <> '' +ORDER BY metrics.string.rust_component_errors_error_type", + ); + + QueryVariable { + label: "Error type".into(), + name: "error_type".into(), + datasource: Datasource::bigquery(), + query, + ..QueryVariable::default() + } +} + +fn error_list_count_panel() -> Panel { + let mut query = Query { + select: vec!["$__timeGroup(submission_timestamp, $__interval) as time".into()], + where_: vec![ + "error_type='${error_type}'".into(), + "$__timeFilter(submission_timestamp)".into(), + "normalized_channel = '${channel}'".into(), + "${filter_sql}".into(), + ], + from: error_subquery().as_subquery(), + group_by: Some("1".into()), + order_by: Some("time DESC".into()), + ..Query::default() + }; + query.add_count_per_day_column("COUNT(*)", "errors"); + + TimeSeriesPanel { + title: "".into(), + grid_pos: GridPos::height(10), + datasource: Datasource::bigquery(), + interval: "30m".into(), + targets: vec![Target::timeseries(query.sql())], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + custom: FieldConfigCustom { + axis_label: "errors / day".into(), + ..FieldConfigCustom::default() + }, + ..FieldConfigDefaults::default() + }, + }, + ..TimeSeriesPanel::default() + } + .into() +} + +fn error_list_log_panel() -> Panel { + let mut query = Query { + select: vec![ + "CONCAT(error_type, ': ', details) as message".into(), + "error_type".into(), + "details".into(), + "ARRAY_TO_STRING(breadcrumbs, '\\n') as breadcrumbs".into(), + ], + where_: vec![ + "error_type='${error_type}'".into(), + "$__timeFilter(submission_timestamp)".into(), + "normalized_channel = '${channel}'".into(), + "${filter_sql}".into(), + ], + from: error_subquery().as_subquery(), + order_by: Some("submission_timestamp DESC".into()), + limit: Some(1000), + ..Query::default() + }; + query.add_standard_glean_columns_no_prefix(); + + LogPanel { + title: "Error list".into(), + grid_pos: GridPos::height(20), + datasource: Datasource::bigquery(), + targets: vec![Target::table(query.sql())], + ..LogPanel::default() + } + .into() +} + +// Select everything from `rust_component_errors_error_type`, but "flatten" the column names. +// +// This means `error_type` instead of `metrics.string.rust_component_errors_error_type`, which is +// needed to make the filters work. +fn error_subquery() -> Query { + let mut subquery = Query { + select: vec![ + "metrics.string.rust_component_errors_error_type as error_type".into(), + "metrics.string.rust_component_errors_details as details".into(), + "metrics.string_list.rust_component_errors_breadcrumbs as breadcrumbs".into(), + "normalized_channel".into(), + ], + ..Query::default() + }; + subquery.add_standard_glean_columns(); + subquery.add_from_using_application_var("rust_component_errors"); + subquery +} + +/// Bigquery statements to define the `error_type_re` variable +/// +/// This is a bigquery variable created from the `components` grafana variable. +/// We use it as a regex to match error pings against. +fn error_type_re_prep_statements(config: &TeamConfig) -> Vec { + // `error_type_re` variable; + let mut query_parts = vec![]; + query_parts.push("SELECT CASE value".into()); + for c in config.components.iter() { + query_parts.push(format!("WHEN '{}' THEN '^{}'", c.slug(), c.error_prefix())); + } + query_parts.push("END".into()); + query_parts.push("FROM UNNEST(SPLIT('${components:csv}', ',')) as value".into()); + vec![ + "DECLARE error_type_re STRING".into(), + format!( + "SET error_type_re = ARRAY_TO_STRING(ARRAY({}), '|')", + query_parts.join(" ") + ), + ] +} diff --git a/tools/generate-rust-dashboards/src/metrics/sync.rs b/tools/generate-rust-dashboards/src/metrics/sync.rs new file mode 100644 index 0000000000..468d439f0f --- /dev/null +++ b/tools/generate-rust-dashboards/src/metrics/sync.rs @@ -0,0 +1,269 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::{ + config::{Application, ReleaseChannel, TeamConfig}, + schema::{ + CustomVariable, Dashboard, DashboardBuilder, DataLink, Datasource, FieldConfig, + FieldConfigCustom, FieldConfigDefaults, GridPos, LogPanel, Panel, PieChartPanel, Target, + TimeSeriesPanel, Transformation, + }, + sql::Query, + util::{Join, UrlBuilder}, + Result, +}; + +pub fn add_to_main_dashboard(builder: &mut DashboardBuilder, config: &TeamConfig) -> Result<()> { + builder.add_panel_title("Sync"); + + for app in config.applications().iter() { + builder.add_panel_third(overview_count_panel(config, *app, ReleaseChannel::Nightly)); + builder.add_panel_third(overview_count_panel(config, *app, ReleaseChannel::Beta)); + builder.add_panel_third(overview_count_panel(config, *app, ReleaseChannel::Release)); + } + Ok(()) +} + +pub fn extra_dashboard(config: &TeamConfig) -> Result { + let mut builder = DashboardBuilder::new( + format!("{} - Sync Errors", config.team_name), + format!("{}-sync-errors", config.team_slug()), + ); + builder.add_application_variable(config)?; + builder.add_channel_variable(); + builder.add_variable(CustomVariable { + label: "Sync Engine".into(), + name: "engine".into(), + query: config + .components + .iter() + .flat_map(|a| a.sync_engines()) + .map(|s| s.to_string()) + .join(","), + ..CustomVariable::default() + }); + builder.add_filter_sql_variable(); + + builder.add_panel_full(error_list_count_panel(config)); + builder.add_panel_full(error_list_log_panel(config)); + + Ok(builder.dashboard) +} + +fn overview_count_panel( + config: &TeamConfig, + application: Application, + channel: ReleaseChannel, +) -> Panel { + let query = if application == Application::Desktop { + desktop_count_query(format!("'{channel}'")) + } else { + mobile_count_query(config, format!("'{channel}'")) + }; + + TimeSeriesPanel { + title: application.display_name(channel), + grid_pos: GridPos::height(8), + datasource: Datasource::bigquery(), + // needs to be fairly large since the total sync count can be low on mobile/nightly + interval: "1d".into(), + targets: vec![Target::table(query)], + field_config: FieldConfig { + defaults: FieldConfigDefaults { + links: vec![DataLink { + url: UrlBuilder::new_dashboard(format!("{}-sync-errors", config.team_slug())) + .with_time_range_param() + .with_param("var-application", application.slug()) + .with_param("var-channel", channel.to_string()) + .with_param("var-engine", "${__data.fields[\"engine_name\"]}") + .build(), + target_blank: true, + one_click: true, + title: "Errors".into(), + }], + custom: FieldConfigCustom { + axis_label: "success rate".into(), + axis_soft_min: 90, + axis_soft_max: 100, + ..FieldConfigCustom::default() + }, + }, + }, + transformations: vec![ + Transformation::PartitionByValues { + fields: vec!["engine_name".into()], + keep_fields: true, + }, + // Fixup the field names for better legend labels + Transformation::RenameByRegex { + regex: "success_rate (.*)".into(), + rename_pattern: "$1".into(), + }, + ], + ..TimeSeriesPanel::default() + } + .into() +} + +/// Subquery to fetch general sync info for desktop +/// +/// We use subqueries to smooth out the differences between desktop and mobile telemetry. +fn desktop_count_query(channel_expr: String) -> String { + // TODO: switch from `telemetry.sync` to the newer glean ping + format!( + "\ +SELECT + engine.name as engine_name, + $__timeGroup(submission_timestamp, $__interval) as time, + SAFE_DIVIDE( + -- 100 * success count + 100 * COUNTIF( + syncs.failure_reason IS NULL + AND engine.failure_reason IS NULL + AND (engine.incoming IS NOT NULL OR ARRAY_LENGTH(engine.outgoing) > 0) + ), + -- count success or failures + COUNTIF( + syncs.failure_reason IS NOT NULL + OR engine.failure_reason IS NOT NULL + OR (engine.incoming IS NOT NULL OR ARRAY_LENGTH(engine.outgoing) > 0) + ) + ) AS success_rate, +FROM telemetry.sync +CROSS JOIN UNNEST(payload.syncs) as syncs +CROSS JOIN UNNEST(syncs.engines) as engine +WHERE normalized_channel = {channel_expr} + AND engine.name <> 'extension-storage' + AND engine.name <> 'bookmarks' + AND $__timeFilter(submission_timestamp) +GROUP BY 1, 2 +ORDER BY engine_name, time" + ) +} + +/// Subquery to fetch general sync info for mobile +/// +/// We use subqueries to smooth out the differences between desktop and mobile telemetry. +fn mobile_count_query(config: &TeamConfig, channel_expr: String) -> String { + let parts = config + .components + .iter() + .flat_map(|c| c.sync_engines()) + .map(|engine_name| { + let table_name = format!("{}_sync", engine_name.replace("-", "_")); + format!( + "\ +SELECT '{engine_name}' AS engine_name, + $__timeGroup(submission_timestamp, $__interval) as time, + SAFE_DIVIDE( + -- 100 * success count + 100 * COUNTIF( + (metrics.labeled_counter.{table_name}_v2_incoming IS NOT NULL + OR metrics.labeled_counter.{table_name}_v2_outgoing IS NOT NULL) + AND metrics.labeled_string.{table_name}_v2_failure_reason IS NULL + ), + -- count success or failures + COUNTIF( + metrics.labeled_string.{table_name}_v2_failure_reason IS NOT NULL + OR metrics.labeled_counter.{table_name}_v2_outgoing IS NOT NULL + OR metrics.labeled_counter.{table_name}_v2_incoming IS NOT NULL + ) + ) AS success_rate, +FROM mozdata.fenix.{table_name} +WHERE normalized_channel={channel_expr} AND $__timeFilter(submission_timestamp) +GROUP BY 1, 2" + ) + }) + .collect::>(); + format!( + "{}\nORDER BY engine_name, time", + parts.join("\nUNION ALL\n") + ) +} + +fn error_list_count_panel(config: &TeamConfig) -> Panel { + let query = Query { + select: vec!["message".into(), "COUNT(*) as count".into()], + where_: vec![ + "engine_name='${engine}'".into(), + "normalized_channel = '${channel}'".into(), + "$__timeFilter(time)".into(), + ], + from: format!("(\n{}\n)", error_subquery(config)), + group_by: Some("1".into()), + order_by: Some("count DESC".into()), + ..Query::default() + }; + + PieChartPanel { + title: "".into(), + grid_pos: GridPos::height(10), + datasource: Datasource::bigquery(), + interval: "30m".into(), + targets: vec![Target::timeseries(query.sql())], + ..PieChartPanel::default() + } + .into() +} + +fn error_list_log_panel(config: &TeamConfig) -> Panel { + let query = Query { + select: vec!["message".into(), "time".into()], + from: format!("(\n{}\n)", error_subquery(config)), + where_: vec![ + "engine_name='${engine}'".into(), + "normalized_channel = '${channel}'".into(), + "$__timeFilter(time)".into(), + ], + order_by: Some("time DESC".into()), + limit: Some(1000), + ..Query::default() + }; + + LogPanel { + title: "Error list".into(), + grid_pos: GridPos::height(20), + datasource: Datasource::bigquery(), + targets: vec![Target::table(query.sql())], + ..LogPanel::default() + } + .into() +} + +// Subquery that combines errors from both the legacy and glean sync tables +fn error_subquery(config: &TeamConfig) -> String { + let mut queries = vec![]; + + // Desktop needs + queries.push("\ +SELECT CONCAT(IFNULL(engine.failure_reason.name, 'unknown'), ': ', IFNULL(engine.failure_reason.error, '')) as message, + submission_timestamp as time, + engine.name as engine_name, + normalized_channel +FROM telemetry.sync +CROSS JOIN UNNEST(payload.syncs) as syncs +CROSS JOIN UNNEST(syncs.engines) as engine +WHERE engine.failure_reason IS NOT NULL +".to_string()); + + queries.extend( + config + .components + .iter() + .flat_map(|c| c.sync_engines()) + .map(|engine_name| { + format!( + "\ +SELECT CONCAT(failure_reason.key, ': ', failure_reason.value) as message, + submission_timestamp as time, + '{engine_name}' as engine_name, + normalized_channel +FROM mozdata.fenix.{engine_name}_sync +CROSS JOIN UNNEST(metrics.labeled_string.{engine_name}_sync_v2_failure_reason) as failure_reason" + ) + }), + ); + + queries.join("\nUNION ALL\n") +} diff --git a/tools/generate-rust-dashboards/src/schema.rs b/tools/generate-rust-dashboards/src/schema.rs new file mode 100644 index 0000000000..845a8eb838 --- /dev/null +++ b/tools/generate-rust-dashboards/src/schema.rs @@ -0,0 +1,720 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +//! Grafana JSON schemas +//! +//! This is an incomplete representation of Grafana's JSON model. +//! It was created by looking at the "JSON Model" settings tab and finding the settings there. +//! Feel free to add new fields/structs if you need additional functionality. + +use std::cmp::max; + +use anyhow::anyhow; +use serde::{Serialize, Serializer}; + +use crate::{config::TeamConfig, Result}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Dashboard { + pub editable: bool, + pub panels: Vec, + pub refresh: String, + pub schema_version: u32, + pub style: String, + pub templating: Templating, + pub time: Timespan, + pub timezone: String, + pub title: String, + pub uid: String, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +#[serde(rename_all = "lowercase")] +pub enum Panel { + Row(PanelRow), + Logs(LogPanel), + TimeSeries(TimeSeriesPanel), + PieChart(PieChartPanel), +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelRow { + pub title: String, + pub collapsed: bool, + pub grid_pos: GridPos, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogPanel { + pub title: String, + pub options: LogOptions, + pub datasource: Datasource, + pub grid_pos: GridPos, + pub targets: Vec, + pub transformations: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogOptions { + pub dedup_strategy: String, + pub enable_infinite_scrolling: bool, + pub enable_log_details: bool, + pub prettify_log_message: bool, + pub show_common_labels: bool, + pub show_labels: bool, + pub show_time: bool, + pub sort_order: SortOrder, + pub wrap_log_message: bool, +} + +pub enum SortOrder { + Descending, + Ascending, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeSeriesPanel { + pub title: String, + pub datasource: Datasource, + pub field_config: FieldConfig, + pub grid_pos: GridPos, + pub interval: String, + pub options: TimeseriesOptions, + pub targets: Vec, + pub transformations: Vec, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldConfig { + pub defaults: FieldConfigDefaults, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldConfigDefaults { + // Grafana defines lots more, but this is all we need so far + pub links: Vec, + pub custom: FieldConfigCustom, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldConfigCustom { + // Grafana defines lots more, but this is all we need so far + pub axis_border_show: bool, + pub axis_centered_zero: bool, + pub axis_color_mode: String, + pub axis_label: String, + pub axis_soft_min: u32, + pub axis_soft_max: u32, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataLink { + pub title: String, + pub url: String, + pub target_blank: bool, + pub one_click: bool, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeseriesOptions { + pub legend: Legend, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Legend { + pub calcs: Vec, + pub display_mode: String, + pub placement: String, + pub show_legend: bool, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Datasource { + #[serde(rename = "type")] + pub type_: Option, + pub uid: Option, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PieChartPanel { + pub title: String, + pub datasource: Datasource, + pub grid_pos: GridPos, + pub interval: String, + pub options: PieChartOptions, + pub targets: Vec, + pub transformations: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PieChartOptions { + pub display_labels: Vec, + pub legend: Legend, + pub pie_type: String, + pub reduce_options: PieChartReduceOptions, + pub sort: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PieChartReduceOptions { + pub calcs: Vec, + pub fields: String, + pub values: bool, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Target { + pub format: TargetFormat, + pub raw_query: bool, + pub raw_sql: String, +} + +#[derive(Default)] +pub enum TargetFormat { + #[default] + Timeseries, + Table, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "id", content = "options")] +pub enum Transformation { + #[serde(rename_all = "camelCase")] + PartitionByValues { + fields: Vec, + keep_fields: bool, + }, + #[serde(rename_all = "camelCase")] + RenameByRegex { + regex: String, + rename_pattern: String, + }, +} + +#[derive(Default, Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct GridPos { + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Timespan { + pub from: String, + pub to: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Templating { + pub enable: bool, + pub list: Vec, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +#[serde(rename_all = "lowercase")] +pub enum Variable { + Custom(CustomVariable), + Query(QueryVariable), + TextBox(TextBoxVariable), + AdHoc(AdHocFiltersVariable), +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomVariable { + pub include_all: bool, + pub name: String, + pub label: String, + pub multi: bool, + pub query: String, + pub allow_custom_value: bool, + pub current: CustomVariableSelection, + pub hide: VariableHide, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryVariable { + pub datasource: Datasource, + pub name: String, + pub label: String, + pub multi: bool, + pub allow_custom_value: bool, + pub query: QueryVariableQuery, + pub hide: VariableHide, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryVariableQuery { + pub editor_mode: String, + pub format: TargetFormat, + pub raw_query: bool, + pub raw_sql: String, + pub regex: String, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextBoxVariable { + pub name: String, + pub label: String, + pub current: TextBoxSelection, + pub hide: VariableHide, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextBoxSelection { + pub text: String, + pub value: String, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdHocFiltersVariable { + pub base_filters: Vec, + pub datasource: Datasource, + pub name: String, + pub filters: Vec, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdHocFilter { + pub key: String, + pub operation: String, + pub value: String, +} + +#[derive(Default)] +pub enum VariableHide { + #[default] + Nothing, + Label, + Variable, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum CustomVariableSelection { + Single(String), + Multiple { value: Vec }, +} + +impl Default for Dashboard { + fn default() -> Self { + Self { + editable: false, + panels: vec![], + refresh: "1h".into(), + schema_version: 36, + style: "dark".into(), + templating: Templating::default(), + time: Timespan::default(), + timezone: "browser".into(), + title: "".into(), + uid: "".into(), + } + } +} + +impl Default for PieChartOptions { + fn default() -> Self { + Self { + display_labels: vec!["percent".into()], + legend: Legend::default(), + pie_type: "pie".into(), + reduce_options: PieChartReduceOptions::default(), + sort: "desc".into(), + } + } +} + +impl Default for PieChartReduceOptions { + fn default() -> Self { + Self { + calcs: vec![], + fields: "".into(), + values: true, + } + } +} + +impl Default for Legend { + fn default() -> Self { + Self { + calcs: vec![], + display_mode: "list".into(), + placement: "bottom".into(), + show_legend: true, + } + } +} + +impl Default for Timespan { + fn default() -> Self { + Timespan { + from: "now-2w".into(), + to: "now".into(), + } + } +} + +impl Default for Templating { + fn default() -> Self { + Self { + enable: true, + list: vec![], + } + } +} + +impl Default for LogOptions { + fn default() -> Self { + Self { + dedup_strategy: "none".into(), + enable_infinite_scrolling: false, + enable_log_details: true, + prettify_log_message: false, + show_common_labels: false, + show_labels: false, + show_time: true, + sort_order: SortOrder::Descending, + wrap_log_message: true, + } + } +} + +impl Default for QueryVariableQuery { + fn default() -> Self { + Self { + editor_mode: "code".into(), + format: TargetFormat::Table, + raw_query: true, + raw_sql: String::default(), + regex: String::default(), + } + } +} + +impl Default for CustomVariableSelection { + fn default() -> Self { + Self::single("") + } +} + +impl Panel { + fn grid_pos_mut(&mut self) -> &mut GridPos { + match self { + Self::Row(p) => &mut p.grid_pos, + Self::Logs(p) => &mut p.grid_pos, + Self::TimeSeries(p) => &mut p.grid_pos, + Self::PieChart(p) => &mut p.grid_pos, + } + } +} + +impl From for Panel { + fn from(p: PanelRow) -> Self { + Self::Row(p) + } +} + +impl From for Panel { + fn from(p: LogPanel) -> Self { + Self::Logs(p) + } +} + +impl From for Panel { + fn from(p: TimeSeriesPanel) -> Self { + Self::TimeSeries(p) + } +} + +impl From for Panel { + fn from(p: PieChartPanel) -> Self { + Self::PieChart(p) + } +} + +impl From for Variable { + fn from(v: TextBoxVariable) -> Self { + Self::TextBox(v) + } +} + +impl From for Variable { + fn from(v: AdHocFiltersVariable) -> Self { + Self::AdHoc(v) + } +} + +impl From for Variable { + fn from(v: CustomVariable) -> Self { + Self::Custom(v) + } +} + +impl From for Variable { + fn from(v: QueryVariable) -> Self { + Self::Query(v) + } +} + +impl Serialize for TargetFormat { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Timeseries => serializer.serialize_i32(0), + Self::Table => serializer.serialize_i32(1), + } + } +} + +impl Serialize for VariableHide { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Nothing => serializer.serialize_i32(0), + Self::Label => serializer.serialize_i32(1), + Self::Variable => serializer.serialize_i32(2), + } + } +} + +impl Serialize for SortOrder { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Descending => serializer.serialize_str("Descending"), + Self::Ascending => serializer.serialize_str("Ascending"), + } + } +} + +impl GridPos { + /// Create a GridPos from a height only + /// + /// Use this with `DashboardPacker`, which will automatically set all other fields + pub fn height(h: u32) -> Self { + Self { + h, + ..Self::default() + } + } +} + +impl CustomVariableSelection { + pub fn single(selected: impl std::fmt::Display) -> Self { + Self::Single(selected.to_string()) + } + + pub fn multi(selected: impl IntoIterator) -> Self { + Self::Multiple { + value: selected.into_iter().map(|s| s.to_string()).collect(), + } + } +} + +impl QueryVariableQuery { + pub fn from_sql(raw_sql: impl Into) -> Self { + Self { + raw_query: true, + raw_sql: raw_sql.into(), + ..QueryVariableQuery::default() + } + } +} + +impl Datasource { + pub fn bigquery() -> Self { + Self { + type_: Some("grafana-bigquery-datasource".into()), + uid: None, + } + } +} + +impl Target { + pub fn timeseries(sql: impl Into) -> Self { + Self { + format: TargetFormat::Timeseries, + raw_query: true, + raw_sql: sql.into(), + } + } + + pub fn table(sql: impl Into) -> Self { + Self { + format: TargetFormat::Table, + raw_query: true, + raw_sql: sql.into(), + } + } +} + +pub struct DashboardBuilder { + pub dashboard: Dashboard, + col: u32, + row: u32, + current_row_height: u32, +} + +impl DashboardBuilder { + pub fn new(title: impl Into, uid: impl Into) -> Self { + Self { + dashboard: Dashboard { + title: title.into(), + uid: uid.into(), + ..Dashboard::default() + }, + col: 0, + row: 0, + current_row_height: 0, + } + } + + pub fn add_variable(&mut self, v: impl Into) { + self.dashboard.templating.list.push(v.into()); + } + + /// Add an `application` variable that the user can select + pub fn add_application_variable(&mut self, config: &TeamConfig) -> Result<()> { + let applications = config.applications(); + + let first_application = applications + .iter() + .next() + .ok_or_else(|| anyhow!("Application list empty for {}", config.team_name))?; + + self.add_variable(CustomVariable { + label: "Application".into(), + name: "application".into(), + query: applications + .iter() + .map(|a| format!("{a} : {}", a.slug())) + .collect::>() + .join(","), + current: CustomVariableSelection::single(first_application.slug()), + ..CustomVariable::default() + }); + Ok(()) + } + + /// Add an `channel` variable that the user can select a release channel from + pub fn add_channel_variable(&mut self) { + self.add_variable(CustomVariable { + label: "Channel".into(), + name: "channel".into(), + multi: false, + query: "nightly,beta,release".into(), + ..CustomVariable::default() + }); + } + + // Add a `filter_sql` variable + // + // This is a WHERE condition to filter queries on, based on the LogPanel's `Filters` variable. + // + // This converts the "ad-hoc filter" syntax to SQL. It's pretty gross, but mostly works. + pub fn add_filter_sql_variable(&mut self) { + self.add_variable( + QueryVariable { + name: "filter_sql".into(), + hide: VariableHide::Variable, + datasource: Datasource::bigquery(), + // Convert a Grafana Ad-hoc query into a SQL expression. + // + // This is extremely hacky and in a regular website would open us up to an SQL + // injection attack. However, it seems okay for this specific scenario since: + // + // * Users can only see a dashboard if they're authorized as a Mozilla employee + // * If they're authorized, then they can create dashboards/queries themselves, so + // there's no point in an injection attack. + // + // The only attack vector we can think of is if an outside user sent a Mozilla + // employee a yardstick link with the `Filters` param set to some nasty SQL. Maybe + // somehow they figure out how to create an expression that emails the data to the + // attacker. However, this seems so hard to pull off in practice and that we feel + // like the risk is negligible. + query: QueryVariableQuery::from_sql( + r#"SELECT IF(STRPOS('${Filters}', '=') <> 0, REPLACE(REPLACE('${Filters}', '",', '" AND '), '\n', '\\n'), 'true')"#, + ), + ..QueryVariable::default() + } + ); + } + + pub fn add_panel_title(&mut self, title: impl Into) { + self.add_panel_full(PanelRow { + title: title.into(), + collapsed: false, + grid_pos: GridPos::height(1), + }) + } + + pub fn add_panel_third(&mut self, p: impl Into) { + if self.col > 16 { + self.start_new_row(); + } + let mut p = p.into(); + let pos = p.grid_pos_mut(); + pos.x = self.col; + pos.y = self.row; + pos.w = 8; + self.current_row_height = max(self.current_row_height, pos.h); + self.col += 8; + + self.dashboard.panels.push(p); + } + + pub fn add_panel_full(&mut self, p: impl Into) { + self.start_new_row(); + let mut p = p.into(); + let pos = p.grid_pos_mut(); + pos.x = self.col; + pos.y = self.row; + pos.w = 24; + self.row += pos.h; + + self.dashboard.panels.push(p); + } + + pub fn start_new_row(&mut self) { + self.row += self.current_row_height; + self.current_row_height = 0; + self.col = 0; + } +} diff --git a/tools/generate-rust-dashboards/src/sql.rs b/tools/generate-rust-dashboards/src/sql.rs new file mode 100644 index 0000000000..0b51d4a728 --- /dev/null +++ b/tools/generate-rust-dashboards/src/sql.rs @@ -0,0 +1,109 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +/// Very simple SQL query builder +/// +/// Use this if it helps or use raw SQL if it's easier. +#[derive(Debug, Default)] +pub struct Query { + pub select: Vec, + pub from: String, + pub joins: Vec, + pub where_: Vec, + pub group_by: Option, + pub order_by: Option, + pub limit: Option, + pub prep_statements: Vec, +} + +impl Query { + pub fn sql(&self) -> String { + let mut sql = String::default(); + for stmt in self.prep_statements.iter() { + sql.push_str(&format!("{stmt};\n")); + } + + sql.push_str(&format!("SELECT {}\n", self.select.join(", "))); + sql.push_str(&format!("FROM {}\n", self.from)); + for join in self.joins.iter() { + sql.push_str(&format!("{join}\n")); + } + if !self.where_.is_empty() { + sql.push_str(&format!( + "WHERE {}\n", + self.where_ + .iter() + .map(|w| format!("({w})")) + .collect::>() + .join(" AND ") + )); + } + if let Some(group_by) = &self.group_by { + sql.push_str(&format!("GROUP BY {group_by}\n")); + } + if let Some(order_by) = &self.order_by { + sql.push_str(&format!("ORDER BY {order_by}\n")); + } + if let Some(limit) = &self.limit { + sql.push_str(&format!("LIMIT {limit}\n")); + } + sql + } + + pub fn as_subquery(&self) -> String { + format!("(\n{})", self.sql()) + } + + pub fn add_count_per_day_column( + &mut self, + count_expr: impl Into, + name: impl Into, + ) { + let count_expr = count_expr.into(); + let name = name.into(); + let ms_per_day = 86400000; + self.select.push(format!( + "(({count_expr}) / ($__interval_ms / {ms_per_day})) as {name}" + )); + } + + pub fn add_standard_glean_columns(&mut self) { + self.select.extend([ + "client_info.app_display_version as app_display_version".into(), + "client_info.architecture as architecture".into(), + "client_info.device_manufacturer as device_manufacturer".into(), + "client_info.device_model as device_model".into(), + "client_info.locale as locale".into(), + "client_info.os as os".into(), + "client_info.os_version as os_version".into(), + "submission_timestamp".into(), + ]) + } + + pub fn add_standard_glean_columns_no_prefix(&mut self) { + self.select.extend([ + "app_display_version".into(), + "architecture".into(), + "device_manufacturer".into(), + "device_model".into(), + "locale".into(), + "os".into(), + "os_version".into(), + "submission_timestamp".into(), + ]) + } + + pub fn add_from_using_application_var(&mut self, table_name: &str) { + // TODO: Add UNIONs once we are enable the glean pipeline for iOS and/or Desktop + // let from = format!(" + // ( + // SELECT * FROM mozdata.fenix.{table_name} WHERE '${{application}}' = 'android' + // UNION ALL SELECT * FROM mozdata.firefox_ios.{table_name} WHERE '${{application}}' = 'ios' + // UNION ALL SELECT * FROM mozdata.firefox_desktop.{table_name} WHERE '${{application}}' = 'desktop' + // )"); + self.from = format!( + "(SELECT * FROM mozdata.fenix.{table_name} WHERE '${{application}}' = 'android')" + ); + } +} diff --git a/tools/generate-rust-dashboards/src/team_config.rs b/tools/generate-rust-dashboards/src/team_config.rs new file mode 100644 index 0000000000..d64699f209 --- /dev/null +++ b/tools/generate-rust-dashboards/src/team_config.rs @@ -0,0 +1,148 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use crate::config::*; + +use Application::*; + +pub fn all_dashboards() -> Vec { + vec![ + TeamConfig { + team_name: "SYNC", + components: vec![ + Component::Autofill, + Component::Fxa, + Component::Logins, + Component::Places, + Component::RemoteSettings, + Component::Tabs, + ], + component_errors: true, + sync_metrics: true, + main_dashboard_metrics: vec![DistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Places run_maintenance() time", + ping: "metrics", + category: "places_manager", + metric: "run_maintenance_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Android], + link_to: Some("Sync Maintenance Times"), + } + .into()], + extra_dashboards: vec![ExtraDashboard { + name: "Sync Maintenance Times", + metrics: vec![ + DistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Places run_maintenance_chk_pnt_time() time", + ping: "metrics", + category: "places_manager", + metric: "run_maintenance_chk_pnt_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Android], + link_to: None, + } + .into(), + DistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Places run_maintenance_optimize_time() time", + ping: "metrics", + category: "places_manager", + metric: "run_maintenance_optimize_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Android], + link_to: None, + } + .into(), + DistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Places run_maintenance_prune_time() time", + ping: "metrics", + category: "places_manager", + metric: "run_maintenance_prune_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Android], + link_to: None, + } + .into(), + DistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Places run_maintenance_vacuum_time() time", + ping: "metrics", + category: "places_manager", + metric: "run_maintenance_vacuum_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Android], + link_to: None, + } + .into(), + ], + }], + }, + TeamConfig { + team_name: "DISCO", + components: vec![Component::Suggest], + component_errors: true, + sync_metrics: false, + main_dashboard_metrics: vec![ + LabeledDistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Suggest ingest time", + ping: "metrics", + category: "suggest", + metric: "ingest_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: Some(100_000), + applications: vec![Desktop], + } + .into(), + LabeledDistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Suggest ingest download time", + ping: "metrics", + category: "suggest", + metric: "ingest_download_time", + axis_label: "time (ms)", + value_divisor: Some(1_000_000), + value_filter: None, + applications: vec![Desktop], + } + .into(), + LabeledDistributionMetric { + kind: DistributionMetricKind::Timing, + display_name: "Suggest query time", + ping: "metrics", + category: "suggest", + metric: "ingest_query_time", + axis_label: "time (us)", + value_divisor: Some(1_000), + value_filter: None, + applications: vec![Desktop], + } + .into(), + ], + extra_dashboards: vec![], + }, + TeamConfig { + team_name: "Credential Management", + components: vec![Component::Logins], + component_errors: true, + sync_metrics: true, + main_dashboard_metrics: vec![], + extra_dashboards: vec![], + }, + ] +} diff --git a/tools/generate-rust-dashboards/src/util.rs b/tools/generate-rust-dashboards/src/util.rs new file mode 100644 index 0000000000..acd4e23f3c --- /dev/null +++ b/tools/generate-rust-dashboards/src/util.rs @@ -0,0 +1,55 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +pub fn slug(text: &str) -> String { + text.replace(|ch: char| !ch.is_alphanumeric(), "-") + .to_ascii_lowercase() +} + +pub struct UrlBuilder { + base_url: String, + params: Vec, +} + +impl UrlBuilder { + pub fn new_dashboard(dashboard_uid: String) -> Self { + Self { + base_url: format!("https://yardstick.mozilla.org/d/{dashboard_uid}"), + params: vec![], + } + } + + pub fn with_param(mut self, name: impl Into, val: impl Into) -> Self { + self.params.push(format!("{}={}", name.into(), val.into())); + self + } + + pub fn with_time_range_param(mut self) -> Self { + self.params.push("${__url_time_range}".into()); + self + } + + pub fn build(self) -> String { + if self.params.is_empty() { + self.base_url.clone() + } else { + format!("{}?{}", self.base_url, self.params.join("&")) + } + } +} + +/// Used to implement `join()` for an iterator +pub trait Join { + fn join(self, sep: &str) -> String; +} + +impl Join for T +where + T: Iterator, + I: Into, +{ + fn join(self, sep: &str) -> String { + self.map(I::into).collect::>().join(sep) + } +}