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,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
# The data model: every value that crosses a file boundary (a probe's output, a metric's output,
|
|
5
|
+
# a store record) is one of these. Stories build against these shapes, so they can be written in
|
|
6
|
+
# parallel; changing one is a contract change (docs/design.md).
|
|
7
|
+
#
|
|
8
|
+
# Units, everywhere:
|
|
9
|
+
# sizes and cumulative I/O bytes (Integer)
|
|
10
|
+
# rates bytes per second (Float)
|
|
11
|
+
# CPU time seconds (Float), never Mach ticks
|
|
12
|
+
# CPU usage percent of one core (Float): 250.0 is two and a half cores busy
|
|
13
|
+
# instants epoch seconds (Float); intervals use the monotonic clock
|
|
14
|
+
# nil unknown (unreadable, or not measured yet), never 0
|
|
15
|
+
|
|
16
|
+
# One probe pass: what every probe returned at one instant. `parts[:processes]` and so on.
|
|
17
|
+
# `errors` maps a probe that raised to its message (its part keeps the previous value).
|
|
18
|
+
Sample = Data.define(:at, :mono, :parts, :errors) do
|
|
19
|
+
def initialize(at:, mono:, parts:, errors: {}) = super
|
|
20
|
+
|
|
21
|
+
def [](name) = parts[name]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# probes/processes.rb: one process. Cumulative counters are since the process started.
|
|
25
|
+
# `readable` is false for processes the kernel won't describe to us (root daemons): then only
|
|
26
|
+
# the ps fields (pid, ppid, path, resident, ps_cpu) are set and the rest are nil.
|
|
27
|
+
ProcessStat = Data.define(
|
|
28
|
+
:pid, :ppid,
|
|
29
|
+
:name, # basename of the executable
|
|
30
|
+
:path, # executable path as ps reports it (comm)
|
|
31
|
+
:readable, # rusage was readable
|
|
32
|
+
:start_ticks, # Mach ticks at start: [pid, start_ticks] survives pid reuse (nil if unreadable)
|
|
33
|
+
:started_at, # epoch seconds (nil if unreadable)
|
|
34
|
+
:cpu_time, # own user+system seconds
|
|
35
|
+
:child_cpu_time, # user+system seconds of children it has reaped
|
|
36
|
+
:resident, # bytes (rusage, or ps rss when unreadable)
|
|
37
|
+
:footprint, # physical footprint, bytes: what Activity Monitor calls Memory
|
|
38
|
+
:peak_footprint, # lifetime max footprint, bytes
|
|
39
|
+
:disk_read, # cumulative bytes
|
|
40
|
+
:disk_written, # cumulative bytes
|
|
41
|
+
:ps_cpu, # ps %cpu (a decaying average): the fallback when rusage is unreadable
|
|
42
|
+
# Paging and scheduling, from the same two calls per process (rusage, PROC_PIDTASKALLINFO).
|
|
43
|
+
# nil when unreadable. Counts are cumulative event counts (Integer), not bytes.
|
|
44
|
+
:wired, # wired (unpageable) bytes now
|
|
45
|
+
:pageins, # count of faults that had to read a page from disk (rusage ri_pageins)
|
|
46
|
+
:faults, # count of page faults of any kind (pti_faults: a 32-bit kernel counter, wraps)
|
|
47
|
+
:cow_faults, # count of copy-on-write faults (pti_cow_faults: 32-bit, wraps)
|
|
48
|
+
:context_switches, # count (pti_csw: 32-bit, wraps)
|
|
49
|
+
:runnable_time, # seconds its threads were runnable: on a CPU *or waiting for one*
|
|
50
|
+
# (ri_runnable_time). Time spent waiting to run = runnable_time - cpu_time
|
|
51
|
+
:threads, # threads now
|
|
52
|
+
:running_threads # threads running now
|
|
53
|
+
) do
|
|
54
|
+
# The paging and scheduling fields default to nil (unknown), so code written before they
|
|
55
|
+
# existed, and fixtures that don't care, still build ProcessStats.
|
|
56
|
+
def initialize(wired: nil, pageins: nil, faults: nil, cow_faults: nil, context_switches: nil, runnable_time: nil,
|
|
57
|
+
threads: nil, running_threads: nil, **) = super
|
|
58
|
+
|
|
59
|
+
def identity = [pid, start_ticks]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# metrics/process_rates.rb output, per pid: rates between the previous sample and this one.
|
|
63
|
+
# A process seen for the first time (or unreadable) has ps's %cpu and nil for every other rate.
|
|
64
|
+
ProcessRates = Data.define(
|
|
65
|
+
:cpu, # percent of one core
|
|
66
|
+
:read_rate, :write_rate, # disk bytes/s
|
|
67
|
+
:pagein_rate, # pageins/s: each one waited for the disk (the closest thing to I/O wait)
|
|
68
|
+
:fault_rate, # page faults/s
|
|
69
|
+
:cow_fault_rate, # copy-on-write faults/s
|
|
70
|
+
:context_switch_rate, # context switches/s
|
|
71
|
+
:run_wait # percent of one core spent runnable but not running, i.e. waiting for
|
|
72
|
+
# a CPU: (Δrunnable_time - Δcpu_time) / interval x 100
|
|
73
|
+
) do
|
|
74
|
+
def initialize(pagein_rate: nil, fault_rate: nil, cow_fault_rate: nil, context_switch_rate: nil, run_wait: nil,
|
|
75
|
+
**) = super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# metrics/sessions.rb: who an agent session is. `kind` is :cli (an outermost claude/codex CLI
|
|
79
|
+
# process) or :app (a desktop app and all its helpers). `id` is stable across agentmon runs:
|
|
80
|
+
# "claude-4242-1790711088" (name, root pid, root start second).
|
|
81
|
+
# `label` is the one display string every consumer shows and searches: "claude 4242 · repo", or
|
|
82
|
+
# with a human name "Agentmon r2ui integration · claude 4242 · repo" (name first, so a table
|
|
83
|
+
# cut at a word boundary keeps it). `title`, `status`, `threads` come from SessionName (nil/[]
|
|
84
|
+
# when unknown).
|
|
85
|
+
SessionInfo = Data.define(:id, :kind, :name, :root_pid, :label, :cwd, :started_at, :title, :status, :threads) do
|
|
86
|
+
def initialize(title: nil, status: nil, threads: [], **) = super
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# metrics/session_names.rb output, per session root pid: what the agent itself calls the session.
|
|
90
|
+
SessionName = Data.define(
|
|
91
|
+
:title, # String: the session's name ("Agentmon r2ui integration"); for a codex process hosting
|
|
92
|
+
# several threads, its newest named thread plus " +N" for N more open threads; nil unknown
|
|
93
|
+
:status, # "busy" / "idle" from Claude Code's session file; nil for codex or unknown
|
|
94
|
+
:threads # Array of String: a codex process's open threads' names, newest first ([] for Claude)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# metrics/sessions.rb output: the sessions alive in this sample and which one each pid is in.
|
|
98
|
+
SessionMap = Data.define(:sessions, :by_pid) do
|
|
99
|
+
def of(pid) = (id = by_pid[pid]) && sessions.find { |s| s.id == id }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# metrics/session_ledger.rb output (one per session, alive or ended in the last
|
|
103
|
+
# SessionLedger::KEEP_ENDED seconds). Current values are 0 once it has ended.
|
|
104
|
+
Session = Data.define(
|
|
105
|
+
:id, :kind, :name, :root_pid, :label, :cwd,
|
|
106
|
+
:started_at, # the root process's start (epoch), or first_seen_at when unknown
|
|
107
|
+
:first_seen_at, # when agentmon first saw it
|
|
108
|
+
:last_seen_at, # last sample it was alive in
|
|
109
|
+
:ended_at, # nil while alive
|
|
110
|
+
:processes, # live member processes
|
|
111
|
+
:cpu, # percent, now
|
|
112
|
+
:footprint, # bytes, now (readable members only)
|
|
113
|
+
:resident, # bytes, now
|
|
114
|
+
:read_rate, # bytes/s, now
|
|
115
|
+
:write_rate, # bytes/s, now
|
|
116
|
+
:peak_footprint, # max footprint seen over its life (bytes)
|
|
117
|
+
:cpu_seconds, # CPU seconds over its life, reaped children included (see design.md)
|
|
118
|
+
:bytes_read, # disk bytes read by members while observed (a lower bound)
|
|
119
|
+
:bytes_written, # disk bytes written by members while observed (a lower bound)
|
|
120
|
+
# From the latest SessionInfo (SessionName): human name, Claude's "busy"/"idle", codex thread
|
|
121
|
+
# names newest first. nil/[] when unknown.
|
|
122
|
+
:title, :status, :threads
|
|
123
|
+
) do
|
|
124
|
+
def initialize(title: nil, status: nil, threads: [], **) = super
|
|
125
|
+
|
|
126
|
+
def alive? = ended_at.nil?
|
|
127
|
+
|
|
128
|
+
def duration = (ended_at || last_seen_at) - (started_at || first_seen_at)
|
|
129
|
+
|
|
130
|
+
# The store line for kind "sessions" (docs/design.md, "Store").
|
|
131
|
+
def to_record = to_h.merge(kind: kind.to_s)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# metrics/process_rows.rb output: one row per process for tables (dashboard and CLI).
|
|
135
|
+
ProcessRow = Data.define(
|
|
136
|
+
:pid, :ppid, :name, :path, :cwd,
|
|
137
|
+
:session, # session label, nil when it isn't in an agent session
|
|
138
|
+
:session_id,
|
|
139
|
+
:cpu, # percent (rusage delta, or ps %cpu when unreadable or first seen)
|
|
140
|
+
:footprint, :resident, :peak_footprint, # bytes
|
|
141
|
+
:read_rate, :write_rate, # bytes/s, nil until two samples
|
|
142
|
+
:cpu_time, :disk_written, # seconds, bytes (cumulative)
|
|
143
|
+
:started_at, :readable,
|
|
144
|
+
# Paging and scheduling (from ProcessStat and ProcessRates); nil when unreadable or before two
|
|
145
|
+
# samples. Optional, so rows built without them still work.
|
|
146
|
+
:disk_read, # bytes (cumulative)
|
|
147
|
+
:wired, # bytes now
|
|
148
|
+
:pageins, :faults, :cow_faults, :context_switches, # counts (cumulative)
|
|
149
|
+
:runnable_time, # seconds (cumulative), time on a CPU included
|
|
150
|
+
:threads, :running_threads, # counts now
|
|
151
|
+
:pagein_rate, :fault_rate, :cow_fault_rate, :context_switch_rate, # per second
|
|
152
|
+
:run_wait, # percent of one core spent waiting for a CPU
|
|
153
|
+
# Network (metrics/network.rb, from nettop); nil when unknown (no snapshot yet, first sighting).
|
|
154
|
+
:net_in_rate, :net_out_rate # bytes/s received / sent over all its sockets
|
|
155
|
+
) do
|
|
156
|
+
def initialize(disk_read: nil, wired: nil, pageins: nil, faults: nil, cow_faults: nil, context_switches: nil,
|
|
157
|
+
runnable_time: nil, threads: nil, running_threads: nil, pagein_rate: nil, fault_rate: nil,
|
|
158
|
+
cow_fault_rate: nil, context_switch_rate: nil, run_wait: nil, net_in_rate: nil,
|
|
159
|
+
net_out_rate: nil, **) = super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# probes/network.rb: one socket (flow) of a process as one `nettop -x -L 0` line prints it.
|
|
163
|
+
NetFlow = Data.define(
|
|
164
|
+
:protocol, # "tcp4", "udp6", "quic4", ...
|
|
165
|
+
:local, # local endpoint as nettop prints it: "192.0.2.5:55584" (IPv4 `:` port),
|
|
166
|
+
:remote, # "2001:db8::1.443" (IPv6 `.` port), "*:*" / "*.*" wildcards
|
|
167
|
+
:remote_host, # remote address or name without the port; nil for the `*` wildcard
|
|
168
|
+
:remote_port, # Integer, nil for `*`
|
|
169
|
+
:interface, # "en0", "lo0"; nil when nettop leaves it empty
|
|
170
|
+
:state, # "Established", "Listen", ...; nil for stateless (UDP) or empty
|
|
171
|
+
:bytes_in, # cumulative bytes received on this flow; nil when nettop leaves it empty
|
|
172
|
+
:bytes_out # cumulative bytes sent, likewise
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# probes/network.rb: one process's sockets in one nettop block.
|
|
176
|
+
NetProcess = Data.define(
|
|
177
|
+
:pid,
|
|
178
|
+
:name, # nettop's (truncated) process name
|
|
179
|
+
:bytes_in, # cumulative bytes received, as nettop counts them for the process
|
|
180
|
+
:bytes_out, # cumulative bytes sent
|
|
181
|
+
:flows # Array of NetFlow
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# probes/network.rb: one complete nettop block (sample[:network]).
|
|
185
|
+
NetSnapshot = Data.define(
|
|
186
|
+
:at_mono, # monotonic seconds when the block's header arrived (rates use these)
|
|
187
|
+
:processes # { pid => NetProcess }; a process without sockets has no entry
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# metrics/network.rb reading[:net_rates], per pid: between the two latest nettop snapshots.
|
|
191
|
+
NetRates = Data.define(:in_rate, :out_rate) # bytes/s; nil when unknown
|
|
192
|
+
|
|
193
|
+
# metrics/network.rb reading[:session_network]: one alive session's network, each pid once.
|
|
194
|
+
SessionNetwork = Data.define(
|
|
195
|
+
:session_id, :label,
|
|
196
|
+
:in_rate, :out_rate, # bytes/s now, summed over members (nil when no member's rate is known)
|
|
197
|
+
:bytes_in, :bytes_out, # cumulative bytes received / sent by members while agentmon observed them
|
|
198
|
+
:in_trend, :out_trend, # the last 120 known rates (bytes/s), oldest first
|
|
199
|
+
:connections, # count of member flows with a concrete remote (not `*`)
|
|
200
|
+
:remote_hosts, # count of distinct remote hosts, loopback and link-local left out
|
|
201
|
+
:flags # Array of Symbols from the anomaly rule (:out_spike, :in_spike, :many_hosts); [] normal
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# metrics/network.rb reading[:connections]: one flow of a process in an agent session with a
|
|
205
|
+
# concrete remote (no `*`, no Listen). Fields as NetFlow; rates bytes/s, nil the first time.
|
|
206
|
+
ConnectionRow = Data.define(
|
|
207
|
+
:pid, :name, :session_id,
|
|
208
|
+
:session, # session label
|
|
209
|
+
:protocol, :local, :remote, :remote_host, :remote_port, :interface, :state,
|
|
210
|
+
:bytes_in, :bytes_out, # cumulative bytes on this flow
|
|
211
|
+
:in_rate, :out_rate # bytes/s between the two latest snapshots
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# probes/memory.rb (story a01): the machine's memory counters. Sizes in bytes; the counters
|
|
215
|
+
# (swapins .. pageins) are cumulative bytes since boot (pages x page size).
|
|
216
|
+
MemoryStat = Data.define(
|
|
217
|
+
:total, # hw.memsize
|
|
218
|
+
:free,
|
|
219
|
+
:app, # anonymous (internal) pages minus purgeable
|
|
220
|
+
:wired,
|
|
221
|
+
:compressed, # pages occupied by the compressor
|
|
222
|
+
:compressor_stored, # uncompressed bytes held in the compressor
|
|
223
|
+
:cached, # file-backed + purgeable
|
|
224
|
+
:purgeable,
|
|
225
|
+
:swap_total, :swap_used,
|
|
226
|
+
:swapins, :swapouts, :compressions, :decompressions, :pageins,
|
|
227
|
+
:memorystatus_level # kern.memorystatus_level: percent of memory available (100 = no pressure)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# metrics/memory.rb (story a02): Activity Monitor's breakdown plus rates and pressure.
|
|
231
|
+
MemoryView = Data.define(
|
|
232
|
+
:total, :used, :app, :wired, :compressed, :cached, :free, :swap_used, :swap_total,
|
|
233
|
+
:compression_ratio, # compressor_stored / compressed (e.g. 3.1)
|
|
234
|
+
:swapin_rate, :swapout_rate, :compression_rate, :decompression_rate, # bytes/s
|
|
235
|
+
:pressure, # percent: 100 - memorystatus_level
|
|
236
|
+
:pressure_trend # recent pressure values, oldest first (Array of Float)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# metrics/pressure_drivers.rb (story a03): sessions ranked by how much they push memory.
|
|
240
|
+
PressureDriver = Data.define(
|
|
241
|
+
:session_id, :label,
|
|
242
|
+
:footprint, # bytes now
|
|
243
|
+
:share, # percent of used memory
|
|
244
|
+
:growth_rate # bytes/s of footprint change over the last minute
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# metrics/session_memory.rb (story a20): one alive session's memory, largest footprint first.
|
|
248
|
+
# Sums over the session's live readable members, each process once. Compressed and swapped
|
|
249
|
+
# bytes per process need task_for_pid (root), so they aren't here; MemoryView has the machine's.
|
|
250
|
+
SessionMemory = Data.define(
|
|
251
|
+
:session_id, :label,
|
|
252
|
+
:processes, # live members
|
|
253
|
+
:footprint, # bytes now (Activity Monitor's Memory; includes the session's compressed pages)
|
|
254
|
+
:resident, # bytes now
|
|
255
|
+
:wired, # bytes now
|
|
256
|
+
:peak_footprint, # bytes: largest footprint sum over its life (Session#peak_footprint)
|
|
257
|
+
:share, # percent of MemoryView#used (nil while memory is unknown)
|
|
258
|
+
:growth_rate, # bytes/s of footprint change over the last minute (0.0 until two samples)
|
|
259
|
+
:pageins, # count, cumulative over live members
|
|
260
|
+
:pagein_rate, # pageins/s now (nil until two samples)
|
|
261
|
+
:fault_rate # page faults/s now (nil until two samples)
|
|
262
|
+
)
|
|
263
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# sample[:cwd]: { pid => working directory } for every process lsof can see.
|
|
4
|
+
#
|
|
5
|
+
# lsof costs ~0.4 s for the whole machine, so it runs at most every CWD_EVERY seconds and the
|
|
6
|
+
# sampler reuses the last map in between (`every:`). A process newer than the map has no cwd until
|
|
7
|
+
# the next run. Session labels ("claude 4242 · repo") and the Directory column come from here.
|
|
8
|
+
module Agentmon
|
|
9
|
+
module Probes
|
|
10
|
+
module Cwd
|
|
11
|
+
CWD_EVERY = 10
|
|
12
|
+
LSOF = %w[lsof -d cwd -Fpn].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def read(text = IO.popen(LSOF, err: File::NULL, &:read)) = parse(text)
|
|
17
|
+
|
|
18
|
+
# lsof -F output: "p<pid>" then "n<path>" lines.
|
|
19
|
+
def parse(text)
|
|
20
|
+
pid = nil
|
|
21
|
+
text.each_line.with_object({}) do |line, cwds|
|
|
22
|
+
case line[0]
|
|
23
|
+
when "p" then pid = line[1..].to_i
|
|
24
|
+
when "n" then cwds[pid] = line[1..].chomp if pid
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
probe(:cwd, every: Probes::Cwd::CWD_EVERY) { Probes::Cwd.read }
|
|
32
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
# sample[:memory]: the machine's memory counters, as a MemoryStat (lib/agentmon/model.rb), in
|
|
6
|
+
# bytes. The same numbers Activity Monitor's Memory tab and vm_stat show, e.g. on a 32 GiB Mac:
|
|
7
|
+
#
|
|
8
|
+
# total 32.0G app 10.4G wired 4.3G compressed 9.4G (stores 24.3G) cached 5.3G
|
|
9
|
+
# swap 1.9G of 3.0G used memorystatus_level 55 (pressure 45%)
|
|
10
|
+
#
|
|
11
|
+
# No subprocesses: host_statistics64(HOST_VM_INFO64) through Fiddle for the page counts
|
|
12
|
+
# (pages x vm_kernel_page_size: 16 KiB on Apple Silicon, 4 KiB on Intel), and sysctlbyname for
|
|
13
|
+
# hw.memsize, vm.swapusage, vm.page_pageable_internal_count and kern.memorystatus_level. A read
|
|
14
|
+
# takes well under a millisecond.
|
|
15
|
+
#
|
|
16
|
+
# app = pageable internal (anonymous) pages - purgeable pages
|
|
17
|
+
# cached = file-backed pages + purgeable pages
|
|
18
|
+
# compressed = pages the compressor occupies; compressor_stored = pages stored in it
|
|
19
|
+
# free = free pages minus speculative ones (those are file-backed, so already in cached)
|
|
20
|
+
# swapins, swapouts, compressions, decompressions, pageins: cumulative bytes since boot
|
|
21
|
+
#
|
|
22
|
+
# A sysctl that fails leaves its fields nil (swap, memorystatus_level); without
|
|
23
|
+
# vm.page_pageable_internal_count, app uses the anonymous page count instead. Off macOS the probe
|
|
24
|
+
# is nil.
|
|
25
|
+
module Agentmon
|
|
26
|
+
module Probes
|
|
27
|
+
module Memory
|
|
28
|
+
HOST_VM_INFO64 = 4
|
|
29
|
+
# struct vm_statistics64 (xnu osfmk/mach/vm_statistics.h): 152 bytes, 38 integer_t's.
|
|
30
|
+
VM_SIZE = 152
|
|
31
|
+
VM_COUNT = VM_SIZE / 4
|
|
32
|
+
VM_FORMAT = "L<4Q<9L<2Q<4L<4Q<"
|
|
33
|
+
VM_FIELDS = %i[
|
|
34
|
+
free_count active_count inactive_count wire_count
|
|
35
|
+
zero_fill_count reactivations pageins pageouts faults cow_faults lookups hits purges
|
|
36
|
+
purgeable_count speculative_count
|
|
37
|
+
decompressions compressions swapins swapouts
|
|
38
|
+
compressor_page_count throttled_count external_page_count internal_page_count
|
|
39
|
+
total_uncompressed_pages_in_compressor
|
|
40
|
+
].freeze
|
|
41
|
+
# struct xsw_usage: xsu_total, xsu_avail, xsu_used (uint64), xsu_pagesize, xsu_encrypted.
|
|
42
|
+
SWAP_SIZE = 32
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Reads the live machine. Raises when host_statistics64 fails.
|
|
47
|
+
def read
|
|
48
|
+
decode(vm: Native.vm_statistics, page_size: Native.page_size, memsize: Native.sysctl_int("hw.memsize"),
|
|
49
|
+
swap: Native.sysctl("vm.swapusage", SWAP_SIZE),
|
|
50
|
+
pageable_internal: Native.sysctl_int("vm.page_pageable_internal_count"),
|
|
51
|
+
memorystatus_level: Native.sysctl_int("kern.memorystatus_level"))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Builds the MemoryStat from the raw kernel answers: `vm` is a vm_statistics64 as the kernel
|
|
55
|
+
# wrote it, `swap` an xsw_usage (or nil), the rest Integers (or nil). Pure; tests feed it
|
|
56
|
+
# recorded bytes.
|
|
57
|
+
def decode(vm:, page_size:, memsize:, swap: nil, pageable_internal: nil, memorystatus_level: nil)
|
|
58
|
+
raise ArgumentError, "vm_statistics64 is #{vm.bytesize} bytes, want #{VM_SIZE}" if vm.bytesize < VM_SIZE
|
|
59
|
+
|
|
60
|
+
v = VM_FIELDS.zip(vm.byteslice(0, VM_SIZE).unpack(VM_FORMAT)).to_h
|
|
61
|
+
bytes = ->(pages) { pages * page_size }
|
|
62
|
+
purgeable = v[:purgeable_count]
|
|
63
|
+
internal = pageable_internal || v[:internal_page_count]
|
|
64
|
+
swap_total, _avail, swap_used = swap&.byteslice(0, 24)&.unpack("Q<3")
|
|
65
|
+
MemoryStat.new(
|
|
66
|
+
total: memsize,
|
|
67
|
+
free: bytes[[v[:free_count] - v[:speculative_count], 0].max],
|
|
68
|
+
app: bytes[[internal - purgeable, 0].max],
|
|
69
|
+
wired: bytes[v[:wire_count]],
|
|
70
|
+
compressed: bytes[v[:compressor_page_count]],
|
|
71
|
+
compressor_stored: bytes[v[:total_uncompressed_pages_in_compressor]],
|
|
72
|
+
cached: bytes[v[:external_page_count] + purgeable],
|
|
73
|
+
purgeable: bytes[purgeable],
|
|
74
|
+
swap_total:, swap_used:,
|
|
75
|
+
swapins: bytes[v[:swapins]], swapouts: bytes[v[:swapouts]],
|
|
76
|
+
compressions: bytes[v[:compressions]], decompressions: bytes[v[:decompressions]],
|
|
77
|
+
pageins: bytes[v[:pageins]],
|
|
78
|
+
memorystatus_level:
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The Fiddle calls. Buffers are per call, so reads from several threads don't share memory.
|
|
83
|
+
module Native
|
|
84
|
+
module_function
|
|
85
|
+
|
|
86
|
+
def vm_statistics
|
|
87
|
+
buffer = Fiddle::Pointer.malloc(VM_SIZE, Fiddle::RUBY_FREE)
|
|
88
|
+
count = Fiddle::Pointer.malloc(4, Fiddle::RUBY_FREE)
|
|
89
|
+
count[0, 4] = [VM_COUNT].pack("L")
|
|
90
|
+
result = functions[:host_statistics64].call(host, HOST_VM_INFO64, buffer, count)
|
|
91
|
+
raise Error, "host_statistics64 failed (kern_return_t #{result})" unless result.zero?
|
|
92
|
+
|
|
93
|
+
buffer[0, VM_SIZE]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# vm_kernel_page_size, a libSystem global (vm_size_t).
|
|
97
|
+
def page_size = @page_size ||= Fiddle::Pointer.new(lib["vm_kernel_page_size"])[0, 8].unpack1("Q")
|
|
98
|
+
|
|
99
|
+
# The sysctl's raw bytes (at most `size`), or nil when it fails.
|
|
100
|
+
def sysctl(name, size)
|
|
101
|
+
buffer = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
|
|
102
|
+
length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
|
|
103
|
+
length[0, 8] = [size].pack("Q")
|
|
104
|
+
return nil unless functions[:sysctlbyname].call(name, buffer, length, nil, 0).zero?
|
|
105
|
+
|
|
106
|
+
buffer[0, length[0, 8].unpack1("Q")]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# An integer sysctl of 4 or 8 bytes (they differ by name and OS version), or nil.
|
|
110
|
+
def sysctl_int(name)
|
|
111
|
+
raw = sysctl(name, 8)
|
|
112
|
+
case raw&.bytesize
|
|
113
|
+
when 4 then raw.unpack1("l<")
|
|
114
|
+
when 8 then raw.unpack1("q<")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# mach_host_self() returns a send right each call; take it once.
|
|
119
|
+
def host = @host ||= functions[:mach_host_self].call
|
|
120
|
+
|
|
121
|
+
def lib = @lib ||= Fiddle.dlopen(nil)
|
|
122
|
+
|
|
123
|
+
def functions
|
|
124
|
+
@functions ||= {
|
|
125
|
+
mach_host_self: Fiddle::Function.new(lib["mach_host_self"], [], -Fiddle::TYPE_INT),
|
|
126
|
+
host_statistics64: Fiddle::Function.new(lib["host_statistics64"],
|
|
127
|
+
[-Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
|
|
128
|
+
Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT),
|
|
129
|
+
sysctlbyname: Fiddle::Function.new(lib["sysctlbyname"],
|
|
130
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
|
|
131
|
+
Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
probe(:memory) { Darwin.available? ? Probes::Memory.read : nil }
|
|
139
|
+
end
|