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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +23 -33
  5. data/app/assets/javascripts/blazer/Sortable.js +826 -1162
  6. data/app/assets/javascripts/blazer/ace/ace.js +15775 -13360
  7. data/app/assets/javascripts/blazer/ace/ext-language_tools.js +1411 -668
  8. data/app/assets/javascripts/blazer/ace/mode-sql.js +12 -6
  9. data/app/assets/javascripts/blazer/ace/theme-twilight.js +3 -3
  10. data/app/assets/javascripts/blazer/application.js +94 -52
  11. data/app/assets/javascripts/blazer/chart.umd.js +6 -6
  12. data/app/assets/javascripts/blazer/daterangepicker.js +193 -199
  13. data/app/assets/javascripts/blazer/highlight.min.js +235 -373
  14. data/app/assets/javascripts/blazer/moment-timezone-with-data.js +116 -82
  15. data/app/assets/javascripts/blazer/moment.js +368 -365
  16. data/app/assets/javascripts/blazer/queries.js +68 -58
  17. data/app/assets/javascripts/blazer/routes.js +11 -11
  18. data/app/assets/javascripts/blazer/tablesort.js +42 -0
  19. data/app/assets/javascripts/blazer/tom-select.base.js +4175 -0
  20. data/app/assets/stylesheets/blazer/application.css +114 -5
  21. data/app/assets/stylesheets/blazer/tom-select.css +502 -0
  22. data/app/controllers/blazer/base_controller.rb +3 -3
  23. data/app/controllers/blazer/checks_controller.rb +5 -2
  24. data/app/controllers/blazer/queries_controller.rb +3 -1
  25. data/app/helpers/blazer/base_helper.rb +1 -1
  26. data/app/models/blazer/check.rb +35 -57
  27. data/app/views/blazer/_nav.html.erb +1 -1
  28. data/app/views/blazer/_variables.html.erb +57 -42
  29. data/app/views/blazer/checks/_form.html.erb +20 -16
  30. data/app/views/blazer/checks/index.html.erb +17 -20
  31. data/app/views/blazer/dashboards/_form.html.erb +63 -48
  32. data/app/views/blazer/dashboards/show.html.erb +23 -16
  33. data/app/views/blazer/queries/_caching.html.erb +1 -1
  34. data/app/views/blazer/queries/_chart.html.erb +8 -0
  35. data/app/views/blazer/queries/_form.html.erb +153 -180
  36. data/app/views/blazer/queries/_map.html.erb +8 -0
  37. data/app/views/blazer/queries/docs.html.erb +6 -6
  38. data/app/views/blazer/queries/edit.html.erb +1 -0
  39. data/app/views/blazer/queries/home.html.erb +101 -102
  40. data/app/views/blazer/queries/new.html.erb +1 -0
  41. data/app/views/blazer/queries/run.html.erb +23 -34
  42. data/app/views/blazer/queries/schema.html.erb +20 -20
  43. data/app/views/blazer/queries/show.html.erb +19 -12
  44. data/app/views/blazer/uploads/index.html.erb +10 -10
  45. data/app/views/layouts/blazer/application.html.erb +3 -3
  46. data/lib/blazer/adapters/athena_adapter.rb +12 -6
  47. data/lib/blazer/adapters/clickhouse_adapter.rb +136 -0
  48. data/lib/blazer/adapters/druid_adapter.rb +0 -2
  49. data/lib/blazer/adapters/snowflake2_adapter.rb +198 -0
  50. data/lib/blazer/adapters/soda_adapter.rb +0 -2
  51. data/lib/blazer/adapters/sql_adapter.rb +5 -1
  52. data/lib/blazer/adapters.rb +2 -0
  53. data/lib/blazer/check_types.rb +20 -0
  54. data/lib/blazer/data_source.rb +1 -1
  55. data/lib/blazer/email_notifier.rb +49 -0
  56. data/lib/blazer/result.rb +15 -0
  57. data/lib/blazer/slack_notifier.rb +39 -7
  58. data/lib/blazer/version.rb +1 -1
  59. data/lib/blazer.rb +32 -26
  60. data/lib/generators/blazer/templates/config.yml.tt +0 -2
  61. data/licenses/LICENSE-chart.js.txt +1 -1
  62. metadata +20 -39
  63. data/app/assets/javascripts/blazer/bootstrap.js +0 -2580
  64. data/app/assets/javascripts/blazer/jquery.js +0 -10872
  65. data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +0 -325
  66. data/app/assets/javascripts/blazer/rails-ujs.js +0 -746
  67. data/app/assets/javascripts/blazer/selectize.js +0 -3891
  68. data/app/assets/javascripts/blazer/stupidtable-custom-settings.js +0 -13
  69. data/app/assets/javascripts/blazer/stupidtable.js +0 -281
  70. data/app/assets/javascripts/blazer/vue.global.prod.js +0 -1
  71. data/app/assets/stylesheets/blazer/selectize.css +0 -403
  72. data/licenses/LICENSE-jquery.txt +0 -20
  73. data/licenses/LICENSE-rails-ujs.txt +0 -20
  74. data/licenses/LICENSE-stickytableheaders.txt +0 -20
  75. data/licenses/LICENSE-stupidtable.txt +0 -19
  76. data/licenses/LICENSE-vue.txt +0 -21
  77. /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
@@ -4,9 +4,7 @@ module Blazer
4
4
  TIMESTAMP_REGEX = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\z/
5
5
 
6
6
  def run_statement(statement, comment, bind_params)
7
- require "json"
8
7
  require "net/http"
9
- require "uri"
10
8
 
11
9
  columns = []
12
10
  rows = []
@@ -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
@@ -2,9 +2,7 @@ module Blazer
2
2
  module Adapters
3
3
  class SodaAdapter < BaseAdapter
4
4
  def run_statement(statement, comment)
5
- require "json"
6
5
  require "net/http"
7
- require "uri"
8
6
 
9
7
  columns = []
10
8
  rows = []
@@ -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
- # seperate from select_all to prevent mysql error
262
+ # separate from select_all to prevent mysql error
259
263
  def execute(statement)
260
264
  connection_model.connection.execute(statement)
261
265
  end
@@ -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
@@ -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.development? || ["bigquery", "athena", "snowflake", "salesforce"].include?(settings["adapter"])
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.state_change(check, state, state_was, rows_count, error, check_type)
6
- check.split_slack_channels.each do |channel|
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 error
9
- error
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
- def self.failing_checks(channel, checks)
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"
@@ -1,3 +1,3 @@
1
1
  module Blazer
2
- VERSION = "3.3.0"
2
+ VERSION = "3.5.0"
3
3
  end
data/lib/blazer.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # dependencies
2
- require "chartkick"
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
- emails = {}
192
- slack_channels = {}
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