data_checks 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 76f3628c85ded9452a6825695726c6814153051c3345812f6e411f447b9114fb
4
+ data.tar.gz: 04bca1e6301d41a901e73432ad98b062e508a40e0156fa8a803e0c3fdb765f2d
5
+ SHA512:
6
+ metadata.gz: 2133d07d50f683647d055aaf877169dadbd62bde6cde8bab24ed785e660c99f844ff80c2be0cd2f3bfec30ec8fba9d902d3ca85ef8dd877f89b54bfd1580d59c
7
+ data.tar.gz: 8c14320fbeaf09ca1a789f7c4232e5908849ede96b645b13a8f12a1922e07902f700a8c6ae36221d47f485d15a72be1f499789a58f4c0a62e4219ee94a65347c
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## master (unreleased)
2
+
3
+ ## 0.1.0 (2022-04-21)
4
+
5
+ - First release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 fatkodima
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # DataChecks
2
+
3
+ This gem provides a small DSL to check your data for inconsistencies and anomalies.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "data_checks"
11
+ ```
12
+
13
+ $ bundle install
14
+ $ bin/rails generate data_checks:install
15
+
16
+ ## Motivation
17
+
18
+ Making sure that data stays valid is not a trivial task. For simple requirements, like "this column is not null" or "this column is unique", you of course just use the database constraints and that's it. Same goes for type validation or reference integrity.
19
+
20
+ However, when you want to check for something more complex, then it all changes. Depending on your DBMS, you can use stored procedures, but this is often harder to write, version and maintain.
21
+
22
+ You could also assume that your data will never get corrupted, and validations directly in the code can do the trick ... but that'd be way too optimistic. Bugs happen all the time, and it's best to plan for the worst.
23
+
24
+ This gem doesn't aim to replace those tools, but provides something else that could serve a close purpose: *ensure that you work with the data you expect*.
25
+
26
+ This gem help you to schedule some verifications on your data and get alerts when something is unexpected.
27
+
28
+ ## Usage
29
+
30
+ A small DSL is provided to help express predicates and an easy way to configure notifications.
31
+
32
+ You will be notified when a check starts failing, and when it starts passing again.
33
+
34
+ ### Checking for inconsistencies
35
+
36
+ For example, we expect every image attachment to have previews in 3 sizes. It is possible, that when a new image was attached, some previews were not generated because of some failure. What we would like to ensure is that no image ends up without a full set of previews. We could write something like:
37
+
38
+ ```ruby
39
+ DataChecks.configure do
40
+ ensure_no :images_without_previews, tag: "hourly" do
41
+ Attachment.images.joins(:previews).having("COUNT(*) < 3").group(:attachment_id)
42
+ end
43
+
44
+ notifier :email,
45
+ from: "production@company.com",
46
+ to: "developer@company.com"
47
+ end
48
+ ```
49
+
50
+ ### Checking for anomalies
51
+
52
+ This gem can be also used to detect anomalies in the data. For example, you expect to have some number of new orders in the system in some period of time. Otherwise, this can hint at some bug in the order placing system worth investigating.
53
+
54
+ ```ruby
55
+ ensure_more :new_orders_per_hour, than: 10, tag: "hourly" do
56
+ Order.where("created_at >= ?", 1.hour.ago).count
57
+ end
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ Custom configurations should be placed in a `data_checks.rb` initializer.
63
+
64
+ ```ruby
65
+ # config/initializers/data_checks.rb
66
+
67
+ DataChecks.configure do
68
+ # ...
69
+ end
70
+ ```
71
+
72
+ ### Notifiers
73
+
74
+ Currently, the following notifiers are supported:
75
+
76
+ - `:email`: Uses `ActionMailer` to send emails. You can pass it any `ActionMailer` options.
77
+ - `:slack`: Sends notifications to Slack. Accepts the following options:
78
+ - `webhook_url`: The webhook url to send notifications to
79
+ - `:logger`: Uses `Logger` to output notifications to the log. Accepts the following params:
80
+ - `logdev`: The log device. This is a filename (String) or IO object (typically STDOUT, STDERR, or an open file).
81
+ - `level`: Logging severity threshold (e.g. Logger::INFO)
82
+
83
+ Each of them accepts a `formatter_class` config to configure the used formatter when generating a notification.
84
+
85
+ You can create custom notifiers by creating a subclass of [Notifier](https://github.com/fatkodima/data_checks/blob/master/lib/data_checks/notifiers/notifier.rb).
86
+
87
+ Create a notifier:
88
+
89
+ ```ruby
90
+ notifier :email,
91
+ from: "production@company.com",
92
+ to: "developer@company.com"
93
+ ```
94
+
95
+ Create multiple notifiers of the same type:
96
+
97
+ ```ruby
98
+ notifier "developers",
99
+ type: :email,
100
+ from: "production@company.com",
101
+ to: ["developer1@company.com", "developer2@company.com"]
102
+
103
+ notifier "tester",
104
+ type: :email,
105
+ from: "production@company.com",
106
+ to: "tester@company.com"
107
+
108
+ ensure_no :images_without_previews, notify: "developers" do # notify only developers
109
+ # ...
110
+ end
111
+ ```
112
+
113
+ ### Checks
114
+
115
+ * `ensure_no` will check that the result of a given block is `zero?`, `empty?` or `false`
116
+ * `ensure_any` will check that the result of a given block is `> 0`
117
+ * `ensure_more` will check that the result of a given block is `>` than a given number or that it contains more than a given number of items
118
+ * `ensure_less` will check that the result of a given block is `<` than a given number or that it contains less than a given number of items
119
+
120
+ ```ruby
121
+ ensure_no :images_without_previews do
122
+ # ...
123
+ end
124
+
125
+ ensure_any :facebook_logins_per_hour do
126
+ # ...
127
+ end
128
+
129
+ ensure_more :new_orders_per_hour, than: 10 do
130
+ # ...
131
+ end
132
+ ```
133
+
134
+ ### Customizing the error handler
135
+
136
+ Exceptions raised while a check runs are rescued and information about the error is persisted in the database.
137
+
138
+ If you want to integrate with an exception monitoring service (e.g. Bugsnag), you can define an error handler:
139
+
140
+ ```ruby
141
+ # config/initializers/data_checks.rb
142
+
143
+ DataChecks.config.error_handler = ->(error, check_context) do
144
+ Bugsnag.notify(error) do |notification|
145
+ notification.add_metadata(:data_checks, check_context)
146
+ end
147
+ end
148
+ ```
149
+
150
+ The error handler should be a lambda that accepts 2 arguments:
151
+
152
+ * `error`: The exception that was raised.
153
+ * `check_context`: A hash with additional information about the check:
154
+ * `check_name`: The name of the check that errored
155
+ * `ran_at`: The time when the check ran
156
+
157
+ ### Customizing the backtrace cleaner
158
+
159
+ `DataChecks.config.backtrace_cleaner` can be configured to specify a backtrace cleaner to use when a check errors and the backtrace is cleaned and persisted. An `ActiveSupport::BacktraceCleaner` should be used.
160
+
161
+ ```ruby
162
+ # config/initializers/data_checks.rb
163
+
164
+ cleaner = ActiveSupport::BacktraceCleaner.new
165
+ cleaner.add_silencer { |line| line =~ /ignore_this_dir/ }
166
+
167
+ DataChecks.config.backtrace_cleaner = cleaner
168
+ ```
169
+
170
+ If none is specified, the default `Rails.backtrace_cleaner` will be used to clean backtraces.
171
+
172
+ ### Schedule checks
173
+
174
+ Schedule checks to run (with cron, [Heroku Scheduler](https://elements.heroku.com/addons/scheduler), etc).
175
+
176
+ ```sh
177
+ rake data_checks:run_checks TAG="5 minutes" # run checks with tag="5 minutes"
178
+ rake data_checks:run_checks TAG="hourly" # run checks with tag="hourly"
179
+ rake data_checks:run_checks TAG="daily" # run checks with tag="daily"
180
+ rake data_checks:run_checks # run all checks
181
+ ```
182
+
183
+ Here's what it looks like with cron.
184
+
185
+ ```
186
+ */5 * * * * rake data_checks:run_checks TAG="5 minutes"
187
+ 0 * * * * rake data_checks:run_checks TAG="hourly"
188
+ 30 7 * * * rake data_checks:run_checks TAG="daily"
189
+ ```
190
+
191
+ You can also manually get a status of all the checks by running:
192
+
193
+ ```sh
194
+ rake data_checks:status
195
+ ```
196
+
197
+ ## Credits
198
+
199
+ Thanks to [checker_jobs gem](https://github.com/drivy/checker_jobs) for the original idea.
200
+
201
+ ## Development
202
+
203
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.
204
+
205
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
206
+
207
+ ## Contributing
208
+
209
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fatkodima/data_checks.
210
+
211
+ ## License
212
+
213
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class Check
5
+ attr_reader :name, :options, :tag, :block
6
+
7
+ def initialize(name, options, block)
8
+ @name = name.to_s
9
+ @options = options
10
+ @tag = options[:tag]&.to_s
11
+ @block = block
12
+ end
13
+
14
+ def run
15
+ result = block.call
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ error_result(e)
18
+ else
19
+ handle_result(result)
20
+ end
21
+
22
+ def check_run
23
+ @check_run ||= CheckRun.find_by(name: name)
24
+ end
25
+
26
+ def notifiers
27
+ configured_notifiers = DataChecks.config.notifier_options
28
+ notifiers = Array(options[:notify] || configured_notifiers.keys).map(&:to_s)
29
+
30
+ notifiers.map do |notifier|
31
+ raise "Unknown notifier: '#{notifier}'" unless configured_notifiers.key?(notifier)
32
+
33
+ type = configured_notifiers[notifier][:type]
34
+ klass = Notifiers.lookup(type)
35
+ klass.new(configured_notifiers[notifier])
36
+ end
37
+ end
38
+
39
+ private
40
+ def handle_result(_result)
41
+ raise NotImplementedError, "#{self.class.name} must implement a 'handle_result' method"
42
+ end
43
+
44
+ def error_result(error)
45
+ CheckResult.new(check: self, error: error)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class CheckResult
5
+ attr_reader :check, :passing, :count, :entries, :error
6
+
7
+ def initialize(check:, passing: false, count: nil, entries: nil, error: nil)
8
+ @check = check
9
+ @passing = passing
10
+ @count = count
11
+ @entries = entries
12
+ @error = error
13
+ end
14
+
15
+ def passing?
16
+ passing
17
+ end
18
+
19
+ def failing?
20
+ !passing && error.nil?
21
+ end
22
+
23
+ def error?
24
+ error
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class CheckRun < ActiveRecord::Base
5
+ self.table_name = :data_checks_runs
6
+
7
+ STATUSES = [:passing, :failing, :error]
8
+ enum status: STATUSES.map { |status| [status, status.to_s] }.to_h
9
+
10
+ serialize :backtrace
11
+
12
+ validates :name, :status, :last_run_at, presence: true
13
+ end
14
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class Config
5
+ attr_reader :checks, :notifier_options
6
+ attr_accessor :error_handler, :backtrace_cleaner
7
+
8
+ def initialize
9
+ @checks = []
10
+ @notifier_options = {}
11
+ end
12
+
13
+ def ensure_no(name, **options, &block)
14
+ add_check(EnsureNo, name, options, block)
15
+ end
16
+
17
+ def ensure_any(name, **options, &block)
18
+ add_check(EnsureMore, name, options.merge(than: 0), block)
19
+ end
20
+
21
+ def ensure_more(name, **options, &block)
22
+ add_check(EnsureMore, name, options, block)
23
+ end
24
+
25
+ def ensure_less(name, **options, &block)
26
+ add_check(EnsureLess, name, options, block)
27
+ end
28
+
29
+ def notifier(name, **options)
30
+ name = name.to_s
31
+
32
+ if notifier_options.key?(name)
33
+ raise ArgumentError, "Duplicate notifier: '#{name}'"
34
+ else
35
+ options[:type] ||= name
36
+ notifier_options[name] = options
37
+ end
38
+ end
39
+
40
+ private
41
+ def add_check(klass, name, options, block)
42
+ name = name.to_s
43
+
44
+ if checks.any? { |check| check.name == name }
45
+ raise ArgumentError, "Duplicate check: '#{name}'"
46
+ else
47
+ checks << klass.new(name, options, block)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class EnsureLess < Check
5
+ private
6
+ def handle_result(result)
7
+ expected = options.fetch(:than)
8
+ passing = true
9
+
10
+ case result
11
+ when Numeric
12
+ count = result
13
+ if result >= expected
14
+ passing = false
15
+ end
16
+ when Enumerable, ActiveRecord::Relation
17
+ count = result.count
18
+ if count >= expected
19
+ passing = false
20
+ end
21
+ else
22
+ raise ArgumentError, "Unsupported result: '#{result.class.name}' for 'ensure_less'"
23
+ end
24
+
25
+ CheckResult.new(check: self, passing: passing, count: count)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class EnsureMore < Check
5
+ private
6
+ def handle_result(result)
7
+ expected = options.fetch(:than)
8
+ passing = true
9
+
10
+ case result
11
+ when Numeric
12
+ count = result
13
+ if result <= expected
14
+ passing = false
15
+ end
16
+ when Enumerable, ActiveRecord::Relation
17
+ count = result.count
18
+ if count <= expected
19
+ passing = false
20
+ end
21
+ else
22
+ raise ArgumentError, "Unsupported result: '#{result.class.name}' for 'ensure_more'"
23
+ end
24
+
25
+ CheckResult.new(check: self, passing: passing, count: count)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class EnsureNo < Check
5
+ private
6
+ def handle_result(result)
7
+ passing = true
8
+ count = 0
9
+
10
+ case result
11
+ when Numeric
12
+ if result != 0
13
+ passing = false
14
+ count = result
15
+ end
16
+ # In ActiveRecord <= 4.2 ActiveRecord::Relation is not an Enumerable!
17
+ when Enumerable, ActiveRecord::Relation
18
+ unless result.empty?
19
+ passing = false
20
+ count = result.size
21
+ entries = result
22
+ end
23
+ when true
24
+ passing = false
25
+ count = 1
26
+ when false
27
+ # ok
28
+ else
29
+ raise ArgumentError, "Unsupported result: '#{result.class.name}' for 'ensure_no'"
30
+ end
31
+
32
+ CheckResult.new(check: self, passing: passing, count: count, entries: entries)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ class EmailDefaultFormatter
6
+ def initialize(check_result)
7
+ @check_run = check_result.check.check_run
8
+ @check_result = check_result
9
+ end
10
+
11
+ def subject
12
+ "Check #{@check_run.status.titleize}: #{@check_run.name}"
13
+ end
14
+
15
+ def body
16
+ error = @check_run.error_message
17
+ count = @check_result.count
18
+ entries = @check_result.entries&.map { |entry| format_entry(entry) }
19
+
20
+ if error
21
+ "<p>#{error}</p>"
22
+ else
23
+ body = "<p>Checker found #{count} element(s).</p>"
24
+ if entries
25
+ if count > 10
26
+ body += "<p>Showing 10 of #{count} entries</p>"
27
+ end
28
+
29
+ body += format("<ul>%s</ul>", entries.map { |entry| "<li>#{entry}</li>" }.join)
30
+ end
31
+ body
32
+ end
33
+ end
34
+
35
+ private
36
+ def format_entry(entry)
37
+ entry.respond_to?(:id) ? entry.id : entry.to_s
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ class EmailNotifier < Notifier
6
+ def initialize(options)
7
+ super
8
+ @formatter_class = options.delete(:formatter_class) || EmailDefaultFormatter
9
+ end
10
+
11
+ def notify(check_result)
12
+ formatter = @formatter_class.new(check_result)
13
+
14
+ body = formatter.body
15
+ email_options = { subject: formatter.subject }.merge(options)
16
+ Mailer.notify(body, email_options).deliver_now
17
+ end
18
+
19
+ class Mailer < ::ActionMailer::Base
20
+ layout false
21
+
22
+ def notify(body, options)
23
+ mail(options) do |format|
24
+ format.html { render html: body.html_safe }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ class LoggerDefaultFormatter
6
+ def initialize(check_result)
7
+ @check_run = check_result.check.check_run
8
+ end
9
+
10
+ def message
11
+ "[data_checks] Check #{@check_run.status.titleize}: #{@check_run.name}"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/logger"
4
+
5
+ module DataChecks
6
+ module Notifiers
7
+ class LoggerNotifier < Notifier
8
+ def initialize(options)
9
+ super
10
+
11
+ logdev = options[:logdev] || $stdout
12
+ level = options[:level] || Logger::INFO
13
+ @logger = ActiveSupport::Logger.new(logdev, level: level)
14
+ @formatter_class = options.delete(:formatter_class) || LoggerDefaultFormatter
15
+ end
16
+
17
+ def notify(check_result)
18
+ formatter = @formatter_class.new(check_result)
19
+ @logger.add(@logger.level, formatter.message)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ class Notifier
6
+ attr_reader :options
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def notify(check_result)
13
+ raise NotImplementedError, "#{self.class.name} must implement a 'notify' method"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ class SlackDefaultFormatter
6
+ def initialize(check_result)
7
+ @check_run = check_result.check.check_run
8
+ @check_result = check_result
9
+ end
10
+
11
+ def title
12
+ escape("Check #{@check_run.status.titleize}: #{@check_run.name}")
13
+ end
14
+
15
+ def text
16
+ error = @check_run.error_message
17
+ count = @check_result.count
18
+ entries = @check_result.entries&.map { |entry| format_entry(entry) }
19
+
20
+ if error
21
+ escape(error)
22
+ else
23
+ text = ["Checker found #{count} element(s)."]
24
+ if entries
25
+ if count > 10
26
+ text << "Showing 10 of #{count} entries"
27
+ end
28
+
29
+ text += entries.first(10).map { |entry| "- #{entry}" }
30
+ end
31
+
32
+ escape(text.join("\n"))
33
+ end
34
+ end
35
+
36
+ def color
37
+ if @check_run.status == CheckRun.statuses[:passing]
38
+ "good"
39
+ else
40
+ "danger"
41
+ end
42
+ end
43
+
44
+ private
45
+ # https://api.slack.com/docs/message-formatting#how_to_escape_characters
46
+ def escape(str)
47
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
48
+ end
49
+
50
+ def format_entry(entry)
51
+ entry.respond_to?(:id) ? entry.id : entry.to_s
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module DataChecks
6
+ module Notifiers
7
+ class SlackNotifier < Notifier
8
+ def initialize(options)
9
+ super
10
+ @formatter_class = options.delete(:formatter_class) || SlackDefaultFormatter
11
+ @webhook_url = options.fetch(:webhook_url) { raise ArgumentError, "webhook_url must be configured" }
12
+ end
13
+
14
+ def notify(check_result)
15
+ formatter = @formatter_class.new(check_result)
16
+
17
+ payload = {
18
+ attachments: [
19
+ {
20
+ title: formatter.title,
21
+ text: formatter.text,
22
+ color: formatter.color,
23
+ },
24
+ ],
25
+ }
26
+
27
+ response = post(payload)
28
+ unless response.is_a?(Net::HTTPSuccess) && response.body == "ok"
29
+ raise "Failed to notify slack: #{response.body.inspect}"
30
+ end
31
+ end
32
+
33
+ private
34
+ def post(payload)
35
+ uri = URI.parse(@webhook_url)
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.use_ssl = true
38
+ http.open_timeout = 3
39
+ http.read_timeout = 5
40
+ http.post(uri.request_uri, payload.to_json)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ module Notifiers
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :Notifier
8
+ autoload :EmailDefaultFormatter
9
+ autoload :EmailNotifier
10
+ autoload :SlackDefaultFormatter
11
+ autoload :SlackNotifier
12
+ autoload :LoggerDefaultFormatter
13
+ autoload :LoggerNotifier
14
+
15
+ def self.lookup(name)
16
+ const_get("#{name.to_s.camelize}Notifier")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load "tasks/data_checks.rake"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class Runner
5
+ def run_checks(tag: nil)
6
+ checks = DataChecks.config.checks
7
+ checks = checks.select { |check| check.tag == tag.to_s } if tag
8
+
9
+ checks.each do |check|
10
+ run_one(check)
11
+ end
12
+ end
13
+
14
+ def run_check(name)
15
+ checks = DataChecks.config.checks
16
+ check = checks.find { |c| c.name == name.to_s }
17
+ raise "Check #{name} not found" unless check
18
+
19
+ run_one(check)
20
+ end
21
+
22
+ private
23
+ def run_one(check)
24
+ previous_check_run = check.check_run
25
+ previous_status = previous_check_run&.status
26
+
27
+ check_result = check.run
28
+
29
+ if check_result.error?
30
+ check_run = mark_check_as_errored(check, check_result.error)
31
+
32
+ check_context = { check_name: check.name, run_at: Time.current }
33
+ handle_exception(check_result.error, context: check_context)
34
+ else
35
+ check_run = mark_check_as_completed(check, check_result.passing?)
36
+ end
37
+
38
+ # Notify if not passing or the status changed
39
+ if !check_result.passing? ||
40
+ (previous_status && previous_status != check_run.status)
41
+ notify(check.notifiers, check_result)
42
+ end
43
+
44
+ check_result
45
+ end
46
+
47
+ def mark_check_as_errored(check, error)
48
+ backtrace_cleaner = DataChecks.config.backtrace_cleaner
49
+
50
+ run = check.check_run || CheckRun.new(name: check.name)
51
+ run.status = :error
52
+ run.error_class = error.class.name
53
+ run.error_message = error.message
54
+ run.backtrace = backtrace_cleaner ? backtrace_cleaner.clean(error.backtrace) : error.backtrace
55
+ run.last_run_at = Time.current
56
+ run.save!
57
+ run
58
+ end
59
+
60
+ def mark_check_as_completed(check, passing)
61
+ run = check.check_run || CheckRun.new(name: check.name)
62
+ run.status = (passing ? :passing : :failing)
63
+ run.error_class = nil
64
+ run.error_message = nil
65
+ run.backtrace = nil
66
+ run.last_run_at = Time.current
67
+ run.save!
68
+ run
69
+ end
70
+
71
+ def notify(notifiers, check_result)
72
+ notifiers.each do |notifier|
73
+ safely { notifier.notify(check_result) }
74
+ end
75
+ end
76
+
77
+ def safely
78
+ yield
79
+ rescue Exception => e # rubocop:disable Lint/RescueException
80
+ handle_exception(e)
81
+ end
82
+
83
+ def handle_exception(exception, context: {})
84
+ if (error_handler = DataChecks.config.error_handler)
85
+ error_handler.call(exception, context)
86
+ else
87
+ raise exception
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ class StatusPrinter
5
+ def initialize(io = $stdout)
6
+ @io = io
7
+ end
8
+
9
+ def print
10
+ runs = DataChecks::CheckRun.all.to_a
11
+ counts = Hash.new { |h, k| h[k] = 0 }
12
+
13
+ DataChecks.config.checks.each do |check|
14
+ run = runs.find { |r| r.name == check.name }
15
+
16
+ if run
17
+ counts[run.status] += 1
18
+
19
+ formatted_time = run.last_run_at.to_formatted_s(:db)
20
+ @io.puts("Check #{check.name} (at #{formatted_time}) - #{run.status.titleize}")
21
+ else
22
+ counts["not_ran"] += 1
23
+ @io.puts("Check #{check.name} - Not ran yet")
24
+ end
25
+ end
26
+
27
+ @io.puts
28
+ print_summary(counts)
29
+ end
30
+
31
+ private
32
+ def print_summary(counts)
33
+ statuses = DataChecks::CheckRun.statuses
34
+ summary = "Error: #{counts[statuses[:error]]}, "\
35
+ "Failing: #{counts[statuses[:failing]]}, "\
36
+ "Passing: #{counts[statuses[:passing]]}"
37
+ summary += ", Not Ran: #{counts['not_ran']}" if counts["not_ran"] > 0
38
+ @io.puts(summary)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataChecks
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "data_checks/check_run"
4
+ require "data_checks/check_result"
5
+ require "data_checks/check"
6
+ require "data_checks/ensure_less"
7
+ require "data_checks/ensure_more"
8
+ require "data_checks/ensure_no"
9
+ require "data_checks/notifiers"
10
+ require "data_checks/config"
11
+ require "data_checks/runner"
12
+ require "data_checks/status_printer"
13
+ require "data_checks/version"
14
+
15
+ require "data_checks/railtie" if defined?(Rails)
16
+
17
+ module DataChecks
18
+ class << self
19
+ def config
20
+ @config ||= Config.new
21
+ end
22
+
23
+ def configure(&block)
24
+ config.instance_exec(&block)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ module DataChecks
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_migration_file
13
+ migration_template("migration.rb", File.join(migrations_dir, "install_data_checks.rb"))
14
+ end
15
+
16
+ def copy_initializer_file
17
+ template("initializer.rb", "config/initializers/data_checks.rb")
18
+ end
19
+
20
+ private
21
+ def start_after
22
+ self.class.next_migration_number(migrations_dir)
23
+ end
24
+
25
+ def migrations_dir
26
+ ar_version >= 5.1 ? db_migrate_path : "db/migrate"
27
+ end
28
+
29
+ def ar_version
30
+ ActiveRecord.version.to_s.to_f
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ DataChecks.configure do
4
+ # ==> Configure notifiers
5
+ #
6
+ # Predefined notifiers are :email, :slack, and :logger.
7
+ #
8
+ # notifier :email,
9
+ # from: "production@company.com",
10
+ # to: "developer@company.com",
11
+ # formatter_class: DataChecks::Notifiers::EmailDefaultFormatter # default
12
+
13
+ # ==> Configure checks
14
+ #
15
+ # Available checks are :ensure_no, :ensure_any, :ensure_more, and :ensure_less.
16
+ #
17
+ # ensure_no :users_without_emails do
18
+ # User.where(email: nil).count
19
+ # end
20
+
21
+ # The Active Support backtrace cleaner that will be used to clean the
22
+ # backtrace of a check that errors.
23
+ self.backtrace_cleaner = Rails.backtrace_cleaner
24
+
25
+ # The callback to perform when an error occurs in the check.
26
+ # self.error_handler = ->(error, check_context) do
27
+ # Bugsnag.notify(error) do |notification|
28
+ # notification.add_metadata(:data_checks, check_context)
29
+ # end
30
+ # end
31
+ end
@@ -0,0 +1,17 @@
1
+ class InstallDataChecks < ActiveRecord::Migration[<%= ar_version %>]
2
+ def change
3
+ create_table :data_checks_runs do |t|
4
+ t.string :name, null: false
5
+ t.string :status, null: false
6
+ t.datetime :last_run_at, null: false
7
+
8
+ t.string :error_class
9
+ t.string :error_message
10
+ t.text :backtrace
11
+
12
+ t.timestamps
13
+
14
+ t.index :name, unique: true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :data_checks do
4
+ desc "Run checks"
5
+ task :run_checks, [:tag] => :environment do |_, args|
6
+ runner = DataChecks::Runner.new
7
+ runner.run_checks(tag: args[:tag] || ENV["TAG"])
8
+ end
9
+
10
+ desc "Show statuses of all checks"
11
+ task status: :environment do
12
+ printer = DataChecks::StatusPrinter.new
13
+ printer.print
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: data_checks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - fatkodima
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-04-21 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - fatkodima123@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/data_checks.rb
24
+ - lib/data_checks/check.rb
25
+ - lib/data_checks/check_result.rb
26
+ - lib/data_checks/check_run.rb
27
+ - lib/data_checks/config.rb
28
+ - lib/data_checks/ensure_less.rb
29
+ - lib/data_checks/ensure_more.rb
30
+ - lib/data_checks/ensure_no.rb
31
+ - lib/data_checks/notifiers.rb
32
+ - lib/data_checks/notifiers/email_default_formatter.rb
33
+ - lib/data_checks/notifiers/email_notifier.rb
34
+ - lib/data_checks/notifiers/logger_default_formatter.rb
35
+ - lib/data_checks/notifiers/logger_notifier.rb
36
+ - lib/data_checks/notifiers/notifier.rb
37
+ - lib/data_checks/notifiers/slack_default_formatter.rb
38
+ - lib/data_checks/notifiers/slack_notifier.rb
39
+ - lib/data_checks/railtie.rb
40
+ - lib/data_checks/runner.rb
41
+ - lib/data_checks/status_printer.rb
42
+ - lib/data_checks/version.rb
43
+ - lib/generators/data_checks/install_generator.rb
44
+ - lib/generators/data_checks/templates/initializer.rb.tt
45
+ - lib/generators/data_checks/templates/migration.rb.tt
46
+ - lib/tasks/data_checks.rake
47
+ homepage: https://github.com/fatkodima/data_checks
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/fatkodima/data_checks
52
+ source_code_uri: https://github.com/fatkodima/data_checks
53
+ changelog_uri: https://github.com/fatkodima/data_checks/blob/master/CHANGELOG.md
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.3.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.3.7
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Regression testing for data
73
+ test_files: []