agentmon 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/exe/agentmon +18 -0
- data/lib/agentmon/commands/cli_options.rb +21 -0
- data/lib/agentmon/commands/footprint.rb +81 -0
- data/lib/agentmon/commands/memory.rb +110 -0
- data/lib/agentmon/commands/record.rb +118 -0
- data/lib/agentmon/commands/report.rb +127 -0
- data/lib/agentmon/commands/sessions.rb +98 -0
- data/lib/agentmon/commands/top.rb +87 -0
- data/lib/agentmon/commands/tree.rb +96 -0
- data/lib/agentmon/darwin.rb +235 -0
- data/lib/agentmon/engine.rb +115 -0
- data/lib/agentmon/focus.rb +88 -0
- data/lib/agentmon/metrics/memory.rb +76 -0
- data/lib/agentmon/metrics/network.rb +245 -0
- data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
- data/lib/agentmon/metrics/process_rates.rb +65 -0
- data/lib/agentmon/metrics/process_rows.rb +41 -0
- data/lib/agentmon/metrics/session_ledger.rb +160 -0
- data/lib/agentmon/metrics/session_memory.rb +84 -0
- data/lib/agentmon/metrics/session_names.rb +169 -0
- data/lib/agentmon/metrics/sessions.rb +80 -0
- data/lib/agentmon/metrics/system.rb +114 -0
- data/lib/agentmon/model.rb +263 -0
- data/lib/agentmon/probes/cwd.rb +32 -0
- data/lib/agentmon/probes/memory.rb +139 -0
- data/lib/agentmon/probes/network.rb +326 -0
- data/lib/agentmon/probes/processes.rb +72 -0
- data/lib/agentmon/probes/system.rb +134 -0
- data/lib/agentmon/program.rb +55 -0
- data/lib/agentmon/reading.rb +83 -0
- data/lib/agentmon/recorders/memory.rb +19 -0
- data/lib/agentmon/recorders/sessions.rb +40 -0
- data/lib/agentmon/registry.rb +155 -0
- data/lib/agentmon/sampler.rb +50 -0
- data/lib/agentmon/store.rb +86 -0
- data/lib/agentmon/ui/connections.rb +75 -0
- data/lib/agentmon/ui/interaction.rb +94 -0
- data/lib/agentmon/ui/memory.rb +125 -0
- data/lib/agentmon/ui/process_actions.rb +49 -0
- data/lib/agentmon/ui/process_detail.rb +148 -0
- data/lib/agentmon/ui/process_network.rb +13 -0
- data/lib/agentmon/ui/process_scopes.rb +32 -0
- data/lib/agentmon/ui/process_waits.rb +99 -0
- data/lib/agentmon/ui/processes.rb +42 -0
- data/lib/agentmon/ui/session_focus.rb +126 -0
- data/lib/agentmon/ui/session_memory.rb +78 -0
- data/lib/agentmon/ui/sessions.rb +130 -0
- data/lib/agentmon/ui/theme.rb +49 -0
- data/lib/agentmon/ui.rb +80 -0
- data/lib/agentmon/version.rb +5 -0
- data/lib/agentmon/views/dense.rb +157 -0
- data/lib/agentmon/views/history.rb +62 -0
- data/lib/agentmon/views/signals.rb +279 -0
- data/lib/agentmon/views/visual.rb +172 -0
- data/lib/agentmon/views/widgets.rb +352 -0
- data/lib/agentmon/views.rb +875 -0
- data/lib/agentmon.rb +38 -0
- metadata +135 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# `agentmon sessions`: the agent sessions running now (Claude Code, Codex, their desktop apps),
|
|
6
|
+
# busiest first, with what each has cost over its life.
|
|
7
|
+
#
|
|
8
|
+
# agentmon sessions a table (boxed on a terminal, plain aligned columns in a pipe)
|
|
9
|
+
# agentmon sessions --all also sessions that ended in the last 15 minutes
|
|
10
|
+
# agentmon sessions --json one JSON object per line, raw units (bytes, seconds, epoch), for scripts
|
|
11
|
+
#
|
|
12
|
+
# Session Processes CPU Footprint Peak CPU time Written Age
|
|
13
|
+
# claude 200 · repo 4 150.0% 628M 628M 15.6s 5.0M 56m 42s
|
|
14
|
+
#
|
|
15
|
+
# CPU is percent of one core now; Footprint is real memory now and Peak the largest seen; CPU time
|
|
16
|
+
# and Written are lifetime totals (Written is a lower bound: see docs/design.md). Sizes are binary
|
|
17
|
+
# (1M = 1024²), as in the dashboard. Age runs from the session's start to now, or to its end.
|
|
18
|
+
module Agentmon
|
|
19
|
+
module Commands
|
|
20
|
+
module SessionList
|
|
21
|
+
HEADERS = ["Session", "Processes", "CPU", "Footprint", "Peak", "CPU time", "Written", "Age"].freeze
|
|
22
|
+
RIGHT = (1..7).to_h { |i| [i, :right] }.freeze
|
|
23
|
+
EMPTY = "No agent sessions running."
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Alive sessions (and ended ones with `all`), alive first, then by CPU, then by root pid.
|
|
28
|
+
def select(ledger, all: false)
|
|
29
|
+
ledger ||= [] # session_ledger failed or isn't there this sample (the error goes to stderr)
|
|
30
|
+
ledger = ledger.select(&:alive?) unless all
|
|
31
|
+
ledger.sort_by { |s| [s.alive? ? 0 : 1, -(s.cpu || 0.0), s.root_pid] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cells(session, now)
|
|
35
|
+
f = R2UI::Format
|
|
36
|
+
human = R2UI::CLI::Ext::HumanFormat
|
|
37
|
+
label = session.alive? ? session.label : "#{session.label} · ended #{human.duration(now - session.ended_at)} ago"
|
|
38
|
+
[label, session.processes.to_s, f.call(:percent, session.cpu), f.call(:bytes, session.footprint),
|
|
39
|
+
f.call(:bytes, session.peak_footprint), session.cpu_seconds && human.duration(session.cpu_seconds),
|
|
40
|
+
f.call(:bytes, session.bytes_written), human.duration(session.duration)].map(&:to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def json(session) = JSON.generate(session.to_record)
|
|
44
|
+
|
|
45
|
+
# Narrowest a label is cut to, however many other columns there are: on a very narrow
|
|
46
|
+
# terminal (or a pipe without COLUMNS, 80) the table overflows rather than lose the label.
|
|
47
|
+
MIN_LABEL = 20
|
|
48
|
+
|
|
49
|
+
# `rows` (Arrays of Strings) with column `column` cut at a word boundary ("·", " ", ...) so
|
|
50
|
+
# the table r2ui draws fits `width`: r2ui's plain table never cuts and its boxed one wraps
|
|
51
|
+
# cells, so a long session name would overflow a pipe or wrap mid-word on a terminal.
|
|
52
|
+
# `boxed` is the terminal table: each cell padded by one space each side plus n+1 borders;
|
|
53
|
+
# plain columns are joined by two spaces. A cell's part matching `keep` (at its end) is kept
|
|
54
|
+
# whole and the rest cut. Used by sessions, footprint and top.
|
|
55
|
+
def fit(rows, headers, width:, boxed:, column: 0, keep: nil)
|
|
56
|
+
return rows if rows.empty?
|
|
57
|
+
|
|
58
|
+
measure = ->(text) { R2UI::CLI::Ext::Tabulate.width(text.to_s) }
|
|
59
|
+
others = headers.each_index.sum do |i|
|
|
60
|
+
i == column ? 0 : [measure[headers[i]], *rows.map { |r| measure[r[i]] }].max
|
|
61
|
+
end
|
|
62
|
+
n = headers.size
|
|
63
|
+
overhead = boxed ? (3 * n) + 1 : 2 * (n - 1)
|
|
64
|
+
room = [width - others - overhead, MIN_LABEL, measure[headers[column]]].max
|
|
65
|
+
rows.map do |row|
|
|
66
|
+
text = row[column].to_s
|
|
67
|
+
tail = (keep && text[keep]).to_s
|
|
68
|
+
head = text.delete_suffix(tail)
|
|
69
|
+
row.dup.tap { |r| r[column] = Views::Widgets.cut(head, [room - measure[tail], MIN_LABEL].max) + tail }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The " · ended 3m ago" an ended session's label ends in: never cut.
|
|
74
|
+
ENDED = / · ended [^·]* ago\z/
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
command :sessions do
|
|
79
|
+
summary "Agent sessions now, with CPU, real memory and lifetime totals"
|
|
80
|
+
flag :all, short: "a", desc: "Also sessions that ended in the last 15 minutes"
|
|
81
|
+
flag :json, desc: "One JSON object per session per line, in raw units"
|
|
82
|
+
|
|
83
|
+
run do
|
|
84
|
+
list = Commands::SessionList
|
|
85
|
+
reading = Agentmon.engine.current
|
|
86
|
+
sessions = list.select(reading[:session_ledger], all: options[:all])
|
|
87
|
+
if options[:json]
|
|
88
|
+
sessions.each { |s| shell.puts(list.json(s)) }
|
|
89
|
+
elsif sessions.empty?
|
|
90
|
+
shell.puts(list::EMPTY)
|
|
91
|
+
else
|
|
92
|
+
rows = list.fit(sessions.map { |s| list.cells(s, reading.at) }, list::HEADERS, width: shell.width,
|
|
93
|
+
boxed: shell.live?, keep: list::ENDED)
|
|
94
|
+
table(rows, headers: list::HEADERS, align: list::RIGHT)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `agentmon top`: the processes of agent sessions, busiest first, with real memory and disk I/O.
|
|
4
|
+
#
|
|
5
|
+
# agentmon top on a terminal: redrawn in place every 2 s until ctrl+c
|
|
6
|
+
# agentmon top --once one table and exit (plain aligned columns in a pipe, for scripts)
|
|
7
|
+
# agentmon top -a -n 5 --sort footprint
|
|
8
|
+
# agentmon top --session repo only one session (its id, root pid or part of its label);
|
|
9
|
+
# no match, or several, exits 1 and lists the sessions
|
|
10
|
+
#
|
|
11
|
+
# PID Name Session CPU Footprint Resident Read Write
|
|
12
|
+
# 4242 claude claude 4242 · repo 12.5% 512M 600M 1.0K/s 0B/s
|
|
13
|
+
#
|
|
14
|
+
# Sizes are binary (1M = 1024²), the same units the dashboard shows. An empty cell is unknown:
|
|
15
|
+
# footprint and disk rates of other users' processes can't be read without root.
|
|
16
|
+
module Agentmon
|
|
17
|
+
module Commands
|
|
18
|
+
module Top
|
|
19
|
+
HEADERS = %w[PID Name Session CPU Footprint Resident Read Write].freeze
|
|
20
|
+
RIGHT = [0, 3, 4, 5, 6, 7].freeze
|
|
21
|
+
SESSION = 2 # the column cut to fit the width (a session label can be a long human name)
|
|
22
|
+
# Seconds between the live loop's own redraws (which surface a dead ticker's error).
|
|
23
|
+
CHECK_EVERY = 1
|
|
24
|
+
SORTS = { "cpu" => :cpu, "footprint" => :footprint, "resident" => :resident, "read" => :read_rate,
|
|
25
|
+
"write" => :write_rate }.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def select(rows, sort: "cpu", limit: 20, all: false)
|
|
30
|
+
key = SORTS.fetch(sort)
|
|
31
|
+
rows ||= [] # process_rows failed this sample: an empty table (the error goes to stderr)
|
|
32
|
+
rows = rows.select(&:session) unless all
|
|
33
|
+
rows.sort_by { |r| [-(r.public_send(key) || -1).to_f, r.pid] }.first(limit)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def cells(row)
|
|
37
|
+
f = R2UI::Format
|
|
38
|
+
[row.pid.to_s, row.name, row.session.to_s, f.call(:percent, row.cpu), f.call(:bytes, row.footprint),
|
|
39
|
+
f.call(:bytes, row.resident), f.call(:bytes_per_sec, row.read_rate), f.call(:bytes_per_sec, row.write_rate)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Plain aligned lines (header first), cut to `width`: the live view.
|
|
43
|
+
def lines(rows, width)
|
|
44
|
+
body = SessionList.fit(rows.map { |r| cells(r) }, HEADERS, width:, boxed: false, column: SESSION)
|
|
45
|
+
table = [HEADERS, *body]
|
|
46
|
+
widths = table.transpose.map { |col| col.map(&:length).max }
|
|
47
|
+
table.map do |cells|
|
|
48
|
+
cells.each_with_index.map { |c, i| RIGHT.include?(i) ? c.rjust(widths[i]) : c.ljust(widths[i]) }
|
|
49
|
+
.join(" ").rstrip[0, width]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
command :top do
|
|
56
|
+
summary "Agent processes by CPU, with footprint and disk I/O"
|
|
57
|
+
flag :once, desc: "Print one table and exit"
|
|
58
|
+
flag :all, short: "a", desc: "Every process, not only agent sessions"
|
|
59
|
+
option :limit, :integer, short: "n", default: 20, desc: "Rows to show"
|
|
60
|
+
option :sort, default: "cpu", in: Commands::Top::SORTS.keys, desc: "Sort by"
|
|
61
|
+
option :session, desc: "Only one session's processes: its id, root pid or part of its label"
|
|
62
|
+
|
|
63
|
+
run do
|
|
64
|
+
top = Commands::Top
|
|
65
|
+
pick = ->(reading) { top.select(reading[:process_rows], sort: options[:sort], limit: options[:limit], all: options[:all]) }
|
|
66
|
+
engine = Agentmon.engine
|
|
67
|
+
if (query = options[:session])
|
|
68
|
+
sessions = engine.current(focused: false)[:session_ledger]&.select(&:alive?) || []
|
|
69
|
+
found = Focus.find(sessions, query)
|
|
70
|
+
listing = ->(list) { list.map { |s| " #{s.label} (#{s.id})" }.join("\n") }
|
|
71
|
+
abort!("no session matches #{query.inspect}. Sessions:\n#{listing.call(sessions)}") if found.empty?
|
|
72
|
+
abort!("#{query.inspect} matches #{found.size} sessions:\n#{listing.call(found)}") if found.size > 1
|
|
73
|
+
engine.focus = found.first.id
|
|
74
|
+
end
|
|
75
|
+
if options[:once] || !shell.live?
|
|
76
|
+
cells = Commands::SessionList.fit(pick.call(engine.current).map { |r| top.cells(r) }, top::HEADERS,
|
|
77
|
+
width: shell.width, boxed: shell.live?, column: top::SESSION)
|
|
78
|
+
table(cells, headers: top::HEADERS, align: top::RIGHT.to_h { |i| [i, :right] })
|
|
79
|
+
else
|
|
80
|
+
live = R2UI::CLI::Live.new(shell, fps: 2) { top.lines(pick.call(engine.current), shell.width).join("\n") }
|
|
81
|
+
# `refresh` redraws on this thread: if the ticker died on a raising view, it raises that
|
|
82
|
+
# error here, Live restores the terminal, and the command fails instead of freezing.
|
|
83
|
+
live.run { loop { sleep top::CHECK_EVERY; live.refresh } }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `agentmon tree [SESSION]`: each agent session's process tree, the session label as root, each
|
|
4
|
+
# process as "pid name · footprint · CPU %".
|
|
5
|
+
#
|
|
6
|
+
# agentmon tree every live session
|
|
7
|
+
# agentmon tree repo one session, by id, root pid or label substring (any case)
|
|
8
|
+
#
|
|
9
|
+
# claude 200 · repo
|
|
10
|
+
# └── 200 claude · 512M · 100.0%
|
|
11
|
+
# ├── 201 node · 100M · 50.0%
|
|
12
|
+
# └── 202 zsh · 8.0M · 0.0%
|
|
13
|
+
# └── 203 git · 8.0M · 0.0%
|
|
14
|
+
#
|
|
15
|
+
# A SESSION that matches nothing, or more than one session, exits 1 and lists the candidates.
|
|
16
|
+
# "n/a" is unknown: other users' processes have no readable footprint without root. Sizes are
|
|
17
|
+
# binary (1M = 1024²). Plain lines in a pipe, so `agentmon tree | grep node` works.
|
|
18
|
+
module Agentmon
|
|
19
|
+
module Commands
|
|
20
|
+
module SessionTree
|
|
21
|
+
NONE = "No agent sessions running."
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The live sessions from a reading, root pid order; [] while `sessions` is unavailable.
|
|
26
|
+
def sessions(reading)
|
|
27
|
+
map = reading[:sessions]
|
|
28
|
+
map ? map.sessions.sort_by(&:root_pid) : []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Sessions a SESSION argument names: an exact id or root pid first, else label substrings.
|
|
32
|
+
def find(sessions, query)
|
|
33
|
+
exact = sessions.select { |s| s.id == query || s.root_pid.to_s == query }
|
|
34
|
+
return exact unless exact.empty?
|
|
35
|
+
|
|
36
|
+
sessions.select { |s| s.label.downcase.include?(query.downcase) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# { "pid name · footprint · cpu" => children } for one session's processes, nested by ppid.
|
|
40
|
+
# Members whose parent isn't in the session (normally just the root) are the top level.
|
|
41
|
+
def branches(session, rows)
|
|
42
|
+
members = (rows || []).select { |r| r.session_id == session.id }
|
|
43
|
+
pids = members.to_h { |r| [r.pid, true] }
|
|
44
|
+
kids = members.group_by(&:ppid)
|
|
45
|
+
top = members.reject { |r| pids[r.ppid] }
|
|
46
|
+
nest(top, kids, {})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def nest(rows, kids, seen)
|
|
50
|
+
rows.sort_by(&:pid).each_with_object({}) do |row, tree|
|
|
51
|
+
next if seen[row.pid] # a ppid cycle (pid reuse between ps lines) would never end
|
|
52
|
+
|
|
53
|
+
seen[row.pid] = true
|
|
54
|
+
below = nest(kids.fetch(row.pid, []), kids, seen)
|
|
55
|
+
tree[label(row)] = below.empty? ? nil : below
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def label(row)
|
|
60
|
+
f = R2UI::Format
|
|
61
|
+
footprint = f.call(:bytes, row.footprint)
|
|
62
|
+
cpu = f.call(:percent, row.cpu)
|
|
63
|
+
"#{row.pid} #{row.name} · #{footprint.empty? ? 'n/a' : footprint} · #{cpu.empty? ? 'n/a' : cpu}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def listing(sessions) = sessions.map { |s| " #{s.label} (#{s.id})" }.join("\n")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
command :tree do
|
|
71
|
+
summary "Process tree of each agent session, with footprint and CPU"
|
|
72
|
+
argument :session, required: false, desc: "Session id, root pid or label substring"
|
|
73
|
+
|
|
74
|
+
run do
|
|
75
|
+
st = Commands::SessionTree
|
|
76
|
+
reading = Agentmon.engine.current
|
|
77
|
+
sessions = st.sessions(reading)
|
|
78
|
+
query = args[:session]
|
|
79
|
+
if query
|
|
80
|
+
abort!("no session matches #{query.inspect}: #{st::NONE}") if sessions.empty?
|
|
81
|
+
found = st.find(sessions, query)
|
|
82
|
+
abort!("no session matches #{query.inspect}. Sessions:\n#{st.listing(sessions)}") if found.empty?
|
|
83
|
+
abort!("#{query.inspect} matches #{found.size} sessions:\n#{st.listing(found)}") if found.size > 1
|
|
84
|
+
sessions = found
|
|
85
|
+
end
|
|
86
|
+
if sessions.empty?
|
|
87
|
+
say st::NONE
|
|
88
|
+
else
|
|
89
|
+
sessions.each_with_index do |s, i|
|
|
90
|
+
say if i.positive?
|
|
91
|
+
tree(s.label, st.branches(s, reading[:process_rows]))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Agentmon
|
|
6
|
+
# macOS kernel data through Fiddle, without subprocesses: per-process resource usage
|
|
7
|
+
# (proc_pid_rusage, RUSAGE_INFO_V4), process start time plus fault and scheduling counters
|
|
8
|
+
# (proc_pidinfo PROC_PIDTASKALLINFO; PROC_PIDTBSDINFO for the start time alone), open file paths
|
|
9
|
+
# (PROC_PIDLISTFDS + proc_pidfdinfo) and the Mach clock (mach_timebase_info).
|
|
10
|
+
#
|
|
11
|
+
# Units: CPU times come from the kernel in Mach absolute-time ticks. On Intel a tick is 1 ns; on
|
|
12
|
+
# Apple Silicon it is 125/3 ns (41.67 ns), so ticks are always converted with the timebase and
|
|
13
|
+
# everything this module returns is in seconds (Float) or bytes (Integer).
|
|
14
|
+
#
|
|
15
|
+
# Other users' processes (root daemons) can't be read without root: the kernel answers EPERM and
|
|
16
|
+
# the readers here return nil. Callers fall back to what ps reports.
|
|
17
|
+
module Darwin
|
|
18
|
+
RUSAGE_INFO_V4 = 4
|
|
19
|
+
# struct rusage_info_v4: a 16-byte uuid, then 35 uint64 fields (ri_user_time ..
|
|
20
|
+
# ri_runnable_time) = 296 bytes. v5 and v6 append fields; v4 is what we ask for.
|
|
21
|
+
RUSAGE_FIELDS = 35
|
|
22
|
+
RUSAGE_SIZE = 16 + (8 * RUSAGE_FIELDS)
|
|
23
|
+
PROC_PIDTASKALLINFO = 2
|
|
24
|
+
PROC_PIDTBSDINFO = 3
|
|
25
|
+
# struct proc_bsdinfo is 136 bytes; pbi_start_tvsec/usec are its last two uint64 fields.
|
|
26
|
+
BSDINFO_SIZE = 136
|
|
27
|
+
BSDINFO_START = 120
|
|
28
|
+
# struct proc_taskallinfo = proc_bsdinfo (136) + proc_taskinfo (96): six uint64s
|
|
29
|
+
# (pti_virtual_size .. pti_threads_system), then twelve int32s (pti_policy .. pti_priority).
|
|
30
|
+
TASKINFO_SIZE = 96
|
|
31
|
+
TASKALLINFO_SIZE = BSDINFO_SIZE + TASKINFO_SIZE
|
|
32
|
+
# Indexes of the int32 fields after the six uint64s (xnu bsd/sys/proc_info.h).
|
|
33
|
+
TASK_FIELDS = { faults: 1, pageins: 2, cow_faults: 3, csw: 8, threadnum: 9, numrunning: 10 }.freeze
|
|
34
|
+
|
|
35
|
+
# Indexes of the uint64 fields after the uuid (xnu bsd/sys/resource.h).
|
|
36
|
+
FIELDS = {
|
|
37
|
+
user_time: 0, system_time: 1, pageins: 4, wired_size: 5, resident_size: 6, phys_footprint: 7,
|
|
38
|
+
proc_start_abstime: 8, child_user_time: 10, child_system_time: 11, diskio_bytesread: 16,
|
|
39
|
+
diskio_byteswritten: 17, lifetime_max_phys_footprint: 28, runnable_time: 34
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Open files (bsd/sys/proc_info.h). proc_pidinfo(PROC_PIDLISTFDS) fills struct proc_fdinfo
|
|
43
|
+
# entries {int32 proc_fd; uint32 proc_fdtype}; proc_pidfdinfo(PROC_PIDFDVNODEPATHINFO) fills a
|
|
44
|
+
# struct vnode_fdinfowithpath: proc_fileinfo (24 bytes) + vnode_info (vinfo_stat 136 + vi_type,
|
|
45
|
+
# vi_pad, vi_fsid = 152 bytes), then vip_path[MAXPATHLEN = 1024], NUL-terminated, at 176.
|
|
46
|
+
PROC_PIDLISTFDS = 1
|
|
47
|
+
PROC_PIDFDVNODEPATHINFO = 2
|
|
48
|
+
PROX_FDTYPE_VNODE = 1
|
|
49
|
+
FDINFO_SIZE = 8
|
|
50
|
+
VNODE_PATH_OFFSET = 24 + 152
|
|
51
|
+
VNODE_PATH_SIZE = VNODE_PATH_OFFSET + 1024
|
|
52
|
+
|
|
53
|
+
EPERM = 1
|
|
54
|
+
ESRCH = 3
|
|
55
|
+
|
|
56
|
+
# One process's counters, converted: seconds and bytes. Cumulative since the process started.
|
|
57
|
+
Rusage = Data.define(
|
|
58
|
+
:cpu_time, # user + system CPU seconds (Float)
|
|
59
|
+
:child_cpu_time, # user + system CPU seconds of children it has reaped (Float)
|
|
60
|
+
:resident, # resident set size, bytes
|
|
61
|
+
:footprint, # physical footprint (Activity Monitor's "Memory"), bytes
|
|
62
|
+
:peak_footprint, # lifetime maximum footprint, bytes
|
|
63
|
+
:disk_read, # bytes read from disk
|
|
64
|
+
:disk_written, # bytes written to disk
|
|
65
|
+
:start_ticks, # start time in Mach ticks: with the pid, identifies the process across pid reuse
|
|
66
|
+
:wired, # wired bytes
|
|
67
|
+
:pageins, # count of faults that read from disk
|
|
68
|
+
:runnable_time # seconds runnable (running or waiting for a CPU)
|
|
69
|
+
) do
|
|
70
|
+
def initialize(wired: nil, pageins: nil, runnable_time: nil, **) = super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# proc_taskallinfo, decoded: start time plus the task's fault and scheduling counters.
|
|
74
|
+
# Counts are the kernel's 32-bit counters, read unsigned (they wrap at 2**32).
|
|
75
|
+
TaskInfo = Data.define(
|
|
76
|
+
:started_at, # epoch seconds (Float)
|
|
77
|
+
:faults, # page faults, any kind
|
|
78
|
+
:cow_faults, # copy-on-write faults
|
|
79
|
+
:context_switches,
|
|
80
|
+
:threads, # threads now
|
|
81
|
+
:running_threads # threads running now
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
def available? = RUBY_PLATFORM.include?("darwin")
|
|
86
|
+
|
|
87
|
+
# Rusage for `pid`, or nil when it can't be read (EPERM for other users' processes, ESRCH
|
|
88
|
+
# when it has exited). `errno` after a nil says which.
|
|
89
|
+
def rusage(pid)
|
|
90
|
+
buffer = Fiddle::Pointer.malloc(RUSAGE_SIZE, Fiddle::RUBY_FREE)
|
|
91
|
+
return fail_with(Fiddle.last_error) unless functions[:rusage].call(pid, RUSAGE_INFO_V4, buffer).zero?
|
|
92
|
+
|
|
93
|
+
Thread.current[:agentmon_errno] = 0
|
|
94
|
+
decode_rusage_bytes(buffer[0, RUSAGE_SIZE])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Decodes a whole rusage_info_v4 as the kernel wrote it (uuid included, native-endian
|
|
98
|
+
# uint64s; every Mac agentmon runs on is little-endian). Public for tests.
|
|
99
|
+
def decode_rusage_bytes(bytes) = decode_rusage(bytes.byteslice(16, RUSAGE_SIZE - 16).unpack("Q<*"))
|
|
100
|
+
|
|
101
|
+
# Decodes the uint64 fields of a rusage_info_v4 (after the uuid), by FIELDS index.
|
|
102
|
+
def decode_rusage(values)
|
|
103
|
+
f = ->(name) { values.fetch(FIELDS.fetch(name)) }
|
|
104
|
+
Rusage.new(
|
|
105
|
+
cpu_time: ticks_to_seconds(f[:user_time] + f[:system_time]),
|
|
106
|
+
child_cpu_time: ticks_to_seconds(f[:child_user_time] + f[:child_system_time]),
|
|
107
|
+
resident: f[:resident_size],
|
|
108
|
+
footprint: f[:phys_footprint],
|
|
109
|
+
peak_footprint: f[:lifetime_max_phys_footprint],
|
|
110
|
+
disk_read: f[:diskio_bytesread],
|
|
111
|
+
disk_written: f[:diskio_byteswritten],
|
|
112
|
+
start_ticks: f[:proc_start_abstime],
|
|
113
|
+
wired: f[:wired_size],
|
|
114
|
+
pageins: f[:pageins],
|
|
115
|
+
# Mach ticks, like the CPU times (checked: 30 busy processes on 10 cores for 2 s report
|
|
116
|
+
# ~2.0 s runnable as ticks, 0.05 s if they were ns; it counts time on a CPU too).
|
|
117
|
+
runnable_time: ticks_to_seconds(f[:runnable_time])
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Wall-clock start time of `pid` as epoch seconds (Float), or nil when unreadable.
|
|
122
|
+
def started_at(pid)
|
|
123
|
+
buffer = Fiddle::Pointer.malloc(BSDINFO_SIZE, Fiddle::RUBY_FREE)
|
|
124
|
+
return fail_with(Fiddle.last_error) unless functions[:pidinfo].call(pid, PROC_PIDTBSDINFO, 0, buffer, BSDINFO_SIZE) == BSDINFO_SIZE
|
|
125
|
+
|
|
126
|
+
decode_start(buffer[0, BSDINFO_SIZE])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Start time plus fault and scheduling counters in one proc_pidinfo(PROC_PIDTASKALLINFO)
|
|
130
|
+
# call (the same cost as PROC_PIDTBSDINFO alone), or nil when unreadable.
|
|
131
|
+
def task_info(pid)
|
|
132
|
+
buffer = Fiddle::Pointer.malloc(TASKALLINFO_SIZE, Fiddle::RUBY_FREE)
|
|
133
|
+
unless functions[:pidinfo].call(pid, PROC_PIDTASKALLINFO, 0, buffer, TASKALLINFO_SIZE) == TASKALLINFO_SIZE
|
|
134
|
+
return fail_with(Fiddle.last_error)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Thread.current[:agentmon_errno] = 0
|
|
138
|
+
decode_task_info_bytes(buffer[0, TASKALLINFO_SIZE])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Decodes a whole proc_taskallinfo as the kernel wrote it. Public for tests.
|
|
142
|
+
def decode_task_info_bytes(bytes)
|
|
143
|
+
counts = bytes.byteslice(BSDINFO_SIZE + 48, 48).unpack("L<12") # unsigned: wrapped counters stay positive
|
|
144
|
+
c = ->(name) { counts.fetch(TASK_FIELDS.fetch(name)) }
|
|
145
|
+
TaskInfo.new(started_at: decode_start(bytes), faults: c[:faults], cow_faults: c[:cow_faults],
|
|
146
|
+
context_switches: c[:csw], threads: c[:threadnum], running_threads: c[:numrunning])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Paths (Strings) of `pid`'s open vnode files (regular files, directories, devices), or nil
|
|
150
|
+
# when its descriptors can't be listed (another user's process, exited). One
|
|
151
|
+
# proc_pidinfo(PROC_PIDLISTFDS) call plus one proc_pidfdinfo per vnode descriptor; a
|
|
152
|
+
# descriptor closed in between is skipped.
|
|
153
|
+
def open_paths(pid)
|
|
154
|
+
size = functions[:pidinfo].call(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
|
155
|
+
return fail_with(Fiddle.last_error) unless size.positive?
|
|
156
|
+
|
|
157
|
+
size += 32 * FDINFO_SIZE # room for descriptors opened between the two calls
|
|
158
|
+
list = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
|
|
159
|
+
filled = functions[:pidinfo].call(pid, PROC_PIDLISTFDS, 0, list, size)
|
|
160
|
+
return fail_with(Fiddle.last_error) unless filled.positive?
|
|
161
|
+
|
|
162
|
+
Thread.current[:agentmon_errno] = 0
|
|
163
|
+
buffer = Fiddle::Pointer.malloc(VNODE_PATH_SIZE, Fiddle::RUBY_FREE)
|
|
164
|
+
decode_fdinfo_list(list[0, filled]).filter_map do |fd, type|
|
|
165
|
+
next unless type == PROX_FDTYPE_VNODE
|
|
166
|
+
next unless functions[:pidfdinfo].call(pid, fd, PROC_PIDFDVNODEPATHINFO, buffer, VNODE_PATH_SIZE) == VNODE_PATH_SIZE
|
|
167
|
+
|
|
168
|
+
path = decode_vnode_path(buffer[0, VNODE_PATH_SIZE])
|
|
169
|
+
path unless path.empty?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# [[fd, fdtype], ...] from the struct proc_fdinfo array PROC_PIDLISTFDS wrote. Public for tests.
|
|
174
|
+
def decode_fdinfo_list(bytes)
|
|
175
|
+
bytes.byteslice(0, bytes.bytesize - (bytes.bytesize % FDINFO_SIZE)).unpack("l<L<" * (bytes.bytesize / FDINFO_SIZE))
|
|
176
|
+
.each_slice(2).to_a
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# vip_path of a struct vnode_fdinfowithpath, as a UTF-8 String ("" when empty). Public for tests.
|
|
180
|
+
def decode_vnode_path(bytes)
|
|
181
|
+
bytes.byteslice(VNODE_PATH_OFFSET, 1024).to_s.unpack1("Z*").force_encoding(Encoding::UTF_8).scrub
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# errno of the last failed read on this thread (EPERM, ESRCH, ...), 0 after a success.
|
|
185
|
+
def errno = Thread.current[:agentmon_errno] || 0
|
|
186
|
+
|
|
187
|
+
# Nanoseconds per Mach tick (Rational): 1 on Intel, 125/3 on Apple Silicon.
|
|
188
|
+
def ns_per_tick
|
|
189
|
+
@ns_per_tick ||= begin
|
|
190
|
+
buffer = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
|
|
191
|
+
functions[:timebase].call(buffer)
|
|
192
|
+
numer, denom = buffer[0, 8].unpack("LL")
|
|
193
|
+
Rational(numer, denom)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Overrides the timebase (tests decode fixtures recorded on another machine). nil resets.
|
|
198
|
+
attr_writer :ns_per_tick
|
|
199
|
+
|
|
200
|
+
def ticks_to_seconds(ticks) = (ticks * ns_per_tick).fdiv(1_000_000_000)
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
# pbi_start_tvsec/usec from the head of a proc_bsdinfo (or proc_taskallinfo).
|
|
205
|
+
def decode_start(bytes)
|
|
206
|
+
sec, usec = bytes.byteslice(BSDINFO_START, 16).unpack("Q<Q<")
|
|
207
|
+
sec + (usec / 1_000_000.0)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def fail_with(errno)
|
|
211
|
+
Thread.current[:agentmon_errno] = errno
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def functions
|
|
216
|
+
@functions ||= begin
|
|
217
|
+
lib = Fiddle.dlopen(nil) # libSystem: libproc and the Mach clock are in the default namespace
|
|
218
|
+
uint64 = -Fiddle::TYPE_LONG_LONG
|
|
219
|
+
{
|
|
220
|
+
rusage: Fiddle::Function.new(lib["proc_pid_rusage"], [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
221
|
+
Fiddle::TYPE_INT),
|
|
222
|
+
pidinfo: Fiddle::Function.new(lib["proc_pidinfo"],
|
|
223
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, uint64, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
224
|
+
Fiddle::TYPE_INT),
|
|
225
|
+
pidfdinfo: Fiddle::Function.new(lib["proc_pidfdinfo"],
|
|
226
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
|
|
227
|
+
Fiddle::TYPE_INT],
|
|
228
|
+
Fiddle::TYPE_INT),
|
|
229
|
+
timebase: Fiddle::Function.new(lib["mach_timebase_info"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
# Sampler -> Reading (metrics) -> Store (recorders), one tick at a time, shared by everything
|
|
5
|
+
# that shows data: the dashboard's resources (each r2ui feed thread calls `current`), and the CLI
|
|
6
|
+
# commands. `current` samples at most once per `max_age`, so three panels refreshing every two
|
|
7
|
+
# seconds cost one sample, not three.
|
|
8
|
+
#
|
|
9
|
+
# The first call takes two samples PRIME_GAP apart: CPU % and disk rates are deltas, so the very
|
|
10
|
+
# first frame or `agentmon top --once` already has them.
|
|
11
|
+
#
|
|
12
|
+
# Metrics and recorders get a state Hash kept for this engine's life (one per metric, one per
|
|
13
|
+
# recorder), so nothing needs module-level state and two engines (tests) never share it.
|
|
14
|
+
class Engine
|
|
15
|
+
PRIME_GAP = 0.5
|
|
16
|
+
|
|
17
|
+
attr_reader :interval, :store, :run_id
|
|
18
|
+
attr_accessor :recording
|
|
19
|
+
|
|
20
|
+
def initialize(sampler: Sampler.new, registry: Agentmon.registry, store: nil, recording: false, interval: 2.0,
|
|
21
|
+
prime_gap: PRIME_GAP, clock: Clock)
|
|
22
|
+
@sampler = sampler
|
|
23
|
+
@registry = registry
|
|
24
|
+
@store = store
|
|
25
|
+
@recording = recording
|
|
26
|
+
@interval = interval
|
|
27
|
+
@prime_gap = prime_gap
|
|
28
|
+
@clock = clock
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
@states = Hash.new { |h, k| h[k] = {} } # metric name => state; [:recorder, name] => state
|
|
31
|
+
@recorded_at = {}
|
|
32
|
+
@recorder_errors = {}
|
|
33
|
+
@run_id = "#{Process.pid}-#{clock.now.to_i}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The latest Reading, sampling first if it's older than `max_age` seconds. While a session is
|
|
37
|
+
# focused it is a FocusedReading narrowed to it (lib/agentmon/focus.rb); `focused: false`
|
|
38
|
+
# returns the whole machine regardless.
|
|
39
|
+
def current(max_age: interval * 0.75, focused: true)
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
if @reading.nil?
|
|
42
|
+
tick!
|
|
43
|
+
sleep(@prime_gap) if @prime_gap.positive?
|
|
44
|
+
tick!
|
|
45
|
+
elsif @clock.mono - @ticked_at >= max_age
|
|
46
|
+
tick!
|
|
47
|
+
end
|
|
48
|
+
focused ? focused_reading : @reading
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The focused session's id (Session#id / ProcessRow#session_id), nil when unfocused.
|
|
53
|
+
def focus = @focus
|
|
54
|
+
|
|
55
|
+
# Focuses every reader of `current` on one session; nil clears. Takes effect on each panel's
|
|
56
|
+
# next feed refresh. Recorders and `errors` always see the whole machine.
|
|
57
|
+
def focus=(session_id)
|
|
58
|
+
@lock.synchronize { @focus = session_id }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The focused Session from the ledger (alive or recently ended), or nil when nothing is
|
|
62
|
+
# focused or the session has left the ledger.
|
|
63
|
+
def focused_session
|
|
64
|
+
id = focus or return nil
|
|
65
|
+
current(focused: false)[:session_ledger]&.find { |s| s.id == id }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Samples now (callers hold no lock: use `current` from threads).
|
|
69
|
+
def tick!
|
|
70
|
+
sample = @sampler.sample
|
|
71
|
+
reading = Reading.new(sample, previous: @reading&.sample, metrics: @registry.metrics, states: @states)
|
|
72
|
+
reading.evaluate_all
|
|
73
|
+
@ticked_at = @clock.mono
|
|
74
|
+
record(reading) if @recording && @store
|
|
75
|
+
@reading = reading
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# What's broken right now: { "probe cwd" => "Errno::ENOENT: ...", "metric memory" => ...,
|
|
79
|
+
# "recorder sessions" => ... } from the latest sample's probes, the latest reading's metrics and
|
|
80
|
+
# the recorders' last runs. The dashboard's status bar and the CLI's stderr show it.
|
|
81
|
+
def errors
|
|
82
|
+
reading = @reading
|
|
83
|
+
found = {}
|
|
84
|
+
reading&.sample&.errors&.each { |name, message| found["probe #{name}"] = message }
|
|
85
|
+
reading&.errors&.each { |name, message| found["metric #{name}"] = message }
|
|
86
|
+
@recorder_errors.each { |name, message| found["recorder #{name}"] = message }
|
|
87
|
+
found
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The latest reading narrowed to the focus, built once per (reading, focus) under the lock.
|
|
93
|
+
def focused_reading
|
|
94
|
+
return @reading unless @focus
|
|
95
|
+
|
|
96
|
+
@focused = nil unless @focused&.unfocused.equal?(@reading) && @focused.focus == @focus
|
|
97
|
+
@focused ||= Focus.apply(@reading, @focus)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record(reading)
|
|
101
|
+
@registry.recorders.each do |recorder|
|
|
102
|
+
last = @recorded_at[recorder.name]
|
|
103
|
+
next if last && @ticked_at - last < recorder.every
|
|
104
|
+
|
|
105
|
+
@recorded_at[recorder.name] = @ticked_at
|
|
106
|
+
rows = recorder.block.call(reading, @states[[:recorder, recorder.name]])
|
|
107
|
+
rows = [rows] if rows.is_a?(Hash) # Array(hash) would split it into pairs
|
|
108
|
+
Array(rows).each { |row| @store.append(recorder.name, row.merge(run: run_id)) }
|
|
109
|
+
@recorder_errors.delete(recorder.name)
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
@recorder_errors[recorder.name] = "#{e.class}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|