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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +23 -27
  4. data/app/assets/javascripts/blazer/Sortable.js +826 -1162
  5. data/app/assets/javascripts/blazer/ace/ace.js +15775 -13360
  6. data/app/assets/javascripts/blazer/ace/ext-language_tools.js +1411 -668
  7. data/app/assets/javascripts/blazer/ace/mode-sql.js +12 -6
  8. data/app/assets/javascripts/blazer/ace/theme-twilight.js +3 -3
  9. data/app/assets/javascripts/blazer/application.js +94 -52
  10. data/app/assets/javascripts/blazer/chart.umd.js +6 -6
  11. data/app/assets/javascripts/blazer/daterangepicker.js +193 -199
  12. data/app/assets/javascripts/blazer/highlight.min.js +235 -373
  13. data/app/assets/javascripts/blazer/moment-timezone-with-data.js +116 -82
  14. data/app/assets/javascripts/blazer/moment.js +368 -365
  15. data/app/assets/javascripts/blazer/queries.js +68 -58
  16. data/app/assets/javascripts/blazer/routes.js +11 -11
  17. data/app/assets/javascripts/blazer/tablesort.js +42 -0
  18. data/app/assets/javascripts/blazer/tom-select.base.js +4175 -0
  19. data/app/assets/stylesheets/blazer/application.css +114 -5
  20. data/app/assets/stylesheets/blazer/tom-select.css +502 -0
  21. data/app/controllers/blazer/base_controller.rb +1 -1
  22. data/app/controllers/blazer/checks_controller.rb +5 -2
  23. data/app/controllers/blazer/queries_controller.rb +3 -1
  24. data/app/helpers/blazer/base_helper.rb +1 -1
  25. data/app/models/blazer/check.rb +35 -57
  26. data/app/views/blazer/_nav.html.erb +1 -1
  27. data/app/views/blazer/_variables.html.erb +57 -42
  28. data/app/views/blazer/checks/_form.html.erb +20 -16
  29. data/app/views/blazer/checks/index.html.erb +17 -20
  30. data/app/views/blazer/dashboards/_form.html.erb +63 -48
  31. data/app/views/blazer/dashboards/show.html.erb +23 -16
  32. data/app/views/blazer/queries/_caching.html.erb +1 -1
  33. data/app/views/blazer/queries/_chart.html.erb +8 -0
  34. data/app/views/blazer/queries/_form.html.erb +153 -180
  35. data/app/views/blazer/queries/_map.html.erb +8 -0
  36. data/app/views/blazer/queries/docs.html.erb +6 -6
  37. data/app/views/blazer/queries/edit.html.erb +1 -0
  38. data/app/views/blazer/queries/home.html.erb +101 -102
  39. data/app/views/blazer/queries/new.html.erb +1 -0
  40. data/app/views/blazer/queries/run.html.erb +23 -34
  41. data/app/views/blazer/queries/schema.html.erb +20 -20
  42. data/app/views/blazer/queries/show.html.erb +19 -12
  43. data/app/views/blazer/uploads/index.html.erb +10 -10
  44. data/app/views/layouts/blazer/application.html.erb +3 -3
  45. data/lib/blazer/adapters/athena_adapter.rb +12 -6
  46. data/lib/blazer/adapters/clickhouse_adapter.rb +136 -0
  47. data/lib/blazer/adapters/druid_adapter.rb +0 -2
  48. data/lib/blazer/adapters/snowflake2_adapter.rb +198 -0
  49. data/lib/blazer/adapters/soda_adapter.rb +0 -2
  50. data/lib/blazer/adapters/sql_adapter.rb +4 -0
  51. data/lib/blazer/adapters.rb +2 -0
  52. data/lib/blazer/check_types.rb +20 -0
  53. data/lib/blazer/email_notifier.rb +49 -0
  54. data/lib/blazer/result.rb +15 -0
  55. data/lib/blazer/slack_notifier.rb +39 -7
  56. data/lib/blazer/version.rb +1 -1
  57. data/lib/blazer.rb +32 -26
  58. data/lib/generators/blazer/templates/config.yml.tt +0 -2
  59. data/licenses/LICENSE-chart.js.txt +1 -1
  60. metadata +12 -31
  61. data/app/assets/javascripts/blazer/bootstrap.js +0 -2580
  62. data/app/assets/javascripts/blazer/jquery.js +0 -10872
  63. data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +0 -325
  64. data/app/assets/javascripts/blazer/rails-ujs.js +0 -746
  65. data/app/assets/javascripts/blazer/selectize.js +0 -3891
  66. data/app/assets/javascripts/blazer/stupidtable-custom-settings.js +0 -13
  67. data/app/assets/javascripts/blazer/stupidtable.js +0 -281
  68. data/app/assets/javascripts/blazer/vue.global.prod.js +0 -1
  69. data/app/assets/stylesheets/blazer/selectize.css +0 -403
  70. data/licenses/LICENSE-jquery.txt +0 -20
  71. data/licenses/LICENSE-rails-ujs.txt +0 -20
  72. data/licenses/LICENSE-stickytableheaders.txt +0 -20
  73. data/licenses/LICENSE-stupidtable.txt +0 -19
  74. data/licenses/LICENSE-vue.txt +0 -21
  75. /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
@@ -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.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.4.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
@@ -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-2022 Chart.js Contributors
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