process-metrics 0.11.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbf463af0812bd192ef672f02852cc6142e4ec07ce54e9101561e236848a9339
4
- data.tar.gz: debc60440d79a2a745ea287e129f22b4a72fbf4b608a8903aafb95a3204b9b38
3
+ metadata.gz: 2822c0b3ad1a6e79d294cc8607359b1a8042818a8b07306c5685064addd4b7fd
4
+ data.tar.gz: f945a7b3bcf467f696c9823a11ecefa996aae3f7e9d075bcfcd6bba87edd13d1
5
5
  SHA512:
6
- metadata.gz: f5b9a378cb31b094f83e180696df41dc7c1cc46f7df1e7af782e9883b07ef1e4d4e0007eab9a90a4526e55f794a9d697a5a5033024ea165392ba2ad6af4face0
7
- data.tar.gz: '0281fd18c13ccc1d5a1bc4092b242209185ef38239b227b350dca21d780b912ca5fcacbc81a30ae819bfe4e37972494c7fb432e899887a1c0453973fed6fed87'
6
+ metadata.gz: 5869ec2e258caad4625fc45f9ee572ebbf9803592f0b8ed1115e80a5f21ad7b36acc3122c9d17aa5c8816c33d76bc4393c3d6620f2ec15f44e065701168ee952
7
+ data.tar.gz: 7b0a434ceba0c1b0d89297825a5bde80999542e10060e5aa89bafc34e44529e75323ad5de3b3ad792e7bdaee87856e64111ba209026e597203eeed8c6ca3aad9
checksums.yaml.gz.sig CHANGED
Binary file
@@ -21,6 +21,7 @@ $ gem install process-metrics
21
21
  The `process-metrics` gem provides a simple interface to collect and analyze process metrics.
22
22
 
23
23
  - {ruby Process::Metrics::General} is the main entry point for process metrics. Use {ruby Process::Metrics::General.capture} to collect metrics for one or more processes.
24
+ - {ruby Process::Metrics::Processor} measures processor utilization over intervals between samples.
24
25
  - {ruby Process::Metrics::Memory} provides additional methods for collecting memory metrics when the host operating system provides the necessary information.
25
26
 
26
27
  ## Usage
@@ -67,7 +68,7 @@ The {ruby Process::Metrics::General} struct contains the following fields:
67
68
  - `process_id` - Process ID, a unique identifier for the process.
68
69
  - `parent_process_id` - Parent Process ID, the process ID of the process that started this process.
69
70
  - `process_group_id` - Process Group ID, the process group ID of the process, which can be shared by multiple processes.
