Nix Observability Daemon
observability nix
2
fork

Configure Feed

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

wowser

+125 -47
+1 -1
Cargo.lock
··· 909 909 ] 910 910 911 911 [[package]] 912 - name = "nix-observability-daemon" 912 + name = "nod" 913 913 version = "0.1.0" 914 914 dependencies = [ 915 915 "anyhow",
+2 -2
Cargo.toml
··· 1 1 [package] 2 - name = "nix-observability-daemon" 2 + name = "nod" 3 3 version = "0.1.0" 4 - edition = "2021" 4 + edition = "2024" 5 5 6 6 [dependencies] 7 7 tokio = { version = "1", features = ["full"] }
+48
README.md
··· 1 + ## nod 2 + 3 + A simple self-contained daemon to gather statistics on Nix builds and substitutions using structured JSON logs. 4 + 5 + ## requirements 6 + 7 + - nix 2.30 or later (for `json-log-path` support). 8 + 9 + ## building 10 + 11 + ``` bash 12 + cargo build --release 13 + ``` 14 + 15 + ## usage 16 + 17 + First you must make sure the nod daemon is running. 18 + 19 + ```bash 20 + nod daemon 21 + ``` 22 + By default, it listens on `/tmp/nix-observability.sock` and stores data in `nix-obs.db`. 23 + 24 + Then configure nix to output detailed trace logs to the socket. 25 + 26 + Add the following to your `nix.conf` (usually `/etc/nix/nix.conf` or `~/.config/nix/nix.conf`): 27 + ```text 28 + json-log-path = /tmp/nix-observability.sock 29 + ``` 30 + *Note: Ensure the Nix process has permissions to write to the socket.* 31 + 32 + Just use Nix as usual: 33 + ```bash 34 + nix build nixpkgs#hello 35 + ``` 36 + 37 + Then view your stats: 38 + ```bash 39 + nod stats 40 + ``` 41 + 42 + ## how? 43 + 44 + The daemon uses the `json-log-path` feature introduced in Nix 45 + 2.30. Instead of parsing the complex binary worker protocol, it 46 + consumes a structured stream of events directly from the Nix 47 + process. Relevant 48 + [code](https://github.com/NixOS/nix/blob/b4de973847370204cf28fe2092abdd21f25ee0e8/src/libutil/include/nix/util/logging.hh)
+15 -24
flake.nix
··· 1 1 { 2 - description = "A self-contained nix-observability-daemon"; 2 + description = "A simple self-contained daemon to gather nix statistics"; 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 - flake-utils.url = "github:numtide/flake-utils"; 7 6 rust-overlay.url = "github:oxalica/rust-overlay"; 8 7 }; 9 8 10 - outputs = { self, nixpkgs, flake-utils, rust-overlay }: 11 - flake-utils.lib.eachDefaultSystem (system: 12 - let 9 + outputs = { self, nixpkgs, rust-overlay }: 10 + let 11 + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 12 + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { 13 + inherit system; 13 14 overlays = [ (import rust-overlay) ]; 14 - pkgs = import nixpkgs { 15 - inherit system overlays; 16 - }; 17 - rustToolchain = pkgs.rust-bin.stable.latest.default; 18 - in 19 - { 20 - packages.default = pkgs.rustPlatform.buildRustPackage { 21 - pname = "nix-observability-daemon"; 22 - version = "0.1.0"; 23 - src = ./.; 24 - cargoLock.lockFile = ./Cargo.lock; 25 - nativeBuildInputs = [ pkgs.pkg-config ]; 26 - buildInputs = [ pkgs.sqlite ]; 27 - }; 28 - 29 - devShells.default = pkgs.mkShell { 15 + })); 16 + in 17 + { 18 + devShells = forAllSystems (pkgs: { 19 + default = pkgs.mkShell { 30 20 buildInputs = [ 31 - rustToolchain 21 + pkgs.rust-bin.stable.latest.default 32 22 pkgs.pkg-config 33 23 pkgs.sqlite 34 24 pkgs.sqlx-cli 35 25 ] ++ (if pkgs.stdenv.isDarwin then [ pkgs.iconv ] else []); 26 + 36 27 shellHook = '' 37 28 export DATABASE_URL="sqlite:nix-obs.db" 38 29 ''; 39 30 }; 40 - } 41 - ); 31 + }); 32 + }; 42 33 }
+59 -20
src/main.rs
··· 305 305 FROM events GROUP BY event_type ORDER BY total_ms DESC" 306 306 ).fetch_all(&pool).await?; 307 307 308 - println!("{:<20} {:>10} {:>15} {:>15}", "Activity", "Count", "Avg Time", "Total Time"); 309 - println!("{:-<20} {:->10} {:->15} {:->15}", "", "", "", ""); 308 + println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); 309 + println!("{}", "-".repeat(57)); 310 310 311 311 for row in rows { 312 - let label = ActivityType::from(row.get::<i64, _>("event_type") as u64).to_string(); 313 - println!("{:<20} {:>10} {:>14.2}s {:>14.2}s", label, row.get::<i64, _>("count"), 314 - row.get::<f64, _>("avg_ms") / 1000.0, row.get::<i64, _>("total_ms") as f64 / 1000.0); 312 + let ty: u64 = row.get::<i64, _>("event_type") as u64; 313 + let label = format!("{:?}", ActivityType::from(ty)); 314 + let count: i64 = row.get("count"); 315 + let avg: f64 = row.get::<f64, _>("avg_ms") / 1000.0; 316 + let total: f64 = row.get::<i64, _>("total_ms") as f64 / 1000.0; 317 + 318 + println!("{:<22} {:>8} {:>11.2}s {:>11.2}s", label, count, avg, total); 315 319 } 316 320 317 - let cache = sqlx::query( 318 - "SELECT COUNT(*) FILTER (WHERE event_type = 108) as hits, 319 - COUNT(*) FILTER (WHERE event_type = 105) as misses, 320 - SUM(total_bytes) as bytes FROM events" 321 + let io = sqlx::query( 322 + "SELECT 323 + COUNT(*) FILTER (WHERE event_type = 108) as hits, 324 + COUNT(*) FILTER (WHERE event_type = 105) as misses, 325 + SUM(total_bytes) as total_bytes, 326 + SUM(duration_ms) FILTER (WHERE event_type IN (101, 108)) as net_ms 327 + FROM events" 321 328 ).fetch_one(&pool).await?; 322 329 323 - let hits: i64 = cache.get("hits"); 324 - let misses: i64 = cache.get("misses"); 325 - let total = hits + misses; 326 - println!("\n--- Cache Performance ---\nHit Rate: {:.1}%\nData Fetched: {:.2} MB", 327 - if total > 0 { (hits as f64 / total as f64) * 100.0 } else { 0.0 }, 328 - cache.get::<i64, _>("bytes") as f64 / 1024.0 / 1024.0); 330 + let hits: i64 = io.get("hits"); 331 + let misses: i64 = io.get("misses"); 332 + let total_io = hits + misses; 333 + let bytes: i64 = io.get::<Option<i64>, _>("total_bytes").unwrap_or(0); 334 + let net_ms: i64 = io.get::<Option<i64>, _>("net_ms").unwrap_or(0); 335 + 336 + let mb = bytes as f64 / 1024.0 / 1024.0; 337 + let speed = if net_ms > 0 { mb / (net_ms as f64 / 1000.0) } else { 0.0 }; 338 + 339 + println!("\n--- PERFORMANCE SUMMARY ---"); 340 + println!("{:<18} {:>10.1}%", "Cache Hit Rate:", if total_io > 0 { (hits as f64 / total_io as f64) * 100.0 } else { 0.0 }); 341 + println!("{:<18} {:>10.2} MB", "Total Data:", mb); 342 + println!("{:<18} {:>10.2} MB/s", "Avg Net Speed:", speed); 343 + 344 + println!("\n{:<12} {:<12} {}", "TIME", "TYPE", "DERIVATION / TASK"); 345 + println!("{}", "-".repeat(57)); 346 + 347 + let heavy = sqlx::query( 348 + "SELECT duration_ms, event_type, drv_path, text 349 + FROM events 350 + WHERE event_type IN (105, 108, 102, 112) 351 + ORDER BY duration_ms DESC LIMIT 10" 352 + ).fetch_all(&pool).await?; 353 + 354 + 355 + for row in heavy { 356 + let ms: i64 = row.get("duration_ms"); 357 + let ty: u64 = row.get::<i64, _>("event_type") as u64; 358 + let label = match ty { 359 + 105 => "Build", 360 + 108 => "Subst", 361 + 112 => "Fetch", 362 + _ => "Other", 363 + }; 364 + 365 + let path = row.get::<Option<String>, _>("drv_path") 366 + .unwrap_or_else(|| row.get::<Option<String>, _>("text").unwrap_or_default()); 367 + 368 + // Remove the directory prefix but keep the hash and the name 369 + let filename = path.strip_prefix("/nix/store/").unwrap_or(&path); 329 370 330 - println!("\n--- Top 5 Build Phases ---\n{:<20} {:>15}", "Phase", "Avg Duration"); 331 - let phases = sqlx::query("SELECT phase_name, AVG(duration_ms) as avg FROM phases GROUP BY phase_name ORDER BY avg DESC LIMIT 5") 332 - .fetch_all(&pool).await?; 333 - for p in phases { 334 - println!("{:<20} {:>14.2}s", p.get::<String, _>("phase_name"), p.get::<f64, _>("avg") / 1000.0); 371 + println!("{:>10.2}s {:<10} {}", ms as f64 / 1000.0, label, filename); 335 372 } 373 + 374 + println!(); 336 375 Ok(()) 337 376 }