process-metrics 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/process/metrics/general/linux.rb +131 -0
- data/lib/process/metrics/general/process_status.rb +109 -0
- data/lib/process/metrics/general.rb +7 -86
- data/lib/process/metrics/host/memory/darwin.rb +89 -0
- data/lib/process/metrics/host/memory/linux.rb +132 -0
- data/lib/process/metrics/host/memory.rb +49 -0
- data/lib/process/metrics/memory/darwin.rb +34 -53
- data/lib/process/metrics/memory/linux.rb +42 -91
- data/lib/process/metrics/memory.rb +12 -2
- data/lib/process/metrics/version.rb +1 -1
- data/readme.md +10 -12
- data/releases.md +10 -0
- data.tar.gz.sig +0 -0
- metadata +6 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb30f9e1ffed610ad4a6735a8875f6438eae603ce53e6cb67274e7f893f1c4d5
|
|
4
|
+
data.tar.gz: 0474ced532e19377ee8b4b7003c21d1a098afc8df5fb3f9865ea163aa756eced
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 898911e287967bb88596747da31bc6e956bdf4aa8bdea23b461df7998922155d72bd577ca0092320514aa41738547576abd379779a89cf66a22d5a63f0e279f8
|
|
7
|
+
data.tar.gz: 0f78a3b25a090f9823feae392358281e10f1828a0cb4d324ad880a0a877e0ae34d8cd9d4fbedf7018fe847b5dff1b2f2c6a27702eebb61d714afb46faf4eda7a
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2019-2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "etc"
|
|
7
|
+
|
|
8
|
+
module Process
|
|
9
|
+
module Metrics
|
|
10
|
+
# General process information by reading /proc. Used on Linux to avoid spawning `ps`.
|
|
11
|
+
# We read directly from the kernel (proc(5)) so there is no subprocess and no parsing of
|
|
12
|
+
# external command output; same data source as the kernel uses for process accounting.
|
|
13
|
+
# Parses /proc/[pid]/stat and /proc/[pid]/cmdline for each process.
|
|
14
|
+
module General::Linux
|
|
15
|
+
# Clock ticks per second for /proc stat times (utime, stime, starttime).
|
|
16
|
+
CLK_TCK = Etc.sysconf(Etc::SC_CLK_TCK) rescue 100
|
|
17
|
+
|
|
18
|
+
# Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
|
|
19
|
+
PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096
|
|
20
|
+
|
|
21
|
+
# Whether /proc is available so we can list processes without ps.
|
|
22
|
+
def self.supported?
|
|
23
|
+
File.directory?("/proc") && File.readable?("/proc/self/stat")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Capture process information from /proc. If given `pid`, captures only those process(es). If given `ppid`, captures that parent and all descendants. Both can be given to capture a process and its children.
|
|
27
|
+
# @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
|
|
28
|
+
# @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
|
|
29
|
+
# @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
|
|
30
|
+
# @returns [Hash<Integer, General>] Map of PID to General instance.
|
|
31
|
+
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
|
|
32
|
+
# When filtering by ppid we need the full process list to build the parent-child tree,
|
|
33
|
+
# so we enumerate all numeric /proc entries; when only pid is set we read just those.
|
|
34
|
+
pids_to_read = if pid && ppid.nil?
|
|
35
|
+
Array(pid)
|
|
36
|
+
else
|
|
37
|
+
Dir.children("/proc").filter{|e| e.match?(/\A\d+\z/)}.map(&:to_i)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
uptime_jiffies = nil
|
|
41
|
+
|
|
42
|
+
processes = {}
|
|
43
|
+
pids_to_read.each do |pid|
|
|
44
|
+
stat_path = "/proc/#{pid}/stat"
|
|
45
|
+
next unless File.readable?(stat_path)
|
|
46
|
+
|
|
47
|
+
stat_content = File.read(stat_path)
|
|
48
|
+
# comm field can contain spaces and parentheses; find the closing ')' (proc(5)).
|
|
49
|
+
closing_paren_index = stat_content.rindex(")")
|
|
50
|
+
next unless closing_paren_index
|
|
51
|
+
|
|
52
|
+
executable_name = stat_content[1...closing_paren_index]
|
|
53
|
+
fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
|
|
54
|
+
# After comm: state(3), ppid(4), pgrp(5), ... utime(14), stime(15), ... starttime(22), vsz(23), rss(24). 0-based: ppid=1, pgrp=2, utime=11, stime=12, starttime=19, vsz=20, rss=21.
|
|
55
|
+
parent_process_id = fields[1].to_i
|
|
56
|
+
process_group_id = fields[2].to_i
|
|
57
|
+
utime = fields[11].to_i
|
|
58
|
+
stime = fields[12].to_i
|
|
59
|
+
starttime = fields[19].to_i
|
|
60
|
+
virtual_size = fields[20].to_i
|
|
61
|
+
resident_pages = fields[21].to_i
|
|
62
|
+
|
|
63
|
+
# Read /proc/uptime once per capture and reuse for every process (starttime is in jiffies since boot).
|
|
64
|
+
uptime_jiffies ||= begin
|
|
65
|
+
uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
|
|
66
|
+
(uptime_seconds * CLK_TCK).to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
processor_time = (utime + stime).to_f / CLK_TCK
|
|
70
|
+
elapsed_time = [(uptime_jiffies - starttime).to_f / CLK_TCK, 0.0].max
|
|
71
|
+
|
|
72
|
+
command = read_command(pid, executable_name)
|
|
73
|
+
|
|
74
|
+
processes[pid] = General.new(
|
|
75
|
+
pid,
|
|
76
|
+
parent_process_id,
|
|
77
|
+
process_group_id,
|
|
78
|
+
0.0, # processor_utilization: would need two samples; not available from single stat read
|
|
79
|
+
virtual_size,
|
|
80
|
+
resident_pages * PAGE_SIZE,
|
|
81
|
+
processor_time,
|
|
82
|
+
elapsed_time,
|
|
83
|
+
command,
|
|
84
|
+
nil
|
|
85
|
+
)
|
|
86
|
+
rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
|
|
87
|
+
# Process disappeared or we can't read it.
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Restrict to the requested pid/ppid subtree using the same tree logic as the ps backend.
|
|
92
|
+
if ppid
|
|
93
|
+
pids = Set.new
|
|
94
|
+
hierarchy = General.build_tree(processes)
|
|
95
|
+
General.expand_children(Array(pid), hierarchy, pids) if pid
|
|
96
|
+
General.expand_children(Array(ppid), hierarchy, pids)
|
|
97
|
+
processes.select!{|process_id, _| pids.include?(process_id)}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
General.capture_memory(processes) if memory
|
|
101
|
+
|
|
102
|
+
processes
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Read command line from /proc/[pid]/cmdline; fall back to executable name from stat if empty.
|
|
106
|
+
# Use binread because cmdline is NUL-separated and may contain non-UTF-8 bytes; we split on NUL and join for display.
|
|
107
|
+
def self.read_command(pid, command_fallback)
|
|
108
|
+
path = "/proc/#{pid}/cmdline"
|
|
109
|
+
return command_fallback unless File.readable?(path)
|
|
110
|
+
|
|
111
|
+
cmdline_content = File.binread(path)
|
|
112
|
+
return command_fallback if cmdline_content.empty?
|
|
113
|
+
|
|
114
|
+
# cmdline is NUL-separated; replace with spaces for display.
|
|
115
|
+
cmdline_content.split("\0").join(" ").strip
|
|
116
|
+
rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
|
|
117
|
+
command_fallback
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if Process::Metrics::General::Linux.supported?
|
|
124
|
+
class << Process::Metrics::General
|
|
125
|
+
def capture(...)
|
|
126
|
+
Process::Metrics::General::Linux.capture(...)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
require_relative "process_status"
|
|
131
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2019-2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Process
|
|
7
|
+
module Metrics
|
|
8
|
+
# General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
|
|
9
|
+
# where there is no /proc; ps is the portable way to get pid, ppid, times, and memory in one pass.
|
|
10
|
+
module General::ProcessStatus
|
|
11
|
+
PS = "ps"
|
|
12
|
+
|
|
13
|
+
# The fields that will be extracted from the `ps` command (order matches -o output).
|
|
14
|
+
FIELDS = {
|
|
15
|
+
pid: ->(value){value.to_i},
|
|
16
|
+
ppid: ->(value){value.to_i},
|
|
17
|
+
pgid: ->(value){value.to_i},
|
|
18
|
+
pcpu: ->(value){value.to_f},
|
|
19
|
+
vsz: ->(value){value.to_i * 1024},
|
|
20
|
+
rss: ->(value){value.to_i * 1024},
|
|
21
|
+
time: Process::Metrics.method(:duration),
|
|
22
|
+
etime: Process::Metrics.method(:duration),
|
|
23
|
+
command: ->(value){value},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Whether process listing via ps is available on this system.
|
|
27
|
+
def self.supported?
|
|
28
|
+
system("which", PS, out: File::NULL, err: File::NULL)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Capture process information using ps. If given a `pid`, captures that process; if given `ppid`, captures that process and all descendants. Specify both to capture a process and its children.
|
|
32
|
+
# @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
|
|
33
|
+
# @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
|
|
34
|
+
# @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
|
|
35
|
+
# @returns [Hash<Integer, General>] Map of PID to General instance.
|
|
36
|
+
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
|
|
37
|
+
spawned_pid = nil
|
|
38
|
+
|
|
39
|
+
header, *lines = IO.pipe do |input, output|
|
|
40
|
+
arguments = [PS]
|
|
41
|
+
|
|
42
|
+
# When filtering by ppid we need the full process list to build the tree, so use "ax"; otherwise limit to -p.
|
|
43
|
+
if pid && ppid.nil?
|
|
44
|
+
arguments.push("-p", Array(pid).join(","))
|
|
45
|
+
else
|
|
46
|
+
arguments.push("ax")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
arguments.push("-o", FIELDS.keys.join(","))
|
|
50
|
+
|
|
51
|
+
spawned_pid = Process.spawn(*arguments, out: output)
|
|
52
|
+
output.close
|
|
53
|
+
|
|
54
|
+
input.readlines.map(&:strip)
|
|
55
|
+
ensure
|
|
56
|
+
input.close
|
|
57
|
+
|
|
58
|
+
# Always kill and reap the ps subprocess so we never leave it hanging if the pipe closes early.
|
|
59
|
+
if spawned_pid
|
|
60
|
+
begin
|
|
61
|
+
Process.kill(:KILL, spawned_pid)
|
|
62
|
+
Process.wait(spawned_pid)
|
|
63
|
+
rescue => error
|
|
64
|
+
warn "Failed to cleanup ps process #{spawned_pid}:\n#{error.full_message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
processes = {}
|
|
70
|
+
|
|
71
|
+
lines.each do |line|
|
|
72
|
+
next if line.empty?
|
|
73
|
+
|
|
74
|
+
values = line.split(/\s+/, FIELDS.size)
|
|
75
|
+
next if values.size < FIELDS.size
|
|
76
|
+
|
|
77
|
+
record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
|
|
78
|
+
instance = General.new(*record, nil)
|
|
79
|
+
processes[instance.process_id] = instance
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Restrict to the requested pid/ppid subtree; exclude our own ps process from the result.
|
|
83
|
+
if ppid
|
|
84
|
+
pids = Set.new
|
|
85
|
+
hierarchy = General.build_tree(processes)
|
|
86
|
+
General.expand_children(Array(pid), hierarchy, pids) if pid
|
|
87
|
+
General.expand_children(Array(ppid), hierarchy, pids)
|
|
88
|
+
processes.select!{|process_id, _| process_id != spawned_pid && pids.include?(process_id)}
|
|
89
|
+
else
|
|
90
|
+
processes.delete(spawned_pid) if spawned_pid
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
General.capture_memory(processes) if memory
|
|
94
|
+
|
|
95
|
+
processes
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Wire General.capture to this implementation when ProcessStatus is available and the Linux backend is not active (so Linux can load both for comparison tests).
|
|
102
|
+
linux_supported = defined?(Process::Metrics::General::Linux) && Process::Metrics::General::Linux.supported?
|
|
103
|
+
if Process::Metrics::General::ProcessStatus.supported? && !linux_supported
|
|
104
|
+
class << Process::Metrics::General
|
|
105
|
+
def capture(...)
|
|
106
|
+
Process::Metrics::General::ProcessStatus.capture(...)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -9,8 +9,6 @@ require "json"
|
|
|
9
9
|
|
|
10
10
|
module Process
|
|
11
11
|
module Metrics
|
|
12
|
-
PS = "ps"
|
|
13
|
-
|
|
14
12
|
DURATION = /\A
|
|
15
13
|
(?:(?<days>\d+)-)? # Optional days (e.g., '2-')
|
|
16
14
|
(?:(?<hours>\d+):)? # Optional hours (e.g., '1:')
|
|
@@ -36,19 +34,6 @@ module Process
|
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
# The fields that will be extracted from the `ps` command.
|
|
40
|
-
FIELDS = {
|
|
41
|
-
pid: ->(value){value.to_i}, # Process ID
|
|
42
|
-
ppid: ->(value){value.to_i}, # Parent Process ID
|
|
43
|
-
pgid: ->(value){value.to_i}, # Process Group ID
|
|
44
|
-
pcpu: ->(value){value.to_f}, # Percentage CPU
|
|
45
|
-
vsz: ->(value){value.to_i * 1024}, # Virtual Size (convert from KiB to bytes)
|
|
46
|
-
rss: ->(value){value.to_i * 1024}, # Resident Size (convert from KiB to bytes)
|
|
47
|
-
time: self.method(:duration), # CPU Time (seconds)
|
|
48
|
-
etime: self.method(:duration), # Elapsed Time (seconds)
|
|
49
|
-
command: ->(value){value}, # Command (name of the process)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
37
|
# General process information.
|
|
53
38
|
class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :command, :memory)
|
|
54
39
|
# Convert the object to a JSON serializable hash.
|
|
@@ -132,77 +117,13 @@ module Process
|
|
|
132
117
|
process.memory = Memory.capture(pid, count: count)
|
|
133
118
|
end
|
|
134
119
|
end
|
|
135
|
-
|
|
136
|
-
# Capture process information. If given a `pid`, it will capture the details of that process. If given a `ppid`, it will capture the details of all child processes. Specify both `pid` and `ppid` if you want to capture a process and all its children.
|
|
137
|
-
#
|
|
138
|
-
# @parameter pid [Integer] The process ID to capture.
|
|
139
|
-
# @parameter ppid [Integer] The parent process ID to capture.
|
|
140
|
-
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
|
|
141
|
-
ps_pid = nil
|
|
142
|
-
|
|
143
|
-
# Extract the information from the `ps` command:
|
|
144
|
-
header, *lines = IO.pipe do |input, output|
|
|
145
|
-
arguments = [PS]
|
|
146
|
-
|
|
147
|
-
if pid && ppid.nil?
|
|
148
|
-
arguments.push("-p", Array(pid).join(","))
|
|
149
|
-
else
|
|
150
|
-
arguments.push("ax")
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
arguments.push("-o", FIELDS.keys.join(","))
|
|
154
|
-
|
|
155
|
-
ps_pid = Process.spawn(*arguments, out: output)
|
|
156
|
-
output.close
|
|
157
|
-
|
|
158
|
-
input.readlines.map(&:strip)
|
|
159
|
-
ensure
|
|
160
|
-
input.close
|
|
161
|
-
|
|
162
|
-
if ps_pid
|
|
163
|
-
begin
|
|
164
|
-
# Make sure to kill the ps process if it's still running:
|
|
165
|
-
Process.kill(:KILL, ps_pid)
|
|
166
|
-
# Reap the process:
|
|
167
|
-
Process.wait(ps_pid)
|
|
168
|
-
rescue => error
|
|
169
|
-
warn "Failed to cleanup ps process #{ps_pid}:\n#{error.full_message}"
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
processes = {}
|
|
175
|
-
|
|
176
|
-
lines.map do |line|
|
|
177
|
-
record = FIELDS.
|
|
178
|
-
zip(line.split(/\s+/, FIELDS.size)).
|
|
179
|
-
map{|(key, type), value| type.call(value)}
|
|
180
|
-
instance = self.new(*record)
|
|
181
|
-
|
|
182
|
-
processes[instance.process_id] = instance
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
if ppid
|
|
186
|
-
pids = Set.new
|
|
187
|
-
|
|
188
|
-
hierarchy = self.build_tree(processes)
|
|
189
|
-
|
|
190
|
-
self.expand_children(Array(pid), hierarchy, pids)
|
|
191
|
-
self.expand_children(Array(ppid), hierarchy, pids)
|
|
192
|
-
|
|
193
|
-
processes.select! do |pid, process|
|
|
194
|
-
if pid != ps_pid
|
|
195
|
-
pids.include?(pid)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
if memory
|
|
201
|
-
self.capture_memory(processes)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
return processes
|
|
205
|
-
end
|
|
206
120
|
end
|
|
207
121
|
end
|
|
208
122
|
end
|
|
123
|
+
|
|
124
|
+
# One backend provides General.capture: Linux uses /proc (no subprocess); other platforms use ps.
|
|
125
|
+
if RUBY_PLATFORM.include?("linux")
|
|
126
|
+
require_relative "general/linux"
|
|
127
|
+
else
|
|
128
|
+
require_relative "general/process_status"
|
|
129
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Process
|
|
7
|
+
module Metrics
|
|
8
|
+
module Host
|
|
9
|
+
# Darwin (macOS) implementation of host memory metrics.
|
|
10
|
+
# Uses sysctl (hw.memsize), vm_stat (free + inactive pages), and vm.swapusage for swap.
|
|
11
|
+
class Memory::Darwin
|
|
12
|
+
# Parse a size string from vm.swapusage (e.g. "1024.00M", "512.00K") into bytes.
|
|
13
|
+
# @parameter size_string [String | Nil] The size string from sysctl vm.swapusage.
|
|
14
|
+
# @returns [Integer | Nil] Size in bytes, or nil if size_string is nil/empty.
|
|
15
|
+
def self.parse_swap_size(size_string)
|
|
16
|
+
return nil unless size_string
|
|
17
|
+
|
|
18
|
+
size_string = size_string.strip
|
|
19
|
+
|
|
20
|
+
case size_string
|
|
21
|
+
when /([\d.]+)M/i then ($1.to_f * 1024 * 1024).round
|
|
22
|
+
when /([\d.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
|
|
23
|
+
when /([\d.]+)K/i then ($1.to_f * 1024).round
|
|
24
|
+
else size_string.to_f.round
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Capture current host memory. Reads total (hw.memsize), free (vm_stat), and swap (vm.swapusage).
|
|
29
|
+
# @returns [Host::Memory | Nil] A Host::Memory instance, or nil if capture fails.
|
|
30
|
+
def self.capture
|
|
31
|
+
total = capture_total
|
|
32
|
+
return nil unless total && total.positive?
|
|
33
|
+
|
|
34
|
+
free = capture_free
|
|
35
|
+
return nil unless free
|
|
36
|
+
|
|
37
|
+
free = 0 if free.negative?
|
|
38
|
+
used = [total - free, 0].max
|
|
39
|
+
swap_total, swap_used = capture_swap
|
|
40
|
+
|
|
41
|
+
return Host::Memory.new(total, used, free, swap_total, swap_used)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Total physical RAM in bytes, from sysctl hw.memsize.
|
|
45
|
+
# @returns [Integer | Nil]
|
|
46
|
+
def self.capture_total
|
|
47
|
+
IO.popen(["sysctl", "-n", "hw.memsize"], "r", &:read)&.strip&.to_i
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Free + inactive (reclaimable) memory in bytes, from vm_stat. Matches Linux MemAvailable semantics.
|
|
51
|
+
# @returns [Integer | Nil]
|
|
52
|
+
def self.capture_free
|
|
53
|
+
output = IO.popen(["vm_stat"], "r", &:read)
|
|
54
|
+
page_size = output[/page size of (\d+) bytes/, 1]&.to_i
|
|
55
|
+
return nil unless page_size && page_size.positive?
|
|
56
|
+
|
|
57
|
+
pages_free = output[/Pages free:\s*(\d+)/, 1]&.to_i || 0
|
|
58
|
+
pages_inactive = output[/Pages inactive:\s*(\d+)/, 1]&.to_i || 0
|
|
59
|
+
return (pages_free + pages_inactive) * page_size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Swap total and used in bytes, from sysctl vm.swapusage (e.g. "total = 64.00M used = 32.00M free = 32.00M").
|
|
63
|
+
# @returns [Array(Integer | Nil, Integer | Nil)] [swap_total_bytes, swap_used_bytes], or [nil, nil] if unavailable.
|
|
64
|
+
def self.capture_swap
|
|
65
|
+
output = IO.popen(["sysctl", "-n", "vm.swapusage"], "r", &:read)
|
|
66
|
+
return [nil, nil] unless output
|
|
67
|
+
|
|
68
|
+
total_string = output[/total\s*=\s*([\d.]+\s*[KMG]?)/i, 1]
|
|
69
|
+
used_string = output[/used\s*=\s*([\d.]+\s*[KMG]?)/i, 1]
|
|
70
|
+
swap_total = total_string ? parse_swap_size(total_string) : nil
|
|
71
|
+
swap_used = used_string ? parse_swap_size(used_string) : nil
|
|
72
|
+
|
|
73
|
+
return swap_total, swap_used
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Wire Host::Memory to this implementation on Darwin.
|
|
81
|
+
class << Process::Metrics::Host::Memory
|
|
82
|
+
def capture
|
|
83
|
+
Process::Metrics::Host::Memory::Darwin.capture
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def supported?
|
|
87
|
+
File.exist?("/usr/bin/vm_stat")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Process
|
|
7
|
+
module Metrics
|
|
8
|
+
module Host
|
|
9
|
+
# Linux implementation of host memory metrics.
|
|
10
|
+
# Uses cgroups v2 (memory.max, memory.current) or cgroups v1 (memory.limit_in_bytes, memory.usage_in_bytes) when in a container;
|
|
11
|
+
# otherwise reads /proc/meminfo (MemTotal, MemAvailable/MemFree, SwapTotal/SwapFree). Parses meminfo once per capture and reuses it.
|
|
12
|
+
class Memory::Linux
|
|
13
|
+
# Threshold for distinguishing actual memory limits from "unlimited" sentinel values in cgroups v1.
|
|
14
|
+
# In cgroups v1, when memory.limit_in_bytes is set to unlimited (by writing -1), the kernel stores a very large sentinel near 2^63.
|
|
15
|
+
# Any value >= 2^60 (1 exabyte) is treated as unlimited and we fall back to /proc/meminfo.
|
|
16
|
+
# Reference: https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
|
|
17
|
+
CGROUP_V1_UNLIMITED_THRESHOLD = 2**60
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@meminfo = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Capture current host memory. Reads total and used (from cgroup or meminfo), computes free, and parses swap from meminfo.
|
|
24
|
+
# @returns [Host::Memory | Nil]
|
|
25
|
+
def capture
|
|
26
|
+
total = capture_total
|
|
27
|
+
return nil unless total && total.positive?
|
|
28
|
+
|
|
29
|
+
used = capture_used(total)
|
|
30
|
+
used = 0 if used.nil? || used.negative?
|
|
31
|
+
used = [used, total].min
|
|
32
|
+
free = total - used
|
|
33
|
+
|
|
34
|
+
swap_total, swap_used = capture_swap
|
|
35
|
+
|
|
36
|
+
return Host::Memory.new(total, used, free, swap_total, swap_used)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Memoized /proc/meminfo contents. Used for total (MemTotal), used (via MemAvailable), and swap when not in a cgroup.
|
|
42
|
+
# @returns [String | Nil]
|
|
43
|
+
def meminfo
|
|
44
|
+
if @meminfo == false
|
|
45
|
+
@meminfo = File.read("/proc/meminfo") rescue nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return @meminfo
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Total memory in bytes: cgroups v2 memory.max, cgroups v1 memory.limit_in_bytes (if < threshold), else MemTotal from meminfo.
|
|
52
|
+
# @returns [Integer | Nil]
|
|
53
|
+
def capture_total
|
|
54
|
+
if File.exist?("/sys/fs/cgroup/memory.max")
|
|
55
|
+
limit = File.read("/sys/fs/cgroup/memory.max").strip
|
|
56
|
+
return limit.to_i if limit != "max"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if File.exist?("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
|
60
|
+
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
|
|
61
|
+
return limit if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless meminfo_content = self.meminfo
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
meminfo_content.each_line do |line|
|
|
69
|
+
if /MemTotal:\s*(?<total>\d+)\s*kB/ =~ line
|
|
70
|
+
return $~[:total].to_i * 1024
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Current memory usage in bytes: cgroups v2 memory.current, cgroups v1 memory.usage_in_bytes, or total - MemAvailable from meminfo.
|
|
78
|
+
# @parameter total [Integer] Total memory (used to compute used from MemAvailable when not in cgroup).
|
|
79
|
+
# @returns [Integer | Nil]
|
|
80
|
+
def capture_used(total)
|
|
81
|
+
if File.exist?("/sys/fs/cgroup/memory.current")
|
|
82
|
+
current = File.read("/sys/fs/cgroup/memory.current").strip.to_i
|
|
83
|
+
return current
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if File.exist?("/sys/fs/cgroup/memory/memory.usage_in_bytes")
|
|
87
|
+
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
|
|
88
|
+
if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
|
|
89
|
+
return File.read("/sys/fs/cgroup/memory/memory.usage_in_bytes").strip.to_i
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless meminfo_content = self.meminfo
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
available_kilobytes = meminfo_content[/MemAvailable:\s*(\d+)\s*kB/, 1]&.to_i
|
|
98
|
+
available_kilobytes ||= meminfo_content[/MemFree:\s*(\d+)\s*kB/, 1]&.to_i
|
|
99
|
+
return nil unless available_kilobytes
|
|
100
|
+
|
|
101
|
+
return [total - (available_kilobytes * 1024), 0].max
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Swap total and used in bytes from meminfo (SwapTotal, SwapFree).
|
|
105
|
+
# @returns [Array(Integer, Integer)] [swap_total_bytes, swap_used_bytes], or [nil, nil] if no swap.
|
|
106
|
+
def capture_swap
|
|
107
|
+
return [nil, nil] unless meminfo_content = self.meminfo
|
|
108
|
+
swap_total_kilobytes = meminfo_content[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
|
|
109
|
+
swap_free_kilobytes = meminfo_content[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
|
|
110
|
+
|
|
111
|
+
return [nil, nil] unless swap_total_kilobytes
|
|
112
|
+
|
|
113
|
+
swap_total_bytes = swap_total_kilobytes * 1024
|
|
114
|
+
swap_used_bytes = (swap_total_kilobytes - (swap_free_kilobytes || 0)) * 1024
|
|
115
|
+
|
|
116
|
+
return swap_total_bytes, swap_used_bytes
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Wire Host::Memory to this implementation on Linux.
|
|
124
|
+
class << Process::Metrics::Host::Memory
|
|
125
|
+
def capture
|
|
126
|
+
Process::Metrics::Host::Memory::Linux.new.capture
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def supported?
|
|
130
|
+
File.exist?("/proc/meminfo")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Process
|
|
7
|
+
module Metrics
|
|
8
|
+
# Per-host (system-wide) memory metrics. Use Host::Memory for total/used/free and swap; use Process::Metrics::Memory for per-process metrics.
|
|
9
|
+
module Host
|
|
10
|
+
# Struct for host memory snapshot. All sizes in bytes.
|
|
11
|
+
# @attribute total [Integer] Total memory (cgroup limit when in a container, else physical RAM).
|
|
12
|
+
# @attribute used [Integer] Memory in use (total - free).
|
|
13
|
+
# @attribute free [Integer] Available memory (MemAvailable-style: free + reclaimable).
|
|
14
|
+
# @attribute swap_total [Integer, nil] Total swap, or nil if not available.
|
|
15
|
+
# @attribute swap_used [Integer, nil] Swap in use, or nil if not available.
|
|
16
|
+
Memory = Struct.new(:total, :used, :free, :swap_total, :swap_used) do
|
|
17
|
+
alias as_json to_h
|
|
18
|
+
|
|
19
|
+
def to_json(*arguments)
|
|
20
|
+
as_json.to_json(*arguments)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create a zero-initialized Host::Memory instance.
|
|
24
|
+
# @returns [Memory]
|
|
25
|
+
def self.zero
|
|
26
|
+
self.new(0, 0, 0, nil, nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether host memory capture is supported on this platform.
|
|
30
|
+
# @returns [Boolean]
|
|
31
|
+
def self.supported?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Capture current host memory. Implemented by Host::Memory::Linux or Host::Memory::Darwin (in host/memory/linux.rb, host/memory/darwin.rb).
|
|
36
|
+
# @returns [Memory | Nil] A Host::Memory instance, or nil if not supported or capture failed.
|
|
37
|
+
def self.capture
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if RUBY_PLATFORM.include?("linux")
|
|
46
|
+
require_relative "memory/linux"
|
|
47
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
48
|
+
require_relative "memory/darwin"
|
|
49
|
+
end
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
module Process
|
|
7
7
|
module Metrics
|
|
8
|
-
# Darwin (macOS) implementation of memory metrics using vmmap.
|
|
8
|
+
# Darwin (macOS) implementation of per-process memory metrics using vmmap(1).
|
|
9
|
+
# Parses vmmap output for virtual/resident/dirty/swap per region and maps sharing mode (PRV, COW, SHM) to private/shared fields.
|
|
9
10
|
class Memory::Darwin
|
|
10
11
|
VMMAP = "/usr/bin/vmmap"
|
|
11
12
|
|
|
@@ -14,32 +15,21 @@ module Process
|
|
|
14
15
|
File.executable?(VMMAP)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if line =~ /hw.memsize: (\d+)/
|
|
23
|
-
return $1.to_i
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Parse a size string from vmmap output into bytes.
|
|
30
|
-
# @parameter string [String | Nil] The size string (e.g., "4K", "1.5M", "2G").
|
|
31
|
-
# @returns [Integer] The size in bytes.
|
|
32
|
-
def self.parse_size(string)
|
|
33
|
-
return 0 unless string
|
|
18
|
+
# Parse a size string from vmmap (e.g. "4K", "1.5M", "2G") into bytes.
|
|
19
|
+
# @parameter size_string [String | Nil]
|
|
20
|
+
# @returns [Integer]
|
|
21
|
+
def self.parse_size(size_string)
|
|
22
|
+
return 0 unless size_string
|
|
34
23
|
|
|
35
|
-
case
|
|
24
|
+
case size_string.strip
|
|
36
25
|
when /([\d\.]+)K/i then ($1.to_f * 1024).round
|
|
37
26
|
when /([\d\.]+)M/i then ($1.to_f * 1024 * 1024).round
|
|
38
27
|
when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
|
|
39
|
-
else (
|
|
28
|
+
else (size_string.to_f).ceil
|
|
40
29
|
end
|
|
41
30
|
end
|
|
42
31
|
|
|
32
|
+
# Regex for vmmap region lines: region name, address range, [virtual resident dirty swap], permissions, SM=sharing_mode.
|
|
43
33
|
LINE = /\A
|
|
44
34
|
\s*
|
|
45
35
|
(?<region_name>.+?)\s+
|
|
@@ -49,26 +39,24 @@ module Process
|
|
|
49
39
|
SM=(?<sharing_mode>\w+)
|
|
50
40
|
/x
|
|
51
41
|
|
|
52
|
-
# Capture memory usage for the given
|
|
42
|
+
# Capture memory usage by running vmmap for the given pid and summing region sizes. Proportional size is estimated as resident_size / count (Darwin has no PSS).
|
|
43
|
+
# @parameter pid [Integer] Process ID.
|
|
44
|
+
# @parameter count [Integer] Number of processes for proportional estimate (default: 1).
|
|
45
|
+
# @returns [Memory | Nil]
|
|
53
46
|
def self.capture(pid, count: 1, **options)
|
|
54
47
|
IO.popen(["vmmap", pid.to_s], "r") do |io|
|
|
55
48
|
usage = Memory.zero
|
|
56
|
-
|
|
57
49
|
io.each_line do |line|
|
|
58
50
|
if match = LINE.match(line)
|
|
59
51
|
usage.map_count += 1
|
|
60
|
-
|
|
61
52
|
virtual_size = parse_size(match[:virtual_size])
|
|
62
53
|
resident_size = parse_size(match[:resident_size])
|
|
63
54
|
dirty_size = parse_size(match[:dirty_size])
|
|
64
55
|
swap_size = parse_size(match[:swap_size])
|
|
65
|
-
|
|
66
56
|
usage.resident_size += resident_size
|
|
67
57
|
usage.swap_size += swap_size
|
|
68
58
|
|
|
69
|
-
# Private vs. Shared memory
|
|
70
|
-
# COW=copy_on_write PRV=private NUL=empty ALI=aliased
|
|
71
|
-
# SHM=shared ZER=zero_filled S/A=shared_alias
|
|
59
|
+
# Private vs. Shared memory: COW=copy_on_write PRV=private NUL=empty ALI=aliased SHM=shared ZER=zero_filled S/A=shared_alias
|
|
72
60
|
case match[:sharing_mode]
|
|
73
61
|
when "PRV"
|
|
74
62
|
usage.private_clean_size += resident_size - dirty_size
|
|
@@ -85,10 +73,8 @@ module Process
|
|
|
85
73
|
end
|
|
86
74
|
end
|
|
87
75
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return nil
|
|
91
|
-
end
|
|
76
|
+
# vmmap might not fail, but also might not return any data.
|
|
77
|
+
return nil if usage.map_count.zero?
|
|
92
78
|
|
|
93
79
|
# Darwin does not expose proportional memory usage, so we guess based on the number of processes. Yes, this is a terrible hack, but it's the most reasonable thing to do given the constraints:
|
|
94
80
|
usage.proportional_size = usage.resident_size / count
|
|
@@ -101,29 +87,24 @@ module Process
|
|
|
101
87
|
return nil
|
|
102
88
|
end
|
|
103
89
|
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Wire Memory.capture and Memory.supported? to this implementation when vmmap is executable.
|
|
94
|
+
if Process::Metrics::Memory::Darwin.supported?
|
|
95
|
+
class << Process::Metrics::Memory
|
|
96
|
+
# Whether memory capture is supported on this platform.
|
|
97
|
+
# @returns [Boolean] True if vmmap is available.
|
|
98
|
+
def supported?
|
|
99
|
+
true
|
|
100
|
+
end
|
|
104
101
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Get total system memory size.
|
|
114
|
-
# @returns [Integer] Total memory in bytes.
|
|
115
|
-
def total_size
|
|
116
|
-
return Memory::Darwin.total_size
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Capture memory metrics for a process.
|
|
120
|
-
# @parameter pid [Integer] The process ID.
|
|
121
|
-
# @parameter options [Hash] Additional options (e.g., count for proportional estimates).
|
|
122
|
-
# @returns [Memory] A Memory instance with captured metrics.
|
|
123
|
-
def capture(...)
|
|
124
|
-
return Memory::Darwin.capture(...)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
102
|
+
# Capture memory metrics for a process.
|
|
103
|
+
# @parameter pid [Integer] The process ID.
|
|
104
|
+
# @parameter options [Hash] Additional options (e.g. count for proportional estimates).
|
|
105
|
+
# @returns [Memory | Nil] A Memory instance with captured metrics.
|
|
106
|
+
def capture(...)
|
|
107
|
+
Process::Metrics::Memory::Darwin.capture(...)
|
|
127
108
|
end
|
|
128
109
|
end
|
|
129
110
|
end
|
|
@@ -5,31 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
module Process
|
|
7
7
|
module Metrics
|
|
8
|
-
# Linux implementation of memory metrics using `/proc/[pid]/smaps` and `/proc/[pid]/stat
|
|
8
|
+
# Linux implementation of per-process memory metrics using `/proc/[pid]/smaps` or `/proc/[pid]/smaps_rollup`, and `/proc/[pid]/stat` for fault counters.
|
|
9
|
+
# Prefers smaps_rollup when readable (single summary); otherwise falls back to full smaps and counts maps from /proc/[pid]/maps.
|
|
9
10
|
class Memory::Linux
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# the kernel stores a very large sentinel value close to 2^63 (approximately 9,223,372,036,854,771,712 bytes).
|
|
14
|
-
# Since no real system would have 1 exabyte (2^60 bytes) of RAM, any value >= this threshold
|
|
15
|
-
# indicates an "unlimited" configuration and should be treated as if no limit is set.
|
|
16
|
-
#
|
|
17
|
-
# This allows us to distinguish between:
|
|
18
|
-
# - Actual container memory limits: typically in GB-TB range (< 1 EB)
|
|
19
|
-
# - Unlimited sentinel values: near 2^63 (>> 1 EB)
|
|
20
|
-
#
|
|
21
|
-
# Reference: https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
|
|
22
|
-
CGROUP_V1_UNLIMITED_THRESHOLD = 2**60 # ~1 exabyte
|
|
23
|
-
|
|
24
|
-
# Extract minor/major page fault counters from `/proc/[pid]/stat` and assign to usage.
|
|
25
|
-
# @parameter pid [Integer] The process ID.
|
|
26
|
-
# @parameter usage [Memory] The Memory instance to populate with fault counters.
|
|
11
|
+
# Extract minor and major page fault counters from `/proc/[pid]/stat` (proc(5): fields 10=minflt, 12=majflt) and assign to usage.
|
|
12
|
+
# @parameter pid [Integer] Process ID.
|
|
13
|
+
# @parameter usage [Memory] Memory instance to populate with minor_faults and major_faults.
|
|
27
14
|
def self.capture_faults(pid, usage)
|
|
28
|
-
|
|
15
|
+
stat_content = File.read("/proc/#{pid}/stat")
|
|
29
16
|
# The comm field can contain spaces and parentheses; find the closing ')':
|
|
30
|
-
|
|
31
|
-
return unless
|
|
32
|
-
fields =
|
|
17
|
+
closing_paren_index = stat_content.rindex(")")
|
|
18
|
+
return unless closing_paren_index
|
|
19
|
+
fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
|
|
33
20
|
# proc(5): field 10=minflt, 12=majflt; our fields array is 0-indexed from field 3.
|
|
34
21
|
usage.minor_faults = fields[10-3].to_i
|
|
35
22
|
usage.major_faults = fields[12-3].to_i
|
|
@@ -37,39 +24,7 @@ module Process
|
|
|
37
24
|
# Ignore.
|
|
38
25
|
end
|
|
39
26
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# @returns [Integer] The total memory size in bytes.
|
|
43
|
-
def self.total_size
|
|
44
|
-
# Check for Kubernetes/cgroup memory limit first (cgroups v2):
|
|
45
|
-
if File.exist?("/sys/fs/cgroup/memory.max")
|
|
46
|
-
limit = File.read("/sys/fs/cgroup/memory.max").strip
|
|
47
|
-
# "max" means unlimited, fall through to other methods:
|
|
48
|
-
if limit != "max"
|
|
49
|
-
return limit.to_i
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Check for Kubernetes/cgroup memory limit (cgroups v1):
|
|
54
|
-
if File.exist?("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
|
55
|
-
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
|
|
56
|
-
# A very large number means unlimited, fall through:
|
|
57
|
-
if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
|
|
58
|
-
return limit
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Fall back to Linux system memory detection:
|
|
63
|
-
if File.exist?("/proc/meminfo")
|
|
64
|
-
File.foreach("/proc/meminfo") do |line|
|
|
65
|
-
if /MemTotal:\s*(?<total>\d+)\s*kB/ =~ line
|
|
66
|
-
return total.to_i * 1024
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# The fields that will be extracted from the `smaps` data.
|
|
27
|
+
# Mapping from smaps/smaps_rollup line names to Memory struct members (values in kB, converted to bytes when parsing).
|
|
73
28
|
SMAP = {
|
|
74
29
|
"Rss" => :resident_size,
|
|
75
30
|
"Pss" => :proportional_size,
|
|
@@ -89,24 +44,24 @@ module Process
|
|
|
89
44
|
true
|
|
90
45
|
end
|
|
91
46
|
|
|
92
|
-
# Capture memory usage
|
|
93
|
-
# @parameter pid [Integer]
|
|
94
|
-
# @parameter faults [Boolean] Whether to capture
|
|
95
|
-
# @
|
|
47
|
+
# Capture memory usage from /proc/[pid]/smaps_rollup and /proc/[pid]/maps. Optionally fill fault counters from /proc/[pid]/stat.
|
|
48
|
+
# @parameter pid [Integer] Process ID.
|
|
49
|
+
# @parameter faults [Boolean] Whether to capture minor_faults and major_faults (default: true).
|
|
50
|
+
# @returns [Memory | Nil]
|
|
96
51
|
def self.capture(pid, faults: true, **options)
|
|
97
52
|
File.open("/proc/#{pid}/smaps_rollup") do |file|
|
|
98
53
|
usage = Memory.zero
|
|
99
|
-
|
|
100
54
|
file.each_line do |line|
|
|
101
55
|
if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
|
|
102
56
|
if key = SMAP[name]
|
|
103
|
-
# Convert from kilobytes to bytes
|
|
57
|
+
# Convert from kilobytes to bytes:
|
|
104
58
|
usage[key] += value.to_i * 1024
|
|
105
59
|
end
|
|
106
60
|
end
|
|
107
61
|
end
|
|
108
62
|
|
|
109
63
|
usage.map_count += File.readlines("/proc/#{pid}/maps").size
|
|
64
|
+
|
|
110
65
|
# Also capture fault counters if requested:
|
|
111
66
|
if faults
|
|
112
67
|
self.capture_faults(pid, usage)
|
|
@@ -124,20 +79,19 @@ module Process
|
|
|
124
79
|
true
|
|
125
80
|
end
|
|
126
81
|
|
|
127
|
-
# Capture memory usage
|
|
128
|
-
# @parameter pid [Integer]
|
|
129
|
-
# @parameter faults [Boolean] Whether to capture
|
|
130
|
-
# @
|
|
82
|
+
# Capture memory usage from /proc/[pid]/smaps (and map count from VmFlags) and /proc/[pid]/maps. Optionally fill fault counters from /proc/[pid]/stat.
|
|
83
|
+
# @parameter pid [Integer] Process ID.
|
|
84
|
+
# @parameter faults [Boolean] Whether to capture minor_faults and major_faults (default: true).
|
|
85
|
+
# @returns [Memory | Nil]
|
|
131
86
|
def self.capture(pid, faults: true, **options)
|
|
132
87
|
File.open("/proc/#{pid}/smaps") do |file|
|
|
133
88
|
usage = Memory.zero
|
|
134
|
-
|
|
135
89
|
file.each_line do |line|
|
|
136
90
|
# The format of this is fixed according to:
|
|
137
91
|
# https://github.com/torvalds/linux/blob/351c8a09b00b5c51c8f58b016fffe51f87e2d820/fs/proc/task_mmu.c#L804-L814
|
|
138
92
|
if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
|
|
139
93
|
if key = SMAP[name]
|
|
140
|
-
# Convert from kilobytes to bytes
|
|
94
|
+
# Convert from kilobytes to bytes:
|
|
141
95
|
usage[key] += value.to_i * 1024
|
|
142
96
|
end
|
|
143
97
|
elsif /VmFlags:\s+(?<flags>.*)/ =~ line
|
|
@@ -148,7 +102,9 @@ module Process
|
|
|
148
102
|
end
|
|
149
103
|
|
|
150
104
|
# Also capture fault counters if requested:
|
|
151
|
-
|
|
105
|
+
if faults
|
|
106
|
+
self.capture_faults(pid, usage)
|
|
107
|
+
end
|
|
152
108
|
|
|
153
109
|
return usage
|
|
154
110
|
end
|
|
@@ -162,30 +118,25 @@ module Process
|
|
|
162
118
|
end
|
|
163
119
|
end
|
|
164
120
|
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Wire Memory.capture and Memory.supported? to this implementation when smaps or smaps_rollup is readable.
|
|
125
|
+
if Process::Metrics::Memory::Linux.supported?
|
|
126
|
+
class << Process::Metrics::Memory
|
|
127
|
+
# Whether memory capture is supported on this platform.
|
|
128
|
+
# @returns [Boolean] True if /proc/[pid]/smaps or smaps_rollup is readable.
|
|
129
|
+
def supported?
|
|
130
|
+
true
|
|
131
|
+
end
|
|
165
132
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# Get total system memory size.
|
|
175
|
-
# @returns [Integer] Total memory in bytes.
|
|
176
|
-
def total_size
|
|
177
|
-
return Memory::Linux.total_size
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# Capture memory metrics for a process.
|
|
181
|
-
# @parameter pid [Integer] The process ID.
|
|
182
|
-
# @parameter faults [Boolean] Whether to capture fault counters (default: true).
|
|
183
|
-
# @parameter options [Hash] Additional options.
|
|
184
|
-
# @returns [Memory] A Memory instance with captured metrics.
|
|
185
|
-
def capture(...)
|
|
186
|
-
return Memory::Linux.capture(...)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
133
|
+
# Capture memory metrics for a process.
|
|
134
|
+
# @parameter pid [Integer] The process ID.
|
|
135
|
+
# @parameter faults [Boolean] Whether to capture fault counters (default: true).
|
|
136
|
+
# @parameter options [Hash] Additional options.
|
|
137
|
+
# @returns [Memory | Nil] A Memory instance with captured metrics.
|
|
138
|
+
def capture(...)
|
|
139
|
+
Process::Metrics::Memory::Linux.capture(...)
|
|
189
140
|
end
|
|
190
141
|
end
|
|
191
142
|
end
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# Copyright, 2019-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
7
|
+
require_relative "host/memory"
|
|
7
8
|
|
|
8
9
|
module Process
|
|
9
10
|
module Metrics
|
|
@@ -33,6 +34,12 @@ module Process
|
|
|
33
34
|
self.new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
# Total system/host memory in bytes. Delegates to Host::Memory.capture.
|
|
38
|
+
# @returns [Integer | Nil]
|
|
39
|
+
def self.total_size
|
|
40
|
+
Host::Memory.capture&.total
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
# Whether the memory usage can be captured on this system.
|
|
37
44
|
def self.supported?
|
|
38
45
|
false
|
|
@@ -46,5 +53,8 @@ module Process
|
|
|
46
53
|
end
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
require_relative "memory/
|
|
56
|
+
if RUBY_PLATFORM.include?("linux")
|
|
57
|
+
require_relative "memory/linux"
|
|
58
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
59
|
+
require_relative "memory/darwin"
|
|
60
|
+
end
|
data/readme.md
CHANGED
|
@@ -16,12 +16,22 @@ Please see the [project documentation](https://socketry.github.io/process-metric
|
|
|
16
16
|
|
|
17
17
|
Please see the [project releases](https://socketry.github.io/process-metrics/releases/index) for all releases.
|
|
18
18
|
|
|
19
|
+
### v0.10.0
|
|
20
|
+
|
|
21
|
+
- **Host::Memory**: New per-host struct `Process::Metrics::Host::Memory` with `total`, `used`, `free`, `swap_total`, `swap_used` (all bytes). Use `Host::Memory.capture` to get a snapshot; `.supported?` indicates platform support.
|
|
22
|
+
|
|
19
23
|
### v0.9.0
|
|
20
24
|
|
|
21
25
|
- `Process::Metrics::Memory.total_size` takes into account cgroup limits.
|
|
22
26
|
- On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
|
|
23
27
|
- Report all sizes in bytes for consistency.
|
|
24
28
|
|
|
29
|
+
### v0.8.0
|
|
30
|
+
|
|
31
|
+
- Kill `ps` before waiting to avoid hanging when using the process-status backend.
|
|
32
|
+
- Ignore `Errno::EACCES` when reading process information.
|
|
33
|
+
- Cleaner process management for the `ps`-based capture path.
|
|
34
|
+
|
|
25
35
|
### v0.7.0
|
|
26
36
|
|
|
27
37
|
- Be more proactive about returning nil if memory capture failed.
|
|
@@ -61,18 +71,6 @@ Please see the [project releases](https://socketry.github.io/process-metrics/rel
|
|
|
61
71
|
- Added missing dependencies: `bake-test-external` and `json` gem.
|
|
62
72
|
- Added summary lines for PSS (Proportional Set Size) and USS (Unique Set Size).
|
|
63
73
|
|
|
64
|
-
### v0.2.1
|
|
65
|
-
|
|
66
|
-
- Added missing dependency to gemspec.
|
|
67
|
-
- Added example of command line usage to documentation.
|
|
68
|
-
- Renamed `rsz` to `rss` (Resident Set Size) for consistency across Darwin and Linux platforms.
|
|
69
|
-
|
|
70
|
-
### v0.2.0
|
|
71
|
-
|
|
72
|
-
- Added `process-metrics` command line interface for monitoring processes.
|
|
73
|
-
- Implemented structured data using Ruby structs for better performance and clarity.
|
|
74
|
-
- Added documentation about PSS (Proportional Set Size) and USS (Unique Set Size) metrics.
|
|
75
|
-
|
|
76
74
|
## Contributing
|
|
77
75
|
|
|
78
76
|
We welcome contributions to this project.
|
data/releases.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
+
## v0.10.0
|
|
4
|
+
|
|
5
|
+
- **Host::Memory**: New per-host struct `Process::Metrics::Host::Memory` with `total`, `used`, `free`, `swap_total`, `swap_used` (all bytes). Use `Host::Memory.capture` to get a snapshot; `.supported?` indicates platform support.
|
|
6
|
+
|
|
3
7
|
## v0.9.0
|
|
4
8
|
|
|
5
9
|
- `Process::Metrics::Memory.total_size` takes into account cgroup limits.
|
|
6
10
|
- On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
|
|
7
11
|
- Report all sizes in bytes for consistency.
|
|
8
12
|
|
|
13
|
+
## v0.8.0
|
|
14
|
+
|
|
15
|
+
- Kill `ps` before waiting to avoid hanging when using the process-status backend.
|
|
16
|
+
- Ignore `Errno::EACCES` when reading process information.
|
|
17
|
+
- Cleaner process management for the `ps`-based capture path.
|
|
18
|
+
|
|
9
19
|
## v0.7.0
|
|
10
20
|
|
|
11
21
|
- Be more proactive about returning nil if memory capture failed.
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: process-metrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -94,6 +94,11 @@ files:
|
|
|
94
94
|
- lib/process/metrics/command/summary.rb
|
|
95
95
|
- lib/process/metrics/command/top.rb
|
|
96
96
|
- lib/process/metrics/general.rb
|
|
97
|
+
- lib/process/metrics/general/linux.rb
|
|
98
|
+
- lib/process/metrics/general/process_status.rb
|
|
99
|
+
- lib/process/metrics/host/memory.rb
|
|
100
|
+
- lib/process/metrics/host/memory/darwin.rb
|
|
101
|
+
- lib/process/metrics/host/memory/linux.rb
|
|
97
102
|
- lib/process/metrics/memory.rb
|
|
98
103
|
- lib/process/metrics/memory/darwin.rb
|
|
99
104
|
- lib/process/metrics/memory/linux.rb
|
metadata.gz.sig
CHANGED
|
Binary file
|