diff --git a/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json b/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json new file mode 100644 index 0000000..06cf717 --- /dev/null +++ b/.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT event_type as \"event_type!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\"\n FROM events GROUP BY event_type ORDER BY total_ms DESC", + "describe": { + "columns": [ + { + "name": "event_type!", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "count!", + "ordinal": 1, + "type_info": "Int" + }, + { + "name": "total_ms", + "ordinal": 2, + "type_info": "Int" + }, + { + "name": "avg_ms!", + "ordinal": 3, + "type_info": "Float" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + true, + true + ] + }, + "hash": "089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53" +} diff --git a/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json b/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json new file mode 100644 index 0000000..9745f26 --- /dev/null +++ b/.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT phase_name as \"name!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\"\n FROM phases GROUP BY phase_name ORDER BY total_ms DESC LIMIT 8", + "describe": { + "columns": [ + { + "name": "name!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "count!", + "ordinal": 1, + "type_info": "Int" + }, + { + "name": "total_ms", + "ordinal": 2, + "type_info": "Int" + }, + { + "name": "avg_ms!", + "ordinal": 3, + "type_info": "Float" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + true, + true + ] + }, + "hash": "1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66" +} diff --git a/.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json b/.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json new file mode 100644 index 0000000..3612d2e --- /dev/null +++ b/.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT duration_ms as \"duration_ms!\", event_type as \"event_type!\", drv_path, text\n FROM events\n WHERE event_type IN (105, 108, 102, 112)\n ORDER BY duration_ms DESC LIMIT 10", + "describe": { + "columns": [ + { + "name": "duration_ms!", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "event_type!", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "drv_path", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "text", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + true, + true + ] + }, + "hash": "794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f" +} diff --git a/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json b/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json new file mode 100644 index 0000000..3389369 --- /dev/null +++ b/.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"cache_hits!\",\n CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"cache_misses!\",\n CAST(COALESCE(SUM(total_bytes), 0) AS INTEGER) as \"total_bytes!\",\n CAST(COALESCE(SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)), 0) AS INTEGER) as \"net_ms!\"\n FROM events", + "describe": { + "columns": [ + { + "name": "cache_hits!", + "ordinal": 0, + "type_info": "Int" + }, + { + "name": "cache_misses!", + "ordinal": 1, + "type_info": "Int" + }, + { + "name": "total_bytes!", + "ordinal": 2, + "type_info": "Int" + }, + { + "name": "net_ms!", + "ordinal": 3, + "type_info": "Int" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50" +} diff --git a/Cargo.lock b/Cargo.lock index ee0a6a0..75cb009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -916,6 +937,7 @@ dependencies = [ "bytes", "chrono", "clap", + "directories", "futures", "serde", "serde_json", @@ -1003,6 +1025,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1192,6 +1220,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "rsa" version = "0.9.10" diff --git a/Cargo.toml b/Cargo.toml index 82ab4e6..aa68205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,9 @@ tracing = "0.1" tracing-subscriber = "0.3" sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } chrono = "0.4" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } anyhow = "1.0" +directories = "5.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" bytes = "1.0" diff --git a/flake.nix b/flake.nix index fd2a321..13fc446 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self, nixpkgs, rust-overlay }: @@ -38,10 +39,6 @@ pkgs.sqlite pkgs.sqlx-cli ] ++ (if pkgs.stdenv.isDarwin then [ pkgs.iconv ] else []); - - shellHook = '' - export DATABASE_URL="sqlite:nod.db" - ''; }; }); @@ -68,13 +65,15 @@ config = lib.mkIf cfg.enable { nix.settings.json-log-path = cfg.socketPath; + environment.sessionVariables.NOD_SOCKET = cfg.socketPath; + systemd.services.nod = { description = "Nix Observability Daemon"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig = { - ExecStart = "${cfg.package}/bin/nod --socket ${cfg.socketPath} --db-url sqlite:${cfg.databasePath} daemon"; + ExecStart = "${cfg.package}/bin/nod daemon --db-url sqlite:${cfg.databasePath} --socket ${cfg.socketPath}"; Restart = "always"; DynamicUser = true; StateDirectory = "nod"; # Creates /var/lib/nod diff --git a/src/main.rs b/src/main.rs index cd9b666..8e68c71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,15 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use clap::{Parser, Subcommand}; +use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Pool, Row, Sqlite}; +use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Pool, Sqlite}; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Mutex; use tracing::{error, info}; @@ -53,6 +54,12 @@ impl From for ActivityType { } } +impl From for ActivityType { + fn from(n: i64) -> Self { + Self::from(n as u64) + } +} + impl fmt::Display for ActivityType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) @@ -93,20 +100,27 @@ impl From for ResultType { #[derive(Parser)] struct Cli { - #[arg(short, long, default_value = "/run/nod/nod.sock")] - socket: PathBuf, - - #[arg(short, long, default_value = "sqlite:/var/lib/nod/nod.db")] - db_url: String, - #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { - Daemon, - Stats, + /// Run the observability daemon + Daemon { + /// SQLite database URL + #[arg(short, long, env = "DATABASE_URL")] + db_url: Option, + /// Path to the Unix socket + #[arg(short, long, env = "NOD_SOCKET")] + socket: Option, + }, + /// Show statistics from the daemon + Stats { + /// Path to the Unix socket + #[arg(short, long, env = "NOD_SOCKET")] + socket: Option, + }, } #[derive(Debug, Deserialize, Serialize)] @@ -124,6 +138,41 @@ struct NixEvent { parent: u64, } +#[derive(Debug, Serialize, Deserialize)] +struct Stats { + by_type: Vec, + cache_hits: i32, + cache_misses: i32, + total_bytes: i32, + net_ms: i32, + by_phase: Vec, + slowest: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TypeStat { + event_type: i64, // table column INTEGER -> i64 + count: i32, // CAST(COUNT()) -> i32 + total_ms: Option, // CAST(SUM()) -> Option + avg_ms: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PhaseStat { + name: String, + count: i32, // CAST(COUNT()) -> i32 + total_ms: Option, // CAST(SUM()) -> Option + avg_ms: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SlowEvent { + duration_ms: i64, // table column INTEGER -> i64 + event_type: i64, // table column INTEGER -> i64 + drv_path: Option, + text: Option, +} + struct Activity { id: u64, parent_id: u64, @@ -145,23 +194,79 @@ async fn main() -> Result<()> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); - let connection_options = SqliteConnectOptions::from_str(&cli.db_url) - .context("Invalid database URL")? - .create_if_missing(true); + let project_dirs = ProjectDirs::from("org", "nixos", "nod"); + + let command = cli.command.unwrap_or(Commands::Daemon { db_url: None, socket: None }); + + match command { + Commands::Daemon { db_url, socket } => { + let db_url = db_url.unwrap_or_else(|| { + let db_path = project_dirs.as_ref() + .map(|d| d.data_dir().join("nod.db")) + .unwrap_or_else(|| PathBuf::from("nod.db")); + format!("sqlite:{}", db_path.display()) + }); + + let socket_path = socket.unwrap_or_else(|| { + project_dirs.as_ref() + .and_then(|d| d.runtime_dir()) + .map(|d| d.join("nod.sock")) + .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock")) + }); + + let connection_options = SqliteConnectOptions::from_str(&db_url) + .context("Invalid database URL")? + .create_if_missing(true); + + if let Ok(opts) = SqliteConnectOptions::from_str(&db_url) { + if let Some(parent) = opts.get_filename().parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create database directory: {}", parent.display()))?; + } + } + } + + if let Some(parent) = socket_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create socket directory: {}", parent.display()))?; + } + } + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(connection_options) + .await + .context("Failed to connect to database")?; + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .context("Failed to run database migrations")?; + + run_daemon(socket_path, pool).await.context("Daemon failed")? + } + Commands::Stats { socket } => { + let socket_path = socket.unwrap_or_else(|| { + project_dirs.as_ref() + .and_then(|d| d.runtime_dir()) + .map(|d| d.join("nod.sock")) + .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock")) + }); - let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with(connection_options) - .await - .context("Failed to connect to database")?; + let mut stream = UnixStream::connect(&socket_path) + .await + .with_context(|| format!("Failed to connect to daemon at {}", socket_path.display()))?; - sqlx::migrate!("./migrations") - .run(&pool) - .await?; + stream.write_all(b"get_stats\n").await?; - match cli.command.unwrap_or(Commands::Daemon) { - Commands::Daemon => run_daemon(cli.socket, pool).await?, - Commands::Stats => show_stats(pool).await?, + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.context("Daemon closed connection without response")?; + let stats: Stats = serde_json::from_str(&line).context("Invalid response from daemon")?; + display_stats(stats); + } } Ok(()) @@ -174,10 +279,11 @@ async fn run_daemon(socket_path: PathBuf, pool: Pool) -> Result<()> { })); if socket_path.exists() { - std::fs::remove_file(&socket_path)?; + std::fs::remove_file(&socket_path).with_context(|| format!("Failed to remove existing socket at {}", socket_path.display()))?; } - let listener = UnixListener::bind(&socket_path)?; + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("Failed to bind to socket at {}", socket_path.display()))?; info!("Daemon listening on {}", socket_path.display()); loop { @@ -191,12 +297,19 @@ async fn run_daemon(socket_path: PathBuf, pool: Pool) -> Result<()> { } } -async fn handle_connection(stream: UnixStream, state: Arc>) -> Result<()> { - let mut reader = BufReader::new(stream); +async fn handle_connection(mut stream: UnixStream, state: Arc>) -> Result<()> { + let (reader, mut writer) = stream.split(); + let mut reader = BufReader::new(reader); let mut line = String::new(); loop { line.clear(); if reader.read_line(&mut line).await? == 0 { break; } + if line.trim() == "get_stats" { + let pool = state.lock().await.pool.clone(); + let stats = collect_stats(pool).await?; + writer.write_all((serde_json::to_string(&stats)? + "\n").as_bytes()).await?; + break; + } if let Ok(event) = serde_json::from_str::(&line) { process_event(event, &state).await?; } @@ -296,78 +409,90 @@ async fn process_event(event: NixEvent, state: &Arc>) -> Result<()> Ok(()) } -async fn show_stats(pool: Pool) -> Result<()> { - let rows = sqlx::query( - "SELECT event_type, COUNT(*) as count, SUM(duration_ms) as total_ms, AVG(duration_ms) as avg_ms +async fn collect_stats(pool: Pool) -> Result { + let by_type = sqlx::query_as!( + TypeStat, + "SELECT event_type as \"event_type!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\" FROM events GROUP BY event_type ORDER BY total_ms DESC" ).fetch_all(&pool).await?; - println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); - println!("{}", "-".repeat(57)); - - for row in rows { - let ty: u64 = row.get::("event_type") as u64; - let label = format!("{:?}", ActivityType::from(ty)); - let count: i64 = row.get("count"); - let avg: f64 = row.get::("avg_ms") / 1000.0; - let total: f64 = row.get::("total_ms") as f64 / 1000.0; - - println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, count, avg, total); - } - - let io = sqlx::query( - "SELECT - COUNT(*) FILTER (WHERE event_type = 108) as hits, - COUNT(*) FILTER (WHERE event_type = 105) as misses, - SUM(total_bytes) as total_bytes, - SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)) as net_ms + let io = sqlx::query!( + "SELECT + CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"cache_hits!\", + CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"cache_misses!\", + CAST(COALESCE(SUM(total_bytes), 0) AS INTEGER) as \"total_bytes!\", + CAST(COALESCE(SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)), 0) AS INTEGER) as \"net_ms!\" FROM events" ).fetch_one(&pool).await?; - let hits: i64 = io.get("hits"); - let misses: i64 = io.get("misses"); - let total_io = hits + misses; - let bytes: i64 = io.get::, _>("total_bytes").unwrap_or(0); - let net_ms: i64 = io.get::, _>("net_ms").unwrap_or(0); - - let mb = bytes as f64 / 1024.0 / 1024.0; - let speed = if net_ms > 0 { mb / (net_ms as f64 / 1000.0) } else { 0.0 }; - - println!("\n--- PERFORMANCE SUMMARY ---"); - println!("{:<18} {:>10.1}%", "Cache Hit Rate:", if total_io > 0 { (hits as f64 / total_io as f64) * 100.0 } else { 0.0 }); - println!("{:<18} {:>10.2} MB", "Total Data:", mb); - println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); - - println!("\n{:<12} {:<12} {}", "TIME", "TYPE", "DERIVATION / TASK"); - println!("{}", "-".repeat(57)); + let by_phase = sqlx::query_as!( + PhaseStat, + "SELECT phase_name as \"name!\", CAST(COUNT(*) AS INTEGER) as \"count!\", CAST(SUM(duration_ms) AS INTEGER) as \"total_ms\", CAST(AVG(duration_ms) AS REAL) as \"avg_ms!\" + FROM phases GROUP BY phase_name ORDER BY total_ms DESC LIMIT 8" + ).fetch_all(&pool).await?; - let heavy = sqlx::query( - "SELECT duration_ms, event_type, drv_path, text - FROM events + let slowest = sqlx::query_as!( + SlowEvent, + "SELECT duration_ms as \"duration_ms!\", event_type as \"event_type!\", drv_path, text + FROM events WHERE event_type IN (105, 108, 102, 112) ORDER BY duration_ms DESC LIMIT 10" ).fetch_all(&pool).await?; + Ok(Stats { + by_type, + cache_hits: io.cache_hits, + cache_misses: io.cache_misses, + total_bytes: io.total_bytes, + net_ms: io.net_ms, + by_phase, + slowest, + }) +} + +fn display_stats(stats: Stats) { + println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); + println!("{}", "-".repeat(57)); + for row in stats.by_type { + let label = format!("{:?}", ActivityType::from(row.event_type)); + let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; + println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, row.count, row.avg_ms / 1000.0, total_s); + } + + let total_io = stats.cache_hits + stats.cache_misses; + let hit_rate = if total_io > 0 { stats.cache_hits as f64 / total_io as f64 * 100.0 } else { 0.0 }; + let mb = stats.total_bytes as f64 / 1024.0 / 1024.0; + let speed = if stats.net_ms > 0 { mb / (stats.net_ms as f64 / 1000.0) } else { 0.0 }; + println!("\n--- PERFORMANCE ---"); + println!("{:<18} {:>10.1}%", "Cache Hit Rate:", hit_rate); + println!("{:<18} {:>10.2} MB", "Total Data:", mb); + println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); - for row in heavy { - let ms: i64 = row.get("duration_ms"); - let ty: u64 = row.get::("event_type") as u64; - let label = match ty { - 105 => "Build", - 108 => "Subst", - 112 => "Fetch", - _ => "Other", - }; - - let path = row.get::, _>("drv_path") - .unwrap_or_else(|| row.get::, _>("text").unwrap_or_default()); - - // Remove the directory prefix but keep the hash and the name - let filename = path.strip_prefix("/nix/store/").unwrap_or(&path); - - println!("{:>10.2}s {:<10} {}", ms as f64 / 1000.0, label, filename); + if !stats.by_phase.is_empty() { + println!("\n{:<22} {:>8} {:>12} {:>12}", "BUILD PHASE", "COUNT", "AVG TIME", "TOTAL TIME"); + println!("{}", "-".repeat(57)); + for row in stats.by_phase { + let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; + println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", row.name, row.count, row.avg_ms / 1000.0, total_s); + } } - + + if !stats.slowest.is_empty() { + println!("\n{:<12} {:<10} {}", "DURATION", "TYPE", "DERIVATION / TASK"); + println!("{}", "-".repeat(57)); + for row in stats.slowest { + let label = match row.event_type { + 105 => "Build", + 108 => "Subst", + 112 => "Fetch", + 102 => "Realise", + _ => "Other", + }; + let path = row.drv_path.or(row.text).unwrap_or_default(); + let name = path.strip_prefix("/nix/store/").unwrap_or(&path); + println!("{:>10.2}s {:<10} {}", row.duration_ms as f64 / 1000.0, label, name); + } + } + println!(); - Ok(()) }