railsblazer 2.0.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +1 -0
  3. data/.github/ISSUE_TEMPLATE.md +0 -0
  4. data/.gitignore +14 -0
  5. data/CHANGELOG.md +247 -0
  6. data/CONTRIBUTING.md +42 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +855 -0
  10. data/Rakefile +1 -0
  11. data/app/assets/fonts/blazer/glyphicons-halflings-regular.eot +0 -0
  12. data/app/assets/fonts/blazer/glyphicons-halflings-regular.svg +288 -0
  13. data/app/assets/fonts/blazer/glyphicons-halflings-regular.ttf +0 -0
  14. data/app/assets/fonts/blazer/glyphicons-halflings-regular.woff +0 -0
  15. data/app/assets/fonts/blazer/glyphicons-halflings-regular.woff2 +0 -0
  16. data/app/assets/javascripts/blazer/Chart.js +14145 -0
  17. data/app/assets/javascripts/blazer/Sortable.js +1144 -0
  18. data/app/assets/javascripts/blazer/ace.js +6 -0
  19. data/app/assets/javascripts/blazer/ace/ace.js +11 -0
  20. data/app/assets/javascripts/blazer/ace/ext-language_tools.js +5 -0
  21. data/app/assets/javascripts/blazer/ace/mode-sql.js +1 -0
  22. data/app/assets/javascripts/blazer/ace/snippets/sql.js +1 -0
  23. data/app/assets/javascripts/blazer/ace/snippets/text.js +1 -0
  24. data/app/assets/javascripts/blazer/ace/theme-twilight.js +1 -0
  25. data/app/assets/javascripts/blazer/application.js +79 -0
  26. data/app/assets/javascripts/blazer/bootstrap.js +2366 -0
  27. data/app/assets/javascripts/blazer/chartkick.js +1693 -0
  28. data/app/assets/javascripts/blazer/daterangepicker.js +1505 -0
  29. data/app/assets/javascripts/blazer/fuzzysearch.js +24 -0
  30. data/app/assets/javascripts/blazer/highlight.pack.js +1 -0
  31. data/app/assets/javascripts/blazer/jquery.js +10308 -0
  32. data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +263 -0
  33. data/app/assets/javascripts/blazer/jquery_ujs.js +469 -0
  34. data/app/assets/javascripts/blazer/moment-timezone.js +1007 -0
  35. data/app/assets/javascripts/blazer/moment.js +3043 -0
  36. data/app/assets/javascripts/blazer/queries.js +110 -0
  37. data/app/assets/javascripts/blazer/routes.js +23 -0
  38. data/app/assets/javascripts/blazer/selectize.js +3667 -0
  39. data/app/assets/javascripts/blazer/stupidtable.js +114 -0
  40. data/app/assets/javascripts/blazer/vue.js +7515 -0
  41. data/app/assets/stylesheets/blazer/application.css +198 -0
  42. data/app/assets/stylesheets/blazer/bootstrap.css.erb +6202 -0
  43. data/app/assets/stylesheets/blazer/daterangepicker-bs3.css +375 -0
  44. data/app/assets/stylesheets/blazer/github.css +125 -0
  45. data/app/assets/stylesheets/blazer/selectize.default.css +387 -0
  46. data/app/controllers/blazer/base_controller.rb +113 -0
  47. data/app/controllers/blazer/checks_controller.rb +56 -0
  48. data/app/controllers/blazer/dashboards_controller.rb +105 -0
  49. data/app/controllers/blazer/queries_controller.rb +337 -0
  50. data/app/helpers/blazer/base_helper.rb +57 -0
  51. data/app/mailers/blazer/check_mailer.rb +27 -0
  52. data/app/mailers/blazer/slack_notifier.rb +76 -0
  53. data/app/models/blazer/audit.rb +6 -0
  54. data/app/models/blazer/check.rb +104 -0
  55. data/app/models/blazer/connection.rb +5 -0
  56. data/app/models/blazer/dashboard.rb +13 -0
  57. data/app/models/blazer/dashboard_query.rb +9 -0
  58. data/app/models/blazer/query.rb +40 -0
  59. data/app/models/blazer/record.rb +5 -0
  60. data/app/views/blazer/_nav.html.erb +16 -0
  61. data/app/views/blazer/_variables.html.erb +102 -0
  62. data/app/views/blazer/check_mailer/failing_checks.html.erb +6 -0
  63. data/app/views/blazer/check_mailer/state_change.html.erb +47 -0
  64. data/app/views/blazer/checks/_form.html.erb +79 -0
  65. data/app/views/blazer/checks/edit.html.erb +1 -0
  66. data/app/views/blazer/checks/index.html.erb +43 -0
  67. data/app/views/blazer/checks/new.html.erb +1 -0
  68. data/app/views/blazer/dashboards/_form.html.erb +76 -0
  69. data/app/views/blazer/dashboards/edit.html.erb +1 -0
  70. data/app/views/blazer/dashboards/new.html.erb +1 -0
  71. data/app/views/blazer/dashboards/show.html.erb +47 -0
  72. data/app/views/blazer/queries/_form.html.erb +240 -0
  73. data/app/views/blazer/queries/edit.html.erb +2 -0
  74. data/app/views/blazer/queries/home.html.erb +152 -0
  75. data/app/views/blazer/queries/new.html.erb +2 -0
  76. data/app/views/blazer/queries/run.html.erb +165 -0
  77. data/app/views/blazer/queries/schema.html.erb +20 -0
  78. data/app/views/blazer/queries/show.html.erb +73 -0
  79. data/app/views/layouts/blazer/application.html.erb +24 -0
  80. data/blazer-0.0.1.gem +0 -0
  81. data/blazer.gemspec +27 -0
  82. data/config/routes.rb +16 -0
  83. data/lib/blazer.rb +223 -0
  84. data/lib/blazer/adapters/athena_adapter.rb +128 -0
  85. data/lib/blazer/adapters/base_adapter.rb +53 -0
  86. data/lib/blazer/adapters/bigquery_adapter.rb +68 -0
  87. data/lib/blazer/adapters/cassandra_adapter.rb +59 -0
  88. data/lib/blazer/adapters/drill_adapter.rb +28 -0
  89. data/lib/blazer/adapters/druid_adapter.rb +67 -0
  90. data/lib/blazer/adapters/elasticsearch_adapter.rb +46 -0
  91. data/lib/blazer/adapters/mongodb_adapter.rb +39 -0
  92. data/lib/blazer/adapters/presto_adapter.rb +45 -0
  93. data/lib/blazer/adapters/snowflake_adapter.rb +73 -0
  94. data/lib/blazer/adapters/sql_adapter.rb +182 -0
  95. data/lib/blazer/data_source.rb +195 -0
  96. data/lib/blazer/detect_anomalies.R +19 -0
  97. data/lib/blazer/engine.rb +30 -0
  98. data/lib/blazer/result.rb +170 -0
  99. data/lib/blazer/run_statement.rb +40 -0
  100. data/lib/blazer/run_statement_job.rb +21 -0
  101. data/lib/blazer/version.rb +3 -0
  102. data/lib/generators/blazer/install_generator.rb +39 -0
  103. data/lib/generators/blazer/templates/config.yml.tt +62 -0
  104. data/lib/generators/blazer/templates/install.rb.tt +46 -0
  105. data/lib/tasks/blazer.rake +11 -0
  106. data/railsblazer-0.0.1.gem +0 -0
  107. metadata +234 -0
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "blazer/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "railsblazer"
8
+ spec.version = Blazer::VERSION
9
+ spec.authors = ["Andrew Kane"]
10
+ spec.email = ["andrew@chartkick.com"]
11
+ spec.summary = "Explore your data with SQL. Easily create charts and dashboards, and share them with your team."
12
+ spec.homepage = "https://github.com/ankane/blazer"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "railties", ">= 4"
21
+ spec.add_dependency "activerecord", ">= 4"
22
+ spec.add_dependency "chartkick"
23
+ spec.add_dependency "safely_block", ">= 0.1.1"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.7"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ end
@@ -0,0 +1,16 @@
1
+ Blazer::Engine.routes.draw do
2
+ resources :queries do
3
+ post :run, on: :collection # err on the side of caution
4
+ post :cancel, on: :collection
5
+ post :refresh, on: :member
6
+ get :tables, on: :collection
7
+ get :schema, on: :collection
8
+ end
9
+ resources :checks, except: [:show] do
10
+ get :run, on: :member
11
+ end
12
+ resources :dashboards do
13
+ post :refresh, on: :member
14
+ end
15
+ root to: "queries#home"
16
+ end
@@ -0,0 +1,223 @@
1
+ require "csv"
2
+ require "yaml"
3
+ require "chartkick"
4
+ require "safely/core"
5
+ require "blazer/version"
6
+ require "blazer/data_source"
7
+ require "blazer/result"
8
+ require "blazer/run_statement"
9
+ require "blazer/adapters/base_adapter"
10
+ require "blazer/adapters/athena_adapter"
11
+ require "blazer/adapters/bigquery_adapter"
12
+ require "blazer/adapters/cassandra_adapter"
13
+ require "blazer/adapters/drill_adapter"
14
+ require "blazer/adapters/druid_adapter"
15
+ require "blazer/adapters/elasticsearch_adapter"
16
+ require "blazer/adapters/mongodb_adapter"
17
+ require "blazer/adapters/presto_adapter"
18
+ require "blazer/adapters/sql_adapter"
19
+ require "blazer/adapters/snowflake_adapter"
20
+ require "blazer/engine"
21
+
22
+ module Blazer
23
+ class Error < StandardError; end
24
+ class TimeoutNotSupported < Error; end
25
+
26
+ class << self
27
+ attr_accessor :audit
28
+ attr_reader :time_zone
29
+ attr_accessor :user_name
30
+ attr_writer :user_class
31
+ attr_writer :user_method
32
+ attr_accessor :before_action
33
+ attr_accessor :from_email
34
+ attr_accessor :cache
35
+ attr_accessor :transform_statement
36
+ attr_accessor :check_schedules
37
+ attr_accessor :anomaly_checks
38
+ attr_accessor :async
39
+ attr_accessor :images
40
+ attr_accessor :query_viewable
41
+ attr_accessor :query_editable
42
+ attr_accessor :override_csp
43
+ attr_accessor :slack_webhook_url
44
+ end
45
+ self.audit = true
46
+ self.user_name = :name
47
+ self.check_schedules = ["5 minutes", "1 hour", "1 day"]
48
+ self.anomaly_checks = false
49
+ self.async = false
50
+ self.images = false
51
+ self.override_csp = false
52
+
53
+ TIMEOUT_MESSAGE = "Query timed out :("
54
+ TIMEOUT_ERRORS = [
55
+ "canceling statement due to statement timeout", # postgres
56
+ "canceling statement due to conflict with recovery", # postgres
57
+ "cancelled on user's request", # redshift
58
+ "canceled on user's request", # redshift
59
+ "system requested abort", # redshift
60
+ "maximum statement execution time exceeded" # mysql
61
+ ]
62
+ BELONGS_TO_OPTIONAL = {}
63
+ BELONGS_TO_OPTIONAL[:optional] = true if Rails::VERSION::MAJOR >= 5
64
+
65
+ def self.time_zone=(time_zone)
66
+ @time_zone = time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone.to_s]
67
+ end
68
+
69
+ def self.user_class
70
+ if !defined?(@user_class)
71
+ @user_class = settings.key?("user_class") ? settings["user_class"] : (User.name rescue nil)
72
+ end
73
+ @user_class
74
+ end
75
+
76
+ def self.user_method
77
+ if !defined?(@user_method)
78
+ @user_method = settings["user_method"]
79
+ if user_class
80
+ @user_method ||= "current_#{user_class.to_s.downcase.singularize}"
81
+ end
82
+ end
83
+ @user_method
84
+ end
85
+
86
+ def self.settings
87
+ @settings ||= begin
88
+ path = Rails.root.join("config", "blazer.yml").to_s
89
+ if File.exist?(path)
90
+ YAML.load(ERB.new(File.read(path)).result)
91
+ else
92
+ {}
93
+ end
94
+ end
95
+ end
96
+
97
+ def self.data_sources
98
+ @data_sources ||= begin
99
+ ds = Hash[
100
+ settings["data_sources"].map do |id, s|
101
+ [id, Blazer::DataSource.new(id, s)]
102
+ end
103
+ ]
104
+ ds.default = ds.values.first
105
+ ds
106
+
107
+ # TODO Blazer 2.0
108
+ # ds2 = Hash.new { |hash, key| raise Blazer::Error, "Unknown data source: #{key}" }
109
+ # ds.each do |k, v|
110
+ # ds2[k] = v
111
+ # end
112
+ # ds2
113
+ end
114
+ end
115
+
116
+ def self.extract_vars(statement)
117
+ # strip commented out lines
118
+ # and regex {1} or {1,2}
119
+ statement.gsub(/\-\-.+/, "").gsub(/\/\*.+\*\//m, "").scan(/\{\w*?\}/i).map { |v| v[1...-1] }.reject { |v| /\A\d+(\,\d+)?\z/.match(v) || v.empty? }.uniq
120
+ end
121
+
122
+ def self.run_checks(schedule: nil)
123
+ checks = Blazer::Check.includes(:query)
124
+ checks = checks.where(schedule: schedule) if schedule
125
+ checks.find_each do |check|
126
+ next if check.state == "disabled"
127
+ Safely.safely { run_check(check) }
128
+ end
129
+ end
130
+
131
+ def self.run_check(check)
132
+ tries = 1
133
+
134
+ ActiveSupport::Notifications.instrument("run_check.blazer", check_id: check.id, query_id: check.query.id, state_was: check.state) do |instrument|
135
+ # try 3 times on timeout errors
136
+ data_source = data_sources[check.query.data_source]
137
+ statement = check.query.statement
138
+ Blazer.transform_statement.call(data_source, statement) if Blazer.transform_statement
139
+
140
+ while tries <= 3
141
+ result = data_source.run_statement(statement, refresh_cache: true, check: check, query: check.query)
142
+ if result.timed_out?
143
+ Rails.logger.info "[blazer timeout] query=#{check.query.name}"
144
+ tries += 1
145
+ sleep(10)
146
+ elsif result.error.to_s.start_with?("PG::ConnectionBad")
147
+ data_source.reconnect
148
+ Rails.logger.info "[blazer reconnect] query=#{check.query.name}"
149
+ tries += 1
150
+ sleep(10)
151
+ else
152
+ break
153
+ end
154
+ end
155
+
156
+ begin
157
+ check.reload # in case state has changed since job started
158
+ check.update_state(result)
159
+ rescue ActiveRecord::RecordNotFound
160
+ # check deleted
161
+ end
162
+
163
+ # TODO use proper logfmt
164
+ Rails.logger.info "[blazer check] query=#{check.query.name} state=#{check.state} rows=#{result.rows.try(:size)} error=#{result.error}"
165
+
166
+ instrument[:statement] = statement
167
+ instrument[:data_source] = data_source
168
+ instrument[:state] = check.state
169
+ instrument[:rows] = result.rows.try(:size)
170
+ instrument[:error] = result.error
171
+ instrument[:tries] = tries
172
+ end
173
+ end
174
+
175
+ def self.send_failing_checks
176
+ emails = {}
177
+ slack_channels = {}
178
+
179
+ Blazer::Check.includes(:query).where(state: ["failing", "error", "timed out", "disabled"]).find_each do |check|
180
+ check.split_emails.each do |email|
181
+ (emails[email] ||= []) << check
182
+ end
183
+ check.split_slack_channels.each do |channel|
184
+ (slack_channels[channel] ||= []) << check
185
+ end
186
+ end
187
+
188
+ emails.each do |email, checks|
189
+ Safely.safely do
190
+ Blazer::CheckMailer.failing_checks(email, checks).deliver_now
191
+ end
192
+ end
193
+
194
+ slack_channels.each do |channel, checks|
195
+ Safely.safely do
196
+ Blazer::SlackNotifier.failing_checks(channel, checks)
197
+ end
198
+ end
199
+ end
200
+
201
+ def self.slack?
202
+ slack_webhook_url.present?
203
+ end
204
+
205
+ def self.adapters
206
+ @adapters ||= {}
207
+ end
208
+
209
+ def self.register_adapter(name, adapter)
210
+ adapters[name] = adapter
211
+ end
212
+ end
213
+
214
+ Blazer.register_adapter "athena", Blazer::Adapters::AthenaAdapter
215
+ Blazer.register_adapter "bigquery", Blazer::Adapters::BigQueryAdapter
216
+ Blazer.register_adapter "cassandra", Blazer::Adapters::CassandraAdapter
217
+ Blazer.register_adapter "drill", Blazer::Adapters::DrillAdapter
218
+ Blazer.register_adapter "druid", Blazer::Adapters::DruidAdapter
219
+ Blazer.register_adapter "elasticsearch", Blazer::Adapters::ElasticsearchAdapter
220
+ Blazer.register_adapter "presto", Blazer::Adapters::PrestoAdapter
221
+ Blazer.register_adapter "mongodb", Blazer::Adapters::MongodbAdapter
222
+ Blazer.register_adapter "sql", Blazer::Adapters::SqlAdapter
223
+ Blazer.register_adapter "snowflake", Blazer::Adapters::SnowflakeAdapter
@@ -0,0 +1,128 @@
1
+ module Blazer
2
+ module Adapters
3
+ class AthenaAdapter < BaseAdapter
4
+ def run_statement(statement, comment)
5
+ require "digest/md5"
6
+
7
+ columns = []
8
+ rows = []
9
+ error = nil
10
+
11
+ begin
12
+ resp =
13
+ client.start_query_execution(
14
+ query_string: statement,
15
+ # use token so we fetch cached results after query is run
16
+ client_request_token: Digest::MD5.hexdigest(statement),
17
+ query_execution_context: {
18
+ database: database,
19
+ },
20
+ result_configuration: {
21
+ output_location: settings["output_location"]
22
+ }
23
+ )
24
+ query_execution_id = resp.query_execution_id
25
+
26
+ timeout = data_source.timeout || 300
27
+ stop_at = Time.now + timeout
28
+ resp = nil
29
+
30
+ begin
31
+ resp = client.get_query_results(
32
+ query_execution_id: query_execution_id
33
+ )
34
+ rescue Aws::Athena::Errors::InvalidRequestException => e
35
+ if e.message != "Query has not yet finished. Current state: RUNNING"
36
+ raise e
37
+ end
38
+ if Time.now < stop_at
39
+ sleep(3)
40
+ retry
41
+ end
42
+ end
43
+
44
+ if resp && resp.result_set
45
+ column_info = resp.result_set.result_set_metadata.column_info
46
+ columns = column_info.map(&:name)
47
+ column_types = column_info.map(&:type)
48
+
49
+ untyped_rows = []
50
+
51
+ # paginated
52
+ resp.each do |page|
53
+ untyped_rows.concat page.result_set.rows.map { |r| r.data.map(&:var_char_value) }
54
+ end
55
+
56
+ utc = ActiveSupport::TimeZone['Etc/UTC']
57
+
58
+ rows = untyped_rows[1..-1] || []
59
+ column_types.each_with_index do |ct, i|
60
+ # TODO more column_types
61
+ case ct
62
+ when "timestamp"
63
+ rows.each do |row|
64
+ row[i] = utc.parse(row[i])
65
+ end
66
+ when "date"
67
+ rows.each do |row|
68
+ row[i] = Date.parse(row[i])
69
+ end
70
+ when "bigint"
71
+ rows.each do |row|
72
+ row[i] = row[i].to_i
73
+ end
74
+ when "double"
75
+ rows.each do |row|
76
+ row[i] = row[i].to_f
77
+ end
78
+ end
79
+ end
80
+ elsif resp
81
+ error = fetch_error(query_execution_id)
82
+ else
83
+ error = Blazer::TIMEOUT_MESSAGE
84
+ end
85
+ rescue Aws::Athena::Errors::InvalidRequestException => e
86
+ error = e.message
87
+ if error == "Query did not finish successfully. Final query state: FAILED"
88
+ error = fetch_error(query_execution_id)
89
+ end
90
+ end
91
+
92
+ [columns, rows, error]
93
+ end
94
+
95
+ def tables
96
+ glue.get_tables(database_name: database).table_list.map(&:name).sort
97
+ end
98
+
99
+ def schema
100
+ glue.get_tables(database_name: database).table_list.map { |t| {table: t.name, columns: t.storage_descriptor.columns.map { |c| {name: c.name, data_type: c.type} }} }
101
+ end
102
+
103
+ def preview_statement
104
+ "SELECT * FROM {table} LIMIT 10"
105
+ end
106
+
107
+ private
108
+
109
+ def database
110
+ @database ||= settings["database"] || "default"
111
+ end
112
+
113
+ def fetch_error(query_execution_id)
114
+ client.get_query_execution(
115
+ query_execution_id: query_execution_id
116
+ ).query_execution.status.state_change_reason
117
+ end
118
+
119
+ def client
120
+ @client ||= Aws::Athena::Client.new
121
+ end
122
+
123
+ def glue
124
+ @glue ||= Aws::Glue::Client.new
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,53 @@
1
+ module Blazer
2
+ module Adapters
3
+ class BaseAdapter
4
+ attr_reader :data_source
5
+
6
+ def initialize(data_source)
7
+ @data_source = data_source
8
+ end
9
+
10
+ def run_statement(statement, comment)
11
+ # the one required method
12
+ end
13
+
14
+ def tables
15
+ [] # optional, but nice to have
16
+ end
17
+
18
+ def schema
19
+ [] # optional, but nice to have
20
+ end
21
+
22
+ def preview_statement
23
+ "" # also optional, but nice to have
24
+ end
25
+
26
+ def reconnect
27
+ # optional
28
+ end
29
+
30
+ def cost(statement)
31
+ # optional
32
+ end
33
+
34
+ def explain(statement)
35
+ # optional
36
+ end
37
+
38
+ def cancel(run_id)
39
+ # optional
40
+ end
41
+
42
+ def cachable?(statement)
43
+ true # optional
44
+ end
45
+
46
+ protected
47
+
48
+ def settings
49
+ @data_source.settings
50
+ end
51
+ end
52
+ end
53
+ end