blazer 3.4.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 +8 -0
- data/README.md +23 -27
- 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 +1 -1
- 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 +4 -0
- data/lib/blazer/adapters.rb +2 -0
- data/lib/blazer/check_types.rb +20 -0
- 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 +12 -31
- 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
|
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
|
|
@@ -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
|
|
@@ -62,11 +62,9 @@ check_schedules:
|
|
|
62
62
|
- "5 minutes"
|
|
63
63
|
|
|
64
64
|
# enable anomaly detection
|
|
65
|
-
# note: with trend, time series are sent to https://trendapi.org
|
|
66
65
|
# anomaly_checks: prophet / trend / anomaly_detection
|
|
67
66
|
|
|
68
67
|
# enable forecasting
|
|
69
|
-
# note: with trend, time series are sent to https://trendapi.org
|
|
70
68
|
# forecasting: prophet / trend
|
|
71
69
|
|
|
72
70
|
# enable map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2014-
|
|
3
|
+
Copyright (c) 2014-2024 Chart.js Contributors
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
6
|
|