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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Visionary
|
|
6
|
+
module System
|
|
7
|
+
# Readers for host and process figures. Linux has /proc; elsewhere most
|
|
8
|
+
# host figures are unavailable and stay NULL. Every reader returns nil
|
|
9
|
+
# (or an empty Hash) when its source fails.
|
|
10
|
+
module Sources
|
|
11
|
+
LINUX = RUBY_PLATFORM.include?("linux")
|
|
12
|
+
WINDOWS = Gem.win_platform?
|
|
13
|
+
CPU_FIELDS = %i[user nice system idle iowait irq softirq steal guest guest_nice].freeze
|
|
14
|
+
# Partitions and virtual devices whose I/O is already counted in a
|
|
15
|
+
# whole disk, so totals are not doubled.
|
|
16
|
+
PARTITION = /\A((sd|vd|xvd|hd)[a-z]+\d+|nvme\d+n\d+p\d+|mmcblk\d+p\d+|loop\d+|ram\d+|zram\d+|dm-\d+|sr\d+|fd\d+|md\d+)\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def quiet
|
|
21
|
+
yield
|
|
22
|
+
rescue StandardError, NotImplementedError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read(path)
|
|
27
|
+
File.read(path)
|
|
28
|
+
rescue SystemCallError, IOError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# /proc/stat: aggregate CPU times, context switches, running and
|
|
33
|
+
# blocked processes.
|
|
34
|
+
def proc_stat
|
|
35
|
+
out = {}
|
|
36
|
+
read("/proc/stat")&.each_line do |line|
|
|
37
|
+
parts = line.split
|
|
38
|
+
case parts[0]
|
|
39
|
+
when "cpu" then out[:cpu] = CPU_FIELDS.zip(parts[1..].map(&:to_f)).to_h.reject { |_, v| v.nil? }
|
|
40
|
+
when "ctxt", "procs_running", "procs_blocked" then out[parts[0].to_sym] = parts[1].to_i
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
out
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def loadavg
|
|
47
|
+
f = read("/proc/loadavg")&.split
|
|
48
|
+
f && f.size >= 3 ? f[0, 3].map(&:to_f) : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# /proc/meminfo in bytes.
|
|
52
|
+
def meminfo
|
|
53
|
+
out = {}
|
|
54
|
+
read("/proc/meminfo")&.each_line do |line|
|
|
55
|
+
name, rest = line.split(":", 2)
|
|
56
|
+
fields = rest.to_s.split
|
|
57
|
+
next if fields.empty?
|
|
58
|
+
out[name] = fields[0].to_i * (fields[1] == "kB" ? 1024 : 1)
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whole disks: name => [read bytes, written bytes, reads, writes, ms
|
|
64
|
+
# reading + writing, ms doing I/O].
|
|
65
|
+
def diskstats
|
|
66
|
+
out = {}
|
|
67
|
+
read("/proc/diskstats")&.each_line do |line|
|
|
68
|
+
p = line.split
|
|
69
|
+
next if p.size < 14 || PARTITION.match?(p[2])
|
|
70
|
+
out[p[2]] = [p[5].to_i * 512, p[9].to_i * 512, p[3].to_i, p[7].to_i, p[6].to_i + p[10].to_i, p[12].to_i]
|
|
71
|
+
end
|
|
72
|
+
out
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# All interfaces: [rx bytes, tx bytes, rx packets, tx packets, errors, drops].
|
|
76
|
+
def net_dev
|
|
77
|
+
text = read("/proc/net/dev") or return nil
|
|
78
|
+
sums = [0, 0, 0, 0, 0, 0]
|
|
79
|
+
text.each_line do |line|
|
|
80
|
+
next unless line.include?(":")
|
|
81
|
+
p = line.split(":", 2)[1].split
|
|
82
|
+
next if p.size < 12
|
|
83
|
+
sums[0] += p[0].to_i
|
|
84
|
+
sums[1] += p[8].to_i
|
|
85
|
+
sums[2] += p[1].to_i
|
|
86
|
+
sums[3] += p[9].to_i
|
|
87
|
+
sums[4] += p[2].to_i + p[10].to_i
|
|
88
|
+
sums[5] += p[3].to_i + p[11].to_i
|
|
89
|
+
end
|
|
90
|
+
sums
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# /proc/net/snmp Tcp counters (CurrEstab, RetransSegs, ...).
|
|
94
|
+
def tcp
|
|
95
|
+
rows = read("/proc/net/snmp")&.each_line&.select { |l| l.start_with?("Tcp:") }&.map(&:split)
|
|
96
|
+
return {} unless rows && rows.size >= 2
|
|
97
|
+
rows[0][1..].zip(rows[1][1..].map(&:to_i)).to_h
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# VmRSS (bytes) and Threads from /proc/self/status.
|
|
101
|
+
def self_status
|
|
102
|
+
out = {}
|
|
103
|
+
read("/proc/self/status")&.each_line do |line|
|
|
104
|
+
if line.start_with?("VmRSS:") then out[:rss] = line.split[1].to_i * 1024
|
|
105
|
+
elsif line.start_with?("Threads:") then out[:threads] = line.split[1].to_i
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
out
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self_io
|
|
112
|
+
vals = {}
|
|
113
|
+
read("/proc/self/io")&.each_line do |line|
|
|
114
|
+
k, v = line.split(":", 2)
|
|
115
|
+
vals[k.strip] = v.to_i
|
|
116
|
+
end
|
|
117
|
+
vals.key?("read_bytes") ? [vals["read_bytes"], vals["write_bytes"].to_i] : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def open_fds
|
|
121
|
+
quiet { Dir.children("/proc/self/fd").size } if LINUX
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def host_procs
|
|
125
|
+
quiet { Dir.children("/proc").count { |n| n.match?(/\A\d+\z/) } } if LINUX
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def max_fds
|
|
129
|
+
soft = quiet { Process.getrlimit(:NOFILE)[0] }
|
|
130
|
+
return nil unless soft
|
|
131
|
+
soft.positive? && soft < (1 << 40) && soft != Process::RLIM_INFINITY ? soft : nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def cpu_count = quiet { Etc.nprocessors }
|
|
135
|
+
|
|
136
|
+
def process_cpu_seconds
|
|
137
|
+
quiet { Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) } ||
|
|
138
|
+
quiet { Process.times.then { |t| t.utime + t.stime } }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The filesystem holding path, from df (POSIX output, 1 KiB blocks):
|
|
142
|
+
# { total:, used:, free: } in bytes, plus inode use when df knows it.
|
|
143
|
+
def disk(path)
|
|
144
|
+
return nil if WINDOWS || @no_df
|
|
145
|
+
blocks = df(["-Pk", path]) or return nil
|
|
146
|
+
out = { total: blocks[1] * 1024, used: blocks[2] * 1024, free: blocks[3] * 1024 }
|
|
147
|
+
if LINUX && (inodes = df(["-Pi", path])) && inodes[1].positive?
|
|
148
|
+
out[:inodes_percent] = inodes[2] * 100.0 / inodes[1]
|
|
149
|
+
end
|
|
150
|
+
out
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def df(args)
|
|
154
|
+
text = IO.popen(["df", *args], err: File::NULL, &:read)
|
|
155
|
+
fields = text.lines[1..].to_a.join(" ").split
|
|
156
|
+
nums = fields[1, 3].to_a
|
|
157
|
+
return nil unless nums.size == 3 && nums.all? { |n| n.match?(/\A\d+\z/) }
|
|
158
|
+
[fields[0], *nums.map(&:to_i)]
|
|
159
|
+
rescue Errno::ENOENT
|
|
160
|
+
@no_df = true
|
|
161
|
+
nil
|
|
162
|
+
rescue StandardError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Ruby heap: live slots and the pages holding them.
|
|
167
|
+
def heap
|
|
168
|
+
st = GC.stat
|
|
169
|
+
consts = defined?(GC::INTERNAL_CONSTANTS) ? GC::INTERNAL_CONSTANTS : {}
|
|
170
|
+
slot = consts[:BASE_SLOT_SIZE] || consts[:RVALUE_SIZE] || 40
|
|
171
|
+
page = consts[:HEAP_PAGE_SIZE]
|
|
172
|
+
{
|
|
173
|
+
live: st[:heap_live_slots],
|
|
174
|
+
alloc_bytes: st[:heap_live_slots] && st[:heap_live_slots] * slot,
|
|
175
|
+
inuse_bytes: page && st[:heap_allocated_pages] && st[:heap_allocated_pages] * page,
|
|
176
|
+
gc_count: GC.count,
|
|
177
|
+
gc_ms: st[:time]
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The app's ActiveRecord pool, when it has one.
|
|
182
|
+
def active_record_pool
|
|
183
|
+
return nil unless defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.respond_to?(:connection_pool)
|
|
184
|
+
st = ::ActiveRecord::Base.connection_pool.stat
|
|
185
|
+
{ total: st[:connections], idle: st[:idle], in_use: st[:busy], max: st[:size] }
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Visionary
|
|
7
|
+
module Telemetry
|
|
8
|
+
# Exception rows: type, message, stack and a fingerprint that groups
|
|
9
|
+
# occurrences of the same error.
|
|
10
|
+
module Errors
|
|
11
|
+
MAX_MESSAGE = 1000
|
|
12
|
+
MAX_STACK = 16 * 1024
|
|
13
|
+
FRAMES = 5
|
|
14
|
+
PER_MINUTE = 20
|
|
15
|
+
SDK_DIR = File.expand_path("..", __dir__)
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# The first 16 hex chars of SHA-1 over the type and the top in-app
|
|
20
|
+
# frames as "function@file" (no line numbers); without frames, over the
|
|
21
|
+
# type and the message with digit runs replaced by 0.
|
|
22
|
+
def fingerprint(exception)
|
|
23
|
+
type = exception.class.name || exception.class.to_s
|
|
24
|
+
frames = app_frames(exception)
|
|
25
|
+
basis = frames.empty? ? "#{type}\n#{exception.message.to_s.gsub(/\d+/, '0')}" : "#{type}\n#{frames.join("\n")}"
|
|
26
|
+
Digest::SHA1.hexdigest(basis)[0, 16]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Top frames from the application's own code: not the standard library,
|
|
30
|
+
# installed gems or this SDK. When every frame is library code, the top
|
|
31
|
+
# frames as they are.
|
|
32
|
+
def app_frames(exception)
|
|
33
|
+
locs = exception.backtrace_locations
|
|
34
|
+
frames = if locs
|
|
35
|
+
locs.map { |l| [l.label.to_s, (l.absolute_path || l.path).to_s] }
|
|
36
|
+
else
|
|
37
|
+
Array(exception.backtrace).map { |line| parse_line(line) }.compact
|
|
38
|
+
end
|
|
39
|
+
own = frames.reject { |_, file| library?(file) }
|
|
40
|
+
own = frames if own.empty?
|
|
41
|
+
own.first(FRAMES).map { |label, file| "#{label}@#{relative(file)}" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_line(line)
|
|
45
|
+
m = line.match(/\A(.+?):\d+:in [`'](.*)'\z/)
|
|
46
|
+
m && [m[2], m[1]]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def library?(file)
|
|
50
|
+
return true if file.empty? || file.start_with?("<internal:") || file.start_with?("<")
|
|
51
|
+
f = file.tr("\\", "/")
|
|
52
|
+
library_dirs.any? { |dir| f.start_with?(dir) } || f.include?("/gems/") || f.include?("/vendor/bundle/")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def library_dirs
|
|
56
|
+
@library_dirs ||= begin
|
|
57
|
+
dirs = [SDK_DIR, RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"], RbConfig::CONFIG["rubylibprefix"]]
|
|
58
|
+
dirs.concat(Gem.path) if defined?(Gem) && Gem.respond_to?(:path)
|
|
59
|
+
dirs << Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
|
|
60
|
+
dirs.compact.map { |d| d.to_s.tr("\\", "/").chomp("/") + "/" }.reject { |d| d == "/" }.uniq.freeze
|
|
61
|
+
rescue StandardError
|
|
62
|
+
[SDK_DIR.tr("\\", "/") + "/"].freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Paths relative to the app root, so instances deployed to different
|
|
67
|
+
# directories fingerprint alike.
|
|
68
|
+
def relative(file)
|
|
69
|
+
f = file.tr("\\", "/")
|
|
70
|
+
root = (@root ||= Dir.pwd.tr("\\", "/").chomp("/") + "/")
|
|
71
|
+
f.start_with?(root) ? f.delete_prefix(root) : f
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def type_name(exception)
|
|
75
|
+
exception.class.name || exception.class.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def message(exception)
|
|
79
|
+
m = exception.message.to_s.scrub
|
|
80
|
+
m.length > MAX_MESSAGE ? m[0, MAX_MESSAGE] : m
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Ruby's own format: "file:line:in 'method': message (Class)" then the
|
|
84
|
+
# "from" lines, causes included.
|
|
85
|
+
def stack(exception)
|
|
86
|
+
s = if exception.backtrace
|
|
87
|
+
exception.full_message(highlight: false, order: :top)
|
|
88
|
+
else
|
|
89
|
+
"#{message(exception)} (#{type_name(exception)})"
|
|
90
|
+
end
|
|
91
|
+
s = s.scrub
|
|
92
|
+
s.bytesize > MAX_STACK ? s.byteslice(0, MAX_STACK).scrub("") : s
|
|
93
|
+
rescue StandardError
|
|
94
|
+
""
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# At most PER_MINUTE rows per fingerprint per minute in this process.
|
|
98
|
+
class Limiter
|
|
99
|
+
def initialize
|
|
100
|
+
@mutex = Mutex.new
|
|
101
|
+
@minute = nil
|
|
102
|
+
@counts = Hash.new(0)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def allow?(fingerprint)
|
|
106
|
+
minute = (Process.clock_gettime(Process::CLOCK_MONOTONIC) / 60).floor
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
if minute != @minute
|
|
109
|
+
@minute = minute
|
|
110
|
+
@counts.clear
|
|
111
|
+
end
|
|
112
|
+
(@counts[fingerprint] += 1) <= PER_MINUTE
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Visionary
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Database spans from ActiveRecord's sql.active_record notifications.
|
|
6
|
+
module ActiveRecordHook
|
|
7
|
+
SKIP = %w[SCHEMA CACHE].freeze
|
|
8
|
+
SYSTEMS = { "postg" => "postgresql", "mysql" => "mysql", "trilogy" => "mysql", "sqlite" => "sqlite" }.freeze
|
|
9
|
+
|
|
10
|
+
@installed = false
|
|
11
|
+
@targets = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def installed? = @installed
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
return if @installed || !defined?(::ActiveSupport::Notifications)
|
|
19
|
+
@installed = true
|
|
20
|
+
::ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, start, finish, _id, payload|
|
|
21
|
+
record(start, finish, payload)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record(start, finish, payload)
|
|
26
|
+
rec = Visionary.telemetry
|
|
27
|
+
return unless rec&.spans_on
|
|
28
|
+
return if payload[:cached] || SKIP.include?(payload[:name])
|
|
29
|
+
return if Telemetry.internal?
|
|
30
|
+
|
|
31
|
+
duration = (finish - start).to_f * 1000.0
|
|
32
|
+
sql = payload[:sql].to_s
|
|
33
|
+
exc = payload[:exception]
|
|
34
|
+
rec.span(
|
|
35
|
+
kind: "db", name: SQLText.name(sql), target: target(payload[:connection]),
|
|
36
|
+
detail: SQLText.sanitize(sql), start_mono: Telemetry.now_ms - duration, duration_ms: duration,
|
|
37
|
+
status: exc ? "error" : "ok", error: exc && "#{exc[0]}: #{exc[1]}",
|
|
38
|
+
row_count: payload[:row_count].is_a?(Integer) ? payload[:row_count] : nil
|
|
39
|
+
)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# "postgresql/voygram", cached per pool.
|
|
45
|
+
def target(conn)
|
|
46
|
+
return nil unless conn.respond_to?(:pool)
|
|
47
|
+
pool = conn.pool
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@targets.fetch(pool.object_id) do
|
|
50
|
+
@targets[pool.object_id] = describe(pool, conn)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def describe(pool, conn)
|
|
58
|
+
cfg = pool.respond_to?(:db_config) ? pool.db_config : nil
|
|
59
|
+
adapter = (cfg ? cfg.adapter : conn.adapter_name).to_s.downcase
|
|
60
|
+
system = SYSTEMS.find { |k, _| adapter.include?(k) }&.last || adapter
|
|
61
|
+
db = cfg&.database.to_s
|
|
62
|
+
db = File.basename(db, ".*") if system == "sqlite"
|
|
63
|
+
db.empty? ? system : "#{system}/#{db}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Outbound HTTP spans from Net::HTTP (and the many clients built on it).
|
|
69
|
+
module NetHTTPHook
|
|
70
|
+
def request(req, body = nil, &block)
|
|
71
|
+
rec = Visionary.telemetry
|
|
72
|
+
# Net::HTTP#request calls itself again once the connection is up;
|
|
73
|
+
# that inner call is the one measured.
|
|
74
|
+
return super unless rec&.spans_on && started? && !Telemetry.internal?
|
|
75
|
+
|
|
76
|
+
t0 = Telemetry.now_ms
|
|
77
|
+
status = "ok"
|
|
78
|
+
error = nil
|
|
79
|
+
begin
|
|
80
|
+
res = super
|
|
81
|
+
status = "error" if res.code.to_i >= 500
|
|
82
|
+
res
|
|
83
|
+
rescue Exception => e # re-raised; the caller sees the same failure
|
|
84
|
+
status = "error"
|
|
85
|
+
error = "#{e.class}: #{e.message}"
|
|
86
|
+
raise
|
|
87
|
+
ensure
|
|
88
|
+
Telemetry::NetHTTPHook.record(rec, self, req, t0, status, error)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.record(rec, http, req, t0, status, error)
|
|
93
|
+
host = http.address.to_s
|
|
94
|
+
scheme = http.use_ssl? ? "https" : "http"
|
|
95
|
+
port = http.port == (http.use_ssl? ? 443 : 80) ? "" : ":#{http.port}"
|
|
96
|
+
path = req.path.to_s.split("?", 2).first
|
|
97
|
+
path = URI(path).path if path.start_with?("http://", "https://")
|
|
98
|
+
rec.span(
|
|
99
|
+
kind: "http", name: "#{req.method} #{host}", target: host,
|
|
100
|
+
detail: "#{req.method} #{scheme}://#{host}#{port}#{path}",
|
|
101
|
+
start_mono: t0, duration_ms: Telemetry.now_ms - t0, status: status, error: error
|
|
102
|
+
)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.install
|
|
108
|
+
return if @installed
|
|
109
|
+
require "net/http"
|
|
110
|
+
Net::HTTP.prepend(self)
|
|
111
|
+
@installed = true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Visionary
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Statement sanitizing and span names for database calls.
|
|
6
|
+
module SQLText
|
|
7
|
+
STRING = /'(?:[^']|'')*'/
|
|
8
|
+
# A number not part of an identifier (t1, $1 placeholders stay).
|
|
9
|
+
NUMBER = /(?<![\w$.])-?\d+(?:\.\d+)?(?:e[+-]?\d+)?(?![\w.])/i
|
|
10
|
+
IN_LIST = /\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/i
|
|
11
|
+
TABLE_AFTER = {
|
|
12
|
+
"SELECT" => /\bFROM\s+([\w."`\[\]]+)/i,
|
|
13
|
+
"DELETE" => /\bFROM\s+([\w."`\[\]]+)/i,
|
|
14
|
+
"INSERT" => /\bINTO\s+([\w."`\[\]]+)/i,
|
|
15
|
+
"REPLACE" => /\bINTO\s+([\w."`\[\]]+)/i,
|
|
16
|
+
"UPDATE" => /\AUPDATE\s+(?:ONLY\s+)?([\w."`\[\]]+)/i
|
|
17
|
+
}.freeze
|
|
18
|
+
MAX_DETAIL = 2000
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Literals become ?, IN lists collapse to IN (?), whitespace to one space.
|
|
23
|
+
def sanitize(sql)
|
|
24
|
+
s = sql.to_s.scrub("")
|
|
25
|
+
s = s.gsub(STRING, "?").gsub(NUMBER, "?").gsub(IN_LIST, "IN (?)")
|
|
26
|
+
s = s.gsub(/\s+/, " ").strip
|
|
27
|
+
s.length > MAX_DETAIL ? s[0, MAX_DETAIL] : s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# "SELECT users", "INSERT bookings", "BEGIN".
|
|
31
|
+
def name(sql)
|
|
32
|
+
s = sql.to_s.lstrip
|
|
33
|
+
s = s.sub(%r{\A(?:/\*.*?\*/\s*)+}m, "")
|
|
34
|
+
verb = s[/\A[a-zA-Z]+/].to_s.upcase
|
|
35
|
+
return "SQL" if verb.empty?
|
|
36
|
+
re = TABLE_AFTER[verb]
|
|
37
|
+
table = re && s[re, 1]
|
|
38
|
+
return verb unless table
|
|
39
|
+
table = table.delete("\"`[]").split(".").last
|
|
40
|
+
table && !table.empty? ? "#{verb} #{table}" : verb
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "telemetry/sql_text"
|
|
4
|
+
require_relative "telemetry/errors"
|
|
5
|
+
|
|
6
|
+
module Visionary
|
|
7
|
+
module Telemetry
|
|
8
|
+
INTERNAL = :visionary_internal
|
|
9
|
+
|
|
10
|
+
# Marks the current thread as Visionary's own, so its database and HTTP
|
|
11
|
+
# calls (the writes themselves) never become spans.
|
|
12
|
+
def self.internal!
|
|
13
|
+
Thread.current[INTERNAL] = true
|
|
14
|
+
RequestContext.current = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.internal? = Thread.current[INTERNAL] == true
|
|
18
|
+
|
|
19
|
+
def self.runtime
|
|
20
|
+
@runtime ||= if RUBY_ENGINE == "ruby" then "CRuby #{RUBY_VERSION}"
|
|
21
|
+
elsif defined?(JRUBY_VERSION) then "JRuby #{JRUBY_VERSION}"
|
|
22
|
+
else "#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.now_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
27
|
+
|
|
28
|
+
# Turns spans and exceptions into rows: inside a request they attach to
|
|
29
|
+
# it (and are written with it), outside they go straight to the writer.
|
|
30
|
+
class Recorder
|
|
31
|
+
attr_reader :spans_on, :errors_on
|
|
32
|
+
|
|
33
|
+
def initialize(cfg, instance, writer)
|
|
34
|
+
@cfg = cfg
|
|
35
|
+
@instance = instance
|
|
36
|
+
@writer = writer
|
|
37
|
+
@spans_on = cfg.spans ? true : false
|
|
38
|
+
@errors_on = cfg.errors ? true : false
|
|
39
|
+
@threshold = cfg.span_threshold_ms.to_f
|
|
40
|
+
@limiter = Errors::Limiter.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# start_mono is the CLOCK_MONOTONIC start in ms, duration in ms.
|
|
44
|
+
def span(kind:, name:, start_mono:, duration_ms:, target: nil, detail: nil, status: "ok", error: nil, row_count: nil)
|
|
45
|
+
return unless @spans_on && @writer
|
|
46
|
+
return if Telemetry.internal?
|
|
47
|
+
|
|
48
|
+
ctx = RequestContext.current
|
|
49
|
+
duration_ms = duration_ms.to_f
|
|
50
|
+
started_at = Time.now - ((Telemetry.now_ms - start_mono) / 1000.0)
|
|
51
|
+
span = Span.new(
|
|
52
|
+
kind: kind.to_s, name: cut(name.to_s, 255), target: target && cut(target.to_s, 255),
|
|
53
|
+
detail: detail && cut(detail.to_s, SQLText::MAX_DETAIL), started_at: started_at,
|
|
54
|
+
offset_ms: ctx ? (start_mono - ctx.started_mono).round(3) : nil,
|
|
55
|
+
duration_ms: duration_ms.round(3), status: status, error: error && cut(error.to_s, 500),
|
|
56
|
+
row_count: row_count, request_id: ctx&.request_id
|
|
57
|
+
)
|
|
58
|
+
if ctx
|
|
59
|
+
ctx.add_span(span)
|
|
60
|
+
elsif duration_ms >= @threshold
|
|
61
|
+
@writer.enqueue([:span, span_row(span)])
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def span_row(span)
|
|
68
|
+
[Visionary.timestamp(span.started_at), span.request_id, @cfg.service, @instance, span.kind, span.name,
|
|
69
|
+
span.target, span.detail, span.offset_ms, span.duration_ms, span.status, span.error, span.row_count]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Rows for a finished request's spans when it was slow or failed.
|
|
73
|
+
def request_span_rows(ctx, duration_ms, status)
|
|
74
|
+
return [] unless @spans_on && (duration_ms >= @threshold || status >= 500)
|
|
75
|
+
ctx.spans.map { |s| span_row(s) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def exception(exception, handled:, ctx: RequestContext.current)
|
|
79
|
+
return unless @errors_on && @writer && exception.is_a?(Exception)
|
|
80
|
+
|
|
81
|
+
fp = Errors.fingerprint(exception)
|
|
82
|
+
return unless @limiter.allow?(fp)
|
|
83
|
+
|
|
84
|
+
row = {
|
|
85
|
+
created_at: Visionary.timestamp(Time.now), type: Errors.type_name(exception),
|
|
86
|
+
message: Errors.message(exception), stack: Errors.stack(exception),
|
|
87
|
+
fingerprint: fp, handled: handled ? 1 : 0
|
|
88
|
+
}
|
|
89
|
+
if ctx
|
|
90
|
+
ctx.add_error(row)
|
|
91
|
+
else
|
|
92
|
+
@writer.enqueue([:error, error_row(row, nil, nil)])
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# route and user come from the request when it has finished.
|
|
99
|
+
def error_row(row, ctx, route, user_id = nil)
|
|
100
|
+
[row[:created_at], @cfg.service, @instance, @cfg.version, ctx&.request_id, ctx&.method, nonempty(route),
|
|
101
|
+
ctx&.path, nonempty(user_id), row[:type], nonempty(row[:message]), nonempty(row[:stack]),
|
|
102
|
+
row[:fingerprint], row[:handled], Telemetry.runtime]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def nonempty(s)
|
|
108
|
+
s.nil? || s.to_s.empty? ? nil : s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cut(s, max)
|
|
112
|
+
s.length > max ? s[0, max] : s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|