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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +202 -0
- data/README.md +198 -0
- data/lib/visionary/agent.rb +174 -0
- data/lib/visionary/body.rb +162 -0
- data/lib/visionary/config.rb +151 -0
- data/lib/visionary/context.rb +97 -0
- data/lib/visionary/fork.rb +16 -0
- data/lib/visionary/middleware.rb +169 -0
- data/lib/visionary/queue.rb +57 -0
- data/lib/visionary/railtie.rb +48 -0
- data/lib/visionary/redactor.rb +94 -0
- data/lib/visionary/retention.rb +90 -0
- data/lib/visionary/schema/mysql/requests.sql +37 -0
- data/lib/visionary/schema/mysql/system.sql +76 -0
- data/lib/visionary/schema/mysql/telemetry.sql +52 -0
- data/lib/visionary/schema/postgres/requests.sql +37 -0
- data/lib/visionary/schema/postgres/system.sql +76 -0
- data/lib/visionary/schema/postgres/telemetry.sql +52 -0
- data/lib/visionary/schema/sqlite/requests.sql +37 -0
- data/lib/visionary/schema/sqlite/system.sql +76 -0
- data/lib/visionary/schema/sqlite/telemetry.sql +52 -0
- data/lib/visionary/schema.rb +163 -0
- data/lib/visionary/schemas.rb +64 -0
- data/lib/visionary/store/active_record.rb +103 -0
- data/lib/visionary/store/d1.rb +129 -0
- data/lib/visionary/store/sql.rb +272 -0
- data/lib/visionary/store.rb +53 -0
- data/lib/visionary/system/recorder.rb +353 -0
- data/lib/visionary/system/sources.rb +191 -0
- data/lib/visionary/telemetry/errors.rb +118 -0
- data/lib/visionary/telemetry/hooks.rb +115 -0
- data/lib/visionary/telemetry/sql_text.rb +44 -0
- data/lib/visionary/telemetry.rb +116 -0
- data/lib/visionary/version.rb +5 -0
- data/lib/visionary/writer.rb +185 -0
- data/lib/visionary.rb +157 -0
- 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
|