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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -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
|