Nix Observability Daemon
observability nix
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

proper daemon <-> client setup

+409 -93
+38
.sqlx/query-089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "event_type!", 8 + "ordinal": 0, 9 + "type_info": "Int64" 10 + }, 11 + { 12 + "name": "count!", 13 + "ordinal": 1, 14 + "type_info": "Int" 15 + }, 16 + { 17 + "name": "total_ms", 18 + "ordinal": 2, 19 + "type_info": "Int" 20 + }, 21 + { 22 + "name": "avg_ms!", 23 + "ordinal": 3, 24 + "type_info": "Float" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 0 29 + }, 30 + "nullable": [ 31 + true, 32 + false, 33 + true, 34 + true 35 + ] 36 + }, 37 + "hash": "089e573957a6b2e2371c5a8e1bd6f7f7408369b8e9a44307088a136231472e53" 38 + }
+38
.sqlx/query-1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "name!", 8 + "ordinal": 0, 9 + "type_info": "Text" 10 + }, 11 + { 12 + "name": "count!", 13 + "ordinal": 1, 14 + "type_info": "Int" 15 + }, 16 + { 17 + "name": "total_ms", 18 + "ordinal": 2, 19 + "type_info": "Int" 20 + }, 21 + { 22 + "name": "avg_ms!", 23 + "ordinal": 3, 24 + "type_info": "Float" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 0 29 + }, 30 + "nullable": [ 31 + true, 32 + false, 33 + true, 34 + true 35 + ] 36 + }, 37 + "hash": "1224d24c36144c233d68aa99cfef9ed88be349a6058e3fd1df2d3860cce7dd66" 38 + }
+38
.sqlx/query-794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "duration_ms!", 8 + "ordinal": 0, 9 + "type_info": "Int64" 10 + }, 11 + { 12 + "name": "event_type!", 13 + "ordinal": 1, 14 + "type_info": "Int64" 15 + }, 16 + { 17 + "name": "drv_path", 18 + "ordinal": 2, 19 + "type_info": "Text" 20 + }, 21 + { 22 + "name": "text", 23 + "ordinal": 3, 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 0 29 + }, 30 + "nullable": [ 31 + true, 32 + true, 33 + true, 34 + true 35 + ] 36 + }, 37 + "hash": "794b666df6d03afb34990aac32c093da05e2d267359d2d3c2cbee3c9994e972f" 38 + }
+38
.sqlx/query-7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "cache_hits!", 8 + "ordinal": 0, 9 + "type_info": "Int" 10 + }, 11 + { 12 + "name": "cache_misses!", 13 + "ordinal": 1, 14 + "type_info": "Int" 15 + }, 16 + { 17 + "name": "total_bytes!", 18 + "ordinal": 2, 19 + "type_info": "Int" 20 + }, 21 + { 22 + "name": "net_ms!", 23 + "ordinal": 3, 24 + "type_info": "Int" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 0 29 + }, 30 + "nullable": [ 31 + false, 32 + false, 33 + false, 34 + false 35 + ] 36 + }, 37 + "hash": "7bb2f27051c89ba91931c129b6b37e3f88ffa1b26248b428c91fa4b367178d50" 38 + }
+39
Cargo.lock
··· 309 309 ] 310 310 311 311 [[package]] 312 + name = "directories" 313 + version = "5.0.1" 314 + source = "registry+https://github.com/rust-lang/crates.io-index" 315 + checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 316 + dependencies = [ 317 + "dirs-sys", 318 + ] 319 + 320 + [[package]] 321 + name = "dirs-sys" 322 + version = "0.4.1" 323 + source = "registry+https://github.com/rust-lang/crates.io-index" 324 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 325 + dependencies = [ 326 + "libc", 327 + "option-ext", 328 + "redox_users", 329 + "windows-sys 0.48.0", 330 + ] 331 + 332 + [[package]] 312 333 name = "displaydoc" 313 334 version = "0.2.5" 314 335 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 916 937 "bytes", 917 938 "chrono", 918 939 "clap", 940 + "directories", 919 941 "futures", 920 942 "serde", 921 943 "serde_json", ··· 1002 1024 version = "1.70.2" 1003 1025 source = "registry+https://github.com/rust-lang/crates.io-index" 1004 1026 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1027 + 1028 + [[package]] 1029 + name = "option-ext" 1030 + version = "0.2.0" 1031 + source = "registry+https://github.com/rust-lang/crates.io-index" 1032 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1005 1033 1006 1034 [[package]] 1007 1035 name = "parking_lot" ··· 1190 1218 checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" 1191 1219 dependencies = [ 1192 1220 "bitflags", 1221 + ] 1222 + 1223 + [[package]] 1224 + name = "redox_users" 1225 + version = "0.4.6" 1226 + source = "registry+https://github.com/rust-lang/crates.io-index" 1227 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 1228 + dependencies = [ 1229 + "getrandom 0.2.17", 1230 + "libredox", 1231 + "thiserror", 1193 1232 ] 1194 1233 1195 1234 [[package]]
+2 -1
Cargo.toml
··· 11 11 tracing-subscriber = "0.3" 12 12 sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } 13 13 chrono = "0.4" 14 - clap = { version = "4", features = ["derive"] } 14 + clap = { version = "4", features = ["derive", "env"] } 15 15 anyhow = "1.0" 16 + directories = "5.0" 16 17 serde = { version = "1.0", features = ["derive"] } 17 18 serde_json = "1.0" 18 19 bytes = "1.0"
+4 -5
flake.nix
··· 4 4 inputs = { 5 5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 6 rust-overlay.url = "github:oxalica/rust-overlay"; 7 + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; 7 8 }; 8 9 9 10 outputs = { self, nixpkgs, rust-overlay }: ··· 38 39 pkgs.sqlite 39 40 pkgs.sqlx-cli 40 41 ] ++ (if pkgs.stdenv.isDarwin then [ pkgs.iconv ] else []); 41 - 42 - shellHook = '' 43 - export DATABASE_URL="sqlite:nod.db" 44 - ''; 45 42 }; 46 43 }); 47 44 ··· 68 65 config = lib.mkIf cfg.enable { 69 66 nix.settings.json-log-path = cfg.socketPath; 70 67 68 + environment.sessionVariables.NOD_SOCKET = cfg.socketPath; 69 + 71 70 systemd.services.nod = { 72 71 description = "Nix Observability Daemon"; 73 72 wantedBy = [ "multi-user.target" ]; 74 73 after = [ "network.target" ]; 75 74 76 75 serviceConfig = { 77 - ExecStart = "${cfg.package}/bin/nod --socket ${cfg.socketPath} --db-url sqlite:${cfg.databasePath} daemon"; 76 + ExecStart = "${cfg.package}/bin/nod daemon --db-url sqlite:${cfg.databasePath} --socket ${cfg.socketPath}"; 78 77 Restart = "always"; 79 78 DynamicUser = true; 80 79 StateDirectory = "nod"; # Creates /var/lib/nod
+212 -87
src/main.rs
··· 1 1 use anyhow::{Context, Result}; 2 2 use chrono::{DateTime, Utc}; 3 3 use clap::{Parser, Subcommand}; 4 + use directories::ProjectDirs; 4 5 use serde::{Deserialize, Serialize}; 5 - use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Pool, Row, Sqlite}; 6 + use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Pool, Sqlite}; 6 7 use std::collections::HashMap; 7 8 use std::fmt; 8 9 use std::path::PathBuf; 9 10 use std::str::FromStr; 10 11 use std::sync::Arc; 11 - use tokio::io::{AsyncBufReadExt, BufReader}; 12 + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 12 13 use tokio::net::{UnixListener, UnixStream}; 13 14 use tokio::sync::Mutex; 14 15 use tracing::{error, info}; ··· 53 54 } 54 55 } 55 56 57 + impl From<i64> for ActivityType { 58 + fn from(n: i64) -> Self { 59 + Self::from(n as u64) 60 + } 61 + } 62 + 56 63 impl fmt::Display for ActivityType { 57 64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 58 65 write!(f, "{:?}", self) ··· 93 100 94 101 #[derive(Parser)] 95 102 struct Cli { 96 - #[arg(short, long, default_value = "/run/nod/nod.sock")] 97 - socket: PathBuf, 98 - 99 - #[arg(short, long, default_value = "sqlite:/var/lib/nod/nod.db")] 100 - db_url: String, 101 - 102 103 #[command(subcommand)] 103 104 command: Option<Commands>, 104 105 } 105 106 106 107 #[derive(Subcommand)] 107 108 enum Commands { 108 - Daemon, 109 - Stats, 109 + /// Run the observability daemon 110 + Daemon { 111 + /// SQLite database URL 112 + #[arg(short, long, env = "DATABASE_URL")] 113 + db_url: Option<String>, 114 + /// Path to the Unix socket 115 + #[arg(short, long, env = "NOD_SOCKET")] 116 + socket: Option<PathBuf>, 117 + }, 118 + /// Show statistics from the daemon 119 + Stats { 120 + /// Path to the Unix socket 121 + #[arg(short, long, env = "NOD_SOCKET")] 122 + socket: Option<PathBuf>, 123 + }, 110 124 } 111 125 112 126 #[derive(Debug, Deserialize, Serialize)] ··· 124 138 parent: u64, 125 139 } 126 140 141 + #[derive(Debug, Serialize, Deserialize)] 142 + struct Stats { 143 + by_type: Vec<TypeStat>, 144 + cache_hits: i32, 145 + cache_misses: i32, 146 + total_bytes: i32, 147 + net_ms: i32, 148 + by_phase: Vec<PhaseStat>, 149 + slowest: Vec<SlowEvent>, 150 + } 151 + 152 + #[derive(Debug, Serialize, Deserialize)] 153 + struct TypeStat { 154 + event_type: i64, // table column INTEGER -> i64 155 + count: i32, // CAST(COUNT()) -> i32 156 + total_ms: Option<i32>, // CAST(SUM()) -> Option<i32> 157 + avg_ms: f64, 158 + } 159 + 160 + #[derive(Debug, Serialize, Deserialize)] 161 + struct PhaseStat { 162 + name: String, 163 + count: i32, // CAST(COUNT()) -> i32 164 + total_ms: Option<i32>, // CAST(SUM()) -> Option<i32> 165 + avg_ms: f64, 166 + } 167 + 168 + #[derive(Debug, Serialize, Deserialize)] 169 + struct SlowEvent { 170 + duration_ms: i64, // table column INTEGER -> i64 171 + event_type: i64, // table column INTEGER -> i64 172 + drv_path: Option<String>, 173 + text: Option<String>, 174 + } 175 + 127 176 struct Activity { 128 177 id: u64, 129 178 parent_id: u64, ··· 145 194 tracing_subscriber::fmt::init(); 146 195 let cli = Cli::parse(); 147 196 148 - let connection_options = SqliteConnectOptions::from_str(&cli.db_url) 149 - .context("Invalid database URL")? 150 - .create_if_missing(true); 197 + let project_dirs = ProjectDirs::from("org", "nixos", "nod"); 151 198 152 - let pool = SqlitePoolOptions::new() 153 - .max_connections(5) 154 - .connect_with(connection_options) 155 - .await 156 - .context("Failed to connect to database")?; 199 + let command = cli.command.unwrap_or(Commands::Daemon { db_url: None, socket: None }); 200 + 201 + match command { 202 + Commands::Daemon { db_url, socket } => { 203 + let db_url = db_url.unwrap_or_else(|| { 204 + let db_path = project_dirs.as_ref() 205 + .map(|d| d.data_dir().join("nod.db")) 206 + .unwrap_or_else(|| PathBuf::from("nod.db")); 207 + format!("sqlite:{}", db_path.display()) 208 + }); 209 + 210 + let socket_path = socket.unwrap_or_else(|| { 211 + project_dirs.as_ref() 212 + .and_then(|d| d.runtime_dir()) 213 + .map(|d| d.join("nod.sock")) 214 + .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock")) 215 + }); 216 + 217 + let connection_options = SqliteConnectOptions::from_str(&db_url) 218 + .context("Invalid database URL")? 219 + .create_if_missing(true); 220 + 221 + if let Ok(opts) = SqliteConnectOptions::from_str(&db_url) { 222 + if let Some(parent) = opts.get_filename().parent() { 223 + if !parent.as_os_str().is_empty() { 224 + std::fs::create_dir_all(parent) 225 + .with_context(|| format!("Failed to create database directory: {}", parent.display()))?; 226 + } 227 + } 228 + } 229 + 230 + if let Some(parent) = socket_path.parent() { 231 + if !parent.as_os_str().is_empty() { 232 + std::fs::create_dir_all(parent) 233 + .with_context(|| format!("Failed to create socket directory: {}", parent.display()))?; 234 + } 235 + } 236 + 237 + let pool = SqlitePoolOptions::new() 238 + .max_connections(5) 239 + .connect_with(connection_options) 240 + .await 241 + .context("Failed to connect to database")?; 242 + 243 + sqlx::migrate!("./migrations") 244 + .run(&pool) 245 + .await 246 + .context("Failed to run database migrations")?; 247 + 248 + run_daemon(socket_path, pool).await.context("Daemon failed")? 249 + } 250 + Commands::Stats { socket } => { 251 + let socket_path = socket.unwrap_or_else(|| { 252 + project_dirs.as_ref() 253 + .and_then(|d| d.runtime_dir()) 254 + .map(|d| d.join("nod.sock")) 255 + .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock")) 256 + }); 157 257 158 - sqlx::migrate!("./migrations") 159 - .run(&pool) 160 - .await?; 258 + let mut stream = UnixStream::connect(&socket_path) 259 + .await 260 + .with_context(|| format!("Failed to connect to daemon at {}", socket_path.display()))?; 161 261 162 - match cli.command.unwrap_or(Commands::Daemon) { 163 - Commands::Daemon => run_daemon(cli.socket, pool).await?, 164 - Commands::Stats => show_stats(pool).await?, 262 + stream.write_all(b"get_stats\n").await?; 263 + 264 + let mut reader = BufReader::new(stream); 265 + let mut line = String::new(); 266 + reader.read_line(&mut line).await.context("Daemon closed connection without response")?; 267 + let stats: Stats = serde_json::from_str(&line).context("Invalid response from daemon")?; 268 + display_stats(stats); 269 + } 165 270 } 166 271 167 272 Ok(()) ··· 174 279 })); 175 280 176 281 if socket_path.exists() { 177 - std::fs::remove_file(&socket_path)?; 282 + std::fs::remove_file(&socket_path).with_context(|| format!("Failed to remove existing socket at {}", socket_path.display()))?; 178 283 } 179 284 180 - let listener = UnixListener::bind(&socket_path)?; 285 + let listener = UnixListener::bind(&socket_path) 286 + .with_context(|| format!("Failed to bind to socket at {}", socket_path.display()))?; 181 287 info!("Daemon listening on {}", socket_path.display()); 182 288 183 289 loop { ··· 191 297 } 192 298 } 193 299 194 - async fn handle_connection(stream: UnixStream, state: Arc<Mutex<State>>) -> Result<()> { 195 - let mut reader = BufReader::new(stream); 300 + async fn handle_connection(mut stream: UnixStream, state: Arc<Mutex<State>>) -> Result<()> { 301 + let (reader, mut writer) = stream.split(); 302 + let mut reader = BufReader::new(reader); 196 303 let mut line = String::new(); 197 304 loop { 198 305 line.clear(); 199 306 if reader.read_line(&mut line).await? == 0 { break; } 307 + if line.trim() == "get_stats" { 308 + let pool = state.lock().await.pool.clone(); 309 + let stats = collect_stats(pool).await?; 310 + writer.write_all((serde_json::to_string(&stats)? + "\n").as_bytes()).await?; 311 + break; 312 + } 200 313 if let Ok(event) = serde_json::from_str::<NixEvent>(&line) { 201 314 process_event(event, &state).await?; 202 315 } ··· 296 409 Ok(()) 297 410 } 298 411 299 - async fn show_stats(pool: Pool<Sqlite>) -> Result<()> { 300 - let rows = sqlx::query( 301 - "SELECT event_type, COUNT(*) as count, SUM(duration_ms) as total_ms, AVG(duration_ms) as avg_ms 412 + async fn collect_stats(pool: Pool<Sqlite>) -> Result<Stats> { 413 + let by_type = sqlx::query_as!( 414 + TypeStat, 415 + "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!\" 302 416 FROM events GROUP BY event_type ORDER BY total_ms DESC" 303 417 ).fetch_all(&pool).await?; 304 418 305 - println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); 306 - println!("{}", "-".repeat(57)); 307 - 308 - for row in rows { 309 - let ty: u64 = row.get::<i64, _>("event_type") as u64; 310 - let label = format!("{:?}", ActivityType::from(ty)); 311 - let count: i64 = row.get("count"); 312 - let avg: f64 = row.get::<f64, _>("avg_ms") / 1000.0; 313 - let total: f64 = row.get::<i64, _>("total_ms") as f64 / 1000.0; 314 - 315 - println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, count, avg, total); 316 - } 317 - 318 - let io = sqlx::query( 319 - "SELECT 320 - COUNT(*) FILTER (WHERE event_type = 108) as hits, 321 - COUNT(*) FILTER (WHERE event_type = 105) as misses, 322 - SUM(total_bytes) as total_bytes, 323 - SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)) as net_ms 419 + let io = sqlx::query!( 420 + "SELECT 421 + CAST(COUNT(*) FILTER (WHERE event_type = 108) AS INTEGER) as \"cache_hits!\", 422 + CAST(COUNT(*) FILTER (WHERE event_type = 105) AS INTEGER) as \"cache_misses!\", 423 + CAST(COALESCE(SUM(total_bytes), 0) AS INTEGER) as \"total_bytes!\", 424 + CAST(COALESCE(SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)), 0) AS INTEGER) as \"net_ms!\" 324 425 FROM events" 325 426 ).fetch_one(&pool).await?; 326 427 327 - let hits: i64 = io.get("hits"); 328 - let misses: i64 = io.get("misses"); 329 - let total_io = hits + misses; 330 - let bytes: i64 = io.get::<Option<i64>, _>("total_bytes").unwrap_or(0); 331 - let net_ms: i64 = io.get::<Option<i64>, _>("net_ms").unwrap_or(0); 332 - 333 - let mb = bytes as f64 / 1024.0 / 1024.0; 334 - let speed = if net_ms > 0 { mb / (net_ms as f64 / 1000.0) } else { 0.0 }; 335 - 336 - println!("\n--- PERFORMANCE SUMMARY ---"); 337 - println!("{:<18} {:>10.1}%", "Cache Hit Rate:", if total_io > 0 { (hits as f64 / total_io as f64) * 100.0 } else { 0.0 }); 338 - println!("{:<18} {:>10.2} MB", "Total Data:", mb); 339 - println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); 428 + let by_phase = sqlx::query_as!( 429 + PhaseStat, 430 + "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!\" 431 + FROM phases GROUP BY phase_name ORDER BY total_ms DESC LIMIT 8" 432 + ).fetch_all(&pool).await?; 340 433 341 - println!("\n{:<12} {:<12} {}", "TIME", "TYPE", "DERIVATION / TASK"); 342 - println!("{}", "-".repeat(57)); 343 - 344 - let heavy = sqlx::query( 345 - "SELECT duration_ms, event_type, drv_path, text 346 - FROM events 434 + let slowest = sqlx::query_as!( 435 + SlowEvent, 436 + "SELECT duration_ms as \"duration_ms!\", event_type as \"event_type!\", drv_path, text 437 + FROM events 347 438 WHERE event_type IN (105, 108, 102, 112) 348 439 ORDER BY duration_ms DESC LIMIT 10" 349 440 ).fetch_all(&pool).await?; 350 441 442 + Ok(Stats { 443 + by_type, 444 + cache_hits: io.cache_hits, 445 + cache_misses: io.cache_misses, 446 + total_bytes: io.total_bytes, 447 + net_ms: io.net_ms, 448 + by_phase, 449 + slowest, 450 + }) 451 + } 351 452 352 - for row in heavy { 353 - let ms: i64 = row.get("duration_ms"); 354 - let ty: u64 = row.get::<i64, _>("event_type") as u64; 355 - let label = match ty { 356 - 105 => "Build", 357 - 108 => "Subst", 358 - 112 => "Fetch", 359 - _ => "Other", 360 - }; 361 - 362 - let path = row.get::<Option<String>, _>("drv_path") 363 - .unwrap_or_else(|| row.get::<Option<String>, _>("text").unwrap_or_default()); 364 - 365 - // Remove the directory prefix but keep the hash and the name 366 - let filename = path.strip_prefix("/nix/store/").unwrap_or(&path); 453 + fn display_stats(stats: Stats) { 454 + println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); 455 + println!("{}", "-".repeat(57)); 456 + for row in stats.by_type { 457 + let label = format!("{:?}", ActivityType::from(row.event_type)); 458 + let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; 459 + println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, row.count, row.avg_ms / 1000.0, total_s); 460 + } 367 461 368 - println!("{:>10.2}s {:<10} {}", ms as f64 / 1000.0, label, filename); 462 + let total_io = stats.cache_hits + stats.cache_misses; 463 + let hit_rate = if total_io > 0 { stats.cache_hits as f64 / total_io as f64 * 100.0 } else { 0.0 }; 464 + let mb = stats.total_bytes as f64 / 1024.0 / 1024.0; 465 + let speed = if stats.net_ms > 0 { mb / (stats.net_ms as f64 / 1000.0) } else { 0.0 }; 466 + println!("\n--- PERFORMANCE ---"); 467 + println!("{:<18} {:>10.1}%", "Cache Hit Rate:", hit_rate); 468 + println!("{:<18} {:>10.2} MB", "Total Data:", mb); 469 + println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); 470 + 471 + if !stats.by_phase.is_empty() { 472 + println!("\n{:<22} {:>8} {:>12} {:>12}", "BUILD PHASE", "COUNT", "AVG TIME", "TOTAL TIME"); 473 + println!("{}", "-".repeat(57)); 474 + for row in stats.by_phase { 475 + let total_s = row.total_ms.unwrap_or(0) as f64 / 1000.0; 476 + println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", row.name, row.count, row.avg_ms / 1000.0, total_s); 477 + } 478 + } 479 + 480 + if !stats.slowest.is_empty() { 481 + println!("\n{:<12} {:<10} {}", "DURATION", "TYPE", "DERIVATION / TASK"); 482 + println!("{}", "-".repeat(57)); 483 + for row in stats.slowest { 484 + let label = match row.event_type { 485 + 105 => "Build", 486 + 108 => "Subst", 487 + 112 => "Fetch", 488 + 102 => "Realise", 489 + _ => "Other", 490 + }; 491 + let path = row.drv_path.or(row.text).unwrap_or_default(); 492 + let name = path.strip_prefix("/nix/store/").unwrap_or(&path); 493 + println!("{:>10.2}s {:<10} {}", row.duration_ms as f64 / 1000.0, label, name); 494 + } 369 495 } 370 - 496 + 371 497 println!(); 372 - Ok(()) 373 498 }