carlos / platform Public

host: raw metric probes (mem/load/disk/release/tunnel) into the heartbeat Box

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a01ed85b0eb126eeb205702a1c6c7099fad9192b parent a65af34
3 files changed, +112 −0
  • internal/host/agent.go +8 −0
  • internal/host/host_test.go +20 −0
  • internal/host/probes.go +84 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 59d4a5e07..8316313ca 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -87,6 +87,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/version"
)
// dataDir resolves the fleet data directory (CARLOS_DATA_DIR, default /data).
@@ -624,6 +625,7 @@ func statusLoop(reg *registry.Registry) {
continue
}
+ memTotal, memAvail := readMemBytes()
p := status.Probes{
MachineID: readMachineID(),
Region: boxRegion,
@@ -647,6 +649,12 @@ func statusLoop(reg *registry.Registry) {
// skew?) would be worse than the honest null CollectBox already
// knows how to carry — wiring this up is a seam for later, not
// a gap to paper over now.
+ MemTotalBytes: memTotal,
+ MemAvailBytes: memAvail,
+ Load1: readLoad1(),
+ DiskFreeBytes: readDiskFreeBytes(),
+ Release: version.Build,
+ Tunnel: tunnelState(),
}
// path.Join (an S3 key, not a filesystem path — matches
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index a94cc4d65..4aa4995cc 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -490,6 +490,26 @@ func TestAdoptTimerStateReading(t *testing.T) {
func boolPtr(v bool) *bool { return &v }
+func TestParseMeminfoBytes(t *testing.T) {
+ total, avail := parseMeminfoBytes(strings.NewReader(
+ "MemTotal: 3961472 kB\nMemFree: 100 kB\nMemAvailable: 1980736 kB\n"))
+ if total == nil || *total != 3961472*1024 || avail == nil || *avail != 1980736*1024 {
+ t.Fatalf("got %v %v", total, avail)
+ }
+ if tt, aa := parseMeminfoBytes(strings.NewReader("garbage")); tt != nil || aa != nil {
+ t.Fatal("unparseable meminfo must yield nil, not zero")
+ }
+}
+
+func TestParseLoad1(t *testing.T) {
+ if l := parseLoad1("0.42 0.36 0.30 1/234 5678\n"); l == nil || *l != 0.42 {
+ t.Fatalf("got %v", l)
+ }
+ if l := parseLoad1(""); l != nil {
+ t.Fatal("empty loadavg must yield nil")
+ }
+}
+
// TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the
// monitoring spec commits to: operator-grade needles live under metrics/ and
// alerts/, and nothing the public status publisher writes may ever be
diff --git a/internal/host/probes.go b/internal/host/probes.go
index 4fe69a30c..d51858002 100644
--- a/internal/host/probes.go
+++ b/internal/host/probes.go
@@ -12,6 +12,7 @@ package host
import (
"bufio"
+ "io"
"os"
"os/exec"
"strconv"
@@ -218,3 +219,86 @@ func wrapListeningProbe(unitBacked map[string]bool, probe func(string) *bool) fu
return probe(sock)
}
}
+
+// parseMeminfoBytes reads /proc/meminfo's MemTotal and MemAvailable (both in
+// kB) and returns them converted to bytes. Either field missing or
+// unparsable -> nil for both.
+func parseMeminfoBytes(r io.Reader) (*int64, *int64) {
+ var totalKB, availKB int64
+ haveTotal, haveAvail := false, false
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 2 {
+ continue
+ }
+ v, err := strconv.ParseInt(fields[1], 10, 64)
+ if err != nil {
+ continue
+ }
+ switch fields[0] {
+ case "MemTotal:":
+ totalKB, haveTotal = v, true
+ case "MemAvailable:":
+ availKB, haveAvail = v, true
+ }
+ }
+ if err := scanner.Err(); err != nil || !haveTotal || !haveAvail || totalKB <= 0 {
+ return nil, nil
+ }
+ totalBytes := totalKB * 1024
+ availBytes := availKB * 1024
+ return &totalBytes, &availBytes
+}
+
+// readMemBytes reads /proc/meminfo and returns MemTotal and MemAvailable as
+// bytes. Unreadable or unparseable -> nil, nil.
+func readMemBytes() (*int64, *int64) {
+ f, err := os.Open("/proc/meminfo")
+ if err != nil {
+ return nil, nil
+ }
+ defer f.Close()
+ return parseMeminfoBytes(f)
+}
+
+// parseLoad1 parses /proc/loadavg's first field (1-minute load average) from
+// a string. The file format is: "load1 load5 load15 running/total lastpid".
+// Empty or unparseable -> nil.
+func parseLoad1(s string) *float64 {
+ fields := strings.Fields(s)
+ if len(fields) == 0 {
+ return nil
+ }
+ l, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return nil
+ }
+ return &l
+}
+
+// readLoad1 reads /proc/loadavg and returns the 1-minute load average.
+// Unreadable or unparseable -> nil.
+func readLoad1() *float64 {
+ b, err := os.ReadFile("/proc/loadavg")
+ if err != nil {
+ return nil
+ }
+ return parseLoad1(string(b))
+}
+
+// readDiskFreeBytes reports how much free space is available on the data
+// volume, via syscall.Statfs on dataDir() — /data by default, CARLOS_DATA_DIR
+// when set. Statfs failure or a zero-block filesystem -> nil rather than a
+// crash.
+func readDiskFreeBytes() *int64 {
+ var st syscall.Statfs_t
+ if err := syscall.Statfs(dataDir(), &st); err != nil {
+ return nil
+ }
+ if st.Blocks == 0 {
+ return nil
+ }
+ freeBytes := int64(st.Bavail) * st.Bsize
+ return &freeBytes
+}