diff --git a/Cargo.lock b/Cargo.lock index ac2af93..ee0a6a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -909,7 +909,7 @@ dependencies = [ ] [[package]] -name = "nix-observability-daemon" +name = "nod" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 2e2a25c..82ab4e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "nix-observability-daemon" +name = "nod" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..5462a55 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +## nod + +A simple self-contained daemon to gather statistics on Nix builds and substitutions using structured JSON logs. + +## requirements + +- nix 2.30 or later (for `json-log-path` support). + +## building + +``` bash +cargo build --release +``` + +## usage + +First you must make sure the nod daemon is running. + +```bash +nod daemon +``` +By default, it listens on `/tmp/nix-observability.sock` and stores data in `nix-obs.db`. + +Then configure nix to output detailed trace logs to the socket. + +Add the following to your `nix.conf` (usually `/etc/nix/nix.conf` or `~/.config/nix/nix.conf`): +```text +json-log-path = /tmp/nix-observability.sock +``` +*Note: Ensure the Nix process has permissions to write to the socket.* + +Just use Nix as usual: +```bash +nix build nixpkgs#hello +``` + +Then view your stats: +```bash +nod stats +``` + +## how? + +The daemon uses the `json-log-path` feature introduced in Nix +2.30. Instead of parsing the complex binary worker protocol, it +consumes a structured stream of events directly from the Nix +process. Relevant +[code](https://github.com/NixOS/nix/blob/b4de973847370204cf28fe2092abdd21f25ee0e8/src/libutil/include/nix/util/logging.hh) diff --git a/flake.nix b/flake.nix index 531844b..64fee71 100644 --- a/flake.nix +++ b/flake.nix @@ -1,42 +1,33 @@ { - description = "A self-contained nix-observability-daemon"; + description = "A simple self-contained daemon to gather nix statistics"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; rust-overlay.url = "github:oxalica/rust-overlay"; }; - outputs = { self, nixpkgs, flake-utils, rust-overlay }: - flake-utils.lib.eachDefaultSystem (system: - let + outputs = { self, nixpkgs, rust-overlay }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { + inherit system; overlays = [ (import rust-overlay) ]; - pkgs = import nixpkgs { - inherit system overlays; - }; - rustToolchain = pkgs.rust-bin.stable.latest.default; - in - { - packages.default = pkgs.rustPlatform.buildRustPackage { - pname = "nix-observability-daemon"; - version = "0.1.0"; - src = ./.; - cargoLock.lockFile = ./Cargo.lock; - nativeBuildInputs = [ pkgs.pkg-config ]; - buildInputs = [ pkgs.sqlite ]; - }; - - devShells.default = pkgs.mkShell { + })); + in + { + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { buildInputs = [ - rustToolchain + pkgs.rust-bin.stable.latest.default pkgs.pkg-config pkgs.sqlite pkgs.sqlx-cli ] ++ (if pkgs.stdenv.isDarwin then [ pkgs.iconv ] else []); + shellHook = '' export DATABASE_URL="sqlite:nix-obs.db" ''; }; - } - ); + }); + }; } diff --git a/src/main.rs b/src/main.rs index b9050a7..a190bb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -305,33 +305,72 @@ async fn show_stats(pool: Pool) -> Result<()> { FROM events GROUP BY event_type ORDER BY total_ms DESC" ).fetch_all(&pool).await?; - println!("{:<20} {:>10} {:>15} {:>15}", "Activity", "Count", "Avg Time", "Total Time"); - println!("{:-<20} {:->10} {:->15} {:->15}", "", "", "", ""); + println!("\n{:<22} {:>8} {:>12} {:>12}", "ACTIVITY TYPE", "COUNT", "AVG TIME", "TOTAL TIME"); + println!("{}", "-".repeat(57)); for row in rows { - let label = ActivityType::from(row.get::("event_type") as u64).to_string(); - println!("{:<20} {:>10} {:>14.2}s {:>14.2}s", label, row.get::("count"), - row.get::("avg_ms") / 1000.0, row.get::("total_ms") as f64 / 1000.0); + 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 cache = sqlx::query( - "SELECT COUNT(*) FILTER (WHERE event_type = 108) as hits, - COUNT(*) FILTER (WHERE event_type = 105) as misses, - SUM(total_bytes) as bytes FROM events" + 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 + FROM events" ).fetch_one(&pool).await?; - let hits: i64 = cache.get("hits"); - let misses: i64 = cache.get("misses"); - let total = hits + misses; - println!("\n--- Cache Performance ---\nHit Rate: {:.1}%\nData Fetched: {:.2} MB", - if total > 0 { (hits as f64 / total as f64) * 100.0 } else { 0.0 }, - cache.get::("bytes") as f64 / 1024.0 / 1024.0); - - println!("\n--- Top 5 Build Phases ---\n{:<20} {:>15}", "Phase", "Avg Duration"); - let phases = sqlx::query("SELECT phase_name, AVG(duration_ms) as avg FROM phases GROUP BY phase_name ORDER BY avg DESC LIMIT 5") - .fetch_all(&pool).await?; - for p in phases { - println!("{:<20} {:>14.2}s", p.get::("phase_name"), p.get::("avg") / 1000.0); + 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 heavy = sqlx::query( + "SELECT duration_ms, 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?; + + + 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); } + + println!(); Ok(()) }