70
- - `processor_utilization` - Processor Utilization (%), the percentage of CPU time used by the process (over a system-specific duration).
71
+ - `processor_utilization` - Average processor utilization in core units over the system's observation period. `1.0` represents one fully occupied CPU core, and multi-threaded processes can exceed `1.0`.
71
72
  - `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
73
  - `resident_size` - Resident (Set) Size (bytes), the amount of physical memory used by the process.
73
74
  - `processor_time` - CPU Time (s), the amount of CPU time used by the process.
@@ -89,3 +90,25 @@ The {ruby Process::Metrics::Memory} struct contains the following fields:
89
90
  - `proportional_swap_size` - Proportional Swap Memory Size (bytes), the amount of memory that has been swapped to disk, excluding shared memory.
90
91
 
91
92
  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.
93
+
94
+ ## Interval Processor Utilization
95
+
96
+ The processor utilization reported by {ruby Process::Metrics::General} is a snapshot based on the operating system's observation period. Use {ruby Process::Metrics::Processor} when you need utilization over a specific sampling interval:
97
+
98
+ ``` ruby
99
+ processor = Process::Metrics::Processor.new
100
+
101
+ # The first sample establishes a baseline:
102
+ processor.sample(Process.pid)
103
+
104
+ sleep(10)
105
+ sample = processor.sample(Process.pid).fetch(Process.pid)
106
+
107
+ sample.duration
108
+ # => approximately 10.0
109
+
110
+ sample.utilization
111
+ # => 1.0 means one fully occupied CPU core during the interval
112
+ ```
113
+
114
+ Both interfaces use the same core-unit scale. A utilization of `0.5` means half of one core on average, while `2.0` means two cores were fully occupied. Utilization is therefore not limited to the range `0.0..1.0`.
@@ -18,9 +18,16 @@ module Process
18
18
  # Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
19
19
  PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096
20
20
 
21
+ # The Unix timestamp when the system booted.
22
+ BOOT_TIME = if File.readable?("/proc/stat")
23
+ File.foreach("/proc/stat") do |line|
24
+ break line.split.last.to_f if line.start_with?("btime ")
25
+ end
26
+ end
27
+
21
28
  # Whether /proc is available so we can list processes without ps.
22
29
  def self.supported?
23
- File.directory?("/proc") && File.readable?("/proc/self/stat")
30
+ File.directory?("/proc") && File.readable?("/proc/self/stat") && File.readable?("/proc/uptime") && !BOOT_TIME.nil?
24
31
  end
25
32
 
26
33
  # 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.
@@ -38,7 +45,6 @@ module Process
38
45
  end
39
46
 
40
47
  uptime_jiffies = nil
41
-
42
48
  processes = {}
43
49
  pids_to_read.each do |pid|
44
50
  stat_path = "/proc/#{pid}/stat"
@@ -56,7 +62,7 @@ module Process
56
62
  process_group_id = fields[2].to_i
57
63
  utime = fields[11].to_i
58
64
  stime = fields[12].to_i
59
- starttime = fields[19].to_i
65
+ start_time = fields[19].to_i
60
66
  virtual_size = fields[20].to_i
61
67
  resident_pages = fields[21].to_i
62
68
 
@@ -65,9 +71,13 @@ module Process
65
71
  uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
66
72
  (uptime_seconds * CLK_TCK).to_i
67
73
  end
68
-
69
74
  processor_time = (utime + stime).to_f / CLK_TCK
70
- elapsed_time = [(uptime_jiffies - starttime).to_f / CLK_TCK, 0.0].max
75
+ elapsed_time = [(uptime_jiffies - start_time).to_f / CLK_TCK, 0.0].max
76
+ processor_utilization = if elapsed_time > 0.0
77
+ processor_time.fdiv(elapsed_time)
78
+ else
79
+ 0.0
80
+ end
71
81
 
72
82
  command = read_command(pid, executable_name)
73
83
 
@@ -75,11 +85,12 @@ module Process
75
85
  pid,
76
86
  parent_process_id,
77
87
  process_group_id,
78
- 0.0, # processor_utilization: would need two samples; not available from single stat read
88
+ processor_utilization,
79
89
  virtual_size,
80
90
  resident_pages * PAGE_SIZE,
81
91
  processor_time,
82
92
  elapsed_time,
93
+ BOOT_TIME + start_time.to_f / CLK_TCK,
83
94
  command,
84
95
  nil
85
96
  )
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
+ require "time"
7
+
6
8
  module Process
7
9
  module Metrics
8
10
  # General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
@@ -12,15 +14,16 @@ module Process
12
14
 
13
15
  # The fields that will be extracted from the `ps` command (order matches -o output).
14
16
  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},
17
+ pid: ->(values){values.shift.to_i},
18
+ ppid: ->(values){values.shift.to_i},
19
+ pgid: ->(values){values.shift.to_i},
20
+ pcpu: ->(values){values.shift.to_f / 100.0},
21
+ vsz: ->(values){values.shift.to_i * 1024},
22
+ rss: ->(values){values.shift.to_i * 1024},
23
+ time: ->(values){Process::Metrics.duration(values.shift)},
24
+ etime: ->(values){Process::Metrics.duration(values.shift)},
25
+ lstart: ->(values){Time.strptime(values.shift(5).join(" "), "%a %b %e %H:%M:%S %Y").to_f},
26
+ command: ->(values){values.join(" ")},
24
27
  }
25
28
 
26
29
  # Whether process listing via ps is available on this system.
@@ -48,7 +51,7 @@ module Process
48
51
 
49
52
  arguments.push("-o", FIELDS.keys.join(","))
50
53
 
51
- spawned_pid = Process.spawn(*arguments, out: output)
54
+ spawned_pid = Process.spawn({"LC_ALL" => "C"}, *arguments, out: output)
52
55
  output.close
53
56
 
54
57
  input.readlines.map(&:strip)
@@ -71,10 +74,9 @@ module Process
71
74
  lines.each do |line|
72
75
  next if line.empty?
73
76
 
74
- values = line.split(/\s+/, FIELDS.size)
75
- next if values.size < FIELDS.size
77
+ values = line.split(/\s+/)
78
+ record = FIELDS.values.map{|parser| parser.call(values)}
76
79
 
77
- record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
78
80
  instance = General.new(*record, nil)
79
81
  processes[instance.process_id] = instance
80
82
  end
@@ -35,7 +35,8 @@ module Process
35
35
  end
36
36
 
37
37
  # General process information.
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)
38
+ # @attribute [Float] The processor utilization in core units, where `1.0` represents one fully occupied CPU core. Multi-threaded processes may report values greater than `1.0`.
39
+ class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :start_time, :command, :memory)
39
40
  # Convert the object to a JSON serializable hash.
40
41
  def as_json
41
42
  {
@@ -48,6 +49,7 @@ module Process
48
49
  resident_size: self.resident_size,
49
50
  processor_time: self.processor_time,
50
51
  elapsed_time: self.elapsed_time,
52
+ start_time: self.start_time,
51
53
  command: self.command,
52
54
  memory: self.memory&.as_json,
53
55
  }
@@ -6,10 +6,14 @@
6
6
  module Process
7
7
  module Metrics
8
8
  module Host
9
+ # Captures host memory limits and usage from a cgroup v1 filesystem.
9
10
  class Memory::Linux::CgroupV1
10
11
  CGROUP_V1_UNLIMITED_THRESHOLD = 2**60
11
12
  DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
12
13
 
14
+ # Whether cgroup v1 memory metrics are available.
15
+ # @parameter cgroup_root [String | Nil] The root of the cgroup filesystem.
16
+ # @returns [Boolean] Whether the required cgroup v1 files exist.
13
17
  def self.supported?(cgroup_root = DEFAULT_CGROUP_ROOT)
14
18
  root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
15
19
 
@@ -20,10 +24,14 @@ module Process
20
24
  return false
21
25
  end
22
26
 
27
+ # Initialize the cgroup v1 memory reader.
28
+ # @parameter cgroup_root [String | Nil] The root of the cgroup filesystem.
23
29
  def initialize(cgroup_root: nil)
24
30
  @cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
25
31
  end
26
32
 
33
+ # Capture host memory from the cgroup v1 filesystem.
34
+ # @returns [Host::Memory | Nil] The captured host memory, if the cgroup has a finite limit.
27
35
  def capture
28
36
  total = read_total
29
37
  return nil unless total && total.positive?
@@ -6,9 +6,13 @@
6
6
  module Process
7
7
  module Metrics
8
8
  module Host
9
+ # Captures host memory limits and usage from a cgroup v2 filesystem.
9
10
  class Memory::Linux::CgroupV2
10
11
  DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
11
12
 
13
+ # Whether cgroup v2 memory metrics are available.
14
+ # @parameter cgroup_root [String | Nil] The root of the cgroup filesystem.
15
+ # @returns [Boolean] Whether the required cgroup v2 files exist.
12
16
  def self.supported?(cgroup_root = DEFAULT_CGROUP_ROOT)
13
17
  root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
14
18
 
@@ -19,10 +23,14 @@ module Process
19
23
  return false
20
24
  end
21
25
 
26
+ # Initialize the cgroup v2 memory reader.
27
+ # @parameter cgroup_root [String | Nil] The root of the cgroup filesystem.
22
28
  def initialize(cgroup_root: nil)
23
29
  @cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
24
30
  end
25
31
 
32
+ # Capture host memory from the cgroup v2 filesystem.
33
+ # @returns [Host::Memory | Nil] The captured host memory, if the cgroup has a finite limit.
26
34
  def capture
27
35
  total = read_total
28
36
  return nil unless total && total.positive?
@@ -6,11 +6,16 @@
6
6
  module Process
7
7
  module Metrics
8
8
  module Host
9
+ # Captures host memory from Linux `/proc/meminfo`.
9
10
  class Memory::Linux::Meminfo
11
+ # Whether `/proc/meminfo` is available.
12
+ # @returns [Boolean] Whether the procfs memory information exists.
10
13
  def self.supported?
11
14
  File.exist?("/proc/meminfo")
12
15
  end
13
16
 
17
+ # Capture host memory from `/proc/meminfo`.
18
+ # @returns [Host::Memory | Nil] The captured host memory, if available.
14
19
  def capture
15
20
  content = File.read("/proc/meminfo") rescue nil
16
21
  return nil unless content
@@ -10,6 +10,9 @@ module Process
10
10
  module Memory::Linux
11
11
  DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
12
12
 
13
+ # Capture host memory from the available Linux interface.
14
+ # @parameter cgroup_root [String] The root of the cgroup filesystem.
15
+ # @returns [Host::Memory | Nil] The captured host memory, if available.
13
16
  def self.capture(cgroup_root: DEFAULT_CGROUP_ROOT)
14
17
  if Memory::Linux::CgroupV2.supported?(cgroup_root)
15
18
  if capture = Memory::Linux::CgroupV2.new(cgroup_root: cgroup_root).capture
@@ -15,12 +15,17 @@ module Process
15
15
  # @attribute swap_used_size [Integer, nil] Swap in use, or nil if not available.
16
16
  # @attribute reclaimable_size [Integer, nil] Reclaimable memory (e.g. page cache, slab), or nil. Included in used_size.
17
17
  Memory = Struct.new(:total_size, :used_size, :swap_total_size, :swap_used_size, :reclaimable_size) do
18
+ # Convert the memory snapshot to a hash, including derived sizes.
19
+ # @returns [Hash(Symbol, Integer | Nil)] The memory snapshot fields.
18
20
  def to_h
19
21
  super.merge(free_size: free_size, available_size: available_size)
20
22
  end
21
23
 
22
24
  alias as_json to_h
23
25
 
26
+ # Convert the memory snapshot to JSON.
27
+ # @parameter arguments [Array] Additional arguments passed to the JSON encoder.
28
+ # @returns [String] The encoded memory snapshot.
24
29
  def to_json(*arguments)
25
30
  as_json.to_json(*arguments)
26
31
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Process
7
+ module Metrics
8
+ # Computes interval CPU utilization from cumulative process metrics.
9
+ class Processor
10
+ # An immutable measurement of process CPU usage over an interval.
11
+ # @attribute [Integer] The process ID.
12
+ # @attribute [Float] The elapsed monotonic time in seconds.
13
+ # @attribute [Float] The CPU time consumed during the interval in seconds.
14
+ # @attribute [Float] The CPU utilization in core units, where `1.0` represents one fully occupied core. Multi-threaded processes may report values greater than `1.0`.
15
+ class Sample < Struct.new(:process_id, :duration, :processor_time, :utilization)
16
+ end
17
+
18
+ # @private
19
+ Snapshot = Struct.new(:start_time, :processor_time, :timestamp)
20
+
21
+ # Initialize a process metrics sampler.
22
+ # @parameter capture [Interface(:call)] The process snapshot capture callable.
23
+ def initialize(capture: General.method(:capture))
24
+ @capture = capture
25
+ @snapshots = {}
26
+ end
27
+
28
+ # Sample CPU utilization for the given processes.
29
+ # The first observation of each process establishes a baseline and does not produce a sample.
30
+ # @parameter process_ids [Integer | Array(Integer)] The process IDs to sample.
31
+ # @returns [Hash(Integer, Processor::Sample)] The valid interval samples keyed by process ID.
32
+ def sample(process_ids)
33
+ processes = @capture.call(pid: process_ids, memory: false)
34
+ timestamp = now
35
+ return {} unless finite?(timestamp)
36
+
37
+ samples = {}
38
+ snapshots = {}
39
+
40
+ processes.each do |process_id, process|
41
+ start_time = process.start_time
42
+ processor_time = process.processor_time
43
+ next unless finite?(start_time) && finite?(processor_time)
44
+
45
+ current = Snapshot.new(start_time, processor_time, timestamp)
46
+ snapshots[process_id] = current
47
+
48
+ if previous = @snapshots[process_id]
49
+ next unless previous.start_time == current.start_time
50
+
51
+ duration = current.timestamp - previous.timestamp
52
+ processor_time = current.processor_time - previous.processor_time
53
+ next unless finite?(duration) && duration > 0.0
54
+ next unless finite?(processor_time) && processor_time >= 0.0
55
+
56
+ utilization = processor_time / duration
57
+ next unless finite?(utilization)
58
+
59
+ samples[process_id] = Sample.new(process_id, duration, processor_time, utilization).freeze
60
+ end
61
+ end
62
+
63
+ @snapshots = snapshots
64
+
65
+ return samples
66
+ end
67
+
68
+ private
69
+
70
+ # Get the current monotonic time.
71
+ def now
72
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ end
74
+
75
+ # Whether the value is a finite number.
76
+ def finite?(value)
77
+ value.is_a?(Numeric) && value.finite?
78
+ end
79
+ end
80
+ end
81
+ end
@@ -7,6 +7,6 @@
7
7
  module Process
8
8
  # @namespace
9
9
  module Metrics
10
- VERSION = "0.11.1"
10
+ VERSION = "0.13.0"
11
11
  end
12
12
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "metrics/version"
7
7
  require_relative "metrics/general"
8
+ require_relative "metrics/processor"
data/readme.md CHANGED
@@ -16,6 +16,14 @@ 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.13.0
20
+
21
+ - Normalize processor utilization to core units, where `1.0` represents one fully occupied CPU core, and restore Linux reporting.
22
+
23
+ ### v0.12.0
24
+
25
+ - Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.
26
+
19
27
  ### v0.11.0
20
28
 
21
29
  - `process-metrics` command is removed, replaced with `bake process:metrics`.
@@ -52,14 +60,6 @@ Please see the [project releases](https://socketry.github.io/process-metrics/rel
52
60
 
53
61
  - Handle `Errno::ESRCH: No such process @ io_fillbuf - fd:xxx /proc/xxx/smaps_rollup` by ignoring it.
54
62
 
55
- ### v0.6.0
56
-
57
- - Add support for major and minor page faults on Linux: `Process::Metrics::Memory#major_faults` and `#minor_faults`. Unfortunately these metrics are not available on Darwin (macOS).
58
-
59
- ### v0.5.1
60
-
61
- - Fixed Linux memory usage capture to correctly read memory statistics.
62
-
63
63
  ## Contributing
64
64
 
65
65
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Releases
2
2
 
3
+ ## v0.13.0
4
+
5
+ - Normalize processor utilization to core units, where `1.0` represents one fully occupied CPU core, and restore Linux reporting.
6
+
7
+ ## v0.12.0
8
+
9
+ - Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.
10
+
3
11
  ## v0.11.0
4
12
 
5
13
  - `process-metrics` command is removed, replaced with `bake process:metrics`.
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.11.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -88,6 +88,7 @@ files:
88
88
  - lib/process/metrics/memory.rb
89
89
  - lib/process/metrics/memory/darwin.rb
90
90
  - lib/process/metrics/memory/linux.rb
91
+ - lib/process/metrics/processor.rb
91
92
  - lib/process/metrics/version.rb
92
93
  - license.md
93
94
  - readme.md
@@ -113,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
114
  - !ruby/object:Gem::Version
114
115
  version: '0'
115
116
  requirements: []
116
- rubygems_version: 4.0.6
117
+ rubygems_version: 4.0.10
117
118
  specification_version: 4
118
119
  summary: Provide detailed OS-specific process metrics.
119
120
  test_files: []
metadata.gz.sig CHANGED
Binary file