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,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pty"
|
|
4
|
+
|
|
5
|
+
module Agentmon
|
|
6
|
+
# sample[:network]: every process's sockets and byte counters, as a NetSnapshot (model.rb), from
|
|
7
|
+
# ONE long-lived `nettop` child. Nil until its first block arrives, when the latest block is older
|
|
8
|
+
# than STALE seconds (nettop stalled), and off macOS.
|
|
9
|
+
#
|
|
10
|
+
# nettop in connection mode (`-x -L 0 -s 1 -J interface,state,bytes_in,bytes_out`, no -P) prints
|
|
11
|
+
# one block a second: a header line `,interface,state,bytes_in,bytes_out,`, then per process a
|
|
12
|
+
# line `Name With Spaces.PID,,,BYTES_IN,BYTES_OUT,` followed by its flows:
|
|
13
|
+
#
|
|
14
|
+
# tcp4 192.0.2.5:55584<->198.51.100.11:5223,en0,Established,3177530,7189392,
|
|
15
|
+
# udp4 *:5353<->*:*,en0,,4823549,2970949,
|
|
16
|
+
# tcp6 fe80::1%lo0.53<->*.*,lo0,Listen,,,
|
|
17
|
+
#
|
|
18
|
+
# IPv6 endpoints put `.` before the port, IPv4 (and host names) `:`; `*` is a wildcard; empty byte
|
|
19
|
+
# fields are unknown (nil). Byte counts are cumulative. A one-shot `nettop -L 1` takes ~5 s, so it
|
|
20
|
+
# is never run per sample or per process.
|
|
21
|
+
#
|
|
22
|
+
# Piped, nettop block-buffers its output (nothing for many seconds), so the child runs under a pty
|
|
23
|
+
# (PTY.spawn), where each block arrives at once. A reader thread parses lines and publishes each
|
|
24
|
+
# complete block, frozen, as soon as the next header arrives or the pty has been idle for IDLE
|
|
25
|
+
# seconds. `at_mono` is when the block's header arrived, so rates come from the snapshots' own
|
|
26
|
+
# clock whatever the engine's interval. The child starts on the first probe call, restarts at most
|
|
27
|
+
# once every RESTART_EVERY seconds when it dies (MISSING_RETRY when nettop is missing), and is
|
|
28
|
+
# stopped (TERM, KILL after KILL_AFTER, then reaped) by an at_exit hook or Stream#stop.
|
|
29
|
+
module Probes
|
|
30
|
+
module Network
|
|
31
|
+
COMMAND = %w[/usr/bin/nettop -x -L 0 -s 1 -J interface,state,bytes_in,bytes_out].freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Every block in captured nettop text, as NetSnapshots (the last one even when its block is
|
|
36
|
+
# cut short). Block i gets at_mono `at_mono + i * every`. Pure; tests feed it fixtures.
|
|
37
|
+
def parse(text, at_mono: 0.0, every: 1.0)
|
|
38
|
+
parser = Parser.new
|
|
39
|
+
blocks = -1
|
|
40
|
+
snapshots = text.each_line.filter_map do |line|
|
|
41
|
+
blocks += 1 if Parser.header?(line)
|
|
42
|
+
parser.feed(line, at_mono + (blocks * every))
|
|
43
|
+
end
|
|
44
|
+
last = parser.flush
|
|
45
|
+
last ? snapshots << last : snapshots
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The process's one stream.
|
|
49
|
+
def stream = @stream ||= Stream.new
|
|
50
|
+
|
|
51
|
+
# The probe: the latest snapshot, starting nettop when needed. Never raises into the sampler.
|
|
52
|
+
def read
|
|
53
|
+
Darwin.available? ? stream.snapshot : nil
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Turns nettop lines into NetSnapshots, one block at a time. Not thread-safe: one feeder.
|
|
59
|
+
class Parser
|
|
60
|
+
HEADER = /\A,interface,/
|
|
61
|
+
|
|
62
|
+
def self.header?(line) = HEADER.match?(line)
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
@block = nil # { at_mono:, processes: { pid => builder Hash } }
|
|
66
|
+
@current = nil # the process the next flow lines belong to
|
|
67
|
+
@dirty = false # changed since the last snapshot handed out
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Feeds one line; returns the previous block's snapshot when this line is the header that
|
|
71
|
+
# closes it (and it changed since it was last handed out), else nil. Lines before the first
|
|
72
|
+
# header (a block we joined midway) are dropped.
|
|
73
|
+
def feed(line, at_mono)
|
|
74
|
+
line = line.delete("\r\n")
|
|
75
|
+
return nil if line.empty?
|
|
76
|
+
|
|
77
|
+
if self.class.header?(line)
|
|
78
|
+
done = flush
|
|
79
|
+
@block = { at_mono:, processes: {} }
|
|
80
|
+
@current = nil
|
|
81
|
+
@dirty = true
|
|
82
|
+
return done
|
|
83
|
+
end
|
|
84
|
+
return nil unless @block
|
|
85
|
+
|
|
86
|
+
add(line)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The block so far as a snapshot, if it changed since the last one handed out; else nil.
|
|
91
|
+
def flush
|
|
92
|
+
return nil unless @block && @dirty
|
|
93
|
+
|
|
94
|
+
@dirty = false
|
|
95
|
+
snapshot
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def add(line)
|
|
101
|
+
head, interface, state, bytes_in, bytes_out = fields(line)
|
|
102
|
+
return unless head
|
|
103
|
+
|
|
104
|
+
if head.include?("<->")
|
|
105
|
+
return unless @current
|
|
106
|
+
|
|
107
|
+
@current[:flows] << flow(head, interface, state, bytes_in, bytes_out)
|
|
108
|
+
else
|
|
109
|
+
name, dot, pid = head.rpartition(".")
|
|
110
|
+
pid = Integer(pid, 10, exception: false)
|
|
111
|
+
return if dot.empty? || pid.nil?
|
|
112
|
+
|
|
113
|
+
@current = @block[:processes][pid] = { pid:, name:, bytes_in: count(bytes_in),
|
|
114
|
+
bytes_out: count(bytes_out), flows: [] }
|
|
115
|
+
end
|
|
116
|
+
@dirty = true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# [first field, interface, state, bytes_in, bytes_out]; the first field may hold commas.
|
|
120
|
+
def fields(line)
|
|
121
|
+
parts = line.split(",", -1)
|
|
122
|
+
parts.pop if line.end_with?(",")
|
|
123
|
+
return nil if parts.size < 5
|
|
124
|
+
|
|
125
|
+
[parts[0...-4].join(","), *parts[-4..]]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def flow(head, interface, state, bytes_in, bytes_out)
|
|
129
|
+
protocol, endpoints = head.split(" ", 2)
|
|
130
|
+
local, remote = endpoints.to_s.split("<->", 2)
|
|
131
|
+
host, port = Network.endpoint(remote, protocol)
|
|
132
|
+
NetFlow.new(protocol:, local:, remote:, remote_host: host, remote_port: port,
|
|
133
|
+
interface: blank(interface), state: blank(state), bytes_in: count(bytes_in),
|
|
134
|
+
bytes_out: count(bytes_out))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def snapshot
|
|
138
|
+
processes = @block[:processes].transform_values do |p|
|
|
139
|
+
NetProcess.new(**p.except(:flows), flows: p[:flows].dup.freeze)
|
|
140
|
+
end
|
|
141
|
+
NetSnapshot.new(at_mono: @block[:at_mono], processes: processes.freeze)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def count(text) = text.nil? || text.empty? ? nil : Integer(text, 10, exception: false)
|
|
145
|
+
|
|
146
|
+
def blank(text) = text.nil? || text.empty? ? nil : text
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# [host, port] of an endpoint as nettop prints it: IPv6 (`protocol` ending in 6) separates the
|
|
150
|
+
# port with the last `.`, IPv4 and names with `:`. `*` (either part) is nil.
|
|
151
|
+
def endpoint(text, protocol)
|
|
152
|
+
return [nil, nil] if text.nil? || text.empty?
|
|
153
|
+
|
|
154
|
+
separators = protocol.to_s.end_with?("6") ? [".", ":"] : [":", "."]
|
|
155
|
+
host, port = nil
|
|
156
|
+
separators.each do |sep|
|
|
157
|
+
h, s, p = text.rpartition(sep)
|
|
158
|
+
next if s.empty?
|
|
159
|
+
|
|
160
|
+
host = h
|
|
161
|
+
port = p
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
host ||= text
|
|
165
|
+
host = nil if host == "*"
|
|
166
|
+
port = port.nil? || port == "*" ? nil : Integer(port, 10, exception: false)
|
|
167
|
+
[host, port]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The nettop child and its reader thread. `feed`/`idle` are the reader's entry points, public
|
|
171
|
+
# so tests drive publishing without spawning.
|
|
172
|
+
class Stream
|
|
173
|
+
IDLE = 0.05 # seconds of pty silence that end a block
|
|
174
|
+
STALE = 5.0 # a snapshot older than this is not returned
|
|
175
|
+
RESTART_EVERY = 10.0 # seconds between starts when the child keeps dying
|
|
176
|
+
MISSING_RETRY = 60.0 # seconds between tries when nettop is missing
|
|
177
|
+
KILL_AFTER = 1.0 # seconds between TERM and KILL on stop
|
|
178
|
+
|
|
179
|
+
attr_reader :pid
|
|
180
|
+
|
|
181
|
+
def initialize(command: COMMAND, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
182
|
+
@command = command
|
|
183
|
+
@clock = clock
|
|
184
|
+
@parser = Parser.new
|
|
185
|
+
@latest = nil
|
|
186
|
+
@next_start = nil
|
|
187
|
+
@lock = Mutex.new
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The probe's call: starts (or restarts) nettop when due, returns the latest fresh snapshot.
|
|
191
|
+
def snapshot
|
|
192
|
+
@lock.synchronize { ensure_running }
|
|
193
|
+
latest
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The latest published snapshot, or nil when none yet or it is older than STALE.
|
|
197
|
+
def latest
|
|
198
|
+
snap = @latest
|
|
199
|
+
snap if snap && @clock.call - snap.at_mono <= STALE
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# One line from nettop (the reader thread, or a test).
|
|
203
|
+
def feed(line) = publish(@parser.feed(line, @clock.call))
|
|
204
|
+
|
|
205
|
+
# The pty went quiet: the block so far is complete.
|
|
206
|
+
def idle = publish(@parser.flush)
|
|
207
|
+
|
|
208
|
+
def running? = !@pid.nil? && !reaped?(@pid)
|
|
209
|
+
|
|
210
|
+
def start
|
|
211
|
+
now = @clock.call
|
|
212
|
+
unless File.executable?(@command.first)
|
|
213
|
+
@next_start = now + MISSING_RETRY
|
|
214
|
+
return false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@next_start = now + RESTART_EVERY
|
|
218
|
+
@parser = Parser.new
|
|
219
|
+
reader, writer, @pid = PTY.spawn(*@command)
|
|
220
|
+
writer.close
|
|
221
|
+
@reader = reader
|
|
222
|
+
@thread = Thread.new { read_loop(reader) }
|
|
223
|
+
@thread.report_on_exception = false
|
|
224
|
+
register_exit_hook
|
|
225
|
+
true
|
|
226
|
+
rescue SystemCallError
|
|
227
|
+
@next_start = now + MISSING_RETRY
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Stops the child (TERM, then KILL after KILL_AFTER) and reaps it; safe to call twice.
|
|
232
|
+
def stop
|
|
233
|
+
@lock.synchronize do
|
|
234
|
+
pid = @pid
|
|
235
|
+
@pid = nil
|
|
236
|
+
terminate(pid) if pid
|
|
237
|
+
begin
|
|
238
|
+
@reader&.close
|
|
239
|
+
rescue IOError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
@thread&.join(1)
|
|
243
|
+
@thread = nil
|
|
244
|
+
@reader = nil
|
|
245
|
+
@next_start = nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def ensure_running
|
|
252
|
+
return if running?
|
|
253
|
+
return if @next_start && @clock.call < @next_start
|
|
254
|
+
|
|
255
|
+
start
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def publish(snap)
|
|
259
|
+
@latest = snap if snap
|
|
260
|
+
snap
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def read_loop(io)
|
|
264
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
265
|
+
loop do
|
|
266
|
+
unless io.wait_readable(IDLE)
|
|
267
|
+
idle
|
|
268
|
+
next
|
|
269
|
+
end
|
|
270
|
+
chunk = io.read_nonblock(65_536, exception: false)
|
|
271
|
+
next if chunk == :wait_readable
|
|
272
|
+
break if chunk.nil?
|
|
273
|
+
|
|
274
|
+
buffer << chunk
|
|
275
|
+
while (i = buffer.index("\n"))
|
|
276
|
+
feed(buffer.slice!(0..i).force_encoding(Encoding::UTF_8).scrub)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
rescue IOError, SystemCallError # Errno::EIO when the child exits; IOError when stop closes it
|
|
280
|
+
nil
|
|
281
|
+
ensure
|
|
282
|
+
idle
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# True when `pid` has exited (and is now reaped).
|
|
286
|
+
def reaped?(pid)
|
|
287
|
+
!Process.waitpid(pid, Process::WNOHANG).nil?
|
|
288
|
+
rescue Errno::ECHILD
|
|
289
|
+
true
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def terminate(pid)
|
|
293
|
+
signal(:TERM, pid)
|
|
294
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_AFTER
|
|
295
|
+
until reaped?(pid)
|
|
296
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
297
|
+
signal(:KILL, pid)
|
|
298
|
+
begin
|
|
299
|
+
Process.waitpid(pid)
|
|
300
|
+
rescue Errno::ECHILD
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
break
|
|
304
|
+
end
|
|
305
|
+
sleep 0.01
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def signal(name, pid)
|
|
310
|
+
Process.kill(name, pid)
|
|
311
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def register_exit_hook
|
|
316
|
+
return if @exit_hook
|
|
317
|
+
|
|
318
|
+
@exit_hook = true
|
|
319
|
+
at_exit { stop }
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
probe(:network) { Probes::Network.read }
|
|
326
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# sample[:processes]: every process, as ProcessStat (lib/agentmon/model.rb).
|
|
4
|
+
#
|
|
5
|
+
# One `ps` for the whole table (pid, ppid, rss, %cpu, executable path: what rusage lacks), then
|
|
6
|
+
# proc_pid_rusage and proc_pidinfo(PROC_PIDTASKALLINFO) through Fiddle for each pid: no
|
|
7
|
+
# subprocess per process. ~800 processes take ~50 ms on an M-series Mac: ~12 ms of Fiddle calls
|
|
8
|
+
# and decoding, the rest ps.
|
|
9
|
+
#
|
|
10
|
+
# Processes the kernel won't describe (other users', EPERM) and ones that exit between ps and
|
|
11
|
+
# rusage (ESRCH) keep their ps values: `readable: false`, resident from ps rss, ps %cpu, and nil
|
|
12
|
+
# footprint, CPU time, disk I/O, faults and scheduling counters.
|
|
13
|
+
module Agentmon
|
|
14
|
+
module Probes
|
|
15
|
+
module Processes
|
|
16
|
+
PS = %w[ps -axo pid=,ppid=,rss=,pcpu=,comm=].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# `ps_text` and `reader` (anything with Darwin's `rusage(pid)`, `task_info(pid)` and
|
|
21
|
+
# `started_at(pid)`) are injectable for tests.
|
|
22
|
+
#
|
|
23
|
+
# Two calls per readable process: rusage, then task_info (start time plus fault and
|
|
24
|
+
# scheduling counters, one proc_pidinfo). Only if task_info fails where rusage worked (not
|
|
25
|
+
# seen on macOS 26) does a third call fetch the start time alone.
|
|
26
|
+
def read(ps_text: IO.popen(PS, err: File::NULL, &:read), reader: Darwin)
|
|
27
|
+
parse_ps(ps_text).map do |row|
|
|
28
|
+
usage = reader.rusage(row[:pid])
|
|
29
|
+
task = usage && reader.task_info(row[:pid])
|
|
30
|
+
build(row, usage, task ? task.started_at : (usage && reader.started_at(row[:pid])), task)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ps lines -> Hashes. comm is last and may contain spaces.
|
|
35
|
+
def parse_ps(text)
|
|
36
|
+
text.each_line.filter_map do |line|
|
|
37
|
+
pid, ppid, rss, pcpu, path = line.strip.split(nil, 5)
|
|
38
|
+
next unless path
|
|
39
|
+
|
|
40
|
+
{ pid: pid.to_i, ppid: ppid.to_i, rss: rss.to_i * 1024, pcpu: pcpu.to_f, path: }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build(row, usage, started_at, task = nil)
|
|
45
|
+
ProcessStat.new(
|
|
46
|
+
pid: row[:pid], ppid: row[:ppid], name: File.basename(row[:path]), path: row[:path],
|
|
47
|
+
readable: !usage.nil?,
|
|
48
|
+
start_ticks: usage&.start_ticks,
|
|
49
|
+
started_at:,
|
|
50
|
+
cpu_time: usage&.cpu_time,
|
|
51
|
+
child_cpu_time: usage&.child_cpu_time,
|
|
52
|
+
resident: usage ? usage.resident : row[:rss],
|
|
53
|
+
footprint: usage&.footprint,
|
|
54
|
+
peak_footprint: usage&.peak_footprint,
|
|
55
|
+
disk_read: usage&.disk_read,
|
|
56
|
+
disk_written: usage&.disk_written,
|
|
57
|
+
ps_cpu: row[:pcpu],
|
|
58
|
+
wired: usage&.wired,
|
|
59
|
+
pageins: usage&.pageins,
|
|
60
|
+
runnable_time: usage&.runnable_time,
|
|
61
|
+
faults: task&.faults,
|
|
62
|
+
cow_faults: task&.cow_faults,
|
|
63
|
+
context_switches: task&.context_switches,
|
|
64
|
+
threads: task&.threads,
|
|
65
|
+
running_threads: task&.running_threads
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
probe(:processes) { Probes::Processes.read }
|
|
72
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Agentmon
|
|
6
|
+
# sample[:system]: machine-wide CPU, load and network counters, read once per sample.
|
|
7
|
+
#
|
|
8
|
+
# cpu_ticks [user, system, idle, nice]: cumulative ticks over all CPUs since boot
|
|
9
|
+
# (host_statistics(HOST_CPU_LOAD_INFO), Integers; only deltas mean anything)
|
|
10
|
+
# ncpu logical CPUs (hw.logicalcpu, else hw.ncpu)
|
|
11
|
+
# load [1, 5, 15]-minute load averages (getloadavg, Floats)
|
|
12
|
+
# net_in cumulative bytes received over every interface except lo0 (netstat -ibn)
|
|
13
|
+
# net_out cumulative bytes sent, likewise
|
|
14
|
+
# at_mono monotonic clock when read (seconds), for rates
|
|
15
|
+
#
|
|
16
|
+
# One `netstat -ibn` per sample (no per-interface work). It prints one `<Link#N>` row per
|
|
17
|
+
# interface plus one row per address with the same counters; only the Link rows are summed, so an
|
|
18
|
+
# interface with three addresses counts once. CPU, load and ncpu come through Fiddle.
|
|
19
|
+
#
|
|
20
|
+
# Each part is read on its own: one that fails (netstat missing, a sysctl refused) leaves its
|
|
21
|
+
# fields nil and the others still arrive. Off macOS the probe is nil.
|
|
22
|
+
SystemStat = Data.define(:cpu_ticks, :ncpu, :load, :net_in, :net_out, :at_mono)
|
|
23
|
+
|
|
24
|
+
module Probes
|
|
25
|
+
module System
|
|
26
|
+
HOST_CPU_LOAD_INFO = 3
|
|
27
|
+
CPU_STATES = 4 # CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE, CPU_STATE_NICE
|
|
28
|
+
NETSTAT = %w[netstat -ibn].freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Reads the live machine.
|
|
33
|
+
def read
|
|
34
|
+
net_in, net_out = attempt { parse_netstat(netstat) }
|
|
35
|
+
SystemStat.new(cpu_ticks: attempt { Native.cpu_ticks }, ncpu: attempt { Native.ncpu },
|
|
36
|
+
load: attempt { Native.load_average }, net_in:, net_out:,
|
|
37
|
+
at_mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# [bytes in, bytes out] summed over the `<Link#N>` rows of `netstat -ibn` output, lo0 left
|
|
41
|
+
# out; nil when there is no Link row (not netstat's table). Pure; tests feed it captured text.
|
|
42
|
+
#
|
|
43
|
+
# Columns: Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll. Address is
|
|
44
|
+
# empty for some interfaces (utun, gif), so the counters are taken from the right.
|
|
45
|
+
def parse_netstat(text)
|
|
46
|
+
found = false
|
|
47
|
+
totals = text.each_line.each_with_object([0, 0]) do |line, sums|
|
|
48
|
+
fields = line.split
|
|
49
|
+
next unless fields.size >= 10 && fields[2]&.start_with?("<Link#")
|
|
50
|
+
next if fields[0].delete_suffix("*") == "lo0"
|
|
51
|
+
|
|
52
|
+
ibytes = Integer(fields[-5], exception: false)
|
|
53
|
+
obytes = Integer(fields[-2], exception: false)
|
|
54
|
+
next unless ibytes && obytes
|
|
55
|
+
|
|
56
|
+
found = true
|
|
57
|
+
sums[0] += ibytes
|
|
58
|
+
sums[1] += obytes
|
|
59
|
+
end
|
|
60
|
+
found ? totals : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def netstat = IO.popen(NETSTAT, err: File::NULL, &:read)
|
|
64
|
+
|
|
65
|
+
# The block's value, or nil when it raises (a part that can't be read stays unknown).
|
|
66
|
+
def attempt
|
|
67
|
+
yield
|
|
68
|
+
rescue StandardError # Fiddle::DLError, Errno::ENOENT (no netstat), Agentmon::Error
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The Fiddle calls. Buffers are per call, so reads from several threads don't share memory.
|
|
73
|
+
module Native
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# host_cpu_load_info: natural_t cpu_ticks[CPU_STATE_MAX], in user/system/idle/nice order.
|
|
77
|
+
def cpu_ticks
|
|
78
|
+
buffer = Fiddle::Pointer.malloc(4 * CPU_STATES, Fiddle::RUBY_FREE)
|
|
79
|
+
count = Fiddle::Pointer.malloc(4, Fiddle::RUBY_FREE)
|
|
80
|
+
count[0, 4] = [CPU_STATES].pack("L")
|
|
81
|
+
result = functions[:host_statistics].call(host, HOST_CPU_LOAD_INFO, buffer, count)
|
|
82
|
+
raise Error, "host_statistics failed (kern_return_t #{result})" unless result.zero?
|
|
83
|
+
|
|
84
|
+
buffer[0, 4 * CPU_STATES].unpack("L<#{CPU_STATES}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_average
|
|
88
|
+
buffer = Fiddle::Pointer.malloc(8 * 3, Fiddle::RUBY_FREE)
|
|
89
|
+
n = functions[:getloadavg].call(buffer, 3)
|
|
90
|
+
raise Error, "getloadavg failed" unless n == 3
|
|
91
|
+
|
|
92
|
+
buffer[0, 24].unpack("d3")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Logical CPUs: constant for the process's life, so read once.
|
|
96
|
+
def ncpu = @ncpu ||= sysctl_int("hw.logicalcpu") || sysctl_int("hw.ncpu")
|
|
97
|
+
|
|
98
|
+
# An integer sysctl of 4 or 8 bytes, or nil.
|
|
99
|
+
def sysctl_int(name)
|
|
100
|
+
buffer = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
|
|
101
|
+
length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
|
|
102
|
+
length[0, 8] = [8].pack("Q")
|
|
103
|
+
return nil unless functions[:sysctlbyname].call(name, buffer, length, nil, 0).zero?
|
|
104
|
+
|
|
105
|
+
case length[0, 8].unpack1("Q")
|
|
106
|
+
when 4 then buffer[0, 4].unpack1("l<")
|
|
107
|
+
when 8 then buffer[0, 8].unpack1("q<")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# mach_host_self() returns a send right each call; take it once.
|
|
112
|
+
def host = @host ||= functions[:mach_host_self].call
|
|
113
|
+
|
|
114
|
+
def lib = @lib ||= Fiddle.dlopen(nil)
|
|
115
|
+
|
|
116
|
+
def functions
|
|
117
|
+
@functions ||= {
|
|
118
|
+
mach_host_self: Fiddle::Function.new(lib["mach_host_self"], [], -Fiddle::TYPE_INT),
|
|
119
|
+
host_statistics: Fiddle::Function.new(lib["host_statistics"],
|
|
120
|
+
[-Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
|
|
121
|
+
Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT),
|
|
122
|
+
getloadavg: Fiddle::Function.new(lib["getloadavg"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
123
|
+
Fiddle::TYPE_INT),
|
|
124
|
+
sysctlbyname: Fiddle::Function.new(lib["sysctlbyname"],
|
|
125
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
|
|
126
|
+
Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
probe(:system) { Darwin.available? ? Probes::System.read : nil }
|
|
134
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
# The `agentmon` command line, an r2ui CLI program: the root (no command) opens the dashboard
|
|
5
|
+
# in a layout (`--layout`, default dense), and every registered command is a subcommand.
|
|
6
|
+
module Program
|
|
7
|
+
# The panel dashboard (rows and panels from Agentmon.row/panel), i.e. UI.install(view: nil).
|
|
8
|
+
CLASSIC = "classic"
|
|
9
|
+
DEFAULT_LAYOUT = "dense"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# The --layout choices: classic plus the registered views.
|
|
14
|
+
def layouts(registry = Agentmon.registry) = [CLASSIC, *UI.layouts(registry).map(&:to_s)]
|
|
15
|
+
|
|
16
|
+
# The view UI.install takes for a --layout choice (nil: the classic dashboard).
|
|
17
|
+
def view(layout) = layout == CLASSIC ? nil : layout&.to_sym
|
|
18
|
+
|
|
19
|
+
def build(registry: Agentmon.registry)
|
|
20
|
+
layouts = Program.layouts(registry)
|
|
21
|
+
default = layouts.include?(DEFAULT_LAYOUT) ? DEFAULT_LAYOUT : CLASSIC
|
|
22
|
+
R2UI.cli("agentmon") do
|
|
23
|
+
summary "What AI coding agents (Claude, Codex) cost this Mac"
|
|
24
|
+
description <<~TEXT.chomp
|
|
25
|
+
With no command, opens the dashboard in the #{default} layout: agent sessions with their
|
|
26
|
+
CPU, real memory (footprint), disk and network, and memory pressure. --layout #{CLASSIC} is
|
|
27
|
+
the panel dashboard. Off a terminal it prints one frame.
|
|
28
|
+
TEXT
|
|
29
|
+
registry.cli_blocks.each { |b| instance_exec(&b) }
|
|
30
|
+
registry.commands.each { |c| command(c.name, &c.block) }
|
|
31
|
+
|
|
32
|
+
option :layout, in: layouts, default:, desc: "Dashboard layout: #{CLASSIC} or a view (docs/views.md)"
|
|
33
|
+
flag :motion, default: true, desc: "Animate meters, changed values and moved rows (AGENTMON_MOTION=0: off)"
|
|
34
|
+
|
|
35
|
+
run do
|
|
36
|
+
engine = Agentmon.engine
|
|
37
|
+
engine.recording = shell.interactive? # history is written while the dashboard is open
|
|
38
|
+
Views.motion = options[:motion] && ENV["AGENTMON_MOTION"] != "0"
|
|
39
|
+
UI.install(engine:, view: Program.view(options[:layout]))
|
|
40
|
+
dashboard UI::DASHBOARD
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start(argv = ARGV) = build.start(argv)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# After any command, a broken probe, metric or recorder is reported on stderr ("⚠ metric
|
|
49
|
+
# memory: ..."), so a table with missing columns says why. stdout stays clean for scripts.
|
|
50
|
+
R2UI::CLI.extension :agentmon_errors do
|
|
51
|
+
after_run do
|
|
52
|
+
Agentmon.engine_errors.each { |name, message| shell.err_puts("#{shell.symbol(:warn)} #{name}: #{message}") }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
# One sample plus everything derived from it: `reading[:process_rows]`, `reading[:sessions]`.
|
|
5
|
+
# A metric's value is computed once, the first time it is asked for (metrics may ask for each
|
|
6
|
+
# other); the Engine asks for all of them before it publishes the reading, so readers on other
|
|
7
|
+
# threads only look values up.
|
|
8
|
+
#
|
|
9
|
+
# Stories land in any order, so a missing or broken metric never stops the others:
|
|
10
|
+
# `reading[:name]` is nil for a metric nobody registered (its story hasn't merged yet), and a
|
|
11
|
+
# metric that raises is nil too, with its error in `reading.errors[:name]` (the dashboard's
|
|
12
|
+
# status bar and the CLI's stderr show them). Consumers treat nil as "not available".
|
|
13
|
+
#
|
|
14
|
+
# Tests build one directly, with fixture samples and preset `values:` for metrics a story
|
|
15
|
+
# consumes but doesn't own:
|
|
16
|
+
#
|
|
17
|
+
# Reading.new(sample, previous: earlier, values: { memory: MemoryView.new(...) })
|
|
18
|
+
class Reading
|
|
19
|
+
attr_reader :sample, :previous, :errors
|
|
20
|
+
|
|
21
|
+
def initialize(sample, previous: nil, metrics: Agentmon.registry.metrics, states: nil, values: {})
|
|
22
|
+
@sample = sample
|
|
23
|
+
@previous = previous
|
|
24
|
+
@metrics = metrics.to_h { |m| [m.name, m] }
|
|
25
|
+
@states = states || Hash.new { |h, k| h[k] = {} }
|
|
26
|
+
@values = values.transform_keys(&:to_sym)
|
|
27
|
+
@errors = {}
|
|
28
|
+
@computing = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Wall time of the sample (epoch seconds).
|
|
32
|
+
def at = sample.at
|
|
33
|
+
|
|
34
|
+
# Seconds between the previous sample and this one (monotonic), nil for the first.
|
|
35
|
+
def interval = previous && (sample.mono - previous.mono)
|
|
36
|
+
|
|
37
|
+
def [](name)
|
|
38
|
+
name = name.to_sym
|
|
39
|
+
return @values[name] if @values.key?(name)
|
|
40
|
+
|
|
41
|
+
metric = @metrics[name] or return nil
|
|
42
|
+
raise Error, "metric cycle: #{[*@computing, name].join(" -> ")}" if @computing.include?(name)
|
|
43
|
+
|
|
44
|
+
@values[name] = compute(metric)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The focused session id: always nil here; a FocusedReading (lib/agentmon/focus.rb) answers
|
|
48
|
+
# its session. Code that may get either calls these instead of checking the class.
|
|
49
|
+
def focus = nil
|
|
50
|
+
|
|
51
|
+
# The whole machine's reading (a FocusedReading returns the one it narrows).
|
|
52
|
+
def unfocused = self
|
|
53
|
+
|
|
54
|
+
# True when a metric of that name is registered (or preset).
|
|
55
|
+
def key?(name) = @values.key?(name.to_sym) || @metrics.key?(name.to_sym)
|
|
56
|
+
|
|
57
|
+
# Computes every metric (the Engine calls this once per sample).
|
|
58
|
+
def evaluate_all
|
|
59
|
+
@metrics.each_key { |name| self[name] }
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def compute(metric)
|
|
66
|
+
@computing.push(metric.name)
|
|
67
|
+
metric.block.call(self, @states[metric.name])
|
|
68
|
+
rescue Error => e
|
|
69
|
+
raise if e.message.start_with?("metric cycle") # a design bug: fail loudly
|
|
70
|
+
|
|
71
|
+
fail_metric(metric, e)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
fail_metric(metric, e)
|
|
74
|
+
ensure
|
|
75
|
+
@computing.pop
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fail_metric(metric, error)
|
|
79
|
+
@errors[metric.name] = "#{error.class}: #{error.message}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Memory history: while agentmon records (the interactive dashboard, `agentmon record`), one line
|
|
4
|
+
# every 10 s in the store's `memory` kind, e.g. ~/.local/state/agentmon/memory/2026-10-01.jsonl:
|
|
5
|
+
#
|
|
6
|
+
# {"t":1790000010.0,"total":17179869184,"used":11811160064,"app":7516192768,"wired":2147483648,
|
|
7
|
+
# "compressed":2147483648,"cached":3221225472,"free":536870912,"swap_used":1073741824,
|
|
8
|
+
# "swap_total":2147483648,"compression_ratio":3.1,"swapin_rate":0.0,"swapout_rate":4096.0,
|
|
9
|
+
# "compression_rate":1572864.0,"decompression_rate":262144.0,"pressure":38.0,"run":"4242-1790000000"}
|
|
10
|
+
#
|
|
11
|
+
# The line is `reading[:memory]` (a MemoryView: sizes in bytes, rates in bytes/s, pressure in
|
|
12
|
+
# percent) without `pressure_trend` (the line's own history is the trend), stamped with the
|
|
13
|
+
# sample's time `t` and the engine's `run`. Nothing is written while memory is unavailable.
|
|
14
|
+
module Agentmon
|
|
15
|
+
recorder(:memory, every: 10) do |reading, _state|
|
|
16
|
+
view = reading[:memory]
|
|
17
|
+
view && { t: reading.at, **view.to_h.except(:pressure_trend) }
|
|
18
|
+
end
|
|
19
|
+
end
|