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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ # Table definitions shared by every store: the shipped CREATE scripts (copies
5
+ # of sdks/go/schema), column orders and the upgrades for older tables
6
+ # (sdks/go/migrate.go).
7
+ module Schema
8
+ DIR = File.expand_path("schema", __dir__)
9
+
10
+ REQUEST_COLUMNS = %w[
11
+ request_id created_at method path route query status duration_ms ip user_agent
12
+ user_id user_handle user_role request_headers request_content_type request_body
13
+ request_body_truncated request_size response_content_type response_body
14
+ response_body_truncated response_size error_code error_key
15
+ ].freeze
16
+
17
+ # api_logs columns added after the first release, in insert order.
18
+ ADDED_REQUEST_COLUMNS = [
19
+ %w[service text], %w[version text], %w[db_count int], %w[db_ms real], %w[http_count int], %w[http_ms real]
20
+ ].freeze
21
+
22
+ SYSTEM_BASE_COLUMNS = %w[
23
+ created_at instance interval_s
24
+ host_cpu_percent load_1 load_5 load_15
25
+ host_mem_used_mb host_mem_total_mb host_mem_percent
26
+ disk_used_percent disk_free_gb net_rx_kbps net_tx_kbps
27
+ proc_cpu_percent proc_rss_mb proc_threads proc_open_fds
28
+ goroutines heap_alloc_mb heap_inuse_mb heap_objects
29
+ gc_count gc_pause_ms gc_cpu_percent
30
+ db_total_conns db_idle_conns db_acquired_conns db_max_conns
31
+ db_acquires db_empty_acquires db_acquire_wait_ms
32
+ http_requests http_errors http_rps http_in_flight
33
+ uptime_s
34
+ ].freeze
35
+
36
+ # system_metrics columns added after the first release, in insert order.
37
+ ADDED_SYSTEM_COLUMNS = [
38
+ %w[cpu_count int], %w[cpu_user_percent real], %w[cpu_system_percent real],
39
+ %w[cpu_iowait_percent real], %w[cpu_steal_percent real],
40
+ %w[host_mem_available_mb real], %w[host_mem_cached_mb real],
41
+ %w[swap_used_mb real], %w[swap_total_mb real],
42
+ %w[disk_total_gb real], %w[disk_inodes_percent real],
43
+ %w[disk_read_kbps real], %w[disk_write_kbps real], %w[disk_read_iops real],
44
+ %w[disk_write_iops real], %w[disk_busy_percent real], %w[disk_await_ms real],
45
+ %w[net_rx_pps real], %w[net_tx_pps real], %w[net_errors int], %w[net_drops int],
46
+ %w[tcp_established int], %w[tcp_retrans int],
47
+ %w[host_procs int], %w[procs_running int], %w[procs_blocked int],
48
+ %w[ctx_switches_ps real], %w[proc_read_kbps real], %w[proc_write_kbps real],
49
+ %w[proc_max_fds int], %w[service text], %w[version text],
50
+ %w[sdk text], %w[runtime text]
51
+ ].freeze
52
+
53
+ SPAN_COLUMNS = %w[
54
+ created_at request_id service instance kind name target detail offset_ms duration_ms status error row_count
55
+ ].freeze
56
+
57
+ ERROR_COLUMNS = %w[
58
+ created_at service instance version request_id method route path user_id
59
+ type message stack fingerprint handled runtime
60
+ ].freeze
61
+
62
+ RELEASE_COLUMNS = %w[created_at service instance version sdk sdk_version runtime].freeze
63
+
64
+ TABLES = {
65
+ "api_logs" => REQUEST_COLUMNS + ADDED_REQUEST_COLUMNS.map(&:first),
66
+ "system_metrics" => SYSTEM_BASE_COLUMNS + ADDED_SYSTEM_COLUMNS.map(&:first),
67
+ "spans" => SPAN_COLUMNS,
68
+ "errors" => ERROR_COLUMNS,
69
+ "releases" => RELEASE_COLUMNS
70
+ }.freeze
71
+
72
+ # Which script creates a table, and the columns older copies may lack.
73
+ GROUPS = {
74
+ requests: { file: "requests.sql", table: "api_logs", added: ADDED_REQUEST_COLUMNS },
75
+ system: { file: "system.sql", table: "system_metrics", added: ADDED_SYSTEM_COLUMNS },
76
+ telemetry: { file: "telemetry.sql", table: nil, added: [] }
77
+ }.freeze
78
+
79
+ PURGE_CHUNK = 5000
80
+
81
+ module_function
82
+
83
+ def folder(dialect)
84
+ %w[postgres mysql].include?(dialect) ? dialect : "sqlite"
85
+ end
86
+
87
+ def statements(dialect, group)
88
+ script = File.read(File.join(DIR, folder(dialect), GROUPS.fetch(group)[:file]), encoding: "UTF-8")
89
+ script.split(";").map(&:strip).reject(&:empty?)
90
+ end
91
+
92
+ def columns_query(dialect, table)
93
+ case dialect
94
+ when "postgres"
95
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = '#{table}'"
96
+ when "mysql"
97
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '#{table}'"
98
+ else
99
+ "SELECT name FROM pragma_table_info('#{table}')"
100
+ end
101
+ end
102
+
103
+ def sql_type(dialect, kind)
104
+ case kind
105
+ when "text" then dialect == "mysql" ? "VARCHAR(255)" : "TEXT"
106
+ when "int" then %w[postgres mysql].include?(dialect) ? "BIGINT" : "INTEGER"
107
+ else { "postgres" => "DOUBLE PRECISION", "mysql" => "DOUBLE" }.fetch(dialect, "REAL")
108
+ end
109
+ end
110
+
111
+ # ALTER statements for the added columns missing from existing names.
112
+ def missing_columns(dialect, table, added, existing)
113
+ have = existing.map { |c| c.to_s.downcase }
114
+ added.reject { |name, _| have.include?(name) }
115
+ .map { |name, kind| "ALTER TABLE #{table} ADD COLUMN #{name} #{sql_type(dialect, kind)}" }
116
+ end
117
+
118
+ # An ALTER that lost a race with another instance.
119
+ def duplicate_column?(error)
120
+ msg = error.message.downcase
121
+ msg.include?("duplicate column") || msg.include?("already exists") || msg.include?("1060")
122
+ end
123
+
124
+ # MySQL rejects LIMIT inside IN (subquery) but allows DELETE ... LIMIT;
125
+ # the others are the other way round.
126
+ def purge_sql(dialect, table)
127
+ if dialect == "mysql"
128
+ "DELETE FROM #{table} WHERE created_at < ? ORDER BY id LIMIT #{PURGE_CHUNK}"
129
+ else
130
+ "DELETE FROM #{table} WHERE id IN (SELECT id FROM #{table} WHERE created_at < ? LIMIT #{PURGE_CHUNK})"
131
+ end
132
+ end
133
+
134
+ # D1 takes string params only, so there each placeholder becomes
135
+ # NULLIF(?, '') and nulls travel as empty strings.
136
+ def insert_sql(table, string_params)
137
+ cols = TABLES.fetch(table)
138
+ mark = string_params ? "NULLIF(?, '')" : "?"
139
+ "INSERT INTO #{table} (#{cols.join(', ')}) VALUES (#{([mark] * cols.size).join(', ')})"
140
+ end
141
+
142
+ # MySQL in strict mode rejects over-long values, which would fail the
143
+ # whole batch, so values are cut to the column size first: characters for
144
+ # VARCHAR(n), bytes for TEXT and MEDIUMTEXT. Read from the shipped schema.
145
+ def mysql_limits
146
+ @mysql_limits ||= begin
147
+ limits = Hash.new { |h, k| h[k] = {} }
148
+ GROUPS.each_value do |g|
149
+ table = nil
150
+ File.foreach(File.join(DIR, "mysql", g[:file]), encoding: "UTF-8") do |line|
151
+ if (m = line.match(/CREATE TABLE IF NOT EXISTS (\w+)/))
152
+ table = m[1]
153
+ elsif table && (m = line.match(/\A\s*(\w+)\s+(VARCHAR\((\d+)\)|MEDIUMTEXT|TEXT)/))
154
+ limits[table][m[1]] = m[3] ? [:chars, m[3].to_i] : [:bytes, m[2] == "TEXT" ? 65_535 : 16_777_215]
155
+ end
156
+ end
157
+ end
158
+ limits.default_proc = nil
159
+ limits.freeze
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ # Creates tables on first use and retries on later writes until it works,
5
+ # so a database that is down at startup doesn't leave tables missing.
6
+ # Older tables gain the columns added since (ALTER TABLE ... ADD COLUMN).
7
+ class Schemas
8
+ def initialize(store, logger)
9
+ @store = store
10
+ @logger = logger
11
+ @ready = {}
12
+ @reported = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # A fork can happen while a parent thread holds the lock.
17
+ def after_fork
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def ensure(group)
22
+ @mutex.synchronize do
23
+ return true if @ready[group]
24
+ attempt(group)
25
+ @ready[group] = true
26
+ rescue StandardError => e
27
+ unless @reported[group]
28
+ @reported[group] = true
29
+ @logger.error("visionary: create #{group} tables failed (will retry): #{e.message}")
30
+ end
31
+ false
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # CREATE ... IF NOT EXISTS is not race-safe in PostgreSQL: when several
38
+ # processes start at once, one may fail while another creates the table.
39
+ def attempt(group)
40
+ tries = 0
41
+ begin
42
+ create(group)
43
+ rescue StandardError
44
+ raise if (tries += 1) >= 3
45
+ sleep(0.25 * tries)
46
+ retry
47
+ end
48
+ end
49
+
50
+ def create(group)
51
+ dialect = @store.dialect
52
+ @store.exec_script(Schema.statements(dialect, group))
53
+ g = Schema::GROUPS.fetch(group)
54
+ return if g[:added].empty?
55
+
56
+ existing = @store.column_names(Schema.columns_query(dialect, g[:table]))
57
+ Schema.missing_columns(dialect, g[:table], g[:added], existing).each do |stmt|
58
+ @store.exec(stmt)
59
+ rescue StandardError => e
60
+ raise unless Schema.duplicate_column?(e)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ module Store
5
+ # Writes through the application's ActiveRecord pool. Each operation
6
+ # checks a connection out for the background thread doing it and returns
7
+ # it right after, so Visionary holds at most one pool connection at a time
8
+ # (the mutex) and only while writing. Values are quoted by the adapter,
9
+ # exactly as ActiveRecord does when prepared statements are off.
10
+ class ActiveRecord
11
+ attr_reader :dialect
12
+
13
+ def initialize(connection_class)
14
+ @klass = connection_class
15
+ @mutex = Mutex.new
16
+ adapter = adapter_name.downcase
17
+ @dialect = if adapter.include?("postg") then "postgres"
18
+ elsif adapter.include?("mysql") || adapter.include?("trilogy") then "mysql"
19
+ elsif adapter.include?("sqlite") then "sqlite"
20
+ else raise ConfigError, "visionary: unsupported ActiveRecord adapter #{adapter.inspect}"
21
+ end
22
+ end
23
+
24
+ def string_params? = false
25
+
26
+ def exec_script(statements)
27
+ statements.each do |s|
28
+ with { |c| c.execute(s) }
29
+ rescue StandardError => e
30
+ next if dialect == "mysql" && Store.duplicate_index?(e)
31
+ raise
32
+ end
33
+ end
34
+
35
+ def exec(sql)
36
+ with { |c| c.execute(sql) }
37
+ end
38
+
39
+ def column_names(sql)
40
+ with { |c| c.select_values(sql).map(&:to_s) }
41
+ end
42
+
43
+ def batch(stmts)
44
+ with do |c|
45
+ c.transaction(requires_new: true) do
46
+ stmts.each { |sql, args| c.execute(bind(c, sql, args)) }
47
+ end
48
+ end
49
+ end
50
+
51
+ def changes(sql, args)
52
+ with { |c| c.exec_delete(bind(c, sql, args)) }
53
+ end
54
+
55
+ # ActiveRecord discards inherited pools itself after fork.
56
+ def after_fork
57
+ @mutex = Mutex.new
58
+ end
59
+
60
+ def close; end
61
+
62
+ private
63
+
64
+ def adapter_name
65
+ if @klass.respond_to?(:connection_db_config)
66
+ @klass.connection_db_config.adapter.to_s
67
+ else
68
+ @klass.connection_pool.with_connection(&:adapter_name).to_s
69
+ end
70
+ end
71
+
72
+ # Our own statements stay out of the app's log.
73
+ def with(&block)
74
+ @mutex.synchronize do
75
+ logger = @klass.logger
76
+ if logger.respond_to?(:silence)
77
+ logger.silence(::Logger::WARN) { @klass.connection_pool.with_connection(&block) }
78
+ else
79
+ @klass.connection_pool.with_connection(&block)
80
+ end
81
+ end
82
+ end
83
+
84
+ def bind(conn, sql, args)
85
+ i = -1
86
+ quote = nil
87
+ out = +""
88
+ sql.each_char do |ch|
89
+ if quote
90
+ quote = nil if ch == quote
91
+ elsif ch == "'"
92
+ quote = ch
93
+ elsif ch == "?"
94
+ out << conn.quote(args[i += 1])
95
+ next
96
+ end
97
+ out << ch
98
+ end
99
+ out
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Visionary
8
+ module Store
9
+ # Cloudflare D1 over its REST API. Every param travels as a string; the
10
+ # inserts wrap placeholders in NULLIF(?, '') so "" means NULL.
11
+ class D1
12
+ DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4"
13
+ MAX_BATCH = 50
14
+ RESPONSE_LIMIT = 1 << 20
15
+
16
+ class Error < StandardError; end
17
+
18
+ def initialize(account_id:, database_id:, api_token:, api_base: "", timeout: 20)
19
+ base = (api_base.to_s.strip.empty? ? DEFAULT_API_BASE : api_base.strip).chomp("/")
20
+ db = "#{base}/accounts/#{account_id.strip}/d1/database/#{database_id.strip}"
21
+ @query_uri = URI("#{db}/query")
22
+ @raw_uri = URI("#{db}/raw")
23
+ @token = api_token.strip
24
+ @timeout = timeout
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def dialect = "d1"
29
+ def string_params? = true
30
+
31
+ def exec_script(statements)
32
+ statements.each_slice(MAX_BATCH) { |chunk| post(@query_uri, { batch: chunk.map { |s| { sql: s, params: [] } } }) }
33
+ end
34
+
35
+ def exec(sql)
36
+ post(@query_uri, { sql: sql })
37
+ end
38
+
39
+ def column_names(sql)
40
+ result = post(@raw_uri, { sql: sql })
41
+ rows = result.is_a?(Array) && result.dig(0, "results", "rows")
42
+ Array(rows).map { |r| r.is_a?(Array) ? r[0].to_s : nil }.compact
43
+ end
44
+
45
+ def batch(stmts)
46
+ stmts.each_slice(MAX_BATCH) do |chunk|
47
+ post(@query_uri, { batch: chunk.map { |sql, args| { sql: sql, params: args.map { |a| self.class.param(a) } } } })
48
+ end
49
+ end
50
+
51
+ def changes(sql, args)
52
+ result = post(@query_uri, { sql: sql, params: args.map { |a| self.class.param(a) } })
53
+ Integer(result.dig(0, "meta", "changes") || 0)
54
+ rescue TypeError, ArgumentError
55
+ 0
56
+ end
57
+
58
+ def after_fork
59
+ @http = nil
60
+ @mutex = Mutex.new
61
+ end
62
+
63
+ def close
64
+ @mutex.synchronize { finish }
65
+ end
66
+
67
+ def self.param(v)
68
+ case v
69
+ when nil then ""
70
+ when true then "1"
71
+ when false then "0"
72
+ when Float
73
+ s = v.to_s
74
+ s.include?("e") ? format("%.6f", v) : s
75
+ else v.to_s
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def post(uri, body)
82
+ @mutex.synchronize do
83
+ req = Net::HTTP::Post.new(uri)
84
+ req["Authorization"] = "Bearer #{@token}"
85
+ req["Content-Type"] = "application/json"
86
+ req.body = JSON.generate(body)
87
+ res = begin
88
+ connection(uri).request(req)
89
+ rescue StandardError => e
90
+ finish
91
+ raise Error, "d1: request: #{e.message}"
92
+ end
93
+ raw = res.body.to_s
94
+ raise Error, "d1: response larger than #{RESPONSE_LIMIT} bytes" if raw.bytesize > RESPONSE_LIMIT
95
+ env = begin
96
+ JSON.parse(raw)
97
+ rescue JSON::ParserError
98
+ raise Error, "d1: status #{res.code}: unreadable response"
99
+ end
100
+ if !env.is_a?(Hash) || env["success"] != true || res.code.to_i >= 300
101
+ msgs = (env.is_a?(Hash) ? Array(env["errors"]) : []).map { |e| e.is_a?(Hash) ? e["message"].to_s : nil }.compact
102
+ raise Error, "d1: status #{res.code}: #{msgs.empty? ? 'unknown D1 error' : msgs.join('; ')}"
103
+ end
104
+ env["result"]
105
+ end
106
+ end
107
+
108
+ # One keep-alive connection, reopened after errors.
109
+ def connection(uri)
110
+ return @http if @http&.started?
111
+ http = Net::HTTP.new(uri.host, uri.port)
112
+ http.use_ssl = uri.scheme == "https"
113
+ http.open_timeout = @timeout
114
+ http.read_timeout = @timeout
115
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
116
+ http.keep_alive_timeout = 30
117
+ @http = http.start
118
+ end
119
+
120
+ def finish
121
+ @http&.finish if @http&.started?
122
+ rescue StandardError
123
+ nil
124
+ ensure
125
+ @http = nil
126
+ end
127
+ end
128
+ end
129
+ end