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,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
|