From bd63686312efb698e0e85ffe0be790f756288638 Mon Sep 17 00:00:00 2001 From: Joel Male Date: Wed, 25 Feb 2026 15:35:43 +1000 Subject: [PATCH] feat: add iowait, TCP connection states, and file descriptor metrics - cpu.iowait_percent: % of time CPU is waiting on disk I/O - tcp: established/time_wait/close_wait/listen counts; max_time_wait system limit included on Linux for context - file_descriptors: used vs max (Linux reads /proc/sys/fs/file-nr, macOS reads kern.openfiles / kern.maxfiles via sysctl) These three metrics directly surface the most common causes of unexplained PHP/Laravel timeouts at the system level. --- main.go | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++- payload.json | 12 ++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 5315bff..963e278 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "syscall" "time" @@ -81,7 +82,9 @@ type Metrics struct { Disks []DiskMetrics `json:"disks"` DiskIO DiskIOMetrics `json:"disk_io"` Network *NetworkMetrics `json:"network"` - Processes []ProcessInfo `json:"processes"` + Processes []ProcessInfo `json:"processes"` + TCP *TCPStats `json:"tcp,omitempty"` + FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"` } type SystemInfo struct { @@ -102,10 +105,24 @@ type LoadMetrics struct { type CPUMetrics struct { Percent float64 `json:"percent"` + Iowait float64 `json:"iowait_percent"` PerCore []float64 `json:"per_core"` Count int `json:"core_count"` } +type TCPStats struct { + Established int `json:"established"` + TimeWait int `json:"time_wait"` + CloseWait int `json:"close_wait"` + Listen int `json:"listen"` + MaxTimeWait int `json:"max_time_wait,omitempty"` // Linux only +} + +type FileDescriptors struct { + Used int `json:"used"` + Max int `json:"max"` +} + type MemoryMetrics struct { Total uint64 `json:"total"` Available uint64 `json:"available"` @@ -877,6 +894,15 @@ func collectMetrics(config *Config) (*Metrics, error) { metrics.CPU.Percent = sum / float64(len(cpuPerCore)) } + cpuTimes, err := cpu.Times(false) + if err == nil && len(cpuTimes) > 0 { + t := cpuTimes[0] + total := t.User + t.System + t.Idle + t.Iowait + t.Irq + t.Softirq + t.Steal + t.Nice + t.Guest + t.GuestNice + if total > 0 { + metrics.CPU.Iowait = (t.Iowait / total) * 100 + } + } + cpuCount, err := cpu.Counts(true) if err != nil { return nil, fmt.Errorf("failed to get CPU count: %w", err) @@ -1005,9 +1031,82 @@ func collectMetrics(config *Config) (*Metrics, error) { metrics.Processes = processes } + metrics.TCP = collectTCPStats() + metrics.FileDescriptors = collectFileDescriptors() + return metrics, nil } +func collectTCPStats() *TCPStats { + conns, err := net.Connections("tcp") + if err != nil { + return nil + } + conns6, _ := net.Connections("tcp6") + conns = append(conns, conns6...) + + stats := &TCPStats{} + for _, c := range conns { + switch c.Status { + case "ESTABLISHED": + stats.Established++ + case "TIME_WAIT": + stats.TimeWait++ + case "CLOSE_WAIT": + stats.CloseWait++ + case "LISTEN": + stats.Listen++ + } + } + + // Linux: include the system TIME_WAIT bucket limit for context + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/sys/net/ipv4/tcp_max_tw_buckets"); err == nil { + if v, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + stats.MaxTimeWait = v + } + } + } + + return stats +} + +func collectFileDescriptors() *FileDescriptors { + switch runtime.GOOS { + case "linux": + // /proc/sys/fs/file-nr: allocated unused max + data, err := os.ReadFile("/proc/sys/fs/file-nr") + if err != nil { + return nil + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return nil + } + used, err1 := strconv.Atoi(fields[0]) + max, err2 := strconv.Atoi(fields[2]) + if err1 != nil || err2 != nil { + return nil + } + return &FileDescriptors{Used: used, Max: max} + + case "darwin": + openOut, err1 := exec.Command("sysctl", "-n", "kern.openfiles").Output() + maxOut, err2 := exec.Command("sysctl", "-n", "kern.maxfiles").Output() + if err1 != nil || err2 != nil { + return nil + } + used, err1 := strconv.Atoi(strings.TrimSpace(string(openOut))) + max, err2 := strconv.Atoi(strings.TrimSpace(string(maxOut))) + if err1 != nil || err2 != nil { + return nil + } + return &FileDescriptors{Used: used, Max: max} + } + + return nil +} + func collectTopProcesses() ([]ProcessInfo, error) { pids, err := process.Pids() if err != nil { diff --git a/payload.json b/payload.json index 2e48688..e314162 100644 --- a/payload.json +++ b/payload.json @@ -21,6 +21,7 @@ }, "cpu": { "percent": 34.5, + "iowait_percent": 4.2, "per_core": [ 42.1, 28.3, @@ -74,6 +75,17 @@ "packets_sent_per_sec": 95.3, "packets_recv_per_sec": 140.7 }, + "tcp": { + "established": 42, + "time_wait": 18, + "close_wait": 3, + "listen": 8, + "max_time_wait": 32768 + }, + "file_descriptors": { + "used": 1248, + "max": 98304 + }, "processes": [ { "pid": 1234,