carlos / platform Public

metrics: pure parsers for /proc and cgroup v2 readings

Adds internal/metrics as the pure half of host-process monitoring:
parsers for /proc/stat, /proc/loadavg, and cgroup v2 cpu.stat/
memory.*/io.stat, plus the CPU-fraction helpers built on top of them.
No OS access lives here — callers in a later task own reading the
files; every parser fails toward nil rather than a guessed zero, per
the internal/host/probes.go convention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f288ec6b48b4c8ce054532a3b66dc312881ef539 parent 63a350b
2 files changed, +298 −0
  • internal/metrics/parse.go +170 −0
  • internal/metrics/parse_test.go +128 −0
diff --git a/internal/metrics/parse.go b/internal/metrics/parse.go
new file mode 100644
index 000000000..f53fcee73
--- /dev/null
+++ b/internal/metrics/parse.go
@@ -0,0 +1,170 @@
+// Package metrics is the pure half of host-process monitoring: every
+// function here parses a string or a pair of prior readings and MUST fail
+// toward nil, never a guessed zero. A zero CPU fraction and "we couldn't
+// read /proc/stat" are different facts, and a caller several layers up
+// (status pages, alerting) cannot tell them apart once a parser has already
+// collapsed one into the other — so a garbled line, a missing field, or a
+// counter that has gone backwards (reboot, cgroup recreated) all return nil
+// rather than 0 or the previous value. Nothing in this package touches the
+// filesystem or the OS: callers own reading /proc/stat, /proc/loadavg, and
+// cgroup v2 files, and pass the resulting text or prior/current readings in.
+package metrics
+
+import (
+ "strconv"
+ "strings"
+ "time"
+)
+
+// CPUCounters is a monotonic reading of /proc/stat's aggregate cpu line.
+type CPUCounters struct{ Busy, Total uint64 }
+
+// ParseProcStat parses the aggregate "cpu" line of /proc/stat (fields after
+// "cpu" are user nice system idle iowait irq softirq steal, with optional
+// guest/guest_nice columns already folded into user/nice by the kernel on
+// modern systems). Total is the sum of the first 8 fields; Busy is Total
+// minus idle and iowait — deliberately only the first 8, so a kernel that
+// adds guest columns is never double-counted. Any missing "cpu" prefix, too
+// few fields, or a non-numeric field -> nil.
+func ParseProcStat(text string) *CPUCounters {
+ for _, line := range strings.Split(text, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 9 || fields[0] != "cpu" {
+ continue
+ }
+ var vals [8]uint64
+ ok := true
+ for i := 0; i < 8; i++ {
+ v, err := strconv.ParseUint(fields[i+1], 10, 64)
+ if err != nil {
+ ok = false
+ break
+ }
+ vals[i] = v
+ }
+ if !ok {
+ continue
+ }
+ var total uint64
+ for _, v := range vals {
+ total += v
+ }
+ idle, iowait := vals[3], vals[4]
+ return &CPUCounters{Busy: total - idle - iowait, Total: total}
+ }
+ return nil
+}
+
+// CPUFrac turns two /proc/stat readings into a fraction of CPU busy over the
+// interval between them. Both readings must be non-nil and cur.Total must
+// exceed prev.Total — counters that went backwards mean the box rebooted or
+// the reading is otherwise invalid, and reporting a fraction from that would
+// be a guess dressed up as a measurement, so it's nil instead.
+func CPUFrac(prev, cur *CPUCounters) *float64 {
+ if prev == nil || cur == nil || cur.Total <= prev.Total {
+ return nil
+ }
+ frac := float64(cur.Busy-prev.Busy) / float64(cur.Total-prev.Total)
+ return &frac
+}
+
+// ParseLoadAvg parses /proc/loadavg's first three whitespace-separated
+// fields (1/5/15-minute load averages). Any missing or non-numeric field
+// fails the whole reading -> all three results nil, since a partial load
+// average (e.g. l1 present but l5 garbled) is not a value worth publishing
+// on its own.
+func ParseLoadAvg(text string) (l1, l5, l15 *float64) {
+ fields := strings.Fields(text)
+ if len(fields) < 3 {
+ return nil, nil, nil
+ }
+ v1, err1 := strconv.ParseFloat(fields[0], 64)
+ v5, err5 := strconv.ParseFloat(fields[1], 64)
+ v15, err15 := strconv.ParseFloat(fields[2], 64)
+ if err1 != nil || err5 != nil || err15 != nil {
+ return nil, nil, nil
+ }
+ return &v1, &v5, &v15
+}
+
+// ParseCgroupCPUUsec parses cpu.stat's usage_usec line. Missing key or a
+// non-numeric value -> nil.
+func ParseCgroupCPUUsec(text string) *int64 {
+ for _, line := range strings.Split(text, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) != 2 || fields[0] != "usage_usec" {
+ continue
+ }
+ v, err := strconv.ParseInt(fields[1], 10, 64)
+ if err != nil {
+ return nil
+ }
+ return &v
+ }
+ return nil
+}
+
+// ParseCgroupInt parses a single cgroup v2 scalar file such as
+// memory.current or memory.peak. cgroup v2 uses the literal string "max" to
+// mean "no limit set" — a real value, but not a number, so it maps to nil
+// exactly like a parse failure would, since callers only want a byte count
+// or nothing.
+func ParseCgroupInt(text string) *int64 {
+ s := strings.TrimSpace(text)
+ if s == "" || s == "max" {
+ return nil
+ }
+ v, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return nil
+ }
+ return &v
+}
+
+// ParseCgroupIO parses io.stat, summing rbytes and wbytes across every
+// per-device line (a cgroup can have I/O against several block devices at
+// once, and the caller wants one whole-cgroup total). A line missing both
+// keys is simply skipped rather than failing the whole parse — io.stat's
+// per-device format is otherwise stable — but if no line anywhere yields
+// either key, both results are nil rather than a false zero.
+func ParseCgroupIO(text string) (rbytes, wbytes *int64) {
+ var rsum, wsum int64
+ found := false
+ for _, line := range strings.Split(text, "\n") {
+ for _, field := range strings.Fields(line) {
+ key, val, ok := strings.Cut(field, "=")
+ if !ok {
+ continue
+ }
+ switch key {
+ case "rbytes":
+ if v, err := strconv.ParseInt(val, 10, 64); err == nil {
+ rsum += v
+ found = true
+ }
+ case "wbytes":
+ if v, err := strconv.ParseInt(val, 10, 64); err == nil {
+ wsum += v
+ found = true
+ }
+ }
+ }
+ }
+ if !found {
+ return nil, nil
+ }
+ return &rsum, &wsum
+}
+
+// UnitCPUFrac turns two cgroup cpu.stat usage_usec readings and the elapsed
+// wall time between them into a fraction of a CPU core busy. Both readings
+// must be non-nil, cur must not be behind prev (cgroup recreated, counter
+// reset), and elapsed must be strictly positive — any of those failing
+// would turn a division into either a guess or a crash, so it's nil instead.
+func UnitCPUFrac(prevUsec, curUsec *int64, elapsed time.Duration) *float64 {
+ if prevUsec == nil || curUsec == nil || *curUsec < *prevUsec || elapsed <= 0 {
+ return nil
+ }
+ frac := float64(*curUsec-*prevUsec) / 1e6 / elapsed.Seconds()
+ return &frac
+}
diff --git a/internal/metrics/parse_test.go b/internal/metrics/parse_test.go
new file mode 100644
index 000000000..726caf91e
--- /dev/null
+++ b/internal/metrics/parse_test.go
@@ -0,0 +1,128 @@
+package metrics
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func TestParseProcStat(t *testing.T) {
+ // user nice system idle iowait irq softirq steal
+ c := ParseProcStat("cpu 100 20 30 800 50 5 5 0 0 0\ncpu0 1 2 3 4 5 6 7 8 9 10\n")
+ if c == nil {
+ t.Fatal("nil for valid input")
+ }
+ // Busy = everything except idle(800) and iowait(50) = 160; Total = 1010.
+ if c.Busy != 160 || c.Total != 1010 {
+ t.Fatalf("got busy=%d total=%d, want 160/1010", c.Busy, c.Total)
+ }
+ if ParseProcStat("") != nil || ParseProcStat("cpu x y z") != nil {
+ t.Fatal("garbage must parse to nil")
+ }
+}
+
+func TestCPUFrac(t *testing.T) {
+ prev := &CPUCounters{Busy: 100, Total: 1000}
+ cur := &CPUCounters{Busy: 160, Total: 1100}
+ f := CPUFrac(prev, cur)
+ if f == nil || math.Abs(*f-0.6) > 1e-9 { // 60 busy / 100 total
+ t.Fatalf("got %v, want 0.6", f)
+ }
+ if CPUFrac(nil, cur) != nil || CPUFrac(prev, nil) != nil {
+ t.Fatal("nil input must be nil")
+ }
+ if CPUFrac(cur, prev) != nil { // counters went backwards (reboot) — no guess
+ t.Fatal("backwards counters must be nil")
+ }
+}
+
+func TestParseLoadAvg(t *testing.T) {
+ l1, l5, l15 := ParseLoadAvg("0.52 0.41 0.30 1/234 5678")
+ if l1 == nil || l5 == nil || l15 == nil {
+ t.Fatal("nil for valid input")
+ }
+ if math.Abs(*l1-0.52) > 1e-9 || math.Abs(*l5-0.41) > 1e-9 || math.Abs(*l15-0.30) > 1e-9 {
+ t.Fatalf("got %v/%v/%v, want 0.52/0.41/0.30", *l1, *l5, *l15)
+ }
+
+ l1, l5, l15 = ParseLoadAvg("garbage")
+ if l1 != nil || l5 != nil || l15 != nil {
+ t.Fatal("garbage must be all-nil")
+ }
+
+ l1, l5, l15 = ParseLoadAvg("")
+ if l1 != nil || l5 != nil || l15 != nil {
+ t.Fatal("empty must be all-nil")
+ }
+}
+
+func TestParseCgroupCPUUsec(t *testing.T) {
+ v := ParseCgroupCPUUsec("usage_usec 4500000\nuser_usec 3000000\nsystem_usec 1500000\n")
+ if v == nil || *v != 4500000 {
+ t.Fatalf("got %v, want 4500000", v)
+ }
+ if ParseCgroupCPUUsec("") != nil {
+ t.Fatal("empty must be nil")
+ }
+ if ParseCgroupCPUUsec("user_usec 3000000\nsystem_usec 1500000\n") != nil {
+ t.Fatal("missing usage_usec must be nil")
+ }
+}
+
+func TestParseCgroupInt(t *testing.T) {
+ v := ParseCgroupInt("123456\n")
+ if v == nil || *v != 123456 {
+ t.Fatalf("got %v, want 123456", v)
+ }
+ if ParseCgroupInt("max\n") != nil {
+ t.Fatal(`"max" must be nil`)
+ }
+ if ParseCgroupInt("") != nil {
+ t.Fatal("empty must be nil")
+ }
+ if ParseCgroupInt("not-a-number\n") != nil {
+ t.Fatal("garbage must be nil")
+ }
+}
+
+func TestParseCgroupIO(t *testing.T) {
+ r, w := ParseCgroupIO("259:0 rbytes=1000 wbytes=2000 rios=10 wios=20 dbytes=0 dios=0\n259:1 rbytes=500 wbytes=100 rios=1 wios=1 dbytes=0 dios=0\n")
+ if r == nil || w == nil || *r != 1500 || *w != 2100 {
+ t.Fatalf("got r=%v w=%v, want 1500/2100", r, w)
+ }
+
+ r, w = ParseCgroupIO("")
+ if r != nil || w != nil {
+ t.Fatal("empty must be both-nil")
+ }
+
+ r, w = ParseCgroupIO("garbage line with no equals signs\n")
+ if r != nil || w != nil {
+ t.Fatal("garbage must be both-nil")
+ }
+}
+
+func TestUnitCPUFrac(t *testing.T) {
+ prev := int64(1_000_000)
+ cur := int64(4_000_000)
+ f := UnitCPUFrac(&prev, &cur, 60*time.Second)
+ if f == nil || math.Abs(*f-0.05) > 1e-9 {
+ t.Fatalf("got %v, want 0.05", f)
+ }
+
+ if UnitCPUFrac(nil, &cur, 60*time.Second) != nil {
+ t.Fatal("nil prev must be nil")
+ }
+ if UnitCPUFrac(&prev, nil, 60*time.Second) != nil {
+ t.Fatal("nil cur must be nil")
+ }
+ if UnitCPUFrac(&cur, &prev, 60*time.Second) != nil {
+ t.Fatal("backwards counters must be nil")
+ }
+ if UnitCPUFrac(&prev, &cur, 0) != nil {
+ t.Fatal("zero elapsed must be nil")
+ }
+ if UnitCPUFrac(&prev, &cur, -time.Second) != nil {
+ t.Fatal("negative elapsed must be nil")
+ }
+}