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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ VERSION = "0.4.0"
5
+ end