| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| SNAPSHOT_INTERVAL_SEC: float = 24.0 * 60.0 * 60.0 # 24 hours | |
| class WorktreeMetricsTracker: | |
| """Collects and aggregates worktree usage metrics locally throughout the day, | |
| uploading a time-weighted statistical summary every 24 hours via fx metrics. | |
| """ | |
| def __init__( | |
| self, | |
| jiri_root: Path, | |
| fuchsia_dir: Path, | |
| metrics_file_path: Path | None = None, | |
| snapshot_interval_sec: float = SNAPSHOT_INTERVAL_SEC, | |
| ): | |
| self.jiri_root = Path(jiri_root).resolve() | |
| self.fuchsia_dir = Path(fuchsia_dir).resolve() | |
| self.metrics_file = ( | |
| Path(metrics_file_path).resolve() | |
| if metrics_file_path is not None | |
| else self.fuchsia_dir / ".fx" / "metrics" / "worktree_metrics.json" | |
| ) | |
| self.snapshot_interval_sec = snapshot_interval_sec | |
| def _load_or_init( | |
| self, | |
| now: float, | |
| current_total: int, | |
| current_leased: int, | |
| current_built_recently: int, | |
| current_not_built_recently: int, | |
| ) -> dict[str, Any]: | |
| if self.metrics_file.exists(): | |
| try: | |
| content = json.loads(self.metrics_file.read_text()) | |
| if ( | |
| "period_start_ts" in content | |
| and "last_change_ts" in content | |
| and "accumulated_total_seconds" in content | |
| and "accumulated_leased_seconds" in content | |
| ): | |
| content.setdefault( | |
| "current_built_recently", current_built_recently | |
| ) | |
| content.setdefault( | |
| "current_not_built_recently", current_not_built_recently | |
| ) | |
| content.setdefault( | |
| "accumulated_built_recently_seconds", 0.0 | |
| ) | |
| content.setdefault( | |
| "accumulated_not_built_recently_seconds", 0.0 | |
| ) | |
| content.setdefault( | |
| "min_built_recently", current_built_recently | |
| ) | |
| content.setdefault( | |
| "max_built_recently", current_built_recently | |
| ) | |
| content.setdefault( | |
| "min_not_built_recently", current_not_built_recently | |
| ) | |
| content.setdefault( | |
| "max_not_built_recently", current_not_built_recently | |
| ) | |
| return content | |
| print( | |
| f"Warning: Corrupted metrics file {self.metrics_file}, resetting state.", | |
| file=sys.stderr, | |
| ) | |
| except Exception as e: | |
| print( | |
| f"Warning: Failed to read metrics file {self.metrics_file} ({e}), resetting state.", | |
| file=sys.stderr, | |
| ) | |
| return { | |
| "period_start_ts": now, | |
| "last_change_ts": now, | |
| "current_total": current_total, | |
| "current_leased": current_leased, | |
| "current_built_recently": current_built_recently, | |
| "current_not_built_recently": current_not_built_recently, | |
| "accumulated_total_seconds": 0.0, | |
| "accumulated_leased_seconds": 0.0, | |
| "accumulated_built_recently_seconds": 0.0, | |
| "accumulated_not_built_recently_seconds": 0.0, | |
| "min_total": current_total, | |
| "max_total": current_total, | |
| "min_leased": current_leased, | |
| "max_leased": current_leased, | |
| "min_built_recently": current_built_recently, | |
| "max_built_recently": current_built_recently, | |
| "min_not_built_recently": current_not_built_recently, | |
| "max_not_built_recently": current_not_built_recently, | |
| } | |
| def record_state( | |
| self, | |
| current_total: int, | |
| current_leased: int, | |
| current_built_recently: int = 0, | |
| current_not_built_recently: int = 0, | |
| now: float | None = None, | |
| ) -> bool: | |
| """Records the latest worktree counts. | |
| Calculates time-weighted metrics and triggers a daily snapshot upload | |
| if the snapshot interval has elapsed. | |
| Returns True if a daily snapshot was emitted, False otherwise. | |
| """ | |
| if now is None: | |
| now = time.time() | |
| data = self._load_or_init( | |
| now, | |
| current_total, | |
| current_leased, | |
| current_built_recently, | |
| current_not_built_recently, | |
| ) | |
| emitted_snapshot = False | |
| # If period duration has exceeded the snapshot interval, finalize and upload | |
| if (now - data["period_start_ts"]) >= self.snapshot_interval_sec: | |
| self._finalize_and_upload(data, now) | |
| emitted_snapshot = True | |
| # Reset period for the new 24h window | |
| data = { | |
| "period_start_ts": now, | |
| "last_change_ts": now, | |
| "current_total": current_total, | |
| "current_leased": current_leased, | |
| "current_built_recently": current_built_recently, | |
| "current_not_built_recently": current_not_built_recently, | |
| "accumulated_total_seconds": 0.0, | |
| "accumulated_leased_seconds": 0.0, | |
| "accumulated_built_recently_seconds": 0.0, | |
| "accumulated_not_built_recently_seconds": 0.0, | |
| "min_total": current_total, | |
| "max_total": current_total, | |
| "min_leased": current_leased, | |
| "max_leased": current_leased, | |
| "min_built_recently": current_built_recently, | |
| "max_built_recently": current_built_recently, | |
| "min_not_built_recently": current_not_built_recently, | |
| "max_not_built_recently": current_not_built_recently, | |
| } | |
| else: | |
| dt = max(0.0, now - data["last_change_ts"]) | |
| data["accumulated_total_seconds"] += data["current_total"] * dt | |
| data["accumulated_leased_seconds"] += data["current_leased"] * dt | |
| data["accumulated_built_recently_seconds"] += ( | |
| data["current_built_recently"] * dt | |
| ) | |
| data["accumulated_not_built_recently_seconds"] += ( | |
| data["current_not_built_recently"] * dt | |
| ) | |
| data["last_change_ts"] = now | |
| data["current_total"] = current_total | |
| data["current_leased"] = current_leased | |
| data["current_built_recently"] = current_built_recently | |
| data["current_not_built_recently"] = current_not_built_recently | |
| data["min_total"] = min(data["min_total"], current_total) | |
| data["max_total"] = max(data["max_total"], current_total) | |
| data["min_leased"] = min(data["min_leased"], current_leased) | |
| data["max_leased"] = max(data["max_leased"], current_leased) | |
| data["min_built_recently"] = min( | |
| data["min_built_recently"], current_built_recently | |
| ) | |
| data["max_built_recently"] = max( | |
| data["max_built_recently"], current_built_recently | |
| ) | |
| data["min_not_built_recently"] = min( | |
| data["min_not_built_recently"], current_not_built_recently | |
| ) | |
| data["max_not_built_recently"] = max( | |
| data["max_not_built_recently"], current_not_built_recently | |
| ) | |
| self._save(data) | |
| return emitted_snapshot | |
| def _compute_averages( | |
| self, data: dict[str, Any], now: float | |
| ) -> tuple[float, dict[str, float]]: | |
| dt = max(0.0, now - data["last_change_ts"]) | |
| total_accum = data["accumulated_total_seconds"] + ( | |
| data["current_total"] * dt | |
| ) | |
| leased_accum = data["accumulated_leased_seconds"] + ( | |
| data["current_leased"] * dt | |
| ) | |
| built_recently_accum = data["accumulated_built_recently_seconds"] + ( | |
| data["current_built_recently"] * dt | |
| ) | |
| not_built_recently_accum = data[ | |
| "accumulated_not_built_recently_seconds" | |
| ] + (data["current_not_built_recently"] * dt) | |
| duration = max(1.0, now - data["period_start_ts"]) | |
| averages = { | |
| "total": round(total_accum / duration, 2), | |
| "leased": round(leased_accum / duration, 2), | |
| "built_recently": round(built_recently_accum / duration, 2), | |
| "not_built_recently": round(not_built_recently_accum / duration, 2), | |
| } | |
| return duration, averages | |
| def _finalize_and_upload(self, data: dict[str, Any], now: float) -> None: | |
| _, averages = self._compute_averages(data, now) | |
| label = json.dumps( | |
| { | |
| "at": averages["total"], | |
| "mnt": data["min_total"], | |
| "mxt": data["max_total"], | |
| "al": averages["leased"], | |
| "mnl": data["min_leased"], | |
| "mxl": data["max_leased"], | |
| "abr": averages["built_recently"], | |
| "mnbr": data["min_built_recently"], | |
| "mxbr": data["max_built_recently"], | |
| "anbr": averages["not_built_recently"], | |
| "mnnbr": data["min_not_built_recently"], | |
| "mxnbr": data["max_not_built_recently"], | |
| }, | |
| separators=(",", ":"), | |
| ) | |
| self._emit_telemetry("daily_snapshot", label) | |
| def _emit_telemetry(self, action: str, label: str) -> None: | |
| report_script = ( | |
| self.fuchsia_dir / "tools/devshell/lib/metrics_custom_report.sh" | |
| ) | |
| if not report_script.exists(): | |
| raise FileNotFoundError( | |
| f"Metrics reporting script not found at {report_script}" | |
| ) | |
| kwargs: dict[str, Any] = { | |
| "stdout": subprocess.DEVNULL, | |
| "stderr": subprocess.DEVNULL, | |
| } | |
| # If file descriptor 10 is open (from scripts/fx), preserve it across exec | |
| # so the telemetry event is bundled into the same GA4 session batch. | |
| try: | |
| os.fstat(10) | |
| kwargs["pass_fds"] = (10,) | |
| except OSError: | |
| pass | |
| subprocess.Popen( | |
| ["bash", str(report_script), "worktree", action, label], | |
| **kwargs, | |
| ) | |
| def get_summary( | |
| self, | |
| current_total: int, | |
| current_leased: int, | |
| current_built_recently: int, | |
| current_not_built_recently: int, | |
| now: float | None = None, | |
| ) -> dict[str, Any]: | |
| if now is None: | |
| now = time.time() | |
| data = self._load_or_init( | |
| now, | |
| current_total, | |
| current_leased, | |
| current_built_recently, | |
| current_not_built_recently, | |
| ) | |
| duration, averages = self._compute_averages(data, now) | |
| return { | |
| "period_start_ts": data["period_start_ts"], | |
| "period_duration_sec": duration, | |
| "current": { | |
| "total": current_total, | |
| "leased": current_leased, | |
| "built_recently": current_built_recently, | |
| "not_built_recently": current_not_built_recently, | |
| }, | |
| "average": averages, | |
| "min": { | |
| "total": min(data["min_total"], current_total), | |
| "leased": min(data["min_leased"], current_leased), | |
| "built_recently": min( | |
| data["min_built_recently"], current_built_recently | |
| ), | |
| "not_built_recently": min( | |
| data["min_not_built_recently"], current_not_built_recently | |
| ), | |
| }, | |
| "max": { | |
| "total": max(data["max_total"], current_total), | |
| "leased": max(data["max_leased"], current_leased), | |
| "built_recently": max( | |
| data["max_built_recently"], current_built_recently | |
| ), | |
| "not_built_recently": max( | |
| data["max_not_built_recently"], current_not_built_recently | |
| ), | |
| }, | |
| "seconds_until_next_snapshot": max( | |
| 0.0, self.snapshot_interval_sec - duration | |
| ), | |
| } | |
| def _save(self, data: dict[str, Any]) -> None: | |
| self.metrics_file.parent.mkdir(parents=True, exist_ok=True) | |
| temp_file = self.metrics_file.with_suffix(".tmp") | |
| temp_file.write_text(json.dumps(data, indent=2)) | |
| temp_file.replace(self.metrics_file) |