visionary-sdk 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +202 -0
  4. data/README.md +198 -0
  5. data/lib/visionary/agent.rb +174 -0
  6. data/lib/visionary/body.rb +162 -0
  7. data/lib/visionary/config.rb +151 -0
  8. data/lib/visionary/context.rb +97 -0
  9. data/lib/visionary/fork.rb +16 -0
  10. data/lib/visionary/middleware.rb +169 -0
  11. data/lib/visionary/queue.rb +57 -0
  12. data/lib/visionary/railtie.rb +48 -0
  13. data/lib/visionary/redactor.rb +94 -0
  14. data/lib/visionary/retention.rb +90 -0
  15. data/lib/visionary/schema/mysql/requests.sql +37 -0
  16. data/lib/visionary/schema/mysql/system.sql +76 -0
  17. data/lib/visionary/schema/mysql/telemetry.sql +52 -0
  18. data/lib/visionary/schema/postgres/requests.sql +37 -0
  19. data/lib/visionary/schema/postgres/system.sql +76 -0
  20. data/lib/visionary/schema/postgres/telemetry.sql +52 -0
  21. data/lib/visionary/schema/sqlite/requests.sql +37 -0
  22. data/lib/visionary/schema/sqlite/system.sql +76 -0
  23. data/lib/visionary/schema/sqlite/telemetry.sql +52 -0
  24. data/lib/visionary/schema.rb +163 -0
  25. data/lib/visionary/schemas.rb +64 -0
  26. data/lib/visionary/store/active_record.rb +103 -0
  27. data/lib/visionary/store/d1.rb +129 -0
  28. data/lib/visionary/store/sql.rb +272 -0
  29. data/lib/visionary/store.rb +53 -0
  30. data/lib/visionary/system/recorder.rb +353 -0
  31. data/lib/visionary/system/sources.rb +191 -0
  32. data/lib/visionary/telemetry/errors.rb +118 -0
  33. data/lib/visionary/telemetry/hooks.rb +115 -0
  34. data/lib/visionary/telemetry/sql_text.rb +44 -0
  35. data/lib/visionary/telemetry.rb +116 -0
  36. data/lib/visionary/version.rb +5 -0
  37. data/lib/visionary/writer.rb +185 -0
  38. data/lib/visionary.rb +157 -0
  39. metadata +90 -0
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Visionary
6
+ module Store
7
+ class ConfigError < StandardError; end
8
+
9
+ # A connection the SDK opens itself from VISIONARY_DATABASE_URL, through
10
+ # the pg, mysql2 or sqlite3 gem (whichever the app has installed). One
11
+ # connection, used by one background thread at a time, reopened after an
12
+ # error.
13
+ class SQL
14
+ attr_reader :dialect
15
+
16
+ def initialize(dialect, &connect)
17
+ @dialect = dialect
18
+ @connect = connect
19
+ @mutex = Mutex.new
20
+ @conn = nil
21
+ end
22
+
23
+ def string_params? = false
24
+
25
+ def exec_script(statements)
26
+ statements.each do |s|
27
+ run { |c| c.execute(s, []) }
28
+ rescue StandardError => e
29
+ next if dialect == "mysql" && Store.duplicate_index?(e)
30
+ raise
31
+ end
32
+ end
33
+
34
+ def exec(sql)
35
+ run { |c| c.execute(sql, []) }
36
+ end
37
+
38
+ def column_names(sql)
39
+ run { |c| c.column(sql) }
40
+ end
41
+
42
+ # One transaction for the whole batch.
43
+ def batch(stmts)
44
+ run do |c|
45
+ c.execute("BEGIN", [])
46
+ begin
47
+ stmts.each { |sql, args| c.execute(sql, args) }
48
+ c.execute("COMMIT", [])
49
+ rescue StandardError
50
+ begin
51
+ c.execute("ROLLBACK", [])
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ raise
56
+ end
57
+ end
58
+ end
59
+
60
+ def changes(sql, args)
61
+ run { |c| c.execute(sql, args) }
62
+ end
63
+
64
+ # The parent's connection (a socket) must not be used or closed from
65
+ # the child: it is kept referenced (so no finalizer closes it) and a new
66
+ # one is opened lazily.
67
+ def after_fork
68
+ @mutex = Mutex.new
69
+ (@inherited ||= []) << @conn if @conn
70
+ @conn = nil
71
+ end
72
+
73
+ def close
74
+ @mutex.synchronize { discard }
75
+ end
76
+
77
+ private
78
+
79
+ def run
80
+ @mutex.synchronize do
81
+ @conn ||= @connect.call
82
+ yield @conn
83
+ rescue StandardError
84
+ discard if @conn&.broken?
85
+ raise
86
+ end
87
+ end
88
+
89
+ def discard
90
+ @conn&.close
91
+ rescue StandardError
92
+ nil
93
+ ensure
94
+ @conn = nil
95
+ end
96
+ end
97
+
98
+ # MySQL has no CREATE INDEX IF NOT EXISTS; an existing index is fine.
99
+ def self.duplicate_index?(error)
100
+ (error.respond_to?(:error_number) && error.error_number == 1061) ||
101
+ error.message.match?(/1061|Duplicate key name/i)
102
+ end
103
+
104
+ # Turns "?" into "$1, $2, ..." for PostgreSQL, leaving quoted text alone.
105
+ def self.rebind(sql)
106
+ return sql unless sql.include?("?")
107
+ n = 0
108
+ quote = nil
109
+ out = +""
110
+ sql.each_char do |ch|
111
+ if quote
112
+ quote = nil if ch == quote
113
+ elsif ch == "'" || ch == '"'
114
+ quote = ch
115
+ elsif ch == "?"
116
+ n += 1
117
+ out << "$" << n.to_s
118
+ next
119
+ end
120
+ out << ch
121
+ end
122
+ out
123
+ end
124
+
125
+ # Opens the store for a database URL: postgres://, postgresql://,
126
+ # mysql://, sqlite:///absolute/path.db or sqlite://relative.db.
127
+ def self.open_url(raw)
128
+ u = URI.parse(raw)
129
+ case u.scheme.to_s.downcase
130
+ when "postgres", "postgresql"
131
+ driver("pg", "postgres")
132
+ SQL.new("postgres") { PgConn.new(PG.connect(raw).tap { |c| c.set_notice_processor { nil } }) }
133
+ when "mysql", "mysql2"
134
+ driver("mysql2", "mysql")
135
+ SQL.new("mysql") { Mysql2Conn.new(Mysql2Conn.client(u)) }
136
+ when "sqlite", "sqlite3"
137
+ driver("sqlite3", "sqlite")
138
+ path = sqlite_path(raw, u.scheme)
139
+ SQL.new("sqlite") { Sqlite3Conn.new(path) }
140
+ else
141
+ raise ConfigError, "visionary: unsupported database URL scheme #{u.scheme.inspect}"
142
+ end
143
+ rescue URI::InvalidURIError => e
144
+ raise ConfigError, "visionary: database URL: #{e.message}"
145
+ end
146
+
147
+ def self.sqlite_path(raw, scheme)
148
+ rest = raw.sub(%r{\A#{Regexp.escape(scheme)}://}i, "").sub(%r{\A/}, "")
149
+ rest = "/#{rest}" if raw.match?(%r{\A#{Regexp.escape(scheme)}:///}i) && !rest.include?(":")
150
+ URI.decode_www_form_component(rest.sub(/\?.*\z/, ""))
151
+ end
152
+
153
+ def self.driver(gem_name, dialect)
154
+ require gem_name
155
+ rescue LoadError
156
+ raise ConfigError, "visionary: #{dialect} needs the #{gem_name} gem (add it to your Gemfile)"
157
+ end
158
+
159
+ # Thin driver adapters: execute returns affected rows, column returns the
160
+ # first column of every row.
161
+ class PgConn
162
+ def initialize(conn)
163
+ @conn = conn
164
+ end
165
+
166
+ def execute(sql, args)
167
+ res = @conn.exec_params(Store.rebind(sql), args.map { |a| pg_param(a) })
168
+ res.cmd_tuples
169
+ ensure
170
+ res&.clear
171
+ end
172
+
173
+ def column(sql)
174
+ res = @conn.exec(sql)
175
+ res.column_values(0).map(&:to_s)
176
+ ensure
177
+ res&.clear
178
+ end
179
+
180
+ def broken?
181
+ @conn.finished? || @conn.status != PG::CONNECTION_OK
182
+ rescue StandardError
183
+ true
184
+ end
185
+
186
+ def close
187
+ @conn.close unless @conn.finished?
188
+ end
189
+
190
+ private
191
+
192
+ # Text params; PostgreSQL types them from the target column. NUL is
193
+ # the one character text columns reject.
194
+ def pg_param(v)
195
+ case v
196
+ when nil then nil
197
+ when Float then D1.param(v)
198
+ when String then v.include?("\0") ? v.delete("\0") : v
199
+ else v.to_s
200
+ end
201
+ end
202
+ end
203
+
204
+ class Mysql2Conn
205
+ def self.client(u)
206
+ q = URI.decode_www_form(u.query.to_s).to_h
207
+ Mysql2::Client.new(
208
+ host: u.host || "localhost", port: u.port || 3306,
209
+ username: u.user && URI.decode_www_form_component(u.user),
210
+ password: u.password && URI.decode_www_form_component(u.password),
211
+ database: u.path.to_s.delete_prefix("/"),
212
+ encoding: q.fetch("encoding", "utf8mb4"),
213
+ connect_timeout: 10, read_timeout: 30, write_timeout: 30
214
+ )
215
+ end
216
+
217
+ def initialize(client)
218
+ @client = client
219
+ @stmts = {}
220
+ end
221
+
222
+ def execute(sql, args)
223
+ return @client.query(sql).then { @client.affected_rows } if args.empty?
224
+
225
+ stmt = (@stmts[sql] ||= @client.prepare(sql))
226
+ stmt.execute(*args)
227
+ stmt.affected_rows
228
+ end
229
+
230
+ def column(sql)
231
+ @client.query(sql, as: :array).map { |r| r[0].to_s }
232
+ end
233
+
234
+ def broken?
235
+ !@client.ping
236
+ rescue StandardError
237
+ true
238
+ end
239
+
240
+ def close
241
+ @stmts.each_value do |s|
242
+ s.close
243
+ rescue StandardError
244
+ nil
245
+ end
246
+ @client.close
247
+ end
248
+ end
249
+
250
+ class Sqlite3Conn
251
+ def initialize(path)
252
+ @db = SQLite3::Database.new(path)
253
+ @db.busy_timeout = 5000
254
+ end
255
+
256
+ def execute(sql, args)
257
+ @db.execute(sql, args)
258
+ @db.changes
259
+ end
260
+
261
+ def column(sql)
262
+ @db.execute(sql).map { |r| r[0].to_s }
263
+ end
264
+
265
+ def broken? = @db.closed?
266
+
267
+ def close
268
+ @db.close unless @db.closed?
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "store/sql"
4
+ require_relative "store/d1"
5
+ require_relative "store/active_record"
6
+
7
+ module Visionary
8
+ # Where rows go: D1 over REST, a connection opened from a database URL, or
9
+ # the app's ActiveRecord pool. Every store answers the same calls:
10
+ # exec_script, exec, column_names, batch([[sql, args], ...]), changes,
11
+ # after_fork and close.
12
+ module Store
13
+ # Picks the store from the config, or nil when none is configured.
14
+ def self.open(cfg)
15
+ return ActiveRecord.new(cfg.connection) if cfg.connection
16
+
17
+ url = cfg.database_url.to_s.strip
18
+ return open_url(url) unless url.empty? || url.downcase.start_with?("d1:")
19
+
20
+ creds = [cfg.d1_account_id, cfg.d1_database_id, cfg.d1_api_token].map { |v| v.to_s.strip }
21
+ return nil if creds.any?(&:empty?)
22
+
23
+ D1.new(account_id: creds[0], database_id: creds[1], api_token: creds[2], api_base: cfg.d1_api_base.to_s)
24
+ end
25
+
26
+ # Makes values acceptable to the backend: PostgreSQL text rejects NUL,
27
+ # and strict MySQL rejects values longer than the column.
28
+ def self.fit(dialect, table, values)
29
+ case dialect
30
+ when "postgres"
31
+ values.map { |v| v.is_a?(String) && v.include?("\0") ? v.delete("\0") : v }
32
+ when "mysql"
33
+ limits = Schema.mysql_limits[table] || {}
34
+ Schema::TABLES.fetch(table).each_with_index.map do |col, i|
35
+ v = values[i]
36
+ (lim = limits[col]) && v.is_a?(String) ? cut(v, *lim) : v
37
+ end
38
+ else
39
+ values
40
+ end
41
+ end
42
+
43
+ def self.cut(s, unit, max)
44
+ if unit == :chars
45
+ s.length > max ? s[0, max] : s
46
+ elsif s.bytesize > max
47
+ s.byteslice(0, max).scrub("")
48
+ else
49
+ s
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ module System
5
+ # Throughput seen by the middleware, read by the sampler.
6
+ class HttpCounters
7
+ def initialize
8
+ @mutex = Mutex.new
9
+ @requests = @errors = @in_flight = 0
10
+ end
11
+
12
+ def started
13
+ @mutex.synchronize { @in_flight += 1 }
14
+ end
15
+
16
+ def finished(status)
17
+ @mutex.synchronize do
18
+ @in_flight -= 1
19
+ @requests += 1
20
+ @errors += 1 if status >= 500
21
+ end
22
+ end
23
+
24
+ def snapshot = @mutex.synchronize { [@requests, @errors, @in_flight] }
25
+ end
26
+
27
+ # Samples host, process and runtime figures every interval on a
28
+ # background thread and writes them four at a time.
29
+ class Recorder
30
+ SAMPLES_PER_WRITE = 4
31
+ MB = 1024.0 * 1024
32
+
33
+ def initialize(cfg, instance, store, schemas, http, logger)
34
+ @cfg = cfg
35
+ @interval = cfg.system_interval.to_f
36
+ @instance = instance
37
+ @store = store
38
+ @schemas = schemas
39
+ @http = http
40
+ @logger = logger
41
+ @disk_path = cfg.disk_path.to_s.empty? ? Dir.pwd : cfg.disk_path
42
+ @sql = Schema.insert_sql("system_metrics", store.string_params?)
43
+ @started = mono
44
+ reset
45
+ end
46
+
47
+ attr_writer :http
48
+
49
+ def start
50
+ prime
51
+ @thread = Thread.new { run }
52
+ @thread.name = "visionary-system" if @thread.respond_to?(:name=)
53
+ self
54
+ end
55
+
56
+ def after_fork
57
+ reset
58
+ start
59
+ end
60
+
61
+ def close(timeout)
62
+ @mutex.synchronize do
63
+ @stopped = true
64
+ @cond.broadcast
65
+ end
66
+ @thread.nil? || !@thread.join(timeout).nil?
67
+ end
68
+
69
+ # One row in Schema::TABLES["system_metrics"] order. A source that
70
+ # fails or doesn't exist here is NULL, never an error.
71
+ def sample
72
+ now = mono
73
+ secs = now - @last
74
+ @last = now
75
+ secs = @interval if secs <= 0
76
+ v = { "created_at" => Visionary.timestamp(Time.now), "instance" => @instance, "interval_s" => secs.round(2) }
77
+ stat = Sources::LINUX ? Sources.proc_stat : {}
78
+ tcp = Sources::LINUX ? Sources.tcp : {}
79
+
80
+ host(v, stat, secs)
81
+ process(v)
82
+ runtime(v, secs)
83
+ db(v)
84
+ throughput(v, secs, now)
85
+ node_gauges(v, stat, tcp)
86
+ counters = read_counters(stat, tcp)
87
+ node_deltas(v, counters, @prev, secs)
88
+ @prev = counters
89
+ v["service"] = @cfg.service
90
+ v["version"] = @cfg.version
91
+ v["sdk"] = "ruby"
92
+ v["runtime"] = Telemetry.runtime
93
+ Schema::TABLES["system_metrics"].map { |c| v[c] }
94
+ end
95
+
96
+ private
97
+
98
+ def reset
99
+ @mutex = Mutex.new
100
+ @cond = ConditionVariable.new
101
+ @stopped = false
102
+ @thread = nil
103
+ @buf = []
104
+ end
105
+
106
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
+
108
+ # Baseline readings so the first sample has real deltas.
109
+ def prime
110
+ @last = mono
111
+ @requests, @errors, = @http.snapshot
112
+ stat = Sources::LINUX ? Sources.proc_stat : {}
113
+ @cpu_prev = stat[:cpu]
114
+ @net_prev = Sources.net_dev
115
+ @proc_cpu_prev = Sources.process_cpu_seconds
116
+ @proc_cpu_at = mono
117
+ heap = Sources.heap
118
+ @gc_count_prev = heap[:gc_count]
119
+ @gc_ms_prev = heap[:gc_ms]
120
+ @db_prev = db_stats || {}
121
+ @prev = read_counters(stat, Sources::LINUX ? Sources.tcp : {})
122
+ end
123
+
124
+ def run
125
+ Telemetry.internal!
126
+ @schemas.ensure(:system)
127
+ next_at = mono + @interval
128
+ loop do
129
+ break if wait_until(next_at)
130
+ next_at += @interval
131
+ next_at = mono + @interval if next_at < mono # fell behind (suspended host); don't burst
132
+ begin
133
+ @buf << sample
134
+ rescue StandardError => e
135
+ @logger.error("visionary: system sample failed: #{e.class}: #{e.message}")
136
+ end
137
+ flush if @buf.size >= SAMPLES_PER_WRITE
138
+ end
139
+ flush
140
+ rescue StandardError => e
141
+ @logger.error("visionary: system sampler stopped: #{e.message}")
142
+ end
143
+
144
+ # True once stopped.
145
+ def wait_until(at)
146
+ @mutex.synchronize do
147
+ until @stopped || (left = at - mono) <= 0
148
+ @cond.wait(@mutex, left)
149
+ end
150
+ @stopped
151
+ end
152
+ end
153
+
154
+ # Keeps the rows (at most an hour's worth) when the write fails.
155
+ def flush
156
+ return if @buf.empty?
157
+ @schemas.ensure(:system)
158
+ begin
159
+ @store.batch(@buf.map { |row| [@sql, Store.fit(@store.dialect, "system_metrics", row)] })
160
+ @buf.clear
161
+ rescue StandardError => e
162
+ @logger.warn("visionary: system metrics write failed (#{@buf.size} rows kept): #{e.message}")
163
+ keep = [1, (3600 / @interval).to_i].max
164
+ @buf.shift(@buf.size - keep) if @buf.size > keep
165
+ end
166
+ end
167
+
168
+ def host(v, stat, secs)
169
+ if (cpu = stat[:cpu]) && @cpu_prev
170
+ total = cpu_total(cpu) - cpu_total(@cpu_prev)
171
+ if total.positive?
172
+ idle = (cpu[:idle].to_f + cpu[:iowait].to_f) - (@cpu_prev[:idle].to_f + @cpu_prev[:iowait].to_f)
173
+ v["host_cpu_percent"] = (100.0 * (total - idle) / total).clamp(0, 100).round(1)
174
+ end
175
+ end
176
+ @cpu_prev = stat[:cpu]
177
+ if (load = Sources::LINUX && Sources.loadavg)
178
+ v["load_1"], v["load_5"], v["load_15"] = load.map { |x| x.round(2) }
179
+ end
180
+ mem = Sources::LINUX ? Sources.meminfo : {}
181
+ if (total = mem["MemTotal"]).to_i.positive?
182
+ cached = mem["Cached"].to_i + mem["SReclaimable"].to_i
183
+ # psutil's definition: used is what isn't available.
184
+ available = mem["MemAvailable"] || (mem["MemFree"].to_i + mem["Buffers"].to_i + cached)
185
+ used = total - available
186
+ v["host_mem_used_mb"] = (used / MB).round(1)
187
+ v["host_mem_total_mb"] = (total / MB).round(1)
188
+ v["host_mem_percent"] = (100.0 * used / total).round(1)
189
+ v["host_mem_available_mb"] = (mem["MemAvailable"] / MB).round(1) if mem["MemAvailable"]
190
+ v["host_mem_cached_mb"] = (cached / MB).round(1) if cached.positive?
191
+ if mem["SwapTotal"]
192
+ v["swap_total_mb"] = (mem["SwapTotal"] / MB).round(1)
193
+ v["swap_used_mb"] = ((mem["SwapTotal"] - mem["SwapFree"].to_i) / MB).round(1)
194
+ end
195
+ end
196
+ if (du = Sources.disk(@disk_path)) && du[:total].positive?
197
+ v["disk_used_percent"] = (100.0 * du[:used] / (du[:used] + du[:free])).round(1)
198
+ v["disk_free_gb"] = (du[:free] / (MB * 1024)).round(2)
199
+ v["disk_total_gb"] = (du[:total] / (MB * 1024)).round(2)
200
+ v["disk_inodes_percent"] = du[:inodes_percent].round(1) if du[:inodes_percent]
201
+ end
202
+ net = Sources.net_dev
203
+ if net && @net_prev && net[0] >= @net_prev[0] && net[1] >= @net_prev[1]
204
+ v["net_rx_kbps"] = ((net[0] - @net_prev[0]) / 1024.0 / secs).round(1)
205
+ v["net_tx_kbps"] = ((net[1] - @net_prev[1]) / 1024.0 / secs).round(1)
206
+ end
207
+ @net_prev = net
208
+ end
209
+
210
+ def process(v)
211
+ cpu = Sources.process_cpu_seconds
212
+ # Timed on its own: under GVL contention the readers before this can
213
+ # take seconds, which would skew a share of the sample's interval.
214
+ at = mono
215
+ v["proc_cpu_percent"] = (100.0 * (cpu - @proc_cpu_prev) / (at - @proc_cpu_at)).round(1) if cpu && @proc_cpu_prev && at > @proc_cpu_at
216
+ @proc_cpu_prev = cpu
217
+ @proc_cpu_at = at
218
+ st = Sources::LINUX ? Sources.self_status : {}
219
+ v["proc_rss_mb"] = (st[:rss] / MB).round(1) if st[:rss]
220
+ v["proc_threads"] = st[:threads] || Thread.list.size
221
+ v["proc_open_fds"] = Sources.open_fds
222
+ end
223
+
224
+ def runtime(v, secs)
225
+ v["goroutines"] = Thread.list.count(&:alive?)
226
+ heap = Sources.heap
227
+ v["heap_alloc_mb"] = (heap[:alloc_bytes] / MB).round(1) if heap[:alloc_bytes]
228
+ v["heap_inuse_mb"] = (heap[:inuse_bytes] / MB).round(1) if heap[:inuse_bytes]
229
+ v["heap_objects"] = heap[:live]
230
+ v["gc_count"] = heap[:gc_count] - @gc_count_prev
231
+ if heap[:gc_ms] && @gc_ms_prev
232
+ ms = (heap[:gc_ms] - @gc_ms_prev).to_f
233
+ v["gc_pause_ms"] = ms.round(2)
234
+ v["gc_cpu_percent"] = (ms / (secs * 1000.0) * 100.0).round(2)
235
+ end
236
+ @gc_count_prev = heap[:gc_count]
237
+ @gc_ms_prev = heap[:gc_ms]
238
+ end
239
+
240
+ def db_stats
241
+ st = @cfg.db_stats ? Sources.quiet { @cfg.db_stats.call } : Sources.active_record_pool
242
+ st.is_a?(Hash) ? st.transform_keys(&:to_sym) : nil
243
+ end
244
+
245
+ def db(v)
246
+ st = db_stats or return
247
+ { total: "db_total_conns", idle: "db_idle_conns", in_use: "db_acquired_conns", max: "db_max_conns" }.each do |k, col|
248
+ v[col] = st[k].to_i unless st[k].nil?
249
+ end
250
+ { acquires: "db_acquires", empty_acquires: "db_empty_acquires" }.each do |k, col|
251
+ v[col] = st[k].to_i - @db_prev[k].to_i unless st[k].nil?
252
+ end
253
+ v["db_acquire_wait_ms"] = (st[:acquire_wait_ms].to_f - @db_prev[:acquire_wait_ms].to_f).round(1) unless st[:acquire_wait_ms].nil?
254
+ @db_prev = st
255
+ end
256
+
257
+ def throughput(v, secs, now)
258
+ reqs, errs, in_flight = @http.snapshot
259
+ d_reqs = reqs - @requests
260
+ v["http_requests"] = d_reqs
261
+ v["http_errors"] = errs - @errors
262
+ v["http_rps"] = (d_reqs / secs).round(2)
263
+ v["http_in_flight"] = in_flight
264
+ @requests = reqs
265
+ @errors = errs
266
+ v["uptime_s"] = (now - @started).to_i
267
+ end
268
+
269
+ def node_gauges(v, stat, tcp)
270
+ v["cpu_count"] = Sources.cpu_count
271
+ v["host_procs"] = Sources.host_procs
272
+ v["procs_running"] = stat[:procs_running]
273
+ v["procs_blocked"] = stat[:procs_blocked]
274
+ v["tcp_established"] = tcp["CurrEstab"]
275
+ v["proc_max_fds"] = Sources.max_fds
276
+ end
277
+
278
+ # Cumulative readings; a key is present only when its source worked.
279
+ def read_counters(stat, tcp)
280
+ c = {}
281
+ c[:cpu] = stat[:cpu] if stat[:cpu]
282
+ if Sources::LINUX && !(disks = Sources.diskstats).empty?
283
+ c[:disk] = (0..4).map { |i| disks.values.sum { |d| d[i] } } << disks.transform_values { |d| d[5] }
284
+ end
285
+ if (net = Sources.net_dev)
286
+ c[:net] = net[2, 4]
287
+ end
288
+ c[:tcp_retrans] = tcp["RetransSegs"] if tcp["RetransSegs"]
289
+ c[:ctxt] = stat[:ctxt] if stat[:ctxt]
290
+ if Sources::LINUX && (io = Sources.self_io)
291
+ c[:proc_io] = io
292
+ end
293
+ c
294
+ end
295
+
296
+ def node_deltas(v, now, prev, secs)
297
+ if now[:cpu] && prev[:cpu]
298
+ a = now[:cpu]
299
+ b = prev[:cpu]
300
+ total = cpu_total(a) - cpu_total(b)
301
+ if total.positive?
302
+ pct = ->(*f) { (100.0 * f.sum { |k| a[k].to_f - b[k].to_f } / total).round(1) }
303
+ v["cpu_user_percent"] = pct.call(:user, :nice)
304
+ v["cpu_system_percent"] = pct.call(:system, :irq, :softirq)
305
+ v["cpu_iowait_percent"] = pct.call(:iowait)
306
+ v["cpu_steal_percent"] = pct.call(:steal)
307
+ end
308
+ end
309
+ if now[:disk] && prev[:disk]
310
+ rb, wb, rc, wc, tm, busy = now[:disk]
311
+ prb, pwb, prc, pwc, ptm, pbusy = prev[:disk]
312
+ if rb >= prb && wb >= pwb
313
+ reads = rc - prc
314
+ writes = wc - pwc
315
+ v["disk_read_kbps"] = ((rb - prb) / 1024.0 / secs).round(1)
316
+ v["disk_write_kbps"] = ((wb - pwb) / 1024.0 / secs).round(1)
317
+ v["disk_read_iops"] = (reads / secs).round(1)
318
+ v["disk_write_iops"] = (writes / secs).round(1)
319
+ v["disk_await_ms"] = ((tm - ptm).to_f / (reads + writes)).round(2) if (reads + writes).positive? && tm >= ptm
320
+ busiest = busy.filter_map { |n, t| pbusy[n] && t >= pbusy[n] ? t - pbusy[n] : nil }.max || 0
321
+ v["disk_busy_percent"] = [100.0, 100.0 * busiest / (secs * 1000.0)].min.round(1) unless busy.empty?
322
+ end
323
+ end
324
+ if now[:net] && prev[:net]
325
+ rx, tx, errs, drops = now[:net]
326
+ prx, ptx, perrs, pdrops = prev[:net]
327
+ if rx >= prx && tx >= ptx
328
+ v["net_rx_pps"] = ((rx - prx) / secs).round(1)
329
+ v["net_tx_pps"] = ((tx - ptx) / secs).round(1)
330
+ v["net_errors"] = errs - perrs if errs >= perrs
331
+ v["net_drops"] = drops - pdrops if drops >= pdrops
332
+ end
333
+ end
334
+ if now[:tcp_retrans] && prev[:tcp_retrans] && now[:tcp_retrans] >= prev[:tcp_retrans]
335
+ v["tcp_retrans"] = now[:tcp_retrans] - prev[:tcp_retrans]
336
+ end
337
+ if now[:ctxt] && prev[:ctxt] && now[:ctxt] >= prev[:ctxt]
338
+ v["ctx_switches_ps"] = ((now[:ctxt] - prev[:ctxt]) / secs).round(1)
339
+ end
340
+ return unless now[:proc_io] && prev[:proc_io]
341
+ (r, w), (pr, pw) = now[:proc_io], prev[:proc_io]
342
+ return unless r >= pr && w >= pw
343
+ v["proc_read_kbps"] = ((r - pr) / 1024.0 / secs).round(1)
344
+ v["proc_write_kbps"] = ((w - pw) / 1024.0 / secs).round(1)
345
+ end
346
+
347
+ # Linux counts guest time inside user/nice already.
348
+ def cpu_total(t)
349
+ t.values.sum - t[:guest].to_f - t[:guest_nice].to_f
350
+ end
351
+ end
352
+ end
353
+ end