Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ $ gem install process-metrics
The `process-metrics` gem provides a simple interface to collect and analyze process metrics.

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

## Usage
Expand Down Expand Up @@ -60,6 +61,26 @@ Process::Metrics::General.capture(pid: Process.pid)

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

### Sampling CPU Utilization

A single process snapshot contains cumulative CPU time. Use {ruby Process::Metrics::Processor} to calculate CPU utilization over an interval:

``` ruby
processor = Process::Metrics::Processor.new

# Establish the initial baseline:
processor.sample(Process.pid)

sleep 1

sample = processor.sample(Process.pid).fetch(Process.pid)
sample.duration
sample.processor_time
sample.utilization
```

`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`.

### Fields

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

The {ruby Process::Metrics::Memory} struct contains the following fields:
Expand Down
1 change: 1 addition & 0 deletions lib/process/metrics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

require_relative "metrics/version"
require_relative "metrics/general"
require_relative "metrics/processor"
3 changes: 2 additions & 1 deletion lib/process/metrics/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def self.duration(value)
end

# General process information.
class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :command, :memory)
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)
# Convert the object to a JSON serializable hash.
def as_json
{
Expand All @@ -48,6 +48,7 @@ def as_json
resident_size: self.resident_size,
processor_time: self.processor_time,
elapsed_time: self.elapsed_time,
start_time: self.start_time,
command: self.command,
memory: self.memory&.as_json,
}
Expand Down
16 changes: 11 additions & 5 deletions lib/process/metrics/general/linux.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ module General::Linux
# Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096

# The Unix timestamp when the system booted.
BOOT_TIME = if File.readable?("/proc/stat")
File.foreach("/proc/stat") do |line|
break line.split.last.to_f if line.start_with?("btime ")
end
end

# Whether /proc is available so we can list processes without ps.
def self.supported?
File.directory?("/proc") && File.readable?("/proc/self/stat")
File.directory?("/proc") && File.readable?("/proc/self/stat") && File.readable?("/proc/uptime") && !BOOT_TIME.nil?
end

# 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.
Expand All @@ -38,7 +45,6 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
end

uptime_jiffies = nil

processes = {}
pids_to_read.each do |pid|
stat_path = "/proc/#{pid}/stat"
Expand All @@ -56,7 +62,7 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
process_group_id = fields[2].to_i
utime = fields[11].to_i
stime = fields[12].to_i
starttime = fields[19].to_i
start_time = fields[19].to_i
virtual_size = fields[20].to_i
resident_pages = fields[21].to_i

Expand All @@ -65,9 +71,8 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
(uptime_seconds * CLK_TCK).to_i
end

processor_time = (utime + stime).to_f / CLK_TCK
elapsed_time = [(uptime_jiffies - starttime).to_f / CLK_TCK, 0.0].max
elapsed_time = [(uptime_jiffies - start_time).to_f / CLK_TCK, 0.0].max

command = read_command(pid, executable_name)

Expand All @@ -80,6 +85,7 @@ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
resident_pages * PAGE_SIZE,
processor_time,
elapsed_time,
BOOT_TIME + start_time.to_f / CLK_TCK,
command,
nil
)
Expand Down
28 changes: 15 additions & 13 deletions lib/process/metrics/general/process_status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "time"

module Process
module Metrics
# General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
Expand All @@ -12,15 +14,16 @@ module General::ProcessStatus

# The fields that will be extracted from the `ps` command (order matches -o output).
FIELDS = {
pid: ->(value){value.to_i},
ppid: ->(value){value.to_i},
pgid: ->(value){value.to_i},
pcpu: ->(value){value.to_f},
vsz: ->(value){value.to_i * 1024},
rss: ->(value){value.to_i * 1024},
time: Process::Metrics.method(:duration),
etime: Process::Metrics.method(:duration),
command: ->(value){value},
pid: ->(values){values.shift.to_i},
ppid: ->(values){values.shift.to_i},
pgid: ->(values){values.shift.to_i},
pcpu: ->(values){values.shift.to_f},
vsz: ->(values){values.shift.to_i * 1024},
rss: ->(values){values.shift.to_i * 1024},
time: ->(values){Process::Metrics.duration(values.shift)},
etime: ->(values){Process::Metrics.duration(values.shift)},
lstart: ->(values){Time.strptime(values.shift(5).join(" "), "%a %b %e %H:%M:%S %Y").to_f},
command: ->(values){values.join(" ")},
}

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

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

