blazer 3.3.0 → 3.5.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +1 -1
- data/README.md +23 -33
- data/app/assets/javascripts/blazer/Sortable.js +826 -1162
- data/app/assets/javascripts/blazer/ace/ace.js +15775 -13360
- data/app/assets/javascripts/blazer/ace/ext-language_tools.js +1411 -668
- data/app/assets/javascripts/blazer/ace/mode-sql.js +12 -6
- data/app/assets/javascripts/blazer/ace/theme-twilight.js +3 -3
- data/app/assets/javascripts/blazer/application.js +94 -52
- data/app/assets/javascripts/blazer/chart.umd.js +6 -6
- data/app/assets/javascripts/blazer/daterangepicker.js +193 -199
- data/app/assets/javascripts/blazer/highlight.min.js +235 -373
- data/app/assets/javascripts/blazer/moment-timezone-with-data.js +116 -82
- data/app/assets/javascripts/blazer/moment.js +368 -365
- data/app/assets/javascripts/blazer/queries.js +68 -58
- data/app/assets/javascripts/blazer/routes.js +11 -11
- data/app/assets/javascripts/blazer/tablesort.js +42 -0
- data/app/assets/javascripts/blazer/tom-select.base.js +4175 -0
- data/app/assets/stylesheets/blazer/application.css +114 -5
- data/app/assets/stylesheets/blazer/tom-select.css +502 -0
- data/app/controllers/blazer/base_controller.rb +3 -3
- data/app/controllers/blazer/checks_controller.rb +5 -2
- data/app/controllers/blazer/queries_controller.rb +3 -1
- data/app/helpers/blazer/base_helper.rb +1 -1
- data/app/models/blazer/check.rb +35 -57
- data/app/views/blazer/_nav.html.erb +1 -1
- data/app/views/blazer/_variables.html.erb +57 -42
- data/app/views/blazer/checks/_form.html.erb +20 -16
- data/app/views/blazer/checks/index.html.erb +17 -20
- data/app/views/blazer/dashboards/_form.html.erb +63 -48
- data/app/views/blazer/dashboards/show.html.erb +23 -16
- data/app/views/blazer/queries/_caching.html.erb +1 -1
- data/app/views/blazer/queries/_chart.html.erb +8 -0
- data/app/views/blazer/queries/_form.html.erb +153 -180
- data/app/views/blazer/queries/_map.html.erb +8 -0
- data/app/views/blazer/queries/docs.html.erb +6 -6
- data/app/views/blazer/queries/edit.html.erb +1 -0
- data/app/views/blazer/queries/home.html.erb +101 -102
- data/app/views/blazer/queries/new.html.erb +1 -0
- data/app/views/blazer/queries/run.html.erb +23 -34
- data/app/views/blazer/queries/schema.html.erb +20 -20
- data/app/views/blazer/queries/show.html.erb +19 -12
- data/app/views/blazer/uploads/index.html.erb +10 -10
- data/app/views/layouts/blazer/application.html.erb +3 -3
- data/lib/blazer/adapters/athena_adapter.rb +12 -6
- data/lib/blazer/adapters/clickhouse_adapter.rb +136 -0
- data/lib/blazer/adapters/druid_adapter.rb +0 -2
- data/lib/blazer/adapters/snowflake2_adapter.rb +198 -0
- data/lib/blazer/adapters/soda_adapter.rb +0 -2
- data/lib/blazer/adapters/sql_adapter.rb +5 -1
- data/lib/blazer/adapters.rb +2 -0
- data/lib/blazer/check_types.rb +20 -0
- data/lib/blazer/data_source.rb +1 -1
- data/lib/blazer/email_notifier.rb +49 -0
- data/lib/blazer/result.rb +15 -0
- data/lib/blazer/slack_notifier.rb +39 -7
- data/lib/blazer/version.rb +1 -1
- data/lib/blazer.rb +32 -26
- data/lib/generators/blazer/templates/config.yml.tt +0 -2
- data/licenses/LICENSE-chart.js.txt +1 -1
- metadata +20 -39
- data/app/assets/javascripts/blazer/bootstrap.js +0 -2580
- data/app/assets/javascripts/blazer/jquery.js +0 -10872
- data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +0 -325
- data/app/assets/javascripts/blazer/rails-ujs.js +0 -746
- data/app/assets/javascripts/blazer/selectize.js +0 -3891
- data/app/assets/javascripts/blazer/stupidtable-custom-settings.js +0 -13
- data/app/assets/javascripts/blazer/stupidtable.js +0 -281
- data/app/assets/javascripts/blazer/vue.global.prod.js +0 -1
- data/app/assets/stylesheets/blazer/selectize.css +0 -403
- data/licenses/LICENSE-jquery.txt +0 -20
- data/licenses/LICENSE-rails-ujs.txt +0 -20
- data/licenses/LICENSE-stickytableheaders.txt +0 -20
- data/licenses/LICENSE-stupidtable.txt +0 -19
- data/licenses/LICENSE-vue.txt +0 -21
- /data/licenses/{LICENSE-selectize.txt → LICENSE-tom-select.txt} +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module Blazer
|
|
2
|
+
module Adapters
|
|
3
|
+
class ClickhouseAdapter < BaseAdapter
|
|
4
|
+
def run_statement(statement, comment, bind_params)
|
|
5
|
+
columns = []
|
|
6
|
+
rows = []
|
|
7
|
+
error = nil
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
res = execute("#{statement} /*#{comment}*/", bind_params)
|
|
11
|
+
|
|
12
|
+
if res.is_a?(Net::HTTPSuccess)
|
|
13
|
+
data = JSON.parse(res.body)
|
|
14
|
+
columns = data["meta"].map { |v| v["name"] }
|
|
15
|
+
rows = data["data"]
|
|
16
|
+
data["meta"].each_with_index do |c, i|
|
|
17
|
+
type = c["type"]
|
|
18
|
+
type = type[9..-2] if type.start_with?("Nullable(")
|
|
19
|
+
if type.start_with?("DateTime")
|
|
20
|
+
utc = ActiveSupport::TimeZone["Etc/UTC"]
|
|
21
|
+
rows.each do |row|
|
|
22
|
+
row[i] &&= utc.parse(row[i])
|
|
23
|
+
end
|
|
24
|
+
elsif ["Date", "Date32"].include?(type)
|
|
25
|
+
rows.each do |row|
|
|
26
|
+
row[i] &&= Date.parse(row[i])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
error = res.body
|
|
32
|
+
error = Blazer::TIMEOUT_MESSAGE if error.include?("TIMEOUT_EXCEEDED")
|
|
33
|
+
end
|
|
34
|
+
rescue Errno::ECONNREFUSED => e
|
|
35
|
+
error = e.message
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[columns, rows, error]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tables
|
|
42
|
+
result = data_source.run_statement("SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = currentDatabase() ORDER BY table_name")
|
|
43
|
+
result.rows.map(&:first)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def schema
|
|
47
|
+
result = data_source.run_statement("SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = currentDatabase() ORDER BY 1, 2")
|
|
48
|
+
result.rows.group_by { |r| [r[0], r[1]] }.map { |k, vs| {schema: k[0], table: k[1], columns: vs.sort_by { |v| v[2] }.map { |v| {name: v[2], data_type: v[3]} }} }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def preview_statement
|
|
52
|
+
"SELECT * FROM {table} LIMIT 10"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# https://clickhouse.com/docs/sql-reference/syntax#string
|
|
56
|
+
def quoting
|
|
57
|
+
:backslash_escape
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parameter_binding
|
|
61
|
+
proc do |statement, variables|
|
|
62
|
+
variables.each do |k, v|
|
|
63
|
+
if v.nil?
|
|
64
|
+
statement = statement.gsub("{#{k}}") { "NULL" }
|
|
65
|
+
else
|
|
66
|
+
type =
|
|
67
|
+
case v
|
|
68
|
+
when Integer
|
|
69
|
+
# TODO improve
|
|
70
|
+
"Int64"
|
|
71
|
+
when Float
|
|
72
|
+
"Float64"
|
|
73
|
+
when ActiveSupport::TimeWithZone
|
|
74
|
+
"DateTime64(9, 'UTC')"
|
|
75
|
+
else
|
|
76
|
+
"String"
|
|
77
|
+
end
|
|
78
|
+
statement = statement.gsub("{#{k}}") { "{#{k}: #{type}}" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
[statement, variables]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cancel(run_id)
|
|
86
|
+
execute("KILL QUERY WHERE query LIKE {query: String} AND query NOT LIKE '%system.processes%' SYNC", {"query" => "%,run_id:#{run_id}%"})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def database
|
|
92
|
+
@database ||= settings["database"] || "default"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def execute(statement, bind_params)
|
|
96
|
+
require "net/http"
|
|
97
|
+
|
|
98
|
+
query_params = {
|
|
99
|
+
"database" => database,
|
|
100
|
+
"default_format" => "JSONCompact",
|
|
101
|
+
"output_format_json_quote_64bit_integers" => 0, # for ClickHouse < 25.8
|
|
102
|
+
"readonly" => 1
|
|
103
|
+
}
|
|
104
|
+
query_params["max_execution_time"] = data_source.timeout if data_source.timeout
|
|
105
|
+
|
|
106
|
+
post_data = {
|
|
107
|
+
"query" => statement
|
|
108
|
+
}
|
|
109
|
+
bind_params.each do |k, v|
|
|
110
|
+
v = v.utc.strftime("%Y-%m-%d %H:%M:%S.%N") if v.is_a?(ActiveSupport::TimeWithZone)
|
|
111
|
+
# https://github.com/ClickHouse/ClickHouse/issues/69656
|
|
112
|
+
v = v.gsub("\\") { "\\\\" } if v.is_a?(String)
|
|
113
|
+
post_data["param_#{k}"] = v.to_s unless v.nil?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
uri = URI(settings["url"])
|
|
117
|
+
raise "Not supported" if uri.query
|
|
118
|
+
uri.query = URI.encode_www_form(query_params)
|
|
119
|
+
|
|
120
|
+
req = Net::HTTP::Post.new(uri)
|
|
121
|
+
req.basic_auth(uri.user, uri.password) if uri.user || uri.password
|
|
122
|
+
req.set_form(post_data, "multipart/form-data")
|
|
123
|
+
|
|
124
|
+
options = {
|
|
125
|
+
use_ssl: uri.scheme == "https",
|
|
126
|
+
open_timeout: 3,
|
|
127
|
+
read_timeout: data_source.timeout ? data_source.timeout + 3 : 30
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Net::HTTP.start(uri.hostname, uri.port, options) do |http|
|
|
131
|
+
http.request(req)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
module Blazer
|
|
2
|
+
module Adapters
|
|
3
|
+
class Snowflake2Adapter < BaseAdapter
|
|
4
|
+
def run_statement(statement, comment, bind_params)
|
|
5
|
+
require "net/http"
|
|
6
|
+
|
|
7
|
+
columns = []
|
|
8
|
+
rows = []
|
|
9
|
+
error = nil
|
|
10
|
+
|
|
11
|
+
api_prefix = "https://#{settings.fetch("account_id")}.snowflakecomputing.com/api/v2/statements/"
|
|
12
|
+
authorization = "Bearer #{settings.fetch("access_token")}"
|
|
13
|
+
|
|
14
|
+
submit_uri = URI(api_prefix)
|
|
15
|
+
# for testing
|
|
16
|
+
# submit_uri.query = URI.encode_www_form({"async" => true})
|
|
17
|
+
|
|
18
|
+
timeout = data_source.timeout || 300
|
|
19
|
+
stop_at = Time.now + timeout
|
|
20
|
+
|
|
21
|
+
post_data = {
|
|
22
|
+
statement: "#{statement} /*#{comment}*/",
|
|
23
|
+
timeout: timeout
|
|
24
|
+
}
|
|
25
|
+
# use empty? since any? doesn't work for [nil]
|
|
26
|
+
unless bind_params.empty?
|
|
27
|
+
post_data[:bindings] =
|
|
28
|
+
bind_params.map.with_index.to_h do |v, i|
|
|
29
|
+
type =
|
|
30
|
+
case v
|
|
31
|
+
when Integer
|
|
32
|
+
"FIXED"
|
|
33
|
+
when Float
|
|
34
|
+
"REAL"
|
|
35
|
+
when ActiveSupport::TimeWithZone
|
|
36
|
+
"TIMESTAMP_NTZ"
|
|
37
|
+
else
|
|
38
|
+
"TEXT"
|
|
39
|
+
end
|
|
40
|
+
v = v.to_i * 1000000000 + v.nsec if v.is_a?(ActiveSupport::TimeWithZone)
|
|
41
|
+
[i + 1, {type: type, value: v&.to_s}]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
post_data[:database] = settings["database"] if settings["database"]
|
|
45
|
+
post_data[:schema] = settings["schema"] if settings["schema"]
|
|
46
|
+
post_data[:warehouse] = settings["warehouse"] if settings["warehouse"]
|
|
47
|
+
post_data[:role] = settings["role"] if settings["role"]
|
|
48
|
+
|
|
49
|
+
req = Net::HTTP::Post.new(submit_uri)
|
|
50
|
+
req["Authorization"] = authorization
|
|
51
|
+
req.body = post_data.to_json
|
|
52
|
+
|
|
53
|
+
options = {
|
|
54
|
+
use_ssl: true,
|
|
55
|
+
open_timeout: 3,
|
|
56
|
+
read_timeout: timeout + 3
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
res = Net::HTTP.start(submit_uri.hostname, submit_uri.port, options) do |http|
|
|
61
|
+
http.request(req)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
while res.is_a?(Net::HTTPAccepted)
|
|
65
|
+
if Time.now > stop_at
|
|
66
|
+
error = Blazer::TIMEOUT_MESSAGE
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sleep(3)
|
|
71
|
+
|
|
72
|
+
data = JSON.parse(res.body)
|
|
73
|
+
statement_uri = URI("#{api_prefix}#{CGI.escape(data["statementHandle"])}")
|
|
74
|
+
req = Net::HTTP::Get.new(statement_uri)
|
|
75
|
+
req["Authorization"] = authorization
|
|
76
|
+
|
|
77
|
+
res = Net::HTTP.start(statement_uri.hostname, statement_uri.port, options) do |http|
|
|
78
|
+
http.request(req)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if res.is_a?(Net::HTTPSuccess)
|
|
83
|
+
data = JSON.parse(res.body)
|
|
84
|
+
metadata = data["resultSetMetaData"]
|
|
85
|
+
columns = metadata["rowType"].map { |v| v["name"].downcase }
|
|
86
|
+
column_types = metadata["rowType"].map { |v| v["type"] }
|
|
87
|
+
rows = data["data"]
|
|
88
|
+
|
|
89
|
+
if metadata["partitionInfo"]
|
|
90
|
+
1.upto(metadata["partitionInfo"].size - 1) do |i|
|
|
91
|
+
statement_uri.query = URI.encode_www_form({"partition" => i})
|
|
92
|
+
req = Net::HTTP::Get.new(statement_uri)
|
|
93
|
+
req["Authorization"] = authorization
|
|
94
|
+
|
|
95
|
+
res = Net::HTTP.start(statement_uri.hostname, statement_uri.port, options) do |http|
|
|
96
|
+
http.request(req)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if res.is_a?(Net::HTTPSuccess)
|
|
100
|
+
data = JSON.parse(res.body)
|
|
101
|
+
rows += data["data"]
|
|
102
|
+
else
|
|
103
|
+
data = JSON.parse(res.body)
|
|
104
|
+
error = data["message"]
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if error
|
|
111
|
+
columns.clear
|
|
112
|
+
rows.clear
|
|
113
|
+
else
|
|
114
|
+
column_types.each_with_index do |c, i|
|
|
115
|
+
# TODO handle more types
|
|
116
|
+
case c
|
|
117
|
+
when "fixed"
|
|
118
|
+
rows.each do |row|
|
|
119
|
+
row[i] &&= row[i].to_i
|
|
120
|
+
end
|
|
121
|
+
when "real"
|
|
122
|
+
rows.each do |row|
|
|
123
|
+
row[i] &&= row[i].to_f
|
|
124
|
+
end
|
|
125
|
+
when "timestamp_ntz"
|
|
126
|
+
utc = ActiveSupport::TimeZone["Etc/UTC"]
|
|
127
|
+
rows.each do |row|
|
|
128
|
+
row[i] &&= utc.strptime(row[i], "%s.%N")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
elsif !error
|
|
134
|
+
data = JSON.parse(res.body)
|
|
135
|
+
error = data["message"]
|
|
136
|
+
error = Blazer::TIMEOUT_MESSAGE if error.include?("Statement reached its statement or warehouse timeout")
|
|
137
|
+
end
|
|
138
|
+
rescue Errno::ECONNREFUSED => e
|
|
139
|
+
error = e.message
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[columns, rows, error]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def tables
|
|
146
|
+
sql = "SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema != 'INFORMATION_SCHEMA'"
|
|
147
|
+
result = data_source.run_statement(sql)
|
|
148
|
+
result.rows.sort_by { |r| [r[0] == default_schema ? "" : r[0], r[1]] }.map do |row|
|
|
149
|
+
table =
|
|
150
|
+
if row[0] == default_schema
|
|
151
|
+
row[1]
|
|
152
|
+
else
|
|
153
|
+
"#{row[0]}.#{row[1]}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# TODO quote if needed
|
|
157
|
+
table.downcase
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def schema
|
|
162
|
+
sql = "SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns WHERE table_schema != 'INFORMATION_SCHEMA'"
|
|
163
|
+
result = data_source.run_statement(sql)
|
|
164
|
+
result.rows.group_by { |r| [r[0], r[1]] }.sort_by { |k, _| [k[0] == default_schema ? "" : k[0], k[1]] }.map { |k, vs| {schema: k[0].downcase, table: k[1].downcase, columns: vs.sort_by { |v| v[2] }.map { |v| {name: v[2].downcase, data_type: v[3].downcase} }} }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def preview_statement
|
|
168
|
+
"SELECT * FROM {table} LIMIT 10"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# https://docs.snowflake.com/en/sql-reference/data-types-text#escape-sequences-in-single-quoted-string-constants
|
|
172
|
+
def quoting
|
|
173
|
+
:backslash_escape
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# https://docs.snowflake.com/en/developer-guide/sql-api/submitting-requests#label-sql-api-bind-variables
|
|
177
|
+
def parameter_binding
|
|
178
|
+
:positional
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# https://docs.snowflake.com/en/developer-guide/sql-api/cancelling-requests
|
|
182
|
+
def cancel(run_id)
|
|
183
|
+
# TODO
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def default_schema
|
|
189
|
+
@default_schema ||=
|
|
190
|
+
if settings["schema"]
|
|
191
|
+
settings["schema"]
|
|
192
|
+
else
|
|
193
|
+
data_source.run_statement("SELECT CURRENT_SCHEMA()").rows[0][0]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -74,6 +74,8 @@ module Blazer
|
|
|
74
74
|
sql =
|
|
75
75
|
if sqlite?
|
|
76
76
|
"SELECT NULL, name FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name"
|
|
77
|
+
elsif postgresql?
|
|
78
|
+
add_schemas("SELECT * FROM (SELECT table_schema, table_name FROM information_schema.tables UNION ALL SELECT n.nspname AS table_schema, c.relname AS table_name FROM pg_class c INNER JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relkind = 'm' AND has_any_column_privilege(c.oid, 'SELECT'))")
|
|
77
79
|
else
|
|
78
80
|
add_schemas("SELECT table_schema, table_name FROM information_schema.tables")
|
|
79
81
|
end
|
|
@@ -103,6 +105,8 @@ module Blazer
|
|
|
103
105
|
sql =
|
|
104
106
|
if sqlite?
|
|
105
107
|
"SELECT NULL, t.name, c.name, c.type, c.cid FROM sqlite_master t INNER JOIN pragma_table_info(t.name) c WHERE t.type IN ('table', 'view')"
|
|
108
|
+
elsif postgresql?
|
|
109
|
+
add_schemas("SELECT * FROM (SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns UNION ALL SELECT n.nspname AS table_schema, c.relname AS table_name, a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnum AS ordinal_position FROM pg_attribute a INNER JOIN pg_class c ON a.attrelid = c.oid INNER JOIN pg_namespace n ON c.relnamespace = n.oid WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind = 'm' AND has_column_privilege(c.oid, a.attnum, 'SELECT'))")
|
|
106
110
|
else
|
|
107
111
|
add_schemas("SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns")
|
|
108
112
|
end
|
|
@@ -255,7 +259,7 @@ module Blazer
|
|
|
255
259
|
connection_model.connection.select_all(statement)
|
|
256
260
|
end
|
|
257
261
|
|
|
258
|
-
#
|
|
262
|
+
# separate from select_all to prevent mysql error
|
|
259
263
|
def execute(statement)
|
|
260
264
|
connection_model.connection.execute(statement)
|
|
261
265
|
end
|
data/lib/blazer/adapters.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Blazer.register_adapter "athena", Blazer::Adapters::AthenaAdapter
|
|
2
2
|
Blazer.register_adapter "bigquery", Blazer::Adapters::BigQueryAdapter
|
|
3
3
|
Blazer.register_adapter "cassandra", Blazer::Adapters::CassandraAdapter
|
|
4
|
+
Blazer.register_adapter "clickhouse", Blazer::Adapters::ClickhouseAdapter
|
|
4
5
|
Blazer.register_adapter "drill", Blazer::Adapters::DrillAdapter
|
|
5
6
|
Blazer.register_adapter "druid", Blazer::Adapters::DruidAdapter
|
|
6
7
|
Blazer.register_adapter "elasticsearch", Blazer::Adapters::ElasticsearchAdapter
|
|
@@ -15,4 +16,5 @@ Blazer.register_adapter "soda", Blazer::Adapters::SodaAdapter
|
|
|
15
16
|
Blazer.register_adapter "spark", Blazer::Adapters::SparkAdapter
|
|
16
17
|
Blazer.register_adapter "sql", Blazer::Adapters::SqlAdapter
|
|
17
18
|
Blazer.register_adapter "snowflake", Blazer::Adapters::SnowflakeAdapter
|
|
19
|
+
Blazer.register_adapter "snowflake2", Blazer::Adapters::Snowflake2Adapter
|
|
18
20
|
Blazer.register_adapter "trino", Blazer::Adapters::PrestoAdapter
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Blazer.register_check_type "bad_data", "Any results (bad data)" do |result|
|
|
2
|
+
result.rows.any? ? "failing" : "passing"
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
Blazer.register_check_type "missing_data", "No results (missing data)" do |result|
|
|
6
|
+
result.rows.any? ? "passing" : "failing"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
Blazer.register_check_type "anomaly", "Anomaly (most recent data point)" do |result|
|
|
10
|
+
anomaly, message = result.detect_anomaly
|
|
11
|
+
state =
|
|
12
|
+
if anomaly.nil?
|
|
13
|
+
"error"
|
|
14
|
+
elsif anomaly
|
|
15
|
+
"failing"
|
|
16
|
+
else
|
|
17
|
+
"passing"
|
|
18
|
+
end
|
|
19
|
+
[state, message]
|
|
20
|
+
end
|
data/lib/blazer/data_source.rb
CHANGED
|
@@ -204,7 +204,7 @@ module Blazer
|
|
|
204
204
|
def adapter_instance
|
|
205
205
|
@adapter_instance ||= begin
|
|
206
206
|
# TODO add required settings to adapters
|
|
207
|
-
unless settings["url"] || Rails.env.
|
|
207
|
+
unless settings["url"] || Rails.env.local? || ["bigquery", "athena", "snowflake", "salesforce"].include?(settings["adapter"])
|
|
208
208
|
raise Blazer::Error, "Empty url for data source: #{id}"
|
|
209
209
|
end
|
|
210
210
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Blazer
|
|
2
|
+
class EmailNotifier
|
|
3
|
+
def self.failing_checks(checks)
|
|
4
|
+
emails = {}
|
|
5
|
+
checks.each do |check|
|
|
6
|
+
split_emails(check).each do |email|
|
|
7
|
+
(emails[email] ||= []) << check
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
emails.each do |email, checks|
|
|
11
|
+
Safely.safely do
|
|
12
|
+
Blazer::CheckMailer.failing_checks(email, checks).deliver_now
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.state_change(check:, state:, state_was:, result:, message:, check_type:)
|
|
18
|
+
Blazer::CheckMailer.state_change(check, state, state_was, result.rows.size, message, result.columns, result.rows.first(10).as_json, result.column_types, check_type).deliver_now if check.emails.present?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# TODO improve name
|
|
22
|
+
def self.notify_list(check)
|
|
23
|
+
split_emails(check)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.before_validation(check)
|
|
27
|
+
# some people like doing ; instead of ,
|
|
28
|
+
# but we know what they mean, so let's fix it
|
|
29
|
+
# also, some people like to use whitespace
|
|
30
|
+
if check.emails.present?
|
|
31
|
+
check.emails = check.emails.strip.gsub(/[;\s]/, ",").gsub(/,+/, ", ")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.validate(check)
|
|
36
|
+
unless split_emails(check).all? { |e| e =~ /\A\S+@\S+\.\S+\z/ }
|
|
37
|
+
check.errors.add(:base, "Invalid emails")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.fields
|
|
42
|
+
[:emails]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.split_emails(check)
|
|
46
|
+
check.emails.to_s.downcase.split(",").map(&:strip)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/blazer/result.rb
CHANGED
|
@@ -20,6 +20,21 @@ module Blazer
|
|
|
20
20
|
cached_at.present?
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def explain
|
|
24
|
+
# TODO move to data source adapters
|
|
25
|
+
case @data_source.adapter
|
|
26
|
+
when "sql"
|
|
27
|
+
# TODO limit to Postgres and add other databases
|
|
28
|
+
if @columns == ["QUERY PLAN"]
|
|
29
|
+
@rows.map { |r| r[0] }.join("\n")
|
|
30
|
+
end
|
|
31
|
+
when "druid"
|
|
32
|
+
if @columns == ["PLAN"] && @rows.size == 1
|
|
33
|
+
@rows[0][0]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
23
38
|
def smart_values
|
|
24
39
|
@smart_values ||= begin
|
|
25
40
|
smart_values = {}
|
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
require "net/http"
|
|
2
|
-
|
|
3
1
|
module Blazer
|
|
4
2
|
class SlackNotifier
|
|
5
|
-
def self.
|
|
6
|
-
|
|
3
|
+
def self.failing_checks(checks)
|
|
4
|
+
slack_channels = {}
|
|
5
|
+
checks.each do |check|
|
|
6
|
+
split_slack_channels(check).each do |channel|
|
|
7
|
+
(slack_channels[channel] ||= []) << check
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
slack_channels.each do |channel, checks|
|
|
11
|
+
Safely.safely do
|
|
12
|
+
channel_failing_checks(channel, checks)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.state_change(check:, state:, state_was:, result:, message:, check_type:)
|
|
18
|
+
rows_count = result.rows.size
|
|
19
|
+
split_slack_channels(check).each do |channel|
|
|
7
20
|
text =
|
|
8
|
-
if
|
|
9
|
-
|
|
21
|
+
if message
|
|
22
|
+
message
|
|
10
23
|
elsif rows_count > 0 && check_type == "bad_data"
|
|
11
24
|
pluralize(rows_count, "row")
|
|
12
25
|
end
|
|
@@ -27,7 +40,24 @@ module Blazer
|
|
|
27
40
|
end
|
|
28
41
|
end
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
# TODO improve name
|
|
44
|
+
def self.notify_list(check)
|
|
45
|
+
split_slack_channels(check)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.fields
|
|
49
|
+
Blazer.slack? ? [:slack_channels] : []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.split_slack_channels(check)
|
|
53
|
+
if Blazer.slack?
|
|
54
|
+
check.slack_channels.to_s.downcase.split(",").map(&:strip)
|
|
55
|
+
else
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.channel_failing_checks(channel, checks)
|
|
31
61
|
text =
|
|
32
62
|
checks.map do |check|
|
|
33
63
|
"<#{query_url(check.query_id)}|#{escape(check.query.name)}> #{escape(check.state)}"
|
|
@@ -69,6 +99,8 @@ module Blazer
|
|
|
69
99
|
|
|
70
100
|
# TODO use return value
|
|
71
101
|
def self.post(payload)
|
|
102
|
+
require "net/http"
|
|
103
|
+
|
|
72
104
|
if Blazer.slack_webhook_url.present?
|
|
73
105
|
response = post_api(Blazer.slack_webhook_url, payload, {})
|
|
74
106
|
response.is_a?(Net::HTTPSuccess) && response.body == "ok"
|
data/lib/blazer/version.rb
CHANGED
data/lib/blazer.rb
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# dependencies
|
|
2
|
-
require "
|
|
2
|
+
require "csv"
|
|
3
3
|
require "safely/core"
|
|
4
4
|
|
|
5
5
|
# stdlib
|
|
6
|
-
require "csv"
|
|
7
6
|
require "digest/sha2"
|
|
8
7
|
require "json"
|
|
9
8
|
require "yaml"
|
|
@@ -21,6 +20,7 @@ require_relative "blazer/adapters/base_adapter"
|
|
|
21
20
|
require_relative "blazer/adapters/athena_adapter"
|
|
22
21
|
require_relative "blazer/adapters/bigquery_adapter"
|
|
23
22
|
require_relative "blazer/adapters/cassandra_adapter"
|
|
23
|
+
require_relative "blazer/adapters/clickhouse_adapter"
|
|
24
24
|
require_relative "blazer/adapters/drill_adapter"
|
|
25
25
|
require_relative "blazer/adapters/druid_adapter"
|
|
26
26
|
require_relative "blazer/adapters/elasticsearch_adapter"
|
|
@@ -35,6 +35,11 @@ require_relative "blazer/adapters/soda_adapter"
|
|
|
35
35
|
require_relative "blazer/adapters/spark_adapter"
|
|
36
36
|
require_relative "blazer/adapters/sql_adapter"
|
|
37
37
|
require_relative "blazer/adapters/snowflake_adapter"
|
|
38
|
+
require_relative "blazer/adapters/snowflake2_adapter"
|
|
39
|
+
|
|
40
|
+
# notifiers
|
|
41
|
+
require_relative "blazer/email_notifier"
|
|
42
|
+
require_relative "blazer/slack_notifier"
|
|
38
43
|
|
|
39
44
|
# engine
|
|
40
45
|
require_relative "blazer/engine"
|
|
@@ -46,8 +51,6 @@ module Blazer
|
|
|
46
51
|
|
|
47
52
|
# actionmailer optional
|
|
48
53
|
autoload :CheckMailer, "blazer/check_mailer"
|
|
49
|
-
# net/http optional
|
|
50
|
-
autoload :SlackNotifier, "blazer/slack_notifier"
|
|
51
54
|
# activejob optional
|
|
52
55
|
autoload :RunStatementJob, "blazer/run_statement_job"
|
|
53
56
|
|
|
@@ -83,6 +86,8 @@ module Blazer
|
|
|
83
86
|
|
|
84
87
|
VARIABLE_MESSAGE = "Variable cannot be used in this position"
|
|
85
88
|
TIMEOUT_MESSAGE = "Query timed out :("
|
|
89
|
+
|
|
90
|
+
# TODO move to SqlAdapter
|
|
86
91
|
TIMEOUT_ERRORS = [
|
|
87
92
|
"canceling statement due to statement timeout", # postgres
|
|
88
93
|
"canceling statement due to conflict with recovery", # postgres
|
|
@@ -188,28 +193,9 @@ module Blazer
|
|
|
188
193
|
end
|
|
189
194
|
|
|
190
195
|
def self.send_failing_checks
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
Blazer::Check.includes(:query).where(state: ["failing", "error", "timed out", "disabled"]).find_each do |check|
|
|
195
|
-
check.split_emails.each do |email|
|
|
196
|
-
(emails[email] ||= []) << check
|
|
197
|
-
end
|
|
198
|
-
check.split_slack_channels.each do |channel|
|
|
199
|
-
(slack_channels[channel] ||= []) << check
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
emails.each do |email, checks|
|
|
204
|
-
Safely.safely do
|
|
205
|
-
Blazer::CheckMailer.failing_checks(email, checks).deliver_now
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
slack_channels.each do |channel, checks|
|
|
210
|
-
Safely.safely do
|
|
211
|
-
Blazer::SlackNotifier.failing_checks(channel, checks)
|
|
212
|
-
end
|
|
196
|
+
checks = Blazer::Check.includes(:query).where(state: ["failing", "error", "timed out", "disabled"]).to_a
|
|
197
|
+
Blazer.notifiers.each do |notifier|
|
|
198
|
+
notifier.failing_checks(checks)
|
|
213
199
|
end
|
|
214
200
|
end
|
|
215
201
|
|
|
@@ -263,6 +249,22 @@ module Blazer
|
|
|
263
249
|
forecasters[name] = forecaster
|
|
264
250
|
end
|
|
265
251
|
|
|
252
|
+
def self.notifiers
|
|
253
|
+
@notifiers ||= []
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.register_notifier(notifier)
|
|
257
|
+
self.notifiers << notifier
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.check_types
|
|
261
|
+
@check_types ||= {}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.register_check_type(name, label, &block)
|
|
265
|
+
check_types[name] = {label: label, block: block}
|
|
266
|
+
end
|
|
267
|
+
|
|
266
268
|
def self.archive_queries
|
|
267
269
|
raise "Audits must be enabled to archive" unless Blazer.audit
|
|
268
270
|
raise "Missing status column - see https://github.com/ankane/blazer#23" unless Blazer::Query.column_names.include?("status")
|
|
@@ -279,4 +281,8 @@ end
|
|
|
279
281
|
|
|
280
282
|
require_relative "blazer/adapters"
|
|
281
283
|
require_relative "blazer/anomaly_detectors"
|
|
284
|
+
require_relative "blazer/check_types"
|
|
282
285
|
require_relative "blazer/forecasters"
|
|
286
|
+
|
|
287
|
+
Blazer.register_notifier Blazer::EmailNotifier
|
|
288
|
+
Blazer.register_notifier Blazer::SlackNotifier
|