sitegoose 0.3.3

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.
@@ -0,0 +1,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ # How the machine is doing (protocol in sdk/protocols/resources.md).
4
+ #
5
+ # CPU, load, memory, swap and disks, sampled every minute and sent to
6
+ # POST /events/resources every few minutes. The companion of the performance
7
+ # module: that says how the requests went, this says whether the box can
8
+ # still serve them.
9
+ #
10
+ # Nothing is guessed. A reading this machine won't give us is left out of the
11
+ # row, never sent as a zero: the collector stores it as NULL and the dashboard
12
+ # shows "-", because "0% CPU" and "we couldn't measure CPU" are different
13
+ # statements and only one of them means healthy.
14
+ #
15
+ # Linux's /proc for everything but the disks. Ruby's standard library has no
16
+ # statvfs, so the disks come from `df -kP` and `df -iP` (POSIX output, the
17
+ # same statvfs figures: used is total minus free, and total is used plus what
18
+ # is available to us, which is the percentage df prints); without df, the
19
+ # disks are left out.
20
+
21
+ require "etc"
22
+ require "io/wait"
23
+
24
+ module SiteGoose
25
+ class Resources
26
+ # Keep in step with the collector (expresscollect/Analytics/resources.js) and
27
+ # the agent (sdk/vps-agent).
28
+ SAMPLE_EVERY = 60
29
+ EVERY = 5 * 60
30
+ RETRY_EVERY = 60
31
+ OFF_EVERY = 600
32
+ MAX_ROWS = 500
33
+ MAX_DISKS = 20
34
+ MIN_DISK_MB = 1024
35
+ MIN_CPU_SECONDS = 1.0
36
+ FSTYPES = %w[ext2 ext3 ext4 xfs btrfs zfs f2fs jfs reiserfs ufs vfat exfat ntfs ntfs3 overlay apfs hfs].freeze
37
+ MB = 1024 * 1024
38
+ IO_SECTOR = 512
39
+ IO_MAX_PAIR_SECONDS = 300
40
+ IO_NOT_A_DISK = /\A(?:loop|ram|zram|sr|fd|dm-|md\d)/.freeze
41
+ IO_PARTITION = /\A(?:(?:sd|vd|xvd|hd)[a-z]+\d+|(?:nvme\d+n\d+|mmcblk\d+)p\d+)\z/.freeze
42
+ NET_NOT_REAL = /\A(?:lo|veth|docker|br-|virbr|tap|tun|vnet)/.freeze
43
+ CPU_SHARES = [
44
+ ["cpu_user_pct", [0, 1]],
45
+ ["cpu_system_pct", [2]],
46
+ ["cpu_iowait_pct", [4]],
47
+ ["cpu_steal_pct", [7]]
48
+ ].freeze
49
+ DF_TIMEOUT = 5
50
+
51
+ class << self
52
+ def read_text(path, limit = 65_536)
53
+ File.open(path, "rb") { |f| SiteGoose.text(f.read(limit).to_s) }
54
+ rescue SystemCallError, IOError
55
+ ""
56
+ end
57
+
58
+ # The jiffie counters from /proc/stat's first line, or nil.
59
+ # In order: user, nice, system, idle, iowait, irq, softirq, steal.
60
+ def cpu_fields(text)
61
+ text.each_line do |line|
62
+ next unless line.start_with?("cpu ")
63
+
64
+ fields = line.split[1..]
65
+ return nil unless fields.all? { |f| f.match?(/\A\d+\z/) }
66
+
67
+ fields = fields.map(&:to_i)
68
+ return fields.size >= 4 ? fields : nil
69
+ end
70
+ nil
71
+ end
72
+
73
+ # guest and guest_nice (columns 9 and 10) are already inside user and nice.
74
+ def cpu_total(fields)
75
+ fields.first(8).sum
76
+ end
77
+
78
+ def cpu_times_of(fields)
79
+ total = cpu_total(fields)
80
+ idle = fields[3] + (fields.size > 4 ? fields[4] : 0)
81
+ [total - idle, total]
82
+ end
83
+
84
+ def cpu_shares(before, after)
85
+ return {} if before.nil? || after.nil?
86
+
87
+ total = cpu_total(after) - cpu_total(before)
88
+ return {} if total <= 0
89
+
90
+ out = {}
91
+ CPU_SHARES.each do |name, columns|
92
+ next if columns.any? { |i| i >= before.size || i >= after.size }
93
+
94
+ moved = columns.sum { |i| after[i] - before[i] }
95
+ return {} if moved.negative?
96
+
97
+ out[name] = [100.0, 100.0 * moved / total].min.round(1)
98
+ end
99
+ out
100
+ end
101
+
102
+ def disk_counters(text)
103
+ read = written = 0
104
+ found = false
105
+ text.each_line do |line|
106
+ fields = line.split
107
+ next if fields.size < 10
108
+
109
+ name = fields[2]
110
+ next if name.match?(IO_NOT_A_DISK) || name.match?(IO_PARTITION)
111
+ next unless fields[5].match?(/\A\d+\z/) && fields[9].match?(/\A\d+\z/)
112
+
113
+ read += fields[5].to_i * IO_SECTOR
114
+ written += fields[9].to_i * IO_SECTOR
115
+ found = true
116
+ end
117
+ found ? [read, written] : nil
118
+ end
119
+
120
+ def net_counters(text)
121
+ rx = tx = 0
122
+ found = false
123
+ text.each_line do |line|
124
+ next unless line.include?(":")
125
+
126
+ name, rest = line.split(":", 2)
127
+ name = name.strip
128
+ next if name.empty? || name.match?(NET_NOT_REAL)
129
+
130
+ fields = rest.split
131
+ next if fields.size < 9 || !fields[0].match?(/\A\d+\z/) || !fields[8].match?(/\A\d+\z/)
132
+
133
+ rx += fields[0].to_i
134
+ tx += fields[8].to_i
135
+ found = true
136
+ end
137
+ found ? [rx, tx] : nil
138
+ end
139
+
140
+ def rate_mbs(before, after, seconds)
141
+ return nil if before.nil? || after.nil?
142
+ return nil if seconds <= 0 || seconds > IO_MAX_PAIR_SECONDS
143
+
144
+ moved = after - before
145
+ return nil if moved.negative?
146
+
147
+ (moved.to_f / seconds / MB).round(1)
148
+ end
149
+
150
+ def cpu_percent(before, after)
151
+ return nil if before.nil? || after.nil?
152
+
153
+ busy = after[0] - before[0]
154
+ total = after[1] - before[1]
155
+ return nil if total <= 0 || busy.negative?
156
+
157
+ [100.0, 100.0 * busy / total].min.round(1)
158
+ end
159
+
160
+ # Used is total - MemAvailable, never total - MemFree: page cache is not pressure.
161
+ def memory_mb(text)
162
+ values = {}
163
+ text.each_line do |line|
164
+ name, _, rest = line.partition(":")
165
+ first = rest.split.first
166
+ values[name] = first.to_i / 1024 if first&.match?(/\A\d+\z/)
167
+ end
168
+ out = {}
169
+ if values.key?("MemTotal")
170
+ out["mem_total_mb"] = values["MemTotal"]
171
+ out["mem_used_mb"] = [0, values["MemTotal"] - values["MemAvailable"]].max if values.key?("MemAvailable")
172
+ end
173
+ if values.key?("SwapTotal")
174
+ out["swap_total_mb"] = values["SwapTotal"]
175
+ out["swap_used_mb"] = [0, values["SwapTotal"] - values["SwapFree"]].max if values.key?("SwapFree")
176
+ end
177
+ out
178
+ end
179
+
180
+ # The real disks behind /proc/mounts, one per device under its shortest
181
+ # mount point, so a bind mount isn't reported as a second disk; a mount
182
+ # that isn't a directory (Docker bind-mounts /etc/hosts) doesn't win.
183
+ # Off Linux, the one filesystem we can name without guessing: "/".
184
+ def mount_points(mounts_text, is_dir = nil)
185
+ return ["/"] if mounts_text.to_s.empty?
186
+
187
+ is_dir ||= ->(path) { File.directory?(path) }
188
+ dirs = {}
189
+ any = {}
190
+ mounts_text.each_line do |line|
191
+ fields = line.split
192
+ next if fields.size < 3
193
+
194
+ device = fields[0]
195
+ mount = fields[1].gsub("\\040", " ")
196
+ next unless FSTYPES.include?(fields[2]) && mount.start_with?("/")
197
+
198
+ any[device] = mount if !any.key?(device) || mount.size < any[device].size
199
+ confirmed = begin
200
+ is_dir.call(mount)
201
+ rescue StandardError
202
+ false
203
+ end
204
+ dirs[device] = mount if confirmed && (!dirs.key?(device) || mount.size < dirs[device].size)
205
+ end
206
+ any.map { |device, mount| dirs[device] || mount }.uniq.sort
207
+ end
208
+
209
+ # df's POSIX output: "<fs> <total> <used> <available> <capacity> <mount>",
210
+ # in 1024-byte blocks (-k) or inodes (-i), by mount point.
211
+ def parse_df(text)
212
+ out = {}
213
+ text.to_s.each_line.drop(1).each do |line|
214
+ m = /\A\S+\s+(\d+)\s+(\d+)\s+(\d+)\s+\S+\s+(.+?)\s*\z/.match(line)
215
+ out[m[4]] = [m[1].to_i, m[2].to_i, m[3].to_i] if m
216
+ end
217
+ out
218
+ end
219
+
220
+ def run(argv, timeout = DF_TIMEOUT)
221
+ reader, writer = IO.pipe
222
+ pid = Process.spawn(*argv, out: writer, err: File::NULL, in: File::NULL)
223
+ writer.close
224
+ output = +""
225
+ deadline = SiteGoose.clock + timeout
226
+ loop do
227
+ left = deadline - SiteGoose.clock
228
+ break if left <= 0 || !reader.wait_readable(left)
229
+
230
+ chunk = reader.read_nonblock(65_536, exception: false)
231
+ break if chunk.nil?
232
+
233
+ output << chunk unless chunk == :wait_readable
234
+ end
235
+ begin
236
+ Process.kill("KILL", pid) if Process.waitpid(pid, Process::WNOHANG).nil?
237
+ rescue SystemCallError
238
+ nil
239
+ end
240
+ begin
241
+ Process.waitpid(pid)
242
+ rescue SystemCallError
243
+ nil
244
+ end
245
+ output
246
+ rescue StandardError
247
+ ""
248
+ ensure
249
+ reader&.close unless reader&.closed?
250
+ writer&.close unless writer&.closed?
251
+ end
252
+
253
+ def df(points)
254
+ return [{}, {}] if points.empty?
255
+
256
+ [parse_df(run(["df", "-kP", *points])), parse_df(run(["df", "-iP", *points]))]
257
+ end
258
+
259
+ # Both figures are df's own, so a customer can check them. One
260
+ # filesystem behind two devices (a container's overlay and the host disk
261
+ # bind-mounted into it) reports the same sizes, so a repeat is dropped.
262
+ def disk_rows(points, blocks = nil, inodes = nil)
263
+ blocks, inodes = df(points) if blocks.nil?
264
+ seen = {}
265
+ rows = []
266
+ points.each do |mount|
267
+ figures = blocks[mount]
268
+ next unless figures
269
+
270
+ _, used_kb, avail_kb = figures
271
+ used = used_kb * 1024
272
+ total = used + avail_kb * 1024
273
+ next if total.zero?
274
+
275
+ row = { "mount" => mount, "total_mb" => total / MB, "used_mb" => used / MB }
276
+ next if row["total_mb"] < MIN_DISK_MB
277
+ next if seen[[total, used]]
278
+
279
+ seen[[total, used]] = true
280
+ # A filesystem can refuse a write with gigabytes free once these meet.
281
+ files = inodes && inodes[mount]
282
+ if files && files[0].positive?
283
+ row["inodes_total"] = files[0]
284
+ row["inodes_used"] = files[1]
285
+ end
286
+ rows << row
287
+ break if rows.size >= MAX_DISKS
288
+ end
289
+ rows
290
+ end
291
+
292
+ def host_uptime(text)
293
+ first = text.to_s.split.first
294
+ first&.match?(/\A\d+(\.\d+)?\z/) ? first.to_f.to_i : nil
295
+ end
296
+
297
+ def load_average(text)
298
+ parts = text.to_s.split
299
+ return {} if parts.size < 3 || !parts.first(3).all? { |p| p.match?(/\A\d+(\.\d+)?\z/) }
300
+
301
+ { "load1" => parts[0].to_f.round(2), "load5" => parts[1].to_f.round(2), "load15" => parts[2].to_f.round(2) }
302
+ end
303
+
304
+ # This package runs inside the application, so unlike the agent it can
305
+ # honestly report the application's own memory.
306
+ def process_rss_mb(text)
307
+ text.each_line do |line|
308
+ next unless line.start_with?("VmRSS:")
309
+
310
+ kb = line.split[1]
311
+ return kb&.match?(/\A\d+\z/) ? [1, kb.to_i / 1024].max : nil
312
+ end
313
+ nil
314
+ end
315
+ end
316
+
317
+ attr_reader :rows, :every, :enabled
318
+
319
+ # Options: server_name (the server's name as the dashboard shows it).
320
+ def initialize(core, server_name: nil, **_)
321
+ @core = core
322
+ @server_name = SiteGoose.text(server_name.nil? ? SiteGoose.hostname : server_name, 100)
323
+ # minute (ISO) -> row, so a minute sampled twice is kept once.
324
+ @rows = {}
325
+ @lock = Mutex.new
326
+ @every = EVERY
327
+ @enabled = true
328
+ @last_cpu = Resources.cpu_fields(Resources.read_text("/proc/stat"))
329
+ @last_disk = Resources.disk_counters(Resources.read_text("/proc/diskstats"))
330
+ @last_net = Resources.net_counters(Resources.read_text("/proc/net/dev"))
331
+ @last_at = Time.now.to_f
332
+ @sampler = core.valid? ? Periodic.new(SAMPLE_EVERY) { sample; SAMPLE_EVERY } : nil
333
+ @job = core.valid? ? Periodic.new(EVERY) { report } : nil
334
+ Forking.register(self)
335
+ end
336
+
337
+ def after_fork_in_child
338
+ @lock = Mutex.new
339
+ @rows = {}
340
+ end
341
+
342
+ # Takes one sample, covering the time since the last one.
343
+ def sample(at = nil)
344
+ at ||= Time.now.to_f
345
+ cpu = Resources.cpu_fields(Resources.read_text("/proc/stat"))
346
+ disk = Resources.disk_counters(Resources.read_text("/proc/diskstats"))
347
+ net = Resources.net_counters(Resources.read_text("/proc/net/dev"))
348
+ percent = Resources.cpu_percent(@last_cpu && Resources.cpu_times_of(@last_cpu), cpu && Resources.cpu_times_of(cpu))
349
+ elapsed = at - @last_at
350
+ # The minute the interval started in, not the one it ended in.
351
+ row = { "minute" => Time.at((@last_at.to_i / 60) * 60).utc.iso8601(3) }
352
+ shares = Resources.cpu_shares(@last_cpu, cpu)
353
+ rates = {}
354
+ if @last_disk && disk
355
+ rates["disk_read_mbs"] = Resources.rate_mbs(@last_disk[0], disk[0], elapsed)
356
+ rates["disk_write_mbs"] = Resources.rate_mbs(@last_disk[1], disk[1], elapsed)
357
+ end
358
+ if @last_net && net
359
+ rates["net_rx_mbs"] = Resources.rate_mbs(@last_net[0], net[0], elapsed)
360
+ rates["net_tx_mbs"] = Resources.rate_mbs(@last_net[1], net[1], elapsed)
361
+ end
362
+ @last_cpu = cpu
363
+ @last_disk = disk
364
+ @last_net = net
365
+ @last_at = at
366
+ row.merge!(shares)
367
+ rates.each { |name, value| row[name] = value unless value.nil? }
368
+ row["cpu_pct"] = percent if percent && elapsed >= MIN_CPU_SECONDS
369
+ cores = begin
370
+ Etc.nprocessors
371
+ rescue StandardError
372
+ nil
373
+ end
374
+ row["cpu_cores"] = cores if cores&.positive?
375
+ row.merge!(Resources.load_average(Resources.read_text("/proc/loadavg")))
376
+ row.merge!(Resources.memory_mb(Resources.read_text("/proc/meminfo")))
377
+ disks = Resources.disk_rows(Resources.mount_points(Resources.read_text("/proc/mounts")))
378
+ row["disks"] = disks unless disks.empty?
379
+ rss = Resources.process_rss_mb(Resources.read_text("/proc/self/status"))
380
+ row["proc_rss_mb"] = rss if rss
381
+ uptime = Resources.host_uptime(Resources.read_text("/proc/uptime"))
382
+ row["host_uptime_s"] = uptime if uptime
383
+ @lock.synchronize do
384
+ # Off in the dashboard: only the newest minute is kept, as the one row
385
+ # that asks the collector, now and then, whether it is back on.
386
+ @rows.clear unless @enabled
387
+ @rows[row["minute"]] = row
388
+ # Oldest first out: a long outage keeps the recent minutes.
389
+ @rows.delete(@rows.keys.min) while @rows.size > MAX_ROWS
390
+ end
391
+ row
392
+ end
393
+
394
+ # Sends what's buffered. Rows are kept only when the collector couldn't
395
+ # take them and retrying may work.
396
+ def flush
397
+ minutes, rows = @lock.synchronize do
398
+ keys = @rows.keys.sort
399
+ [keys, keys.map { |m| @rows[m] }]
400
+ end
401
+ return true if rows.empty?
402
+
403
+ body = { "site_id" => @core.site_id, "server_name" => @server_name, "runtime" => RUNTIME[0, 32], "rows" => rows }
404
+ res = @core.request("resources", "/events/resources", auth: :bearer, body: body)
405
+ return false if res.nil?
406
+
407
+ unless res.ok?
408
+ # Unreachable or storage down: these rows are still good. A 4xx means
409
+ # resending won't make them acceptable.
410
+ return false if res.status >= 500
411
+
412
+ drop(minutes)
413
+ return true
414
+ end
415
+ answer = res.json
416
+ if answer.is_a?(Hash)
417
+ @every = [RETRY_EVERY, answer["every"].to_i].max if answer["every"].is_a?(Numeric) && answer["every"].positive?
418
+ @enabled = answer["enabled"] != false
419
+ end
420
+ drop(minutes)
421
+ true
422
+ end
423
+
424
+ def drop(minutes)
425
+ @lock.synchronize { minutes.each { |m| @rows.delete(m) } }
426
+ end
427
+
428
+ def report
429
+ unless @core.on?("resources")
430
+ # Switched off: keep nothing, and ask again later.
431
+ @lock.synchronize { @rows.clear }
432
+ return [OFF_EVERY, @every].max
433
+ end
434
+ sent = flush
435
+ # The collector said the module is off: ask again later, as the agent does.
436
+ return [OFF_EVERY, @every].max unless @enabled
437
+
438
+ sent ? @every : RETRY_EVERY
439
+ end
440
+
441
+ def close
442
+ @sampler&.stop
443
+ @job&.stop
444
+ Forking.unregister(self)
445
+ # What's buffered goes out, as with every other module.
446
+ flush if @core.on?("resources") && @enabled
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sidekiq: every job runs as a platform.job() named after its class (the
4
+ # ActiveJob class when Sidekiq runs ActiveJob). Its errors carry
5
+ # "/(job) <class>" as their route, and each run is timed (Health -> Server
6
+ # speed -> Jobs).
7
+ #
8
+ # # config/initializers/sidekiq.rb
9
+ # require "sitegoose/sidekiq"
10
+ # SiteGoose::Sidekiq.install
11
+ #
12
+ # A failure that will be retried is timed as failed but not sent: the error
13
+ # is sent when the last attempt fails (retry: false, retry: 0, or the retries
14
+ # used up), or when Sidekiq gives the job up (its death handlers: a job sent
15
+ # to the Dead set, sidekiq_retry_in returning :kill). Sidekiq stopping a job
16
+ # mid-run (Sidekiq::Shutdown) is neither timed nor sent: it runs again. What's
17
+ # waiting is sent when Sidekiq shuts down.
18
+
19
+ module SiteGoose
20
+ module Sidekiq
21
+ SKIP = :@__sitegoose_skip
22
+ DEFAULT_RETRIES = 25
23
+
24
+ module_function
25
+
26
+ # Adds the server middleware, the death handler and the shutdown flush.
27
+ # platform: default the configured one (SiteGoose.platform).
28
+ def install(platform = nil)
29
+ ::Sidekiq.configure_server do |config|
30
+ config.server_middleware { |chain| chain.add ServerMiddleware, platform }
31
+ config.death_handlers << ->(job, error) { Sidekiq.died(platform, job, error) } if config.respond_to?(:death_handlers)
32
+ config.on(:shutdown) { SiteGoose.within { (platform || SiteGoose.platform)&.flush } } if config.respond_to?(:on)
33
+ end
34
+ end
35
+
36
+ def name_of(job)
37
+ (job["wrapped"] || job["class"]).to_s
38
+ end
39
+
40
+ def max_retries
41
+ config = if ::Sidekiq.respond_to?(:default_configuration) then ::Sidekiq.default_configuration
42
+ elsif ::Sidekiq.respond_to?(:options) then ::Sidekiq.options
43
+ end
44
+ value = config && config[:max_retries]
45
+ value.is_a?(Integer) ? value : DEFAULT_RETRIES
46
+ rescue StandardError
47
+ DEFAULT_RETRIES
48
+ end
49
+
50
+ # Whether this run is the job's last: no retries, or the retries used up.
51
+ # retry_count is nil on the first run and 0 on the first retry.
52
+ def last_attempt?(job)
53
+ retry_option = job["retry"]
54
+ return true if retry_option == false || retry_option.nil?
55
+
56
+ allowed = retry_option == true ? max_retries : retry_option.to_i
57
+ attempts = job["retry_count"].nil? ? 0 : job["retry_count"].to_i + 1
58
+ attempts >= allowed
59
+ end
60
+
61
+ def died(platform, job, error)
62
+ made = platform || SiteGoose.platform
63
+ return unless made && error.is_a?(Exception)
64
+
65
+ error.remove_instance_variable(SKIP) if error.instance_variable_defined?(SKIP)
66
+ route = "/(job) #{SiteGoose.text(name_of(job), 180)}"
67
+ made.capture_exception(error, status: 500, kind: "error",
68
+ request: { "method" => "JOB", "path" => route, "route" => route, "session_id" => "" })
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ class ServerMiddleware
74
+ include ::Sidekiq::ServerMiddleware if defined?(::Sidekiq::ServerMiddleware)
75
+
76
+ def initialize(platform = nil)
77
+ @platform = platform
78
+ end
79
+
80
+ def call(_worker, job, _queue, &block)
81
+ made = @platform || SiteGoose.platform
82
+ return yield if made.nil?
83
+
84
+ Forking.check
85
+ last = Sidekiq.last_attempt?(job)
86
+ made.job(Sidekiq.name_of(job), report: lambda { |error|
87
+ # Retried: Rails' own error report (its executor wraps the job) is
88
+ # told to leave it too.
89
+ error.instance_variable_set(SKIP, true) if !last && !error.frozen?
90
+ last
91
+ }, &block)
92
+ end
93
+ end
94
+ end
95
+ end