blob: 8be7c3865a3d12688a285fb762a170cc24d13d75 [file] [edit]
# 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)