Skip to content

Commit fc50467

Browse files
Add processor metrics. (#10)
1 parent 002a4bb commit fc50467

10 files changed

Lines changed: 254 additions & 19 deletions

File tree

context/getting-started.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ $ gem install process-metrics
2121
The `process-metrics` gem provides a simple interface to collect and analyze process metrics.
2222

2323
- {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} computes CPU utilization over intervals from consecutive process snapshots.
2425
- {ruby Process::Metrics::Memory} provides additional methods for collecting memory metrics when the host operating system provides the necessary information.
2526

2627
## Usage
@@ -60,6 +61,26 @@ Process::Metrics::General.capture(pid: Process.pid)
6061

6162
If you want to capture a tree of processes, you can specify the `ppid:` option instead.
6263

64+
### Sampling CPU Utilization
65+
66+
A single process snapshot contains cumulative CPU time. Use {ruby Process::Metrics::Processor} to calculate CPU utilization over an interval:
67+
68+
``` ruby
69+
processor = Process::Metrics::Processor.new
70+
71+
# Establish the initial baseline:
72+
processor.sample(Process.pid)
73+
74+
sleep 1
75+
76+
sample = processor.sample(Process.pid).fetch(Process.pid)
77+
sample.duration
78+
sample.processor_time
79+
sample.utilization
80+
```
81+
82+
`processor_time` is the CPU time consumed during `duration`. `utilization` is a ratio, where one fully occupied CPU core is approximately `1.0`. A process using multiple cores can exceed `1.0`.
83+
6384
### Fields
6485

6586
The {ruby Process::Metrics::General} struct contains the following fields:
@@ -72,6 +93,7 @@ The {ruby Process::Metrics::General} struct contains the following fields:
7293
- `resident_size` - Resident (Set) Size (bytes), the amount of physical memory used by the process.
7394
- `processor_time` - CPU Time (s), the amount of CPU time used by the process.
7495
- `elapsed_time` - Elapsed Time (s), the amount of time the process has been running.
96+
- `start_time` - Start Time, expressed as seconds since the Unix epoch.
7597
- `command` - Command Name, the name of the command that started the process.
7698

7799
The {ruby Process::Metrics::Memory} struct contains the following fields:

lib/process/metrics.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55

66
require_relative "metrics/version"
77
require_relative "metrics/general"
8+
require_relative "metrics/processor"

lib/process/metrics/general.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def self.duration(value)
3535
end
3636

3737
# 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)
3939
# Convert the object to a JSON serializable hash.
4040
def as_json
4141
{
@@ -48,6 +48,7 @@ def as_json
4848
resident_size: self.resident_size,
4949
processor_time: self.processor_time,
5050
elapsed_time: self.elapsed_time,
51+
start_time: self.start_time,
5152
command: self.command,
5253
memory: self.memory&.as_json,
5354
}

lib/process/metrics/general/linux.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,16 @@ module General::Linux
1818
# Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
1919
PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096
2020

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+
2128
# Whether /proc is available so we can list processes without ps.
2229
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?
2431
end
2532

2633
# 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 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
3845
end
3946

4047
uptime_jiffies = nil
41-
4248
processes = {}
4349
pids_to_read.each do |pid|
4450
stat_path = "/proc/#{pid}/stat"
@@ -56,7 +62,7 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
5662
process_group_id = fields[2].to_i
5763
utime = fields[11].to_i
5864
stime = fields[12].to_i
59-
starttime = fields[19].to_i
65+
start_time = fields[19].to_i
6066
virtual_size = fields[20].to_i
6167
resident_pages = fields[21].to_i
6268

@@ -65,9 +71,8 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
6571
uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
6672
(uptime_seconds * CLK_TCK).to_i
6773
end
68-
6974
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
7176

7277
command = read_command(pid, executable_name)
7378

@@ -80,6 +85,7 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
8085
resident_pages * PAGE_SIZE,
8186
processor_time,
8287
elapsed_time,
88+
BOOT_TIME + start_time.to_f / CLK_TCK,
8389
command,
8490
nil
8591
)

lib/process/metrics/general/process_status.rb

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# Released under the MIT License.
44
# Copyright, 2026, by Samuel Williams.
55

6+
require "time"
7+
68
module Process
79
module Metrics
810
# General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
@@ -12,15 +14,16 @@ module General::ProcessStatus
1214

1315
# The fields that will be extracted from the `ps` command (order matches -o output).
1416
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(" ")},
2427
}
2528

2629
# Whether process listing via ps is available on this system.
@@ -48,7 +51,7 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
4851

4952
arguments.push("-o", FIELDS.keys.join(","))
5053

51-
spawned_pid = Process.spawn(*arguments, out: output)
54+
spawned_pid = Process.spawn({"LC_ALL" => "C"}, *arguments, out: output)
5255
output.close
5356

5457
input.readlines.map(&:strip)
@@ -71,10 +74,9 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
7174
lines.each do |line|
7275
next if line.empty?
7376

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)}
7679

77-
record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
7880
instance = General.new(*record, nil)
7981
processes[instance.process_id] = instance
8082
end

lib/process/metrics/processor.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.
6+
37
## v0.11.0
48

59
- `process-metrics` command is removed, replaced with `bake process:metrics`.

test/process/general.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
expect(process.process_id).to be == pid
3535
expect(process.parent_process_id).to be_a(Integer)
3636
expect(process.process_group_id).to be_a(Integer)
37+
expect(process.start_time).to be_a(Numeric)
3738
end
3839
end
3940

test/process/general/linux.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def assert_backends_match(linux_capture, process_status_capture)
3232
expect(linux_process.command).to be == process_status_process.command
3333
expect((linux_process.processor_time - process_status_process.processor_time).abs).to be < 1.0
3434
expect((linux_process.elapsed_time - process_status_process.elapsed_time).abs).to be < 1.0
35+
expect((linux_process.start_time - process_status_process.start_time).abs).to be < 2.0
3536
end
3637
end
3738

0 commit comments

Comments
 (0)