rake_audit 0.1.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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +7 -0
  4. data/README.md +235 -0
  5. data/app/controllers/rake_audit/application_controller.rb +53 -0
  6. data/app/controllers/rake_audit/dashboard_controller.rb +36 -0
  7. data/app/controllers/rake_audit/executions_controller.rb +39 -0
  8. data/app/models/rake_audit/task_execution.rb +33 -0
  9. data/app/views/layouts/rake_audit/application.html.erb +70 -0
  10. data/app/views/rake_audit/dashboard/index.html.erb +48 -0
  11. data/app/views/rake_audit/executions/index.html.erb +74 -0
  12. data/app/views/rake_audit/executions/show.html.erb +46 -0
  13. data/config/routes.rb +13 -0
  14. data/db/migrate/20260531120000_create_rake_task_executions.rb +62 -0
  15. data/lib/generators/rake_audit/install_generator.rb +42 -0
  16. data/lib/generators/rake_audit/templates/create_rake_task_executions.rb +55 -0
  17. data/lib/generators/rake_audit/templates/initializer.rb +17 -0
  18. data/lib/rake_audit/adapters/active_record_adapter.rb +73 -0
  19. data/lib/rake_audit/adapters/base.rb +94 -0
  20. data/lib/rake_audit/adapters/execution_record.rb +20 -0
  21. data/lib/rake_audit/adapters/mongo_adapter.rb +137 -0
  22. data/lib/rake_audit/adapters/redis_adapter.rb +174 -0
  23. data/lib/rake_audit/builders/task_execution_record_builder.rb +81 -0
  24. data/lib/rake_audit/configuration.rb +62 -0
  25. data/lib/rake_audit/execution_recorder.rb +78 -0
  26. data/lib/rake_audit/rails/engine.rb +20 -0
  27. data/lib/rake_audit/rails/railtie.rb +17 -0
  28. data/lib/rake_audit/record_not_found.rb +5 -0
  29. data/lib/rake_audit/task_execution_record.rb +46 -0
  30. data/lib/rake_audit/task_patch.rb +19 -0
  31. data/lib/rake_audit/version.rb +5 -0
  32. data/lib/rake_audit.rb +82 -0
  33. metadata +120 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'execution_record'
