process-metrics 0.8.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/context/getting-started.md +29 -28
- data/lib/process/metrics/command/summary.rb +15 -9
- 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 +9 -88
- 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 +38 -57
- data/lib/process/metrics/memory/linux.rb +52 -53
- data/lib/process/metrics/memory.rb +14 -4
- data/lib/process/metrics/version.rb +1 -1
- data/license.md +1 -1
- data/readme.md +16 -18
- data/releases.md +16 -0
- data.tar.gz.sig +0 -0
- metadata +7 -2
- 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
|
data/context/getting-started.md
CHANGED
|
@@ -32,29 +32,30 @@ Process::Metrics::General.capture(pid: Process.pid)
|
|
|
32
32
|
# =>
|
|
33
33
|
# {3517456=>
|
|
34
34
|
# #<struct Process::Metrics::General
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
35
|
+
# process_id=3517456,
|
|
36
|
+
# parent_process_id=3517432,
|
|
37
|
+
# process_group_id=3517456,
|
|
38
38
|
# processor_utilization=0.0,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
39
|
+
# virtual_size=445768278528,
|
|
40
|
+
# resident_size=20348928,
|
|
41
|
+
# processor_time=0.05,
|
|
42
|
+
# elapsed_time=2.0,
|
|
43
43
|
# command="irb",
|
|
44
44
|
# memory=
|
|
45
45
|
# #<struct Process::Metrics::Memory
|
|
46
46
|
# map_count=193,
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# shared_clean_size=5008,
|
|
47
|
+
# resident_size=31289344,
|
|
48
|
+
# proportional_size=26288128,
|
|
49
|
+
# shared_clean_size=5128192,
|
|
51
50
|
# shared_dirty_size=0,
|
|
52
|
-
# private_clean_size=
|
|
53
|
-
# private_dirty_size=
|
|
54
|
-
# referenced_size=
|
|
55
|
-
# anonymous_size=
|
|
51
|
+
# private_clean_size=5304320,
|
|
52
|
+
# private_dirty_size=20856832,
|
|
53
|
+
# referenced_size=31281152,
|
|
54
|
+
# anonymous_size=20865024,
|
|
56
55
|
# swap_size=0,
|
|
57
|
-
# proportional_swap_size=0
|
|
56
|
+
# proportional_swap_size=0,
|
|
57
|
+
# minor_faults=0,
|
|
58
|
+
# major_faults=0>>}
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
If you want to capture a tree of processes, you can specify the `ppid:` option instead.
|
|
@@ -67,8 +68,8 @@ The {ruby Process::Metrics::General} struct contains the following fields:
|
|
|
67
68
|
- `parent_process_id` - Parent Process ID, the process ID of the process that started this process.
|
|
68
69
|
- `process_group_id` - Process Group ID, the process group ID of the process, which can be shared by multiple processes.
|
|
69
70
|
- `processor_utilization` - Processor Utilization (%), the percentage of CPU time used by the process (over a system-specific duration).
|
|
70
|
-
- `total_size` - Memory Size (
|
|
71
|
-
- `resident_size` - Resident (Set) Size (
|
|
71
|
+
- `total_size` - Memory Size (bytes), the total size of the process's memory space (usually over-estimated as it doesn't take into account shared memory).
|
|
72
|
+
- `resident_size` - Resident (Set) Size (bytes), the amount of physical memory used by the process.
|
|
72
73
|
- `processor_time` - CPU Time (s), the amount of CPU time used by the process.
|
|
73
74
|
- `elapsed_time` - Elapsed Time (s), the amount of time the process has been running.
|
|
74
75
|
- `command` - Command Name, the name of the command that started the process.
|
|
@@ -76,15 +77,15 @@ The {ruby Process::Metrics::General} struct contains the following fields:
|
|
|
76
77
|
The {ruby Process::Metrics::Memory} struct contains the following fields:
|
|
77
78
|
|
|
78
79
|
- `map_count` - Number of Memory Mappings, e.g. number of thread stacks, fiber stacks, shared libraries, memory mapped files, etc.
|
|
79
|
-
- `resident_size` - Resident Memory Size (
|
|
80
|
-
- `proportional_size` - Proportional Memory Size (
|
|
81
|
-
- `shared_clean_size` - Shared Clean Memory Size (
|
|
82
|
-
- `shared_dirty_size` - Shared Dirty Memory Size (
|
|
83
|
-
- `private_clean_size` - Private Clean Memory Size (
|
|
84
|
-
- `private_dirty_size` - Private Dirty Memory Size (
|
|
85
|
-
- `referenced_size` - Referenced Memory Size (
|
|
86
|
-
- `anonymous_size` - Anonymous Memory Size (
|
|
87
|
-
- `swap_size` - Swap Memory Size (
|
|
88
|
-
- `proportional_swap_size` - Proportional Swap Memory Size (
|
|
80
|
+
- `resident_size` - Resident Memory Size (bytes), the amount of physical memory used by the process.
|
|
81
|
+
- `proportional_size` - Proportional Memory Size (bytes), the amount of memory that the process is using, taking into account shared memory.
|
|
82
|
+
- `shared_clean_size` - Shared Clean Memory Size (bytes), the amount of shared memory that is clean (not modified).
|
|
83
|
+
- `shared_dirty_size` - Shared Dirty Memory Size (bytes), the amount of shared memory that is dirty (modified).
|
|
84
|
+
- `private_clean_size` - Private Clean Memory Size (bytes), the amount of private memory that is clean (not modified).
|
|
85
|
+
- `private_dirty_size` - Private Dirty Memory Size (bytes), the amount of private memory that is dirty (modified).
|
|
86
|
+
- `referenced_size` - Referenced Memory Size (bytes), active page-cache that isn't going to be reclaimed any time soon.
|
|
87
|
+
- `anonymous_size` - Anonymous Memory Size (bytes), mapped memory that isn't backed by a file.
|
|
88
|
+
- `swap_size` - Swap Memory Size (bytes), the amount of memory that has been swapped to disk.
|
|
89
|
+
- `proportional_swap_size` - Proportional Swap Memory Size (bytes), the amount of memory that has been swapped to disk, excluding shared memory.
|
|
89
90
|
|
|
90
91
|
In general, the interpretation of these fields is operating system specific. At best, they provide a rough estimate of the process's memory usage, but you should consult the documentation for your operating system for more details on exactly what each field represents.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2020-
|
|
4
|
+
# Copyright, 2020-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "samovar"
|
|
7
7
|
|
|
@@ -90,23 +90,28 @@ module Process
|
|
|
90
90
|
UNITS = ["KiB", "MiB", "GiB"]
|
|
91
91
|
|
|
92
92
|
# Format a memory size value in human-readable units.
|
|
93
|
-
# @parameter value [Numeric] The size value in
|
|
93
|
+
# @parameter value [Numeric] The size value in bytes.
|
|
94
94
|
# @parameter units [Array(String)] The unit labels to use for scaling.
|
|
95
95
|
# @returns [String] A formatted string with value and unit (e.g., "512KiB", "1.5MiB").
|
|
96
96
|
def format_size(value, units: UNITS)
|
|
97
|
-
unit =
|
|
97
|
+
unit = -1
|
|
98
98
|
|
|
99
|
-
while value
|
|
99
|
+
while value >= 1024.0 && unit < units.size - 1
|
|
100
100
|
value /= 1024.0
|
|
101
101
|
unit += 1
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
if unit < 0
|
|
105
|
+
# Value is less than 1 KiB, show in bytes
|
|
106
|
+
return "#{value.round(0)}B"
|
|
107
|
+
else
|
|
108
|
+
return "#{value.round(unit)}#{units[unit]}"
|
|
109
|
+
end
|
|
105
110
|
end
|
|
106
111
|
|
|
107
112
|
# Format a memory value with a horizontal bar showing utilization relative to total.
|
|
108
|
-
# @parameter value [Numeric] The memory value in
|
|
109
|
-
# @parameter total [Numeric] The total memory available in
|
|
113
|
+
# @parameter value [Numeric] The memory value in bytes.
|
|
114
|
+
# @parameter total [Numeric] The total memory available in bytes.
|
|
110
115
|
# @parameter terminal [Console::Terminal] The terminal to output styled text.
|
|
111
116
|
def format_memory(value, total, terminal)
|
|
112
117
|
if value > (total * 0.8)
|
|
@@ -123,10 +128,11 @@ module Process
|
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
# Get the total memory to use for percentage calculations.
|
|
126
|
-
# @returns [Integer] Total memory in
|
|
131
|
+
# @returns [Integer] Total memory in bytes.
|
|
127
132
|
def total_memory
|
|
128
133
|
if total_memory = @options[:total_memory]
|
|
129
|
-
|
|
134
|
+
# Convert from MiB to bytes
|
|
135
|
+
return total_memory * 1024 * 1024
|
|
130
136
|
else
|
|
131
137
|
return Process::Metrics::Memory.total_size
|
|
132
138
|
end
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2019-
|
|
4
|
+
# Copyright, 2019-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "memory"
|
|
7
7
|
require "set"
|
|
@@ -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}, # Virtual Size (KiB)
|
|
46
|
-
rss: ->(value){value.to_i}, # Resident Size (KiB)
|
|
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.
|
|
@@ -73,7 +58,7 @@ module Process
|
|
|
73
58
|
as_json.to_json(*arguments)
|
|
74
59
|
end
|
|
75
60
|
|
|
76
|
-
# The total size of the process in memory, in
|
|
61
|
+
# The total size of the process in memory, in bytes.
|
|
77
62
|
def total_size
|
|
78
63
|
if memory = self.memory
|
|
79
64
|
memory.proportional_size
|
|
@@ -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
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2025, by Samuel Williams.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
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 / 1024
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Parse a size string from vmmap output into kilobytes.
|
|
30
|
-
# @parameter string [String | Nil] The size string (e.g., "4K", "1.5M", "2G").
|
|
31
|
-
# @returns [Integer] The size in kilobytes.
|
|
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
|
|
36
|
-
when /([\d\.]+)K/i then ($1.to_f).round
|
|
37
|
-
when /([\d\.]+)M/i then ($1.to_f * 1024).round
|
|
38
|
-
when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024).round
|
|
39
|
-
else (
|
|
24
|
+
case size_string.strip
|
|
25
|
+
when /([\d\.]+)K/i then ($1.to_f * 1024).round
|
|
26
|
+
when /([\d\.]+)M/i then ($1.to_f * 1024 * 1024).round
|
|
27
|
+
when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
|
|
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 kilobytes.
|
|
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
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2025, by Samuel Williams.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
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
|
-
# Extract minor
|
|
11
|
-
# @parameter pid [Integer]
|
|
12
|
-
# @parameter usage [Memory]
|
|
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.
|
|
13
14
|
def self.capture_faults(pid, usage)
|
|
14
|
-
|
|
15
|
+
stat_content = File.read("/proc/#{pid}/stat")
|
|
15
16
|
# The comm field can contain spaces and parentheses; find the closing ')':
|
|
16
|
-
|
|
17
|
-
return unless
|
|
18
|
-
fields =
|
|
17
|
+
closing_paren_index = stat_content.rindex(")")
|
|
18
|
+
return unless closing_paren_index
|
|
19
|
+
fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
|
|
19
20
|
# proc(5): field 10=minflt, 12=majflt; our fields array is 0-indexed from field 3.
|
|
20
21
|
usage.minor_faults = fields[10-3].to_i
|
|
21
22
|
usage.major_faults = fields[12-3].to_i
|
|
@@ -23,16 +24,7 @@ module Process
|
|
|
23
24
|
# Ignore.
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
#
|
|
27
|
-
def self.total_size
|
|
28
|
-
File.read("/proc/meminfo").each_line do |line|
|
|
29
|
-
if /MemTotal:\s+(?<total>\d+) kB/ =~ line
|
|
30
|
-
return total.to_i
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# 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).
|
|
36
28
|
SMAP = {
|
|
37
29
|
"Rss" => :resident_size,
|
|
38
30
|
"Pss" => :proportional_size,
|
|
@@ -52,22 +44,28 @@ module Process
|
|
|
52
44
|
true
|
|
53
45
|
end
|
|
54
46
|
|
|
55
|
-
# Capture memory usage
|
|
56
|
-
|
|
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]
|
|
51
|
+
def self.capture(pid, faults: true, **options)
|
|
57
52
|
File.open("/proc/#{pid}/smaps_rollup") do |file|
|
|
58
53
|
usage = Memory.zero
|
|
59
|
-
|
|
60
54
|
file.each_line do |line|
|
|
61
55
|
if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
|
|
62
56
|
if key = SMAP[name]
|
|
63
|
-
|
|
57
|
+
# Convert from kilobytes to bytes:
|
|
58
|
+
usage[key] += value.to_i * 1024
|
|
64
59
|
end
|
|
65
60
|
end
|
|
66
61
|
end
|
|
67
62
|
|
|
68
63
|
usage.map_count += File.readlines("/proc/#{pid}/maps").size
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
|
|
65
|
+
# Also capture fault counters if requested:
|
|
66
|
+
if faults
|
|
67
|
+
self.capture_faults(pid, usage)
|
|
68
|
+
end
|
|
71
69
|
|
|
72
70
|
return usage
|
|
73
71
|
end
|
|
@@ -81,17 +79,20 @@ module Process
|
|
|
81
79
|
true
|
|
82
80
|
end
|
|
83
81
|
|
|
84
|
-
# Capture memory usage
|
|
85
|
-
|
|
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]
|
|
86
|
+
def self.capture(pid, faults: true, **options)
|
|
86
87
|
File.open("/proc/#{pid}/smaps") do |file|
|
|
87
88
|
usage = Memory.zero
|
|
88
|
-
|
|
89
89
|
file.each_line do |line|
|
|
90
90
|
# The format of this is fixed according to:
|
|
91
91
|
# https://github.com/torvalds/linux/blob/351c8a09b00b5c51c8f58b016fffe51f87e2d820/fs/proc/task_mmu.c#L804-L814
|
|
92
92
|
if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
|
|
93
93
|
if key = SMAP[name]
|
|
94
|
-
|
|
94
|
+
# Convert from kilobytes to bytes:
|
|
95
|
+
usage[key] += value.to_i * 1024
|
|
95
96
|
end
|
|
96
97
|
elsif /VmFlags:\s+(?<flags>.*)/ =~ line
|
|
97
98
|
# It should be possible to extract the number of fibers and each fiber's memory usage.
|
|
@@ -100,8 +101,10 @@ module Process
|
|
|
100
101
|
end
|
|
101
102
|
end
|
|
102
103
|
|
|
103
|
-
# Also capture fault counters:
|
|
104
|
-
|
|
104
|
+
# Also capture fault counters if requested:
|
|
105
|
+
if faults
|
|
106
|
+
self.capture_faults(pid, usage)
|
|
107
|
+
end
|
|
105
108
|
|
|
106
109
|
return usage
|
|
107
110
|
end
|
|
@@ -115,29 +118,25 @@ module Process
|
|
|
115
118
|
end
|
|
116
119
|
end
|
|
117
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
|
|
118
132
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# Get total system memory size.
|
|
128
|
-
# @returns [Integer] Total memory in kilobytes.
|
|
129
|
-
def total_size
|
|
130
|
-
return Memory::Linux.total_size
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Capture memory metrics for a process.
|
|
134
|
-
# @parameter pid [Integer] The process ID.
|
|
135
|
-
# @parameter options [Hash] Additional options.
|
|
136
|
-
# @returns [Memory] A Memory instance with captured metrics.
|
|
137
|
-
def capture(...)
|
|
138
|
-
return Memory::Linux.capture(...)
|
|
139
|
-
end
|
|
140
|
-
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(...)
|
|
141
140
|
end
|
|
142
141
|
end
|
|
143
142
|
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2019-
|
|
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
|
|
10
|
-
# Represents memory usage for a process, sizes are in
|
|
11
|
+
# Represents memory usage for a process, sizes are in bytes.
|
|
11
12
|
class Memory < Struct.new(:map_count, :resident_size, :proportional_size, :shared_clean_size, :shared_dirty_size, :private_clean_size, :private_dirty_size, :referenced_size, :anonymous_size, :swap_size, :proportional_swap_size, :minor_faults, :major_faults)
|
|
12
13
|
|
|
13
14
|
alias as_json to_h
|
|
@@ -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/license.md
CHANGED
data/readme.md
CHANGED
|
@@ -16,6 +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
|
+
|
|
23
|
+
### v0.9.0
|
|
24
|
+
|
|
25
|
+
- `Process::Metrics::Memory.total_size` takes into account cgroup limits.
|
|
26
|
+
- On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
|
|
27
|
+
- Report all sizes in bytes for consistency.
|
|
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
|
+
|
|
19
35
|
### v0.7.0
|
|
20
36
|
|
|
21
37
|
- Be more proactive about returning nil if memory capture failed.
|
|
@@ -55,24 +71,6 @@ Please see the [project releases](https://socketry.github.io/process-metrics/rel
|
|
|
55
71
|
- Added missing dependencies: `bake-test-external` and `json` gem.
|
|
56
72
|
- Added summary lines for PSS (Proportional Set Size) and USS (Unique Set Size).
|
|
57
73
|
|
|
58
|
-
### v0.2.1
|
|
59
|
-
|
|
60
|
-
- Added missing dependency to gemspec.
|
|
61
|
-
- Added example of command line usage to documentation.
|
|
62
|
-
- Renamed `rsz` to `rss` (Resident Set Size) for consistency across Darwin and Linux platforms.
|
|
63
|
-
|
|
64
|
-
### v0.2.0
|
|
65
|
-
|
|
66
|
-
- Added `process-metrics` command line interface for monitoring processes.
|
|
67
|
-
- Implemented structured data using Ruby structs for better performance and clarity.
|
|
68
|
-
- Added documentation about PSS (Proportional Set Size) and USS (Unique Set Size) metrics.
|
|
69
|
-
|
|
70
|
-
### v0.1.1
|
|
71
|
-
|
|
72
|
-
- Removed `Gemfile.lock` from version control.
|
|
73
|
-
- Fixed process metrics to exclude the `ps` command itself from measurements.
|
|
74
|
-
- Fixed documentation formatting issues.
|
|
75
|
-
|
|
76
74
|
## Contributing
|
|
77
75
|
|
|
78
76
|
We welcome contributions to this project.
|
data/releases.md
CHANGED
|
@@ -1,5 +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
|
+
|
|
7
|
+
## v0.9.0
|
|
8
|
+
|
|
9
|
+
- `Process::Metrics::Memory.total_size` takes into account cgroup limits.
|
|
10
|
+
- On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
|
|
11
|
+
- Report all sizes in bytes for consistency.
|
|
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
|
+
|
|
3
19
|
## v0.7.0
|
|
4
20
|
|
|
5
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
|
|
@@ -122,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
122
127
|
- !ruby/object:Gem::Version
|
|
123
128
|
version: '0'
|
|
124
129
|
requirements: []
|
|
125
|
-
rubygems_version:
|
|
130
|
+
rubygems_version: 4.0.3
|
|
126
131
|
specification_version: 4
|
|
127
132
|
summary: Provide detailed OS-specific process metrics.
|
|
128
133
|
test_files: []
|
metadata.gz.sig
CHANGED
|
Binary file
|