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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +7 -0
- data/README.md +235 -0
- data/app/controllers/rake_audit/application_controller.rb +53 -0
- data/app/controllers/rake_audit/dashboard_controller.rb +36 -0
- data/app/controllers/rake_audit/executions_controller.rb +39 -0
- data/app/models/rake_audit/task_execution.rb +33 -0
- data/app/views/layouts/rake_audit/application.html.erb +70 -0
- data/app/views/rake_audit/dashboard/index.html.erb +48 -0
- data/app/views/rake_audit/executions/index.html.erb +74 -0
- data/app/views/rake_audit/executions/show.html.erb +46 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260531120000_create_rake_task_executions.rb +62 -0
- data/lib/generators/rake_audit/install_generator.rb +42 -0
- data/lib/generators/rake_audit/templates/create_rake_task_executions.rb +55 -0
- data/lib/generators/rake_audit/templates/initializer.rb +17 -0
- data/lib/rake_audit/adapters/active_record_adapter.rb +73 -0
- data/lib/rake_audit/adapters/base.rb +94 -0
- data/lib/rake_audit/adapters/execution_record.rb +20 -0
- data/lib/rake_audit/adapters/mongo_adapter.rb +137 -0
- data/lib/rake_audit/adapters/redis_adapter.rb +174 -0
- data/lib/rake_audit/builders/task_execution_record_builder.rb +81 -0
- data/lib/rake_audit/configuration.rb +62 -0
- data/lib/rake_audit/execution_recorder.rb +78 -0
- data/lib/rake_audit/rails/engine.rb +20 -0
- data/lib/rake_audit/rails/railtie.rb +17 -0
- data/lib/rake_audit/record_not_found.rb +5 -0
- data/lib/rake_audit/task_execution_record.rb +46 -0
- data/lib/rake_audit/task_patch.rb +19 -0
- data/lib/rake_audit/version.rb +5 -0
- data/lib/rake_audit.rb +82 -0
- 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,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
|
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!
|