diff --git a/context/getting-started.md b/context/getting-started.md index f8aa0b1..4d1a321 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -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 @@ -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: @@ -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: diff --git a/lib/process/metrics.rb b/lib/process/metrics.rb index e3ea47c..c627179 100644 --- a/lib/process/metrics.rb +++ b/lib/process/metrics.rb @@ -5,3 +5,4 @@ require_relative "metrics/version" require_relative "metrics/general" +require_relative "metrics/processor" diff --git a/lib/process/metrics/general.rb b/lib/process/metrics/general.rb index ad6db1b..47c9c59 100644 --- a/lib/process/metrics/general.rb +++ b/lib/process/metrics/general.rb @@ -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 { @@ -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, } diff --git a/lib/process/metrics/general/linux.rb b/lib/process/metrics/general/linux.rb index d0cd3ad..6417eff 100644 --- a/lib/process/metrics/general/linux.rb +++ b/lib/process/metrics/general/linux.rb @@ -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. @@ -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" @@ -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 @@ -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) @@ -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 ) diff --git a/lib/process/metrics/general/process_status.rb b/lib/process/metrics/general/process_status.rb index b11600f..a409cb7 100644 --- a/lib/process/metrics/general/process_status.rb +++ b/lib/process/metrics/general/process_status.rb @@ -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) @@ -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. @@ -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) @@ -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 diff --git a/lib/process/metrics/processor.rb b/lib/process/metrics/processor.rb new file mode 100644 index 0000000..6f3570e --- /dev/null +++ b/lib/process/metrics/processor.rb @@ -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 diff --git a/releases.md b/releases.md index db78a76..7f35502 100644 --- a/releases.md +++ b/releases.md @@ -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`. diff --git a/test/process/general.rb b/test/process/general.rb index 9638c6b..870a89b 100644 --- a/test/process/general.rb +++ b/test/process/general.rb @@ -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 diff --git a/test/process/general/linux.rb b/test/process/general/linux.rb index 5b43956..1714aef 100644 --- a/test/process/general/linux.rb +++ b/test/process/general/linux.rb @@ -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 diff --git a/test/process/processor.rb b/test/process/processor.rb new file mode 100644 index 0000000..c6fa1a4 --- /dev/null +++ b/test/process/processor.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "process/metrics" + +describe Process::Metrics::Processor do + def process(process_id, processor_time, start_time = 1000.0) + Process::Metrics::General.new(process_id, nil, nil, nil, nil, nil, processor_time, nil, start_time, nil, nil) + end + + def processor(captures, timestamps) + capture = ->(**options) do + @capture_options = options + captures.shift + end + + processor_class = Class.new(Process::Metrics::Processor) do + define_method(:now) {timestamps.shift} + private :now + end + + processor_class.new(capture: capture) + end + + with "#sample" do + it "establishes a baseline before returning an interval sample" do + instance = processor([ + {1 => process(1, 1.0)}, + {1 => process(1, 2.5)}, + ], [10.0, 11.0]) + + expect(instance.sample(1)).to be(:empty?) + sample = instance.sample(1).fetch(1) + + expect(@capture_options).to be == {pid: 1, memory: false} + expect(sample.process_id).to be == 1 + expect(sample.duration).to be == 1.0 + expect(sample.processor_time).to be == 1.5 + expect(sample.utilization).to be == 1.5 + expect(sample).to be(:frozen?) + end + + it "reports an idle process" do + instance = processor([ + {1 => process(1, 1.0)}, + {1 => process(1, 1.0)}, + ], [10.0, 12.0]) + + instance.sample(1) + sample = instance.sample(1).fetch(1) + + expect(sample.utilization).to be == 0.0 + end + + it "samples multiple processes independently" do + instance = processor([ + {1 => process(1, 1.0), 2 => process(2, 4.0)}, + {1 => process(1, 1.5), 2 => process(2, 5.5)}, + ], [10.0, 11.0]) + + instance.sample([1, 2]) + samples = instance.sample([1, 2]) + + expect(samples[1].utilization).to be == 0.5 + expect(samples[2].utilization).to be == 1.5 + end + + it "establishes new baselines as the process set changes" do + instance = processor([ + {1 => process(1, 1.0)}, + {2 => process(2, 2.0)}, + {1 => process(1, 2.0), 2 => process(2, 3.0)}, + ], [10.0, 11.0, 12.0]) + + instance.sample([1]) + expect(instance.sample([2])).to be(:empty?) + samples = instance.sample([1, 2]) + + expect(samples.keys).to be == [2] + end + + it "does not compare different process incarnations" do + instance = processor([ + {1 => process(1, 1.0, 1000.0)}, + {1 => process(1, 2.0, 2000.0)}, + {1 => process(1, 3.0, 2000.0)}, + ], [10.0, 11.0, 12.0]) + + instance.sample(1) + expect(instance.sample(1)).to be(:empty?) + expect(instance.sample(1).fetch(1).utilization).to be == 1.0 + end + + it "rejects non-positive durations" do + instance = processor([ + {1 => process(1, 1.0)}, + {1 => process(1, 2.0)}, + ], [10.0, 10.0]) + + instance.sample(1) + expect(instance.sample(1)).to be(:empty?) + end + + it "rejects decreasing processor time" do + instance = processor([ + {1 => process(1, 2.0)}, + {1 => process(1, 1.0)}, + ], [10.0, 11.0]) + + instance.sample(1) + expect(instance.sample(1)).to be(:empty?) + end + end +end