5
+ require_relative '../record_not_found'
6
+ require 'json'
7
+ require 'securerandom'
8
+
9
+ module RakeAudit
10
+ module Adapters
11
+ # Persists and queries TaskExecutionRecord instances via Redis.
12
+ #
13
+ # Each record is serialized as JSON (with a generated UUID +id+ field) and
14
+ # prepended to the +rake_audit:executions+ list key via +LPUSH+. Query
15
+ # methods read the full list and filter/sort/paginate in Ruby, making this
16
+ # adapter suitable for low-to-moderate write volumes where a full DB is
17
+ # unavailable but basic Web UI introspection is still desired.
18
+ class RedisAdapter < Base # rubocop:disable Metrics/ClassLength
19
+ # @param client [Redis] a connected Redis client instance.
20
+ def initialize(client:)
21
+ super()
22
+ @client = client
23
+ end
24
+
25
+ # Push the serialized record onto the executions list.
26
+ # A UUID +id+ is generated and embedded so the Web UI can link to details.
27
+ #
28
+ # @param record [TaskExecutionRecord]
29
+ def save(record)
30
+ data = record.to_h.merge(id: SecureRandom.uuid)
31
+ @client.lpush('rake_audit:executions', data.to_json)
32
+ end
33
+
34
+ # @param filters [Hash] pre-validated non-blank filter values.
35
+ # @param page [Integer, String, nil]
36
+ # @param per_page [Integer]
37
+ # @return [Kaminari::PaginatableArray]
38
+ def query(filters: {}, page: nil, per_page: 25)
39
+ require 'kaminari'
40
+ records = filtered_and_sorted(filters)
41
+ total = records.size
42
+ current = [page.to_i, 1].max
43
+ paged = records.slice((current - 1) * per_page, per_page) || []
44
+
45
+ Kaminari
46
+ .paginate_array(paged.map { |r| to_execution_record(r) }, total_count: total)
47
+ .page(current)
48
+ .per(per_page)
49
+ end
50
+
51
+ # @param id [String] UUID stored at write time.
52
+ # @return [RakeAudit::Adapters::ExecutionRecord]
53
+ # @raise [RakeAudit::RecordNotFound]
54
+ def find(id)
55
+ raw = all_records.find { |r| r[:id].to_s == id.to_s }
56
+ raise RakeAudit::RecordNotFound, "Couldn't find execution with id=#{id}" unless raw
57
+
58
+ to_execution_record(raw)
59
+ end
60
+
61
+ # @return [Integer]
62
+ def count
63
+ all_records.size
64
+ end
65
+
66
+ # @param status [String]
67
+ # @return [Integer]
68
+ def count_by_status(status)
69
+ all_records.count { |r| r[:status].to_s == status }
70
+ end
71
+
72
+ # @return [Float, nil]
73
+ def average_duration_ms
74
+ records = all_records
75
+ return nil if records.empty?
76
+
77
+ records.sum { |r| r[:duration_ms].to_f } / records.size
78
+ end
79
+
80
+ # @param limit [Integer]
81
+ # @return [Array<Array(String, Integer)>]
82
+ def top_failed_tasks(limit: 10)
83
+ top_failed_from(all_records.select { |r| r[:status].to_s == 'failure' }, limit: limit)
84
+ end
85
+
86
+ # Single-pass override: reads the full Redis list once and derives all five
87
+ # dashboard metrics in memory, replacing the default five-separate-LRANGE
88
+ # implementation inherited from Base.
89
+ #
90
+ # @return [Hash]
91
+ def stats
92
+ records = all_records
93
+ total = records.size
94
+ failure_recs = records.select { |r| r[:status].to_s == 'failure' }
95
+
96
+ {
97
+ total: total,
98
+ success_count: records.count { |r| r[:status].to_s == 'success' },
99
+ failure_count: failure_recs.size,
100
+ average_duration_ms: avg_duration(records, total),
101
+ top_failed_tasks: top_failed_from(failure_recs)
102
+ }
103
+ end
104
+
105
+ private
106
+
107
+ def all_records
108
+ @client
109
+ .lrange('rake_audit:executions', 0, -1)
110
+ .map { |json| JSON.parse(json, symbolize_names: true) }
111
+ end
112
+
113
+ def avg_duration(records, total)
114
+ total.zero? ? nil : records.sum { |r| r[:duration_ms].to_f } / total
115
+ end
116
+
117
+ def top_failed_from(failure_records, limit: 10)
118
+ failure_records
119
+ .group_by { |r| r[:task_name].to_s }
120
+ .transform_values(&:size)
121
+ .sort_by { |_, c| -c }
122
+ .first(limit)
123
+ .map { |name, c| [name, c] }
124
+ end
125
+
126
+ def filtered_and_sorted(filters)
127
+ apply_date_range(apply_exact_filters(all_records, filters), filters)
128
+ .sort_by { |r| parse_time(r[:started_at]).to_i }
129
+ .reverse
130
+ end
131
+
132
+ def apply_exact_filters(records, filters)
133
+ %i[task_name status hostname rails_env].reduce(records) do |recs, col|
134
+ next recs unless filters.key?(col)
135
+
136
+ recs.select { |r| r[col].to_s == filters[col].to_s }
137
+ end
138
+ end
139
+
140
+ def apply_date_range(records, filters)
141
+ records = records.select { |r| parse_time(r[:started_at]) >= parse_time(filters[:from]) } if filters.key?(:from)
142
+ records = records.select { |r| parse_time(r[:started_at]) <= parse_time(filters[:to]) } if filters.key?(:to)
143
+ records
144
+ end
145
+
146
+ def parse_time(value)
147
+ return Time.at(0) if value.nil?
148
+ return value if value.is_a?(Time)
149
+
150
+ Time.parse(value.to_s)
151
+ rescue ArgumentError
152
+ Time.at(0)
153
+ end
154
+
155
+ def to_execution_record(raw)
156
+ RakeAudit::Adapters::ExecutionRecord.new(
157
+ id: raw[:id]&.to_s,
158
+ task_name: raw[:task_name],
159
+ arguments: raw[:arguments],
160
+ started_at: raw[:started_at],
161
+ finished_at: raw[:finished_at],
162
+ duration_ms: raw[:duration_ms],
163
+ status: raw[:status],
164
+ error_class: raw[:error_class],
165
+ error_message: raw[:error_message],
166
+ hostname: raw[:hostname],
167
+ pid: raw[:pid],
168
+ ruby_version: raw[:ruby_version],
169
+ rails_env: raw[:rails_env]
170
+ )
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ require_relative '../task_execution_record'
6
+
7
+ module RakeAudit
8
+ module Builders
9
+ # Assembles a {RakeAudit::TaskExecutionRecord} from the raw inputs gathered
10
+ # by {RakeAudit::ExecutionRecorder}: the task, its arguments, the timing
11
+ # window, and any exception that was raised.
12
+ #
13
+ # Environment fields (hostname, pid, ruby version, rails env) are captured
14
+ # here and are individually gated by the active {RakeAudit::Configuration}.
15
+ module TaskExecutionRecordBuilder
16
+ module_function
17
+
18
+ # Build a record describing one execution.
19
+ #
20
+ # @param task [#name] the executed Rake task (only +#name+ is required).
21
+ # @param args [Object, nil] arguments passed to the task.
22
+ # @param started_at [Time] when execution began.
23
+ # @param finished_at [Time] when execution finished (success or failure).
24
+ # @param exception [Exception, nil] the raised exception, if the task failed.
25
+ # @param config [RakeAudit::Configuration] configuration controlling capture.
26
+ # @return [RakeAudit::TaskExecutionRecord]
27
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity
28
+ def build(task:, args:, started_at:, finished_at:, exception:, config:)
29
+ TaskExecutionRecord.new(
30
+ task_name: task_name_for(task),
31
+ arguments: normalize_arguments(args),
32
+ started_at: started_at,
33
+ finished_at: finished_at,
34
+ duration_ms: duration_ms_for(started_at, finished_at),
35
+ status: exception ? 'failure' : 'success',
36
+ error_class: exception&.class&.name,
37
+ error_message: exception&.message,
38
+ hostname: config.capture_hostname ? Socket.gethostname : nil,
39
+ pid: config.capture_pid ? Process.pid : nil,
40
+ ruby_version: config.capture_ruby_version ? RUBY_VERSION : nil,
41
+ rails_env: config.capture_rails_env ? rails_env : nil
42
+ )
43
+ end
44
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity
45
+
46
+ # @api private
47
+ def task_name_for(task)
48
+ task.respond_to?(:name) ? task.name.to_s : task.to_s
49
+ end
50
+
51
+ # Coerce the task arguments into a plain Hash for stable serialization.
52
+ #
53
+ # Rake passes a +Rake::TaskArguments+ which responds to +#to_hash+; we also
54
+ # accept a raw Hash and treat anything else (including +nil+) as empty.
55
+ #
56
+ # @api private
57
+ # @return [Hash]
58
+ def normalize_arguments(args)
59
+ if args.respond_to?(:to_hash)
60
+ args.to_hash
61
+ else
62
+ {}
63
+ end
64
+ end
65
+
66
+ # @api private
67
+ # @return [Integer] elapsed wall-clock time in whole milliseconds.
68
+ def duration_ms_for(started_at, finished_at)
69
+ ((finished_at - started_at) * 1000).round
70
+ end
71
+
72
+ # @api private
73
+ # @return [String, nil] the current Rails environment, when available.
74
+ def rails_env
75
+ return nil unless defined?(Rails) && Rails.respond_to?(:env)
76
+
77
+ Rails.env.to_s
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module RakeAudit
6
+ # Holds all tunable settings for RakeAudit.
7
+ #
8
+ # An instance is created lazily by {RakeAudit.config} and mutated through
9
+ # {RakeAudit.configure}. All attributes carry sane defaults so that the gem
10
+ # is safe to load even when nothing has been configured.
11
+ class Configuration
12
+ # @return [Object, nil] storage adapter instance responding to +#save(record)+.
13
+ # When +nil+, recording is a no-op.
14
+ attr_accessor :adapter
15
+
16
+ # @return [Logger] logger used to report adapter save failures.
17
+ attr_accessor :logger
18
+
19
+ # @return [Boolean] whether to capture the machine hostname.
20
+ attr_accessor :capture_hostname
21
+
22
+ # @return [Boolean] whether to capture the process id.
23
+ attr_accessor :capture_pid
24
+
25
+ # @return [Boolean] whether to capture the Ruby version.
26
+ attr_accessor :capture_ruby_version
27
+
28
+ # @return [Boolean] whether to capture the Rails environment.
29
+ attr_accessor :capture_rails_env
30
+
31
+ # @return [Boolean] whether the bundled Web UI is enabled.
32
+ attr_accessor :web_ui_enabled
33
+
34
+ # @return [Proc, nil] callable used to authenticate Web UI requests.
35
+ attr_accessor :authenticate_with
36
+
37
+ def initialize
38
+ @adapter = nil
39
+ @logger = default_logger
40
+ @capture_hostname = true
41
+ @capture_pid = true
42
+ @capture_ruby_version = true
43
+ @capture_rails_env = true
44
+ @web_ui_enabled = true
45
+ @authenticate_with = nil
46
+ end
47
+
48
+ private
49
+
50
+ # Prefer +Rails.logger+ when running inside a Rails app, otherwise fall back
51
+ # to a plain stdout logger so the gem works standalone.
52
+ #
53
+ # @return [Logger]
54
+ def default_logger
55
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
56
+ Rails.logger
57
+ else
58
+ Logger.new($stdout)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'builders/task_execution_record_builder'
4
+
5
+ module RakeAudit
6
+ # Wraps a single Rake task execution to capture timing, success/failure, and
7
+ # any raised exception, then persists a {TaskExecutionRecord} via the
8
+ # configured adapter.
9
+ #
10
+ # Design invariants:
11
+ # - The original task exception always propagates unchanged (re-raised).
12
+ # - Persistence happens in an +ensure+ block so it runs on both success and
13
+ # failure paths.
14
+ # - Adapter errors are logged and swallowed; they never affect the task result.
15
+ # - When no adapter is configured, saving is skipped entirely.
16
+ class ExecutionRecorder
17
+ # @param task [#name] the Rake task being executed.
18
+ # @param args [Object, nil] arguments passed to the task.
19
+ def initialize(task:, args: nil)
20
+ @task = task
21
+ @args = args
22
+ end
23
+
24
+ # Execute the given block, capturing timing and outcome.
25
+ #
26
+ # @yield runs the wrapped task body (typically +super+ from the patch).
27
+ # @return [Object] the block's return value on success.
28
+ # @raise [Exception] re-raises whatever the block raised, unchanged.
29
+ def record
30
+ started_at = now
31
+ exception = nil
32
+ begin
33
+ yield
34
+ rescue Exception => e # rubocop:disable Lint/RescueException
35
+ exception = e
36
+ raise
37
+ ensure
38
+ finished_at = now
39
+ save_record(started_at: started_at, finished_at: finished_at, exception: exception)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :task, :args
46
+
47
+ # Build and persist the record. Never raises: adapter failures are logged.
48
+ def save_record(started_at:, finished_at:, exception:)
49
+ config = RakeAudit.config
50
+ adapter = config.adapter
51
+ return if adapter.nil?
52
+
53
+ record = Builders::TaskExecutionRecordBuilder.build(
54
+ task: task,
55
+ args: args,
56
+ started_at: started_at,
57
+ finished_at: finished_at,
58
+ exception: exception,
59
+ config: config
60
+ )
61
+ adapter.save(record)
62
+ rescue StandardError => e
63
+ log_save_failure(config, e)
64
+ end
65
+
66
+ def log_save_failure(config, error)
67
+ logger = config&.logger
68
+ return unless logger.respond_to?(:error)
69
+
70
+ logger.error("[RakeAudit] Save failed: #{error.class}: #{error.message}")
71
+ end
72
+
73
+ # @return [Time] monotonic-friendly current wall-clock time.
74
+ def now
75
+ Time.now
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kaminari powers the execution list's pagination (+.page+ in the controller and
4
+ # the +paginate+ helper in the view). Requiring it alongside the Engine — which
5
+ # is itself only loaded when Rails is present — guarantees Kaminari's
6
+ # ActiveRecord and ActionView integrations are active for the Web UI without
7
+ # forcing the dependency on a plain-Ruby (no Rails) load of the gem.
8
+ require 'kaminari'
9
+
10
+ module RakeAudit
11
+ # Rails Engine for RakeAudit.
12
+ #
13
+ # Mounting an isolated Engine namespaces the gem's models, controllers, and
14
+ # routes under +RakeAudit+ so they never collide with the host application.
15
+ # The Engine also makes the gem's +app/+ directory part of the host app's
16
+ # load paths, which is how {RakeAudit::TaskExecution} becomes autoloadable.
17
+ class Engine < ::Rails::Engine
18
+ isolate_namespace RakeAudit
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RakeAudit
4
+ # Hooks RakeAudit into the Rails boot process.
5
+ #
6
+ # The {RakeAudit::TaskPatch} is prepended into +Rake::Task+ only after the
7
+ # full Rails application has initialized, and only when a storage adapter has
8
+ # been configured. Deferring to +after_initialize+ guarantees that the
9
+ # initializer (e.g. +config/initializers/rake_audit.rb+) has already run and
10
+ # that every dependency the adapter needs is loaded; skipping the prepend when
11
+ # no adapter is present keeps recording a true no-op for unconfigured apps.
12
+ class Railtie < ::Rails::Railtie
13
+ config.after_initialize do
14
+ RakeAudit.install! if RakeAudit.config.adapter
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RakeAudit
4
+ class RecordNotFound < StandardError; end
5
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RakeAudit
4
+ # Immutable data transfer object describing a single Rake task execution.
5
+ #
6
+ # This Struct is the serialization boundary used by every storage adapter:
7
+ # adapters must rely on {#to_h} and never reach into internal state directly.
8
+ TaskExecutionRecord = Struct.new(
9
+ :task_name,
10
+ :arguments,
11
+ :started_at,
12
+ :finished_at,
13
+ :duration_ms,
14
+ :status,
15
+ :error_class,
16
+ :error_message,
17
+ :hostname,
18
+ :pid,
19
+ :ruby_version,
20
+ :rails_env,
21
+ keyword_init: true
22
+ ) do
23
+ # The canonical, adapter-facing serialization of this record.
24
+ #
25
+ # Always returns a plain Hash containing all 12 fields, regardless of how
26
+ # the record was constructed. Adapters depend on this exact contract.
27
+ #
28
+ # @return [Hash{Symbol=>Object}]
29
+ def to_h
30
+ {
31
+ task_name: task_name,
32
+ arguments: arguments,
33
+ started_at: started_at,
34
+ finished_at: finished_at,
35
+ duration_ms: duration_ms,
36
+ status: status,
37
+ error_class: error_class,
38
+ error_message: error_message,
39
+ hostname: hostname,
40
+ pid: pid,
41
+ ruby_version: ruby_version,
42
+ rails_env: rails_env
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'execution_recorder'
4
+
5
+ module RakeAudit
6
+ # Module prepended into +Rake::Task+ so that every executed task is routed
7
+ # through an {ExecutionRecorder}. Prepending lets us wrap +#execute+ while
8
+ # still delegating to the original implementation via +super+.
9
+ module TaskPatch
10
+ # Intercept Rake task execution.
11
+ #
12
+ # @param args [Object, nil] arguments Rake passes to the task.
13
+ # @return [Object] the original +#execute+ return value.
14
+ # @raise [Exception] propagates whatever the underlying task raised.
15
+ def execute(args = nil)
16
+ ExecutionRecorder.new(task: self, args: args).record { super }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RakeAudit
4
+ VERSION = '0.1.0'
5
+ end
data/lib/rake_audit.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rake_audit/version'
4
+ require_relative 'rake_audit/record_not_found'
5
+ require_relative 'rake_audit/configuration'
6
+ require_relative 'rake_audit/task_execution_record'
7
+ require_relative 'rake_audit/builders/task_execution_record_builder'
8
+ require_relative 'rake_audit/execution_recorder'
9
+ require_relative 'rake_audit/task_patch'
10
+
11
+ # Top-level namespace and public entry point for the gem.
12
+ #
13
+ # Typical usage:
14
+ #
15
+ # RakeAudit.configure do |c|
16
+ # c.adapter = MyAdapter.new
17
+ # end
18
+ #
19
+ # Loading this file also installs the {RakeAudit::TaskPatch} into +Rake::Task+
20
+ # when Rake is available, so task execution is recorded automatically.
21
+ module RakeAudit
22
+ # Error namespace base class for the gem.
23
+ class Error < StandardError; end
24
+
25
+ class << self
26
+ # The current configuration, created on first access.
27
+ #
28
+ # @return [RakeAudit::Configuration]
29
+ def config
30
+ @config ||= Configuration.new
31
+ end
32
+
33
+ alias configuration config
34
+
35
+ # Yield the configuration for mutation.
36
+ #
37
+ # RakeAudit.configure { |c| c.adapter = MyAdapter.new }
38
+ #
39
+ # @yieldparam config [RakeAudit::Configuration]
40
+ # @return [RakeAudit::Configuration]
41
+ def configure
42
+ yield config if block_given?
43
+ config
44
+ end
45
+
46
+ # Reset configuration to defaults. Primarily useful in tests.
47
+ #
48
+ # @return [RakeAudit::Configuration]
49
+ def reset_config!
50
+ @config = Configuration.new
51
+ end
52
+
53
+ # Install the execution hook into +Rake::Task+.
54
+ #
55
+ # Idempotent: prepending the same module twice is a no-op in Ruby, so this
56
+ # is safe to call multiple times.
57
+ #
58
+ # @return [Boolean] true if Rake was present and the patch is installed.
59
+ # rubocop:disable Naming/PredicateMethod
60
+ def install!
61
+ return false unless defined?(Rake::Task)
62
+
63
+ Rake::Task.prepend(TaskPatch)
64
+ true
65
+ end
66
+ # rubocop:enable Naming/PredicateMethod
67
+ end
68
+ end
69
+
70
+ # Load the Rails integration only when Rails is present. The Engine exposes the
71
+ # gem's +app/+ models to the host application and the Railtie installs the task
72
+ # patch after initialization; both require Rails and are skipped otherwise so
73
+ # the gem remains usable in a plain Ruby or standalone Rake setup.
74
+ if defined?(Rails::Engine)
75
+ require_relative 'rake_audit/rails/engine'
76
+ require_relative 'rake_audit/rails/railtie'
77
+ end
78
+
79
+ # Auto-install the patch when Rake is already loaded. When Rake loads later
80
+ # (e.g. a Railtie or the application's Rakefile), callers can invoke
81
+ # RakeAudit.install! explicitly.
82
+ RakeAudit.install!