data_checks 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +213 -0
- data/lib/data_checks/check.rb +48 -0
- data/lib/data_checks/check_result.rb +27 -0
- data/lib/data_checks/check_run.rb +14 -0
- data/lib/data_checks/config.rb +51 -0
- data/lib/data_checks/ensure_less.rb +28 -0
- data/lib/data_checks/ensure_more.rb +28 -0
- data/lib/data_checks/ensure_no.rb +35 -0
- data/lib/data_checks/notifiers/email_default_formatter.rb +41 -0
- data/lib/data_checks/notifiers/email_notifier.rb +30 -0
- data/lib/data_checks/notifiers/logger_default_formatter.rb +15 -0
- data/lib/data_checks/notifiers/logger_notifier.rb +23 -0
- data/lib/data_checks/notifiers/notifier.rb +17 -0
- data/lib/data_checks/notifiers/slack_default_formatter.rb +55 -0
- data/lib/data_checks/notifiers/slack_notifier.rb +44 -0
- data/lib/data_checks/notifiers.rb +19 -0
- data/lib/data_checks/railtie.rb +9 -0
- data/lib/data_checks/runner.rb +91 -0
- data/lib/data_checks/status_printer.rb +41 -0
- data/lib/data_checks/version.rb +5 -0
- data/lib/data_checks.rb +27 -0
- data/lib/generators/data_checks/install_generator.rb +33 -0
- data/lib/generators/data_checks/templates/initializer.rb.tt +31 -0
- data/lib/generators/data_checks/templates/migration.rb.tt +17 -0
- data/lib/tasks/data_checks.rake +15 -0
- metadata +73 -0
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
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("&", "&").gsub("<", "<").gsub(">", ">")
|
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,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
|
data/lib/data_checks.rb
ADDED
@@ -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: []
|