Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/memory/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type VirtualMemory struct {
TotalBytes uint64
AvailableBytes uint64
FreeBytes uint64
UsedBytes uint64
UsedPercent float64
BuffersBytes uint64
Expand Down Expand Up @@ -51,6 +52,7 @@ func (gopsutilCollector) Collect() (VirtualMemory, SwapMemory, error) {
return VirtualMemory{
TotalBytes: vm.Total,
AvailableBytes: vm.Available,
FreeBytes: vm.Free,
UsedBytes: vm.Used,
UsedPercent: vm.UsedPercent,
BuffersBytes: vm.Buffers,
Expand Down
62 changes: 39 additions & 23 deletions internal/memory/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,36 @@ func captureProfile(c Collector) ProfileInfo {
return ProfileInfo{TotalBytes: vm.TotalBytes}
}

// Name returns "memory".
func (m *Module) Name() string { return "memory" }
// Enabled reports that the memory module is always active.
func (m *Module) Enabled() bool { return true }
// DisabledReason returns ""; the memory module is never disabled.
func (m *Module) DisabledReason() string { return "" }
// Profile returns the static metadata captured at startup.
func (m *Module) Profile() any { return m.profile }
// Shutdown is a no-op; memory does not hold external resources.
// Name returns "memory".
func (m *Module) Name() string { return "memory" }

// Enabled reports that the memory module is always active.
func (m *Module) Enabled() bool { return true }

// DisabledReason returns ""; the memory module is never disabled.
func (m *Module) DisabledReason() string { return "" }

// Profile returns the static metadata captured at startup.
func (m *Module) Profile() any { return m.profile }

// Shutdown is a no-op; memory does not hold external resources.
func (m *Module) Shutdown(_ context.Context) error { return nil }

// Start launches the sampling goroutine and returns immediately.
// Start launches the sampling goroutine and returns immediately.
func (m *Module) Start(ctx context.Context) error {
go metrics.RunLoop(ctx, m.base.Interval, m.collectOnce)
return nil
}

// Latest returns the most recent sample or nil.
// Latest returns the most recent sample or nil.
func (m *Module) Latest() any {
if m.base == nil {
return nil
}
return m.base.Latest()
}

// History returns samples within the trailing duration d, oldest first.
// History returns samples within the trailing duration d, oldest first.
func (m *Module) History(d time.Duration) []any {
if m.base == nil {
return nil
Expand Down Expand Up @@ -120,15 +124,15 @@ func (m *Module) Peak(d time.Duration) any {
return peak
}

// LastSampleAge returns the time since the most recent sample.
// LastSampleAge returns the time since the most recent sample.
func (m *Module) LastSampleAge() time.Duration {
if m.base == nil {
return time.Duration(1<<63 - 1)
}
return m.base.LastSampleAge()
}

// RegisterRoutes adds /metrics/memory and /metrics/memory/history to mux.
// RegisterRoutes adds /metrics/memory and /metrics/memory/history to mux.
func (m *Module) RegisterRoutes(mux *http.ServeMux) {
metrics.RegisterRoutes(mux, m.base, "memory", m.base.Latest, m.History, nil)
}
Expand All @@ -139,15 +143,11 @@ func (m *Module) collectOnce() {
m.base.Publish(Sample{Timestamp: nowUTC(), Error: err.Error()})
return
}
// htop's "used" excludes reclaimable buffers and page cache. Match
// that here so the webview's stacked bar segments add up to ~100%
// of total without overlap. Saturating subtraction would be wrong
// on non-Linux platforms where the kernel fields are zero; the
// conditional guards against that.
var usedNoCache uint64
if vm.BuffersBytes+vm.CachedBytes < vm.UsedBytes {
usedNoCache = vm.UsedBytes - vm.BuffersBytes - vm.CachedBytes
}
// Compute the htop-style "used" segment from raw meminfo fields when
// available. Do not subtract Buffers/Cached from gopsutil Used; on Linux
// Used is already derived from availability, so that double subtraction
// collapses the webview value to 0 on hosts with large page cache.
usedNoCache := usedWithoutReclaimable(vm)
m.base.Publish(Sample{
Timestamp: nowUTC(),
TotalBytes: vm.TotalBytes,
Expand All @@ -162,3 +162,19 @@ func (m *Module) collectOnce() {
SharedBytes: vm.SharedBytes,
})
}

func usedWithoutReclaimable(vm VirtualMemory) uint64 {
if vm.TotalBytes > 0 && vm.FreeBytes > 0 && vm.FreeBytes <= vm.TotalBytes {
used := vm.TotalBytes - vm.FreeBytes
if vm.BuffersBytes <= used {
used -= vm.BuffersBytes
if vm.CachedBytes <= used {
return used - vm.CachedBytes
}
}
}
if vm.TotalBytes > 0 && vm.AvailableBytes > 0 && vm.AvailableBytes <= vm.TotalBytes {
return vm.TotalBytes - vm.AvailableBytes
}
return vm.UsedBytes
}
40 changes: 31 additions & 9 deletions internal/memory/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ func TestMemory_PublishesSample(t *testing.T) {
if p.TotalBytes != 1000 || p.UsedBytes != 400 || p.UsedPercent != 40 {
t.Errorf("unexpected sample: %+v", *p)
}
if p.UsedNoCacheBytes != 400 {
t.Errorf("used_no_cache = %d, want 400", p.UsedNoCacheBytes)
}
if p.SwapTotalBytes != 200 || p.SwapUsedBytes != 50 {
t.Errorf("swap fields wrong: %+v", *p)
}
Expand All @@ -70,6 +73,25 @@ func TestMemory_PublishesSample(t *testing.T) {
}
}

func TestMemory_UsedNoCacheDoesNotSubtractCacheFromUsed(t *testing.T) {
fc := &fakeCollector{
vm: VirtualMemory{
TotalBytes: 1000,
AvailableBytes: 700,
FreeBytes: 100,
UsedBytes: 300,
UsedPercent: 30,
BuffersBytes: 100,
CachedBytes: 500,
},
}
m := NewWithCollector(fc, time.Second, 4, newTestLogger())
p := m.Latest().(*Sample)
if p.UsedNoCacheBytes != 300 {
t.Errorf("used_no_cache = %d, want 300", p.UsedNoCacheBytes)
}
}

func TestMemory_PropagatesError(t *testing.T) {
fc := &fakeCollector{err: errors.New("boom")}
m := NewWithCollector(fc, time.Second, 4, newTestLogger())
Expand All @@ -87,14 +109,14 @@ func TestMemory_PropagatesError(t *testing.T) {

func TestMemory_Peak(t *testing.T) {
// Total stays constant at 1000 across the window. The "real used"
// (Total - Free - Buffers - Cached) goes 250450150. The peak
// should report 450 and a recomputed UsedPercent of 45.
// (Total - Free - Buffers - Cached) goes 400600300. The peak
// should report 600 and a recomputed UsedPercent of 60.
fc := &fakeCollector{
sw: SwapMemory{TotalBytes: 200, UsedBytes: 50},
seq: []VirtualMemory{
{TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, UsedPercent: 40, BuffersBytes: 100, CachedBytes: 50},
{TotalBytes: 1000, UsedBytes: 600, AvailableBytes: 400, UsedPercent: 60, BuffersBytes: 100, CachedBytes: 50},
{TotalBytes: 1000, UsedBytes: 300, AvailableBytes: 700, UsedPercent: 30, BuffersBytes: 100, CachedBytes: 50},
{TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, FreeBytes: 500, UsedPercent: 40, BuffersBytes: 50, CachedBytes: 50},
{TotalBytes: 1000, UsedBytes: 600, AvailableBytes: 400, FreeBytes: 300, UsedPercent: 60, BuffersBytes: 50, CachedBytes: 50},
{TotalBytes: 1000, UsedBytes: 300, AvailableBytes: 700, FreeBytes: 600, UsedPercent: 30, BuffersBytes: 50, CachedBytes: 50},
},
}
m := NewWithCollector(fc, 30*time.Millisecond, 8, newTestLogger())
Expand All @@ -108,13 +130,13 @@ func TestMemory_Peak(t *testing.T) {
t.Fatal("expected non-nil peak")
}
p := peak.(*Sample)
if p.UsedNoCacheBytes != 450 {
t.Errorf("peak used_no_cache = %d, want 450", p.UsedNoCacheBytes)
if p.UsedNoCacheBytes != 600 {
t.Errorf("peak used_no_cache = %d, want 600", p.UsedNoCacheBytes)
}
// UsedPercent is recomputed from the peaked used_no_cache, not taken
// from any one sample.
if p.UsedPercent != 45 {
t.Errorf("peak used_percent = %f, want 45", p.UsedPercent)
if p.UsedPercent != 60 {
t.Errorf("peak used_percent = %f, want 60", p.UsedPercent)
}
// Non-stress fields come from the latest in-window sample (300 used).
if p.UsedBytes != 300 {
Expand Down
12 changes: 5 additions & 7 deletions internal/memory/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ import "time"
// underlying gopsutil calls failed; in that case all numeric fields are
// zero values.
//
// UsedBytes is gopsutil's "Total - Free", which INCLUDES buffers and
// page cache. Most `free -h` style tools report this number. htop's
// "used" bar shows a different concept — non-reclaimable memory only
// (Total - Free - Buffers - Cached); that value is exposed as
// UsedNoCacheBytes so the webview can render an htop-faithful stacked
// bar where the used / buffers / cached segments add up to ~100% of
// total without overlap.
// UsedBytes is gopsutil's human-consumable used memory. On Linux that is
// Total - Available, not Total - Free. htop's "used" bar shows a narrower
// concept: Total - Free - Buffers - Cached. That value is exposed as
// UsedNoCacheBytes so the webview can render the used / buffers / cached
// segments without double-counting reclaimable cache.
//
// The kernel breakdown fields (BuffersBytes / CachedBytes / SharedBytes)
// are populated on Linux from /proc/meminfo via gopsutil; they are zero on
Expand Down
Loading