spawned_pid = Process.spawn(*arguments, out: output)
spawned_pid = Process.spawn({"LC_ALL" => "C"}, *arguments, out: output)
output.close

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

values = line.split(/\s+/, FIELDS.size)
next if values.size < FIELDS.size
values = line.split(/\s+/)
record = FIELDS.values.map{|parser| parser.call(values)}

record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
instance = General.new(*record, nil)
processes[instance.process_id] = instance
end
Expand Down
81 changes: 81 additions & 0 deletions lib/process/metrics/processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

module Process
module Metrics
# Computes interval CPU utilization from cumulative process metrics.
class Processor
# An immutable measurement of process CPU usage over an interval.
# @attribute [Integer] The process ID.
# @attribute [Float] The elapsed monotonic time in seconds.
# @attribute [Float] The CPU time consumed during the interval in seconds.
# @attribute [Float] The CPU utilization, where one fully occupied core is `1.0`.
class Sample < Struct.new(:process_id, :duration, :processor_time, :utilization)
end

# @private
Snapshot = Struct.new(:start_time, :processor_time, :timestamp)

# Initialize a process metrics sampler.
# @parameter capture [Interface(:call)] The process snapshot capture callable.
def initialize(capture: General.method(:capture))
@capture = capture
@snapshots = {}
end

# Sample CPU utilization for the given processes.
# The first observation of each process establishes a baseline and does not produce a sample.
# @parameter process_ids [Integer | Array(Integer)] The process IDs to sample.
# @returns [Hash(Integer, Processor::Sample)] The valid interval samples keyed by process ID.
def sample(process_ids)
processes = @capture.call(pid: process_ids, memory: false)
timestamp = now
return {} unless finite?(timestamp)

samples = {}
snapshots = {}

processes.each do |process_id, process|
start_time = process.start_time
processor_time = process.processor_time
next unless finite?(start_time) && finite?(processor_time)

current = Snapshot.new(start_time, processor_time, timestamp)
snapshots[process_id] = current

if previous = @snapshots[process_id]
next unless previous.start_time == current.start_time

duration = current.timestamp - previous.timestamp
processor_time = current.processor_time - previous.processor_time
next unless finite?(duration) && duration > 0.0
next unless finite?(processor_time) && processor_time >= 0.0

utilization = processor_time / duration
next unless finite?(utilization)

samples[process_id] = Sample.new(process_id, duration, processor_time, utilization).freeze
end
end

@snapshots = snapshots

return samples
end

private

# Get the current monotonic time.
def now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

# Whether the value is a finite number.
def finite?(value)
value.is_a?(Numeric) && value.finite?
end
end
end
end
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releases

## Unreleased

- Add `Process::Metrics::Processor` for measuring per-process CPU utilization over an interval.

## v0.11.0

- `process-metrics` command is removed, replaced with `bake process:metrics`.
Expand Down
1 change: 1 addition & 0 deletions test/process/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
expect(process.process_id).to be == pid
expect(process.parent_process_id).to be_a(Integer)
expect(process.process_group_id).to be_a(Integer)
expect(process.start_time).to be_a(Numeric)
end
end

Expand Down
1 change: 1 addition & 0 deletions test/process/general/linux.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def assert_backends_match(linux_capture, process_status_capture)
expect(linux_process.command).to be == process_status_process.command
expect((linux_process.processor_time - process_status_process.processor_time).abs).to be < 1.0
expect((linux_process.elapsed_time - process_status_process.elapsed_time).abs).to be < 1.0
expect((linux_process.start_time - process_status_process.start_time).abs).to be < 2.0
end
end

Expand Down
Loading
Loading