From 5f75b6bf40c87377ef7bb2422a67882872c1fec9 Mon Sep 17 00:00:00 2001 From: Azat Khuzhin Date: Mon, 25 May 2026 22:44:18 +0200 Subject: [PATCH] Add CLI export for perfetto traces Adds `chdig export perfetto` to generate trace files without the TUI - useful for testing different perfetto versions and for sharing traces for research/debugging. Co-Authored-By: Claude Co-Authored-By: Azat Khuzhin --- .gitignore | 1 + src/bin.rs | 104 +++++++++++++++++++- src/interpreter/clickhouse.rs | 63 ++++++++++++ src/interpreter/mod.rs | 1 + src/interpreter/options.rs | 175 +++++++++++++++++++++++++++++++++- src/interpreter/worker.rs | 8 +- src/view/navigation.rs | 4 +- src/view/settings_view.rs | 2 +- 8 files changed, 350 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index f107a3f..ca48291 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist *.rpm # intellij .idea/ +*.pftrace diff --git a/src/bin.rs b/src/bin.rs index f82a235..6a5fcf1 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -1,14 +1,19 @@ use anyhow::{Result, anyhow}; use backtrace::Backtrace; +use chrono::TimeDelta; use flexi_logger::{FileSpec, LogSpecification, Logger}; use std::ffi::OsString; use std::panic::{self, PanicHookInfo}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use cursive::view::Resizable; use crate::{ - interpreter::{ClickHouse, Context, ContextArc, options}, + interpreter::{ + ClickHouse, Context, ContextArc, Query, fetch_and_populate_perfetto_trace, + fetch_server_perfetto_sources, options, perfetto::PerfettoTraceBuilder, + }, view::Navigation, }; @@ -39,6 +44,98 @@ fn panic_hook(info: &PanicHookInfo<'_>) { ); } +fn derive_output_path( + user_path: Option<&Path>, + is_server_scope: bool, + query_id: Option<&str>, +) -> PathBuf { + if let Some(p) = user_path { + return p.to_path_buf(); + } + if is_server_scope { + PathBuf::from("server_perfetto_trace.pftrace") + } else { + // query_id presence is guaranteed by the !is_server_scope branch in the caller + PathBuf::from(format!("{}.pftrace", query_id.unwrap())) + } +} + +async fn run_cli_perfetto_export( + options: &options::ChDigOptions, + clickhouse: &Arc, +) -> Result<()> { + let cmd = options + .perfetto_command() + .expect("run_cli_perfetto_export requires the perfetto export subcommand"); + + let perfetto_options = cmd.apply(options.perfetto.clone()); + + let is_server_scope = options.view.query_id.is_none(); + let view_start = options.view.start.clone().into(); + let view_end = options.view.end.clone().into(); + let mut scope = match &options.view.query_id { + Some(query_id) => { + clickhouse + .get_perfetto_query_scope(query_id, view_start, view_end) + .await? + } + None => crate::interpreter::clickhouse::PerfettoQueryScope { + start: view_start, + end: view_end, + query_ids: None, + }, + }; + // Match TUI behavior: include events that arrived in the same second as the query end. + scope.end += TimeDelta::seconds(1); + + let query_block = clickhouse + .get_queries_for_perfetto(scope.start, scope.end, &scope.query_ids) + .await?; + let mut queries = Vec::new(); + for i in 0..query_block.row_count() { + match Query::from_clickhouse_block(&query_block, i, false) { + Ok(q) => queries.push(q), + Err(e) => log::warn!("Perfetto: failed to parse query row {}: {}", i, e), + } + } + + let mut builder = PerfettoTraceBuilder::new( + perfetto_options.per_server, + perfetto_options.text_log_android, + ); + builder.add_queries(&queries); + fetch_and_populate_perfetto_trace( + clickhouse, + &mut builder, + &perfetto_options, + scope.query_ids.as_deref(), + scope.start, + scope.end, + ) + .await; + + if is_server_scope { + fetch_server_perfetto_sources( + clickhouse, + &mut builder, + &perfetto_options, + scope.start, + scope.end, + ) + .await; + } + + let output = derive_output_path( + options.view.output.as_deref(), + is_server_scope, + options.view.query_id.as_deref(), + ); + + std::fs::write(&output, builder.build())?; + println!("Perfetto trace exported to {}", output.display()); + Ok(()) +} + pub async fn chdig_main_async(itr: I) -> Result<()> where I: IntoIterator, @@ -61,6 +158,11 @@ where // panic hook will clear the screen). let clickhouse = Arc::new(ClickHouse::new(options.clickhouse.clone()).await?); + if options.perfetto_command().is_some() { + run_cli_perfetto_export(&options, &clickhouse).await?; + return Ok(()); + } + let server_warnings = match clickhouse.get_warnings().await { Ok(w) => w, Err(e) => { diff --git a/src/interpreter/clickhouse.rs b/src/interpreter/clickhouse.rs index bc23a08..c620377 100644 --- a/src/interpreter/clickhouse.rs +++ b/src/interpreter/clickhouse.rs @@ -55,6 +55,13 @@ pub struct TextLogArguments { pub end: RelativeDateTime, } +#[derive(Debug, Clone)] +pub struct PerfettoQueryScope { + pub query_ids: Option>, + pub start: DateTime, + pub end: DateTime, +} + #[derive(Default)] pub struct ClickHouseServerCPU { pub count: u64, @@ -1589,6 +1596,7 @@ impl ClickHouse { &self, start: DateTime, end: DateTime, + query_ids: &Option>, ) -> Result { let dbtable = self.get_log_table_name("system", "query_log"); return self @@ -1620,6 +1628,7 @@ impl ClickHouse { WHERE type != 'QueryStart' AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) + {query_ids} "#, start = start .timestamp_nanos_opt() @@ -1634,12 +1643,66 @@ impl ClickHouse { } else { "length(thread_ids)" }, + query_ids = if let Some(query_id) = query_ids { + format!("AND query_id IN ('{}')", query_id.join("','")) + } else { + String::new() + }, ) .as_str(), ) .await; } + pub async fn get_perfetto_query_scope( + &self, + query_id: &str, + start: DateTime, + end: DateTime, + ) -> Result { + let query_log = self.get_log_table_name("system", "query_log"); + let query_id_escaped = query_id.replace('\'', "''"); + let block = self + .execute( + format!( + r#" + WITH + '{query_id}' AS root_query_id, + fromUnixTimestamp64Nano({start}) AS start_, + fromUnixTimestamp64Nano({end}) AS end_ + SELECT + groupUniqArray(query_id) AS query_ids, + min(query_start_time_microseconds) AS query_start_time_microseconds, + max(event_time_microseconds) AS query_end_time_microseconds + FROM {query_log} + WHERE + type != 'QueryStart' + AND (query_id = root_query_id OR initial_query_id = root_query_id) + AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) + AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) + "#, + query_id = query_id_escaped, + query_log = query_log, + start = start + .timestamp_nanos_opt() + .ok_or(Error::msg("Invalid start"))?, + end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, + ) + .as_str(), + ) + .await?; + + return Ok(PerfettoQueryScope { + query_ids: Some(block.get::, _>(0, "query_ids")?), + start: block + .get::, _>(0, "query_start_time_microseconds")? + .with_timezone(&Local), + end: block + .get::, _>(0, "query_end_time_microseconds")? + .with_timezone(&Local), + }); + } + pub async fn get_metric_log_for_perfetto( &self, start: DateTime, diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 175037a..ec09e04 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -18,6 +18,7 @@ pub use clickhouse_quirks::ClickHouseQuirks; pub use context::Context; pub use context::ContextArc; pub use worker::Worker; +pub(crate) use worker::{fetch_and_populate_perfetto_trace, fetch_server_perfetto_sources}; pub type WorkerEvent = worker::Event; pub type Query = query::Query; diff --git a/src/interpreter/options.rs b/src/interpreter/options.rs index 3190218..c2538e1 100644 --- a/src/interpreter/options.rs +++ b/src/interpreter/options.rs @@ -146,6 +146,89 @@ pub enum ChDigViews { Client, } +/// Generate `PerfettoCommand` (clap `--` / `--no-` flag pairs) and its +/// `apply()` method from a single list of `(field, no_field)` identifiers. Each pair +/// maps 1:1 to a field on `ChDigPerfettoConfig`; if neither flag is given the default +/// from `ChDigPerfettoConfig::default()` (or the YAML config) is kept. +/// +/// Coverage is enforced at compile time: `apply()` builds `ChDigPerfettoConfig` with +/// an exhaustive struct literal (no `..base`), so a field added to the config without +/// a matching row here fails with E0063, and a misspelled row fails with E0560. +macro_rules! perfetto_flags { + ( $( ($name:ident, $no_name:ident) ),+ $(,)? ) => { + #[derive(Args, Debug, Clone, Default)] + pub struct PerfettoCommand { + $( + #[arg(long, overrides_with = stringify!($no_name), action = ArgAction::SetTrue)] + pub $name: bool, + #[arg(long, overrides_with = stringify!($name), action = ArgAction::SetTrue)] + pub $no_name: bool, + )+ + } + + impl PerfettoCommand { + /// Resolve a `--foo`/`--no-foo` pair against a base value. + #[inline] + fn pick(on: bool, off: bool, base: bool) -> bool { + if on { true } else if off { false } else { base } + } + + /// Merge CLI flags into `base` (config-file values or `Default`). + pub fn apply(&self, base: ChDigPerfettoConfig) -> ChDigPerfettoConfig { + ChDigPerfettoConfig { + $( $name: Self::pick(self.$name, self.$no_name, base.$name), )+ + } + } + } + }; +} + +perfetto_flags! { + (opentelemetry_span_log, no_opentelemetry_span_log), + (trace_log, no_trace_log), + (query_metric_log, no_query_metric_log), + (part_log, no_part_log), + (query_thread_log, no_query_thread_log), + (text_log, no_text_log), + (text_log_android, no_text_log_android), + (per_server, no_per_server), + (metric_log, no_metric_log), + (asynchronous_metric_log, no_asynchronous_metric_log), + (asynchronous_insert_log, no_asynchronous_insert_log), + (error_log, no_error_log), + (s3_queue_log, no_s3_queue_log), + (azure_queue_log, no_azure_queue_log), + (blob_storage_log, no_blob_storage_log), + (background_schedule_pool_log, no_background_schedule_pool_log), + (session_log, no_session_log), + (aggregated_zookeeper_log, no_aggregated_zookeeper_log), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ExportCommand { + /// Export Perfetto trace + Perfetto(PerfettoCommand), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ChDigCommand { + /// Open a TUI view (default) + #[command(subcommand)] + View(ChDigViews), + /// Export data in various formats + #[command(subcommand)] + Export(ExportCommand), +} + +impl ChDigCommand { + pub fn as_view(&self) -> Option { + match self { + ChDigCommand::View(v) => Some(*v), + ChDigCommand::Export(_) => None, + } + } +} + #[derive(Parser, Clone)] #[command(name = "chdig")] #[command(author, version, about, long_about = None)] @@ -155,13 +238,26 @@ pub struct ChDigOptions { #[command(flatten)] pub view: ViewOptions, #[command(subcommand)] - pub start_view: Option, + pub command: Option, #[command(flatten)] pub service: ServiceOptions, #[clap(skip)] pub perfetto: ChDigPerfettoConfig, } +impl ChDigOptions { + pub fn start_view(&self) -> Option { + self.command.as_ref().and_then(ChDigCommand::as_view) + } + + pub fn perfetto_command(&self) -> Option<&PerfettoCommand> { + match &self.command { + Some(ChDigCommand::Export(ExportCommand::Perfetto(cmd))) => Some(cmd), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LogsOrder { @@ -293,6 +389,14 @@ pub struct ViewOptions { #[arg(long, default_value_t = 10000)] pub queries_limit: u64, + /// Specified query_id for commands that support it + #[arg(long = "query-id", alias = "query_id", value_name = "QUERY_ID")] + pub query_id: Option, + + /// Output path for CLI export (derived automatically when omitted) + #[arg(long, short = 'o', value_name = "PATH")] + pub output: Option, + /// Columns to show in queries views, in display order (labels match table headers, /// e.g. "io_wait", "net"). Defaults to all columns; "host" is still gated on /// cluster mode with no selected host. Not exposed on CLI; populated from YAML @@ -1439,4 +1543,73 @@ mod tests { time::Duration::from_millis(5000) ); } + + #[test] + fn test_perfetto_query_cli_options() { + let options = parse_from([ + "chdig", + "--query_id", + "query-123", + "--output", + "/tmp/query.pftrace", + "export", + "perfetto", + ]) + .unwrap(); + + let _cmd = options.perfetto_command().unwrap(); + assert_eq!(options.view.query_id.as_deref(), Some("query-123")); + assert_eq!( + options.view.output.as_deref(), + Some(path::Path::new("/tmp/query.pftrace")), + ); + } + + #[test] + fn test_perfetto_server_cli_options() { + let options = parse_from([ + "chdig", "--start", "10minute", "--end", "5minute", "export", "perfetto", + ]) + .unwrap(); + + let _cmd = options.perfetto_command().unwrap(); + assert!(options.view.query_id.is_none()); + } + + #[test] + fn test_perfetto_flag_pairs() { + // No flag → inherit defaults. + let options = parse_from(["chdig", "export", "perfetto"]).unwrap(); + let cfg = options + .perfetto_command() + .unwrap() + .apply(ChDigPerfettoConfig::default()); + assert_eq!(cfg.text_log, true); + assert_eq!(cfg.query_metric_log, false); + + // --no-text-log turns off a default-true field. + let options = parse_from(["chdig", "export", "perfetto", "--no-text-log"]).unwrap(); + let cfg = options + .perfetto_command() + .unwrap() + .apply(ChDigPerfettoConfig::default()); + assert_eq!(cfg.text_log, false); + + // --query-metric-log turns on a default-false field. + let options = parse_from(["chdig", "export", "perfetto", "--query-metric-log"]).unwrap(); + let cfg = options + .perfetto_command() + .unwrap() + .apply(ChDigPerfettoConfig::default()); + assert_eq!(cfg.query_metric_log, true); + + // Last of --foo/--no-foo wins (overrides_with). + let options = + parse_from(["chdig", "export", "perfetto", "--no-text-log", "--text-log"]).unwrap(); + let cfg = options + .perfetto_command() + .unwrap() + .apply(ChDigPerfettoConfig::default()); + assert_eq!(cfg.text_log, true); + } } diff --git a/src/interpreter/worker.rs b/src/interpreter/worker.rs index 365bbcd..8c83c22 100644 --- a/src/interpreter/worker.rs +++ b/src/interpreter/worker.rs @@ -365,7 +365,7 @@ async fn render_or_share_flamegraph( use crate::interpreter::options::ChDigPerfettoConfig; -async fn fetch_and_populate_perfetto_trace( +pub(crate) async fn fetch_and_populate_perfetto_trace( clickhouse: &Arc, builder: &mut PerfettoTraceBuilder, cfg: &ChDigPerfettoConfig, @@ -490,7 +490,7 @@ async fn fetch_and_populate_perfetto_trace( } } -async fn fetch_server_perfetto_sources( +pub(crate) async fn fetch_server_perfetto_sources( clickhouse: &Arc, builder: &mut PerfettoTraceBuilder, cfg: &ChDigPerfettoConfig, @@ -1209,7 +1209,9 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) } Event::ServerPerfettoExport(start, end) => { let perfetto_cfg = context.lock().unwrap().options.perfetto.clone(); - let query_block = clickhouse.get_queries_for_perfetto(start, end).await?; + let query_block = clickhouse + .get_queries_for_perfetto(start, end, &None) + .await?; let mut queries = Vec::new(); for i in 0..query_block.row_count() { match Query::from_clickhouse_block(&query_block, i, false) { diff --git a/src/view/navigation.rs b/src/view/navigation.rs index 3f6a76c..465f74c 100644 --- a/src/view/navigation.rs +++ b/src/view/navigation.rs @@ -258,7 +258,7 @@ impl Navigation for Cursive { .lock() .unwrap() .options - .start_view + .start_view() .unwrap_or(ChDigViews::Queries); let provider = context @@ -760,7 +760,7 @@ impl Navigation for Cursive { context .current_view - .or(context.options.start_view) + .or(context.options.start_view()) .unwrap_or(ChDigViews::Queries) }; diff --git a/src/view/settings_view.rs b/src/view/settings_view.rs index a5df0cc..1b9c5a2 100644 --- a/src/view/settings_view.rs +++ b/src/view/settings_view.rs @@ -221,7 +221,7 @@ fn apply_settings(siv: &mut Cursive, context: &ContextArc) { let ctx = context.lock().unwrap(); let current_view = ctx .current_view - .or(ctx.options.start_view) + .or(ctx.options.start_view()) .unwrap_or(ChDigViews::Queries); ( ctx.view_registry.get_by_view_type(current_view),