···2323chrono = "0.4"
2424clap = { version = "4", features = ["derive", "env"] }
2525anyhow = "1.0"
2626-directories = "5.0"
2726serde = { version = "1.0", features = ["derive"] }
2827serde_json = "1.0"
2828+directories = "5.0"
29293030[dev-dependencies]
3131criterion = { version = "0.5", features = ["html_reports"] }
+33-12
README.md
···11## nod
2233-A simple daemon that collects Nix build and substitution statistics using structured JSON logs.
33+A daemon that collects Nix build and substitution statistics using structured JSON logs.
4455## requirements
66···2323Point Nix at the socket in `nix.conf`:
24242525```
2626-json-log-path = /run/user/1000/nod/nod.sock
2626+json-log-path = /tmp/nod.sock
2727```
28282929-Then use Nix normally. View accumulated stats with:
2929+Then use Nix normally. Query accumulated stats:
30303131```bash
3232-nod stats # all time
3333-nod stats -d 7 # last 7 days
3434-nod stats -m 3 # last 3 months
3535-nod stats -y 1 # last year
3636-nod clean # reset the database
3232+nod # aggregate stats, all time
3333+nod -d 30 # last 30 days
3434+nod -m 3 # last 3 months
3535+nod --drv firefox # filter to derivations matching "firefox"
3636+nod --sort count --group # group by derivation, sort by frequency
3737+nod --bucket day # time series by day
3838+nod --bucket month # time series by month
3939+nod --bucket day --output test # Mann-Whitney significance test vs prior period
4040+nod --bucket day --output csv # CSV output
4141+nod clean # delete all events
4242+nod clean --before-days 90 # delete events older than 90 days
4343+```
4444+4545+## NixOS
4646+4747+```nix
4848+{
4949+ services.nod = {
5050+ enable = true;
5151+ retainDays = 180; # default
5252+ };
5353+}
3754```
38555656+The module sets `nix.settings.json-log-path` automatically and exports `NOD_SOCKET` into `/etc/environment` so every session (login, SSH, scripts) finds the daemon without `--socket`.
5757+3958## configuration
40594160| flag | env | default |
4261|------|-----|---------|
4343-| `--socket` | `NOD_SOCKET` | `$XDG_RUNTIME_DIR/nod/nod.sock` |
4444-| `--db` | `NOD_DB` | `$XDG_DATA_HOME/nod/nod.db` |
6262+| `--socket` | `NOD_SOCKET` | `/tmp/nod.sock` |
6363+| `--db` | `NOD_DB` | `nod.db` (current directory) |
45644646-Both directories are created automatically. The socket path in `nix.conf` must match `--socket`.
6565+When using the NixOS module both are pinned to fixed system paths and exported automatically.
47664867## how?
49685050-Nix 2.30 added `json-log-path`, which writes a stream of structured activity events (start/result/stop) to a file or Unix socket while a build runs. nod listens on that socket, tracks in-flight activities by ID, and on each stop event inserts a completed row into a local SQLite database. `nod stats` queries that database through the daemon.
6969+Nix 2.30 added `json-log-path`, which writes a stream of structured activity events (start/result/stop) to a file or Unix socket while a build runs. nod listens on that socket, tracks in-flight activities by ID, and on each stop event inserts a completed row into a local SQLite database. `nod` queries that database through the daemon socket.
7070+7171+Stats queries are served from a pre-aggregated `daily_stats` table (one row per day per event type) maintained in lockstep with inserts, so summary queries are O(days) regardless of total event count. The current day's aggregates are held in memory and flushed on day rollover.
51725273Relevant Nix source: [logging.hh](https://github.com/NixOS/nix/blob/b4de973847370204cf28fe2092abdd21f25ee0e8/src/libutil/include/nix/util/logging.hh)
···8484 socketPath = lib.mkOption {
8585 type = lib.types.path;
8686 default = "/run/nod/nod.sock";
8787- description = "Path to the Unix socket. Exposed via NOD_SOCKET in the session environment.";
8787+ description = "Path to the Unix socket. Propagated to all sessions via /etc/environment so nod always finds the daemon without --socket.";
8888 };
8989 databasePath = lib.mkOption {
9090 type = lib.types.path;
9191 default = "/var/lib/nod/nod.db";
9292- description = "Path to the SQLite database";
9292+ description = "Path to the SQLite database.";
9393+ };
9494+ retainDays = lib.mkOption {
9595+ type = lib.types.nullOr lib.types.ints.positive;
9696+ default = null;
9797+ description = "Override the retention period in days. When null the daemon default of 180 days is used.";
9398 };
9499 };
95100···101106 };
102107 users.groups.${cfg.group} = {};
103108104104- # Tell nix to forward its internal JSON log to the socket
109109+ # Forward Nix's internal JSON activity log to the daemon socket.
110110+ # The nix-daemon runs as root so the socket directory must be world-searchable
111111+ # and the socket itself must be group-writable (handled by RuntimeDirectoryMode
112112+ # and UMask below). Users that only need to query nod require no group membership.
105113 nix.settings.json-log-path = cfg.socketPath;
106114107107- # Make the socket path available to interactive shells so users
108108- # can run `nod stats` without passing --socket explicitly.
109109- environment.sessionVariables.NOD_SOCKET = cfg.socketPath;
115115+ # Expose the socket path to every session (login, SSH, scripts) via /etc/environment
116116+ # so that `nod` always resolves the socket without needing --socket or NOD_SOCKET set
117117+ # manually. sessionVariables only reaches interactive login shells and would cause
118118+ # "cannot connect to socket" errors in non-login SSH sessions and cron jobs.
119119+ environment.variables.NOD_SOCKET = cfg.socketPath;
120120+ environment.variables.NOD_DB = cfg.databasePath;
121121+122122+ # Make `nod` available to all users without manual systemPackages entries.
123123+ environment.systemPackages = [ cfg.package ];
110124111125 systemd.services.nod = {
112126 description = "Nix Observability Daemon";
113127 wantedBy = ["multi-user.target"];
114114- after = ["network.target"];
128128+ after = ["local-fs.target"];
115129116130 serviceConfig = {
117131 User = cfg.user;
118132 Group = cfg.group;
119119- ExecStart = "${cfg.package}/bin/nod daemon --db ${cfg.databasePath} --socket ${cfg.socketPath}";
133133+ ExecStart = "${cfg.package}/bin/nod daemon --db ${cfg.databasePath} --socket ${cfg.socketPath}"
134134+ + lib.optionalString (cfg.retainDays != null) " --retain-days ${toString cfg.retainDays}";
120135 Restart = "always";
121136 StateDirectory = "nod";
122137 StateDirectoryMode = "0750";
+24-9
src/daemon.rs
···269269 since: Option<i64>,
270270 bucket: BucketSize,
271271 drv: Option<String>,
272272+ // true = return individual duration samples (needed for --output test / Mann-Whitney).
273273+ // false = return aggregates only via the daily_stats fast path.
274274+ #[serde(default)]
275275+ full: bool,
272276 },
273277 Clean {
274278 // Unix timestamp; None means delete everything.
···404408405409fn run_retention(conn: &Connection, retain_days: u32) -> Result<()> {
406410 assert!(retain_days > 0, "retain_days must be > 0");
407407- let cutoff = Utc::now().timestamp() - retain_days as i64 * 86400;
411411+ let cutoff = Utc::now().timestamp() - retain_days as i64 * 86400;
412412+ let cutoff_day = cutoff / 86400;
408413 let deleted = conn.execute("DELETE FROM events WHERE start_time < ?1", [cutoff])
409414 .context("Retention DELETE failed")?;
415415+ conn.execute("DELETE FROM daily_stats WHERE day < ?1", [cutoff_day])
416416+ .context("Retention DELETE daily_stats failed")?;
417417+ conn.execute("DELETE FROM daily_cache_stats WHERE day < ?1", [cutoff_day])
418418+ .context("Retention DELETE daily_cache_stats failed")?;
410419 // TRUNCATE resets and shrinks the WAL file; use RESTART as fallback if readers
411420 // are active (TRUNCATE fails when a reader holds the WAL open).
412421 if conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)").is_err() {
···559568pub async fn run_daemon(
560569 socket_path: PathBuf,
561570 db: Arc<DbConnections>,
562562- retain_days: Option<u32>,
571571+ retain_days: u32,
563572) -> Result<()> {
573573+ assert!(retain_days > 0, "retain_days must be > 0");
564574 let today_day = Utc::now().timestamp() / 86400;
565575 let today = {
566576 let conn = db.reader.lock().unwrap();
···578588 }
579589580590 // Run retention on startup, then every 6 hours.
581581- if let Some(days) = retain_days {
582582- assert!(days > 0, "retain_days must be > 0");
591591+ {
583592 let db_startup = Arc::clone(&db);
584593 tokio::task::spawn_blocking(move || {
585585- run_retention(&db_startup.writer.lock().unwrap(), days)
594594+ run_retention(&db_startup.writer.lock().unwrap(), retain_days)
586595 }).await??;
587596588597 let db_timer = Arc::clone(&db);
···593602 interval.tick().await;
594603 let db2 = Arc::clone(&db_timer);
595604 if let Err(e) = tokio::task::spawn_blocking(move || {
596596- run_retention(&db2.writer.lock().unwrap(), days)
605605+ run_retention(&db2.writer.lock().unwrap(), retain_days)
597606 }).await {
598607 error!("Retention timer task failed: {}", e);
599608 }
···635644 writer.write_all((serde_json::to_string(&stats)? + "\n").as_bytes()).await?;
636645 break;
637646 }
638638- Ok(SocketMessage::Command(ClientCommand::GetTrend { since, bucket, drv })) => {
647647+ Ok(SocketMessage::Command(ClientCommand::GetTrend { since, bucket, drv, full })) => {
648648+ let today = if !full && drv.is_none() && !matches!(bucket, BucketSize::Hour) {
649649+ Some(state.lock().unwrap().today.snapshot())
650650+ } else {
651651+ None
652652+ };
639653 let db = Arc::clone(&db);
640640- let trend = tokio::task::spawn_blocking(move || collect_trend(&db.reader, since, bucket, drv))
641641- .await??;
654654+ let trend = tokio::task::spawn_blocking(move || {
655655+ collect_trend(&db.reader, since, bucket, drv, today, full)
656656+ }).await??;
642657 writer.write_all((serde_json::to_string(&trend)? + "\n").as_bytes()).await?;
643658 break;
644659 }
+18-25
src/main.rs
···7979enum Commands {
8080 /// Run the observability daemon
8181 Daemon {
8282- /// Delete events older than N days; cleanup runs on startup and every 6 hours
8383- #[arg(long, env = "NOD_RETAIN_DAYS")]
8484- retain_days: Option<u32>,
8282+ /// Delete events older than N days; cleanup runs on startup and every 6 hours (default: 180)
8383+ #[arg(long, env = "NOD_RETAIN_DAYS", default_value = "180")]
8484+ retain_days: u32,
8585 },
8686 /// Clear data from the database
8787 Clean {
···9191 },
9292}
93939494-fn resolve_db_path(db: Option<PathBuf>, project_dirs: &Option<ProjectDirs>) -> PathBuf {
9494+fn resolve_db_path(db: Option<PathBuf>) -> PathBuf {
9595 db.unwrap_or_else(|| {
9696- project_dirs.as_ref()
9797- .map(|d| d.data_dir().join("nod.db"))
9696+ ProjectDirs::from("", "", "nod")
9797+ .map(|dirs| dirs.data_local_dir().join("nod.db"))
9898 .unwrap_or_else(|| PathBuf::from("nod.db"))
9999 })
100100}
101101102102-fn resolve_socket_path(socket: Option<PathBuf>, project_dirs: &Option<ProjectDirs>) -> PathBuf {
103103- socket.unwrap_or_else(|| {
104104- project_dirs.as_ref()
105105- .and_then(|d| d.runtime_dir())
106106- .map(|d| d.join("nod.sock"))
107107- .unwrap_or_else(|| PathBuf::from("/tmp/nod.sock"))
108108- })
102102+fn resolve_socket_path(socket: Option<PathBuf>) -> PathBuf {
103103+ socket.unwrap_or_else(|| PathBuf::from("/tmp/nod.sock"))
109104}
110105111106fn is_connection_error(e: &std::io::Error) -> bool {
···153148 tracing_subscriber::fmt::init();
154149 let cli = Cli::parse();
155150156156- let project_dirs = ProjectDirs::from("org", "nixos", "nod");
157157-158151 match cli.command {
159152 Some(Commands::Daemon { retain_days }) => {
160160- if let Some(days) = retain_days {
161161- assert!(days > 0, "--retain-days must be > 0");
162162- }
153153+ assert!(retain_days > 0, "--retain-days must be > 0");
163154164164- let db_path = resolve_db_path(cli.db, &project_dirs);
165165- let socket_path = resolve_socket_path(cli.socket, &project_dirs);
155155+ let db_path = resolve_db_path(cli.db);
156156+ let socket_path = resolve_socket_path(cli.socket);
166157167158 if let Some(parent) = db_path.parent() {
168159 if !parent.as_os_str().is_empty() {
···193184 }
194185195186 Some(Commands::Clean { before_days }) => {
196196- let socket_path = resolve_socket_path(cli.socket, &project_dirs);
187187+ let socket_path = resolve_socket_path(cli.socket);
197188 let before: Option<i64> = before_days.map(|d| {
198189 assert!(d > 0, "--before-days must be > 0");
199190 (Utc::now() - chrono::Duration::days(d as i64)).timestamp()
···216207 }
217208 Err(e) if is_connection_error(&e) => {
218209 // Daemon not running — operate directly on the database.
219219- let db_path = resolve_db_path(cli.db, &project_dirs);
210210+ let db_path = resolve_db_path(cli.db);
220211 let conn = open_db(&db_path)?;
221212 if let Some(ts) = before {
222213 let deleted = conn.execute(
···242233 }
243234244235 let since = compute_since(cli.days, cli.months, cli.years);
245245- let socket_path = resolve_socket_path(cli.socket, &project_dirs);
236236+ let socket_path = resolve_socket_path(cli.socket);
246237247238 match UnixStream::connect(&socket_path).await {
248239 Ok(stream) => {
249240 query_via_socket(stream, since, cli.drv, cli.bucket, cli.sort, cli.limit, cli.group, cli.output).await?;
250241 }
251242 Err(e) if is_connection_error(&e) => {
252252- let db_path = resolve_db_path(cli.db, &project_dirs);
243243+ let db_path = resolve_db_path(cli.db);
253244 query_direct(&db_path, since, cli.drv, cli.bucket, cli.sort, cli.limit, cli.group, &cli.output)?;
254245 }
255246 Err(e) => return Err(e).context("Failed to connect to daemon"),
···278269 "since": since,
279270 "bucket": bucket_size,
280271 "drv": drv,
272272+ "full": matches!(output, OutputFormat::Test),
281273 });
282274 stream.write_all((cmd.to_string() + "\n").as_bytes()).await?;
283275···323315 let conn = Mutex::new(conn);
324316325317 if let Some(bucket_size) = bucket {
326326- let trend = collect_trend(&conn, since, bucket_size, drv)?;
318318+ let full = matches!(output, OutputFormat::Test);
319319+ let trend = collect_trend(&conn, since, bucket_size, drv, None, full)?;
327320 display_trend_output(&trend, output);
328321 } else {
329322 let s = nod::stats::collect_stats(&conn, since, drv.as_deref(), sort, limit, group, None)?;
+148-38
src/stats.rs
···441441#[derive(Debug, Serialize, Deserialize)]
442442pub struct TrendBucket {
443443 pub bucket: String,
444444+ pub build_count: i64,
445445+ pub build_total_ms: i64,
446446+ pub subst_count: i64,
447447+ pub subst_total_ms: i64,
448448+ pub download_bytes: i64,
449449+ // Only populated when full duration data is requested (--output test).
450450+ #[serde(default)]
444451 pub build_durations: Vec<i64>,
452452+ #[serde(default)]
445453 pub subst_durations: Vec<i64>,
446446- pub download_bytes: i64,
447454}
448455449456#[derive(Debug, Serialize, Deserialize)]
···453460 pub drv_filter: Option<String>,
454461}
455462456456-// Query returns raw start_time integers — strftime is computed in Rust via
457457-// bucket_label()/bucket_end() to avoid N SQLite string allocations. Bucket
458458-// boundaries are detected with a single integer comparison per row (ts >= next_bucket)
459459-// so string allocs happen only once per bucket, not once per row.
460460-pub fn collect_trend(
461461- db: &Mutex<Connection>,
463463+// Events-table scan. Returns buckets with individual duration samples populated.
464464+// Required when individual samples are needed (--output test / Mann-Whitney) or
465465+// when a drv filter or hour granularity rules out the daily_stats path.
466466+//
467467+// Raw start_time integers from SQLite — bucket boundaries detected with a single
468468+// integer comparison per row (ts >= next_bucket) so string allocs happen only
469469+// once per bucket, not once per row.
470470+fn trend_from_events(
471471+ conn: &Connection,
462472 since: Option<i64>,
463463- bucket: BucketSize,
464464- drv: Option<String>,
465465-) -> Result<Trend> {
466466- if let Some(ref d) = drv {
467467- assert!(!d.is_empty(), "drv filter must not be empty");
468468- }
469469-470470- let conn = db.lock().unwrap();
471471- let drv_ref = drv.as_deref();
472472-473473+ bucket: &BucketSize,
474474+ drv: Option<&str>,
475475+) -> Result<Vec<TrendBucket>> {
473476 // FileTransfer (101) has NULL drv_path and is intentionally excluded by the drv filter.
474477 // INDEXED BY forces the covering start_time-first index so all four projected columns
475478 // (start_time, event_type, duration_ms, total_bytes) are served without table lookups.
···485488 let mut buckets: Vec<TrendBucket> = vec![];
486489 let mut next_bucket: i64 = 0;
487490488488- for row in stmt.query_map(rusqlite::params![since, drv_ref], |r| {
491491+ for row in stmt.query_map(rusqlite::params![since, drv], |r| {
489492 Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?))
490493 })?.filter_map(|r| r.ok()) {
491494 let (ts, etype, dur, bytes) = row;
···493496494497 if ts >= next_bucket {
495498 buckets.push(TrendBucket {
496496- bucket: bucket_label(ts, &bucket),
499499+ bucket: bucket_label(ts, bucket),
500500+ build_count: 0, build_total_ms: 0,
501501+ subst_count: 0, subst_total_ms: 0,
502502+ download_bytes: 0,
497503 build_durations: vec![],
498504 subst_durations: vec![],
499499- download_bytes: 0,
500505 });
501501- next_bucket = bucket_end(ts, &bucket);
506506+ next_bucket = bucket_end(ts, bucket);
502507 }
503508504509 let last = buckets.last_mut().unwrap();
505510 match etype {
506506- 105 => { assert!(dur >= 0); last.build_durations.push(dur); }
507507- 108 => { assert!(dur >= 0); last.subst_durations.push(dur); }
511511+ 105 => { assert!(dur >= 0); last.build_count += 1; last.build_total_ms += dur; last.build_durations.push(dur); }
512512+ 108 => { assert!(dur >= 0); last.subst_count += 1; last.subst_total_ms += dur; last.subst_durations.push(dur); }
508513 101 => { assert!(bytes >= 0); last.download_bytes += bytes; }
509514 _ => {}
510515 }
511516 }
512517518518+ Ok(buckets)
519519+}
520520+521521+// daily_stats query. O(days) — does not populate build_durations/subst_durations.
522522+// Closed days come from the table; today's partial data is merged from the in-memory
523523+// snapshot so the current day is always included when running via the daemon.
524524+fn trend_from_daily_stats(
525525+ conn: &Connection,
526526+ since: Option<i64>,
527527+ bucket: &BucketSize,
528528+ today: Option<TodaySummary>,
529529+) -> Result<Vec<TrendBucket>> {
530530+ let since_day: Option<i64> = since.map(|ts| ts / 86400);
531531+ let today_day = today.as_ref().map(|t| t.day)
532532+ .unwrap_or_else(|| Utc::now().timestamp() / 86400);
533533+534534+ let mut stmt = conn.prepare(
535535+ "SELECT day * 86400, event_type, count, total_ms, total_bytes
536536+ FROM daily_stats
537537+ WHERE event_type IN (101, 105, 108)
538538+ AND (?1 IS NULL OR day >= ?1)
539539+ AND day < ?2
540540+ ORDER BY day ASC",
541541+ ).context("Failed to prepare trend query")?;
542542+543543+ let mut buckets: Vec<TrendBucket> = vec![];
544544+ let mut next_bucket: i64 = 0;
545545+546546+ for row in stmt.query_map(rusqlite::params![since_day, today_day], |r| {
547547+ Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(4)?))
548548+ })?.filter_map(|r| r.ok()) {
549549+ let (ts, etype, count, total_ms, total_bytes) = row;
550550+ assert!(ts >= 0);
551551+ assert!(count >= 0);
552552+553553+ if ts >= next_bucket {
554554+ buckets.push(TrendBucket {
555555+ bucket: bucket_label(ts, bucket),
556556+ build_count: 0, build_total_ms: 0,
557557+ subst_count: 0, subst_total_ms: 0,
558558+ download_bytes: 0,
559559+ build_durations: vec![],
560560+ subst_durations: vec![],
561561+ });
562562+ next_bucket = bucket_end(ts, bucket);
563563+ }
564564+565565+ let last = buckets.last_mut().unwrap();
566566+ match etype {
567567+ 105 => { last.build_count += count; last.build_total_ms += total_ms; }
568568+ 108 => { last.subst_count += count; last.subst_total_ms += total_ms; }
569569+ 101 => { last.download_bytes += total_bytes; }
570570+ _ => {}
571571+ }
572572+ }
573573+574574+ if let Some(t) = today {
575575+ if t.build_count > 0 || t.subst_count > 0 || t.download_bytes > 0 {
576576+ let today_ts = t.day * 86400;
577577+ if since.map_or(true, |s| today_ts >= s) {
578578+ if today_ts >= next_bucket {
579579+ buckets.push(TrendBucket {
580580+ bucket: bucket_label(today_ts, bucket),
581581+ build_count: t.build_count,
582582+ build_total_ms: t.build_total_ms,
583583+ subst_count: t.subst_count,
584584+ subst_total_ms: t.subst_total_ms,
585585+ download_bytes: t.download_bytes,
586586+ build_durations: vec![],
587587+ subst_durations: vec![],
588588+ });
589589+ } else if let Some(last) = buckets.last_mut() {
590590+ // Today falls in the same week/month bucket as the last historical day.
591591+ last.build_count += t.build_count;
592592+ last.build_total_ms += t.build_total_ms;
593593+ last.subst_count += t.subst_count;
594594+ last.subst_total_ms += t.subst_total_ms;
595595+ last.download_bytes += t.download_bytes;
596596+ }
597597+ }
598598+ }
599599+ }
600600+601601+ Ok(buckets)
602602+}
603603+604604+pub fn collect_trend(
605605+ db: &Mutex<Connection>,
606606+ since: Option<i64>,
607607+ bucket: BucketSize,
608608+ drv: Option<String>,
609609+ today: Option<TodaySummary>,
610610+ full: bool,
611611+) -> Result<Trend> {
612612+ if let Some(ref d) = drv {
613613+ assert!(!d.is_empty(), "drv filter must not be empty");
614614+ }
615615+616616+ let conn = db.lock().unwrap();
617617+618618+ // daily_stats path when individual samples are not needed, no drv filter is active
619619+ // (daily_stats has no per-drv breakdown), and bucket granularity is at least a day
620620+ // (daily_stats has no intra-day resolution).
621621+ let buckets = if !full && drv.is_none() && !matches!(bucket, BucketSize::Hour) {
622622+ trend_from_daily_stats(&conn, since, &bucket, today)?
623623+ } else {
624624+ trend_from_events(&conn, since, &bucket, drv.as_deref())?
625625+ };
626626+513627 for i in 1..buckets.len() {
514628 assert!(buckets[i].bucket > buckets[i - 1].bucket, "buckets must be strictly ascending");
515629 }
···526640 println!("filter: {}", drv);
527641 }
528642529529- print!("{:<bw$} {:>6} {:>10} {:>6} {:>10}", "period", "builds", "build med", "subst", "subst med");
643643+ print!("{:<bw$} {:>6} {:>10} {:>6} {:>10}", "period", "builds", "build avg", "subst", "subst avg");
530644 if has_downloads { print!(" {:>8}", "dl (MB)"); }
531645 println!();
532646···536650 }
537651538652 for b in &trend.buckets {
539539- let mut bs = b.build_durations.clone(); bs.sort_unstable();
540540- let mut ss = b.subst_durations.clone(); ss.sort_unstable();
541541- let build_med = if bs.is_empty() { 0 } else { median_sorted(&bs) as i64 };
542542- let subst_med = if ss.is_empty() { 0 } else { median_sorted(&ss) as i64 };
653653+ let build_avg = if b.build_count > 0 { b.build_total_ms / b.build_count } else { 0 };
654654+ let subst_avg = if b.subst_count > 0 { b.subst_total_ms / b.subst_count } else { 0 };
543655544656 print!("{:<bw$} {:>6} {:>10} {:>6} {:>10}",
545545- b.bucket, b.build_durations.len(), fmt_ms(build_med),
546546- b.subst_durations.len(), fmt_ms(subst_med));
657657+ b.bucket, b.build_count, fmt_ms(build_avg),
658658+ b.subst_count, fmt_ms(subst_avg));
547659 if has_downloads { print!(" {:>8.1}", b.download_bytes as f64 / 1_048_576.0); }
548660 println!();
549661 }
···606718}
607719608720pub fn output_csv_trend(trend: &Trend) {
609609- println!("period,build_count,build_median_ms,subst_count,subst_median_ms,download_bytes");
721721+ println!("period,build_count,build_avg_ms,subst_count,subst_avg_ms,download_bytes");
610722 for b in &trend.buckets {
611611- let mut bs = b.build_durations.clone(); bs.sort_unstable();
612612- let mut ss = b.subst_durations.clone(); ss.sort_unstable();
613613- let build_med = if bs.is_empty() { 0 } else { median_sorted(&bs) as i64 };
614614- let subst_med = if ss.is_empty() { 0 } else { median_sorted(&ss) as i64 };
615615- assert!(build_med >= 0);
616616- assert!(subst_med >= 0);
617617- println!("{},{},{},{},{},{}", b.bucket, b.build_durations.len(), build_med, b.subst_durations.len(), subst_med, b.download_bytes);
723723+ let build_avg = if b.build_count > 0 { b.build_total_ms / b.build_count } else { 0 };
724724+ let subst_avg = if b.subst_count > 0 { b.subst_total_ms / b.subst_count } else { 0 };
725725+ assert!(build_avg >= 0);
726726+ assert!(subst_avg >= 0);
727727+ println!("{},{},{},{},{},{}", b.bucket, b.build_count, build_avg, b.subst_count, subst_avg, b.download_bytes);
618728 }
619729}
620730