Skip to content

Commit 4aecd3a

Browse files
authored
Merge pull request #171 from devdanzin/fleet-observability-stats-agent
feat(fleet): per-session stats sidecar (StatsAgent) + `fleet report` observability
2 parents 5e36be8 + bc16431 commit 4aecd3a

10 files changed

Lines changed: 1226 additions & 1 deletion

File tree

fleet/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ and capture their logs — no VMs or containers needed.
3434
```bash
3535
sudo ./fleet up # start (nproc-1) instances; or: sudo ./fleet up 8
3636
./fleet status # per-instance state + crashes kept + NEW candidates
37+
./fleet report # rich observability: sessions, throughput, crash taxonomy, disk, health
38+
./fleet report 3 # ...for one instance; add --watch for a live view, --json for scripts
3739
./fleet finds # list the oomNEW dirs across the whole fleet
3840
./fleet tail 3 # follow instance 3's output live
3941
sudo ./fleet down # stop everything

fleet/fleet

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# sudo ./fleet down stop + disable all instances
77
# ./fleet status per-instance state + crash/NEW-find counts
88
# ./fleet finds list this fleet's oomNEW (new-bug candidate) dirs
9+
# ./fleet report [N] observability report: per-instance + campaign (--watch, --json, --no-systemd)
910
# ./fleet triage dedupe oomNEW/oomSEGV candidates across instances (needs INGEST); extra args pass to ingest (e.g. --gdb)
1011
# ./fleet tail [N] follow instance N's log (default: 1)
1112
# sudo ./fleet restart restart all instances (e.g. to pick up a new catalog)
@@ -151,6 +152,15 @@ cmd_triage(){
151152
fi
152153
}
153154

155+
cmd_report(){
156+
# Observability report (per-instance + campaign). Pure-Python reader; put the repo on
157+
# PYTHONPATH so RUNNER_PY can import fusil.python.fleet_report even when fusil isn't
158+
# pip-installed in that venv. All args (N, --watch, --json, --no-systemd) pass through.
159+
local repo; repo="$(dirname "$(dirname "$FUSIL_PY")")"
160+
PYTHONPATH="$repo${PYTHONPATH:+:$PYTHONPATH}" \
161+
"$RUNNER_PY" -m fusil.python.fleet_report --fleet-dir "$FLEET_DIR" "$@"
162+
}
163+
154164
cmd_tail(){
155165
local i="${1:-1}" dir; dir="$(inst_dir "$i")"
156166
if [ "${LOG:-file}" = "none" ]; then
@@ -170,6 +180,7 @@ case "${1:-status}" in
170180
status) cmd_status ;;
171181
finds) cmd_finds ;;
172182
triage) shift; cmd_triage "$@" ;;
183+
report) shift; cmd_report "$@" ;;
173184
tail) shift; cmd_tail "$@" ;;
174-
*) sed -n '2,13p' "$HERE/fleet" | sed 's/^# \{0,1\}//' ;;
185+
*) sed -n '2,14p' "$HERE/fleet" | sed 's/^# \{0,1\}//' ;;
175186
esac

fusil/python/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ def setupProject(self) -> None:
406406
self.source = PythonSource(
407407
project, self.options, source_output_path=self.options.source_output_path
408408
)
409+
# Score-neutral observer: fold each finished session into the run dir's
410+
# fusil_stats.json sidecar (read back by `fleet report`). Parent-side only; does not
411+
# override getScore(), so it never affects scoring or the generated source.
412+
from fusil.python.stats_agent import StatsAgent
413+
414+
StatsAgent(project, self.source)
409415
process = PythonProcess(
410416
project,
411417
self.options,

fusil/python/_report_format.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Small, dependency-free text-formatting helpers for the fleet reporter.
2+
3+
Adapted from lafleur's observability layer (``lafleur/utils.py``, ``lafleur/report.py``) --
4+
copied rather than imported so fusil takes no dependency on lafleur. Pure stdlib.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
11+
12+
def human_bytes(n: float | None) -> str:
13+
"""1536 -> '1.5K', 1234567890 -> '1.1G'. None/negative -> '-'."""
14+
if n is None or n < 0:
15+
return "-"
16+
n = float(n)
17+
for unit in ("B", "K", "M", "G", "T", "P"):
18+
if n < 1024 or unit == "P":
19+
if unit == "B":
20+
return "%dB" % int(n)
21+
return "%.1f%s" % (n, unit)
22+
n /= 1024
23+
return "%.1fP" % n
24+
25+
26+
def format_duration(seconds: float | None) -> str:
27+
"""90061 -> '1d 1h', 305 -> '5m 5s', None -> '-'. Two most-significant units."""
28+
if seconds is None or seconds < 0:
29+
return "-"
30+
seconds = int(seconds)
31+
days, rem = divmod(seconds, 86400)
32+
hours, rem = divmod(rem, 3600)
33+
minutes, secs = divmod(rem, 60)
34+
parts = []
35+
if days:
36+
parts += ["%dd" % days, "%dh" % hours]
37+
elif hours:
38+
parts += ["%dh" % hours, "%dm" % minutes]
39+
elif minutes:
40+
parts += ["%dm" % minutes, "%ds" % secs]
41+
else:
42+
parts = ["%ds" % secs]
43+
return " ".join(parts[:2])
44+
45+
46+
def format_rate(count: int, seconds: float | None) -> str:
47+
"""Per-minute rate, e.g. 3092 sessions over 2h -> '25.8/m'. '-' if no elapsed time."""
48+
if not seconds or seconds <= 0:
49+
return "-"
50+
return "%.1f/m" % (count / (seconds / 60.0))
51+
52+
53+
def dir_size_bytes(path: str) -> int:
54+
"""Total size of a directory tree in bytes (best-effort; unreadable entries skipped)."""
55+
total = 0
56+
for root, _dirs, files in os.walk(path, onerror=lambda _e: None):
57+
for name in files:
58+
try:
59+
total += os.lstat(os.path.join(root, name)).st_size
60+
except OSError:
61+
pass
62+
return total

0 commit comments

Comments
 (0)