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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/exe/agentmon +18 -0
  6. data/lib/agentmon/commands/cli_options.rb +21 -0
  7. data/lib/agentmon/commands/footprint.rb +81 -0
  8. data/lib/agentmon/commands/memory.rb +110 -0
  9. data/lib/agentmon/commands/record.rb +118 -0
  10. data/lib/agentmon/commands/report.rb +127 -0
  11. data/lib/agentmon/commands/sessions.rb +98 -0
  12. data/lib/agentmon/commands/top.rb +87 -0
  13. data/lib/agentmon/commands/tree.rb +96 -0
  14. data/lib/agentmon/darwin.rb +235 -0
  15. data/lib/agentmon/engine.rb +115 -0
  16. data/lib/agentmon/focus.rb +88 -0
  17. data/lib/agentmon/metrics/memory.rb +76 -0
  18. data/lib/agentmon/metrics/network.rb +245 -0
  19. data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
  20. data/lib/agentmon/metrics/process_rates.rb +65 -0
  21. data/lib/agentmon/metrics/process_rows.rb +41 -0
  22. data/lib/agentmon/metrics/session_ledger.rb +160 -0
  23. data/lib/agentmon/metrics/session_memory.rb +84 -0
  24. data/lib/agentmon/metrics/session_names.rb +169 -0
  25. data/lib/agentmon/metrics/sessions.rb +80 -0
  26. data/lib/agentmon/metrics/system.rb +114 -0
  27. data/lib/agentmon/model.rb +263 -0
  28. data/lib/agentmon/probes/cwd.rb +32 -0
  29. data/lib/agentmon/probes/memory.rb +139 -0
  30. data/lib/agentmon/probes/network.rb +326 -0
  31. data/lib/agentmon/probes/processes.rb +72 -0
  32. data/lib/agentmon/probes/system.rb +134 -0
  33. data/lib/agentmon/program.rb +55 -0
  34. data/lib/agentmon/reading.rb +83 -0
  35. data/lib/agentmon/recorders/memory.rb +19 -0
  36. data/lib/agentmon/recorders/sessions.rb +40 -0
  37. data/lib/agentmon/registry.rb +155 -0
  38. data/lib/agentmon/sampler.rb +50 -0
  39. data/lib/agentmon/store.rb +86 -0
  40. data/lib/agentmon/ui/connections.rb +75 -0
  41. data/lib/agentmon/ui/interaction.rb +94 -0
  42. data/lib/agentmon/ui/memory.rb +125 -0
  43. data/lib/agentmon/ui/process_actions.rb +49 -0
  44. data/lib/agentmon/ui/process_detail.rb +148 -0
  45. data/lib/agentmon/ui/process_network.rb +13 -0
  46. data/lib/agentmon/ui/process_scopes.rb +32 -0
  47. data/lib/agentmon/ui/process_waits.rb +99 -0
  48. data/lib/agentmon/ui/processes.rb +42 -0
  49. data/lib/agentmon/ui/session_focus.rb +126 -0
  50. data/lib/agentmon/ui/session_memory.rb +78 -0
  51. data/lib/agentmon/ui/sessions.rb +130 -0
  52. data/lib/agentmon/ui/theme.rb +49 -0
  53. data/lib/agentmon/ui.rb +80 -0
  54. data/lib/agentmon/version.rb +5 -0
  55. data/lib/agentmon/views/dense.rb +157 -0
  56. data/lib/agentmon/views/history.rb +62 -0
  57. data/lib/agentmon/views/signals.rb +279 -0
  58. data/lib/agentmon/views/visual.rb +172 -0
  59. data/lib/agentmon/views/widgets.rb +352 -0
  60. data/lib/agentmon/views.rb +875 -0
  61. data/lib/agentmon.rb +38 -0
  62. 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