process-metrics 0.11.1 → 0.12.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: 4692590f9565bbf61e0854c736a4225749d1e12021b0fa769fdc6cab6cee7002
4
+ data.tar.gz: 138b82d832444faa4eafe5137ae4f25ffd4652b5d2b313fd698b1a11c871cb09
5
5
  SHA512:
6
- metadata.gz: f5b9a378cb31b094f83e180696df41dc7c1cc46f7df1e7af782e9883b07ef1e4d4e0007eab9a90a4526e55f794a9d697a5a5033024ea165392ba2ad6af4face0
7
- data.tar.gz: '0281fd18c13ccc1d5a1bc4092b242209185ef38239b227b350dca21d780b912ca5fcacbc81a30ae819bfe4e37972494c7fb432e899887a1c0453973fed6fed87'
6
+ metadata.gz: be2a880bd7a6285d5472425860ab1cdf1f031e0aca0e0565e849825e51449a9f1278646a34796f6b43f6e9c19cc7b120850e1470e1cc8bc86d9114e27709b779
7
+ data.tar.gz: 6c149bd8d92118add7f24d2a28d6c9b1ac22e97bd048fb5e30f3ed9e93f4b88bccf3be428255469e566d538ee81ebea72aaa82e7b7db0dce7841e89d8fcba5e0
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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,8 @@ 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
71
76
 
72
77
  command = read_command(pid, executable_name)
73
78
 
@@ -80,6 +85,7 @@ module Process
80
85
  resident_pages * PAGE_SIZE,
81
86
  processor_time,
82
87
  elapsed_time,
88
+ BOOT_TIME + start_time.to_f / CLK_TCK,
83
89
  command,
84
90
  nil
85
91
  )
@@ -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},
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,7 @@ 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
+ 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
39
  # Convert the object to a JSON serializable hash.
40
40
  def as_json
41
41
  {
@@ -48,6 +48,7 @@ module Process
48
48
  resident_size: self.resident_size,
49
49
  processor_time: self.processor_time,
50
50
  elapsed_time: self.elapsed_time,
51
+ start_time: self.start_time,
51
52
  command: self.command,
52
53
  memory: self.memory&.as_json,
53
54
  }
@@ -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, where one fully occupied core is `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.12.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,10 @@ 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.12.0
20
+
21
+ - Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.
22
+
19
23
  ### v0.11.0
20
24
 
21
25
  - `process-metrics` command is removed, replaced with `bake process:metrics`.
@@ -56,10 +60,6 @@ Please see the [project releases](https://socketry.github.io/process-metrics/rel
56
60
 
57
61
  - 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
62
 
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,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.
6
+
3
7
  ## v0.11.0
4
8
 
5
9
  - `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.12.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