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,46 @@
|
|
|
1
|
+
<h1>Execution #<%= @execution.id %></h1>
|
|
2
|
+
|
|
3
|
+
<p><%= link_to "← Back to executions", executions_path %></p>
|
|
4
|
+
|
|
5
|
+
<section class="section">
|
|
6
|
+
<h2>Task Info</h2>
|
|
7
|
+
<dl>
|
|
8
|
+
<dt>Task Name</dt><dd><%= @execution.task_name %></dd>
|
|
9
|
+
<dt>Status</dt>
|
|
10
|
+
<dd class="status-<%= @execution.status %>"><%= @execution.status %></dd>
|
|
11
|
+
</dl>
|
|
12
|
+
</section>
|
|
13
|
+
|
|
14
|
+
<section class="section">
|
|
15
|
+
<h2>Arguments</h2>
|
|
16
|
+
<pre><%= JSON.pretty_generate(@execution.arguments) if @execution.arguments.present? %></pre>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<section class="section">
|
|
20
|
+
<h2>Execution Info</h2>
|
|
21
|
+
<dl>
|
|
22
|
+
<dt>Started At</dt><dd><%= @execution.started_at %></dd>
|
|
23
|
+
<dt>Finished At</dt><dd><%= @execution.finished_at %></dd>
|
|
24
|
+
<dt>Duration (ms)</dt><dd><%= @execution.duration_ms %></dd>
|
|
25
|
+
</dl>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<% unless @execution.status == "success" %>
|
|
29
|
+
<section class="section">
|
|
30
|
+
<h2>Error Info</h2>
|
|
31
|
+
<dl>
|
|
32
|
+
<dt>Error Class</dt><dd><%= @execution.error_class %></dd>
|
|
33
|
+
<dt>Error Message</dt><dd><%= @execution.error_message %></dd>
|
|
34
|
+
</dl>
|
|
35
|
+
</section>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<section class="section">
|
|
39
|
+
<h2>Environment Info</h2>
|
|
40
|
+
<dl>
|
|
41
|
+
<dt>Hostname</dt><dd><%= @execution.hostname %></dd>
|
|
42
|
+
<dt>PID</dt><dd><%= @execution.pid %></dd>
|
|
43
|
+
<dt>Ruby Version</dt><dd><%= @execution.ruby_version %></dd>
|
|
44
|
+
<dt>Rails Env</dt><dd><%= @execution.rails_env %></dd>
|
|
45
|
+
</dl>
|
|
46
|
+
</section>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Routes for the mounted RakeAudit Engine.
|
|
4
|
+
#
|
|
5
|
+
# Because the Engine isolates its namespace, these paths live beneath the mount
|
|
6
|
+
# point the host application chooses (conventionally +/rake_audit+). The root
|
|
7
|
+
# points at the execution list so the mount point itself is useful; the
|
|
8
|
+
# dashboard and the execution +index+/+show+ actions round out the UI.
|
|
9
|
+
RakeAudit::Engine.routes.draw do
|
|
10
|
+
root to: 'executions#index'
|
|
11
|
+
get 'dashboard', to: 'dashboard#index'
|
|
12
|
+
resources :executions, only: %i[index show]
|
|
13
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the +rake_task_executions+ table that backs
|
|
4
|
+
# {RakeAudit::TaskExecution}.
|
|
5
|
+
#
|
|
6
|
+
# The +arguments+ column type is chosen per database so the same migration runs
|
|
7
|
+
# on PostgreSQL, MySQL, and SQLite: +jsonb+ on PostgreSQL, +json+ on MySQL, and
|
|
8
|
+
# +text+ everywhere else (SQLite stores the serialized JSON as text). All six
|
|
9
|
+
# indexes from the specification are created: four single-column lookups plus
|
|
10
|
+
# two composite indexes that match the Web UI's primary query patterns.
|
|
11
|
+
class CreateRakeTaskExecutions < ActiveRecord::Migration[6.1]
|
|
12
|
+
def change
|
|
13
|
+
create_table :rake_task_executions do |t|
|
|
14
|
+
t.string :task_name, null: false, limit: 255
|
|
15
|
+
t.column :arguments, arguments_column_type, null: true
|
|
16
|
+
t.datetime :started_at, null: false
|
|
17
|
+
t.datetime :finished_at, null: false
|
|
18
|
+
t.bigint :duration_ms, null: true
|
|
19
|
+
t.string :status, null: false, limit: 20
|
|
20
|
+
t.string :error_class, null: true, limit: 255
|
|
21
|
+
t.text :error_message, null: true
|
|
22
|
+
t.string :hostname, null: true, limit: 255
|
|
23
|
+
t.integer :pid, null: true
|
|
24
|
+
t.string :ruby_version, null: true, limit: 50
|
|
25
|
+
t.string :rails_env, null: true, limit: 50
|
|
26
|
+
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_audit_indexes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Add the four single-column and two composite indexes that back the audit
|
|
36
|
+
# table's lookup and Web UI query patterns.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def add_audit_indexes
|
|
40
|
+
add_index :rake_task_executions, :task_name
|
|
41
|
+
add_index :rake_task_executions, :status
|
|
42
|
+
add_index :rake_task_executions, :started_at
|
|
43
|
+
add_index :rake_task_executions, :hostname
|
|
44
|
+
add_index :rake_task_executions, %i[task_name started_at]
|
|
45
|
+
add_index :rake_task_executions, %i[status started_at]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Pick a JSON-capable column type the current database supports, falling back
|
|
49
|
+
# to +text+ (used by SQLite) so the migration is portable across backends.
|
|
50
|
+
#
|
|
51
|
+
# @return [Symbol]
|
|
52
|
+
def arguments_column_type
|
|
53
|
+
adapter = connection.adapter_name.downcase
|
|
54
|
+
if adapter.include?('postgresql')
|
|
55
|
+
:jsonb
|
|
56
|
+
elsif adapter.include?('mysql')
|
|
57
|
+
:json
|
|
58
|
+
else
|
|
59
|
+
:text
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module RakeAudit
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds RakeAudit into a host Rails application.
|
|
9
|
+
#
|
|
10
|
+
# rails generate rake_audit:install
|
|
11
|
+
#
|
|
12
|
+
# Running the generator copies a commented-out initializer to
|
|
13
|
+
# +config/initializers/rake_audit.rb+ and writes a timestamped migration to
|
|
14
|
+
# +db/migrate+ that creates the +rake_task_executions+ table. The migration
|
|
15
|
+
# is produced through +migration_template+ so the host's schema version and
|
|
16
|
+
# migration numbering are respected.
|
|
17
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
18
|
+
include ::ActiveRecord::Generators::Migration
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path('templates', __dir__)
|
|
21
|
+
|
|
22
|
+
desc 'Creates the RakeAudit initializer and the rake_task_executions migration.'
|
|
23
|
+
|
|
24
|
+
# Copy the pre-filled, fully commented initializer into the host app.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def create_initializer
|
|
28
|
+
template 'initializer.rb', 'config/initializers/rake_audit.rb'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate the timestamped migration that creates the audit table.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
def create_migration_file
|
|
35
|
+
migration_template(
|
|
36
|
+
'create_rake_task_executions.rb',
|
|
37
|
+
'db/migrate/create_rake_task_executions.rb'
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the +rake_task_executions+ table that backs
|
|
4
|
+
# RakeAudit::TaskExecution.
|
|
5
|
+
#
|
|
6
|
+
# The +arguments+ column type is chosen per database so the same migration runs
|
|
7
|
+
# on PostgreSQL, MySQL, and SQLite: +jsonb+ on PostgreSQL, +json+ on MySQL, and
|
|
8
|
+
# +text+ everywhere else (SQLite stores the serialized JSON as text).
|
|
9
|
+
class CreateRakeTaskExecutions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
10
|
+
def change
|
|
11
|
+
create_table :rake_task_executions do |t|
|
|
12
|
+
t.string :task_name, null: false, limit: 255
|
|
13
|
+
t.column :arguments, arguments_column_type, null: true
|
|
14
|
+
t.datetime :started_at, null: false
|
|
15
|
+
t.datetime :finished_at, null: false
|
|
16
|
+
t.bigint :duration_ms, null: true
|
|
17
|
+
t.string :status, null: false, limit: 20
|
|
18
|
+
t.string :error_class, null: true, limit: 255
|
|
19
|
+
t.text :error_message, null: true
|
|
20
|
+
t.string :hostname, null: true, limit: 255
|
|
21
|
+
t.integer :pid, null: true
|
|
22
|
+
t.string :ruby_version, null: true, limit: 50
|
|
23
|
+
t.string :rails_env, null: true, limit: 50
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_audit_indexes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Add the four single-column and two composite indexes.
|
|
34
|
+
def add_audit_indexes
|
|
35
|
+
add_index :rake_task_executions, :task_name
|
|
36
|
+
add_index :rake_task_executions, :status
|
|
37
|
+
add_index :rake_task_executions, :started_at
|
|
38
|
+
add_index :rake_task_executions, :hostname
|
|
39
|
+
add_index :rake_task_executions, %i[task_name started_at]
|
|
40
|
+
add_index :rake_task_executions, %i[status started_at]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Pick a JSON-capable column type the current database supports, falling back
|
|
44
|
+
# to +text+ (used by SQLite) so the migration is portable across backends.
|
|
45
|
+
def arguments_column_type
|
|
46
|
+
adapter = connection.adapter_name.downcase
|
|
47
|
+
if adapter.include?('postgresql')
|
|
48
|
+
:jsonb
|
|
49
|
+
elsif adapter.include?('mysql')
|
|
50
|
+
:json
|
|
51
|
+
else
|
|
52
|
+
:text
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RakeAudit configuration.
|
|
4
|
+
#
|
|
5
|
+
# Every option below is shown with its default and left commented out. Uncomment
|
|
6
|
+
# and edit the lines you want to override. At minimum, set an adapter to enable
|
|
7
|
+
# recording — without one, RakeAudit loads but records nothing.
|
|
8
|
+
RakeAudit.configure do |config|
|
|
9
|
+
# config.adapter = RakeAudit::Adapters::ActiveRecordAdapter.new
|
|
10
|
+
# config.logger = Rails.logger
|
|
11
|
+
# config.capture_hostname = true
|
|
12
|
+
# config.capture_pid = true
|
|
13
|
+
# config.capture_ruby_version = true
|
|
14
|
+
# config.capture_rails_env = true
|
|
15
|
+
# config.web_ui_enabled = true
|
|
16
|
+
# config.authenticate_with = ->(controller) { controller.authenticate_admin! }
|
|
17
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../record_not_found'
|
|
5
|
+
|
|
6
|
+
module RakeAudit
|
|
7
|
+
module Adapters
|
|
8
|
+
# Persists and queries TaskExecutionRecord instances via ActiveRecord.
|
|
9
|
+
class ActiveRecordAdapter < Base
|
|
10
|
+
EXACT_FILTERS = %i[task_name status hostname rails_env].freeze
|
|
11
|
+
private_constant :EXACT_FILTERS
|
|
12
|
+
|
|
13
|
+
# Create a TaskExecution row from the given record.
|
|
14
|
+
#
|
|
15
|
+
# @param record [TaskExecutionRecord]
|
|
16
|
+
def save(record)
|
|
17
|
+
RakeAudit::TaskExecution.create!(record.to_h)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param filters [Hash] pre-validated non-blank filter values.
|
|
21
|
+
# @param page [Integer, String, nil]
|
|
22
|
+
# @param per_page [Integer]
|
|
23
|
+
# @return [ActiveRecord::Relation] kaminari-paginated relation.
|
|
24
|
+
def query(filters: {}, page: nil, per_page: 25)
|
|
25
|
+
scope = RakeAudit::TaskExecution.order(started_at: :desc)
|
|
26
|
+
|
|
27
|
+
EXACT_FILTERS.each do |col|
|
|
28
|
+
scope = scope.where(col => filters[col]) if filters.key?(col)
|
|
29
|
+
end
|
|
30
|
+
scope = scope.where(started_at: filters[:from]..) if filters.key?(:from)
|
|
31
|
+
scope = scope.where(started_at: ..filters[:to]) if filters.key?(:to)
|
|
32
|
+
|
|
33
|
+
scope.page(page).per(per_page)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param id [Integer, String]
|
|
37
|
+
# @return [RakeAudit::TaskExecution]
|
|
38
|
+
# @raise [RakeAudit::RecordNotFound]
|
|
39
|
+
def find(id)
|
|
40
|
+
RakeAudit::TaskExecution.find(id)
|
|
41
|
+
rescue ActiveRecord::RecordNotFound
|
|
42
|
+
raise RakeAudit::RecordNotFound, "Couldn't find execution with id=#{id}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Integer]
|
|
46
|
+
def count
|
|
47
|
+
RakeAudit::TaskExecution.count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param status [String]
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
def count_by_status(status)
|
|
53
|
+
RakeAudit::TaskExecution.where(status: status).count
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float, nil]
|
|
57
|
+
def average_duration_ms
|
|
58
|
+
RakeAudit::TaskExecution.average(:duration_ms)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param limit [Integer]
|
|
62
|
+
# @return [Array<Array(String, Integer)>]
|
|
63
|
+
def top_failed_tasks(limit: 10)
|
|
64
|
+
RakeAudit::TaskExecution
|
|
65
|
+
.where(status: 'failure')
|
|
66
|
+
.group(:task_name)
|
|
67
|
+
.count
|
|
68
|
+
.sort_by { |_, c| -c }
|
|
69
|
+
.first(limit)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RakeAudit
|
|
4
|
+
module Adapters
|
|
5
|
+
# Abstract storage adapter defining the full persistence and query contract.
|
|
6
|
+
#
|
|
7
|
+
# Every concrete adapter must implement +#save+. Adapters that back the Web
|
|
8
|
+
# UI must also implement the six query methods below; the default
|
|
9
|
+
# implementations raise +NotImplementedError+ so omissions surface loudly.
|
|
10
|
+
class Base
|
|
11
|
+
# Persist a TaskExecutionRecord.
|
|
12
|
+
#
|
|
13
|
+
# @param record [TaskExecutionRecord] the record to save.
|
|
14
|
+
# @raise [NotImplementedError] when not overridden.
|
|
15
|
+
def save(record)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#save is not implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Return a kaminari-paginated, filtered, newest-first collection.
|
|
20
|
+
#
|
|
21
|
+
# @param filters [Hash] any subset of: +:task_name+, +:status+,
|
|
22
|
+
# +:hostname+, +:rails_env+, +:from+, +:to+ (all pre-validated,
|
|
23
|
+
# non-blank values only — blank keys are never present).
|
|
24
|
+
# @param page [Integer, String, nil] 1-based page number.
|
|
25
|
+
# @param per_page [Integer] records per page.
|
|
26
|
+
# @raise [NotImplementedError] when not overridden.
|
|
27
|
+
def query(filters: {}, page: nil, per_page: 25)
|
|
28
|
+
raise NotImplementedError, "#{self.class}#query is not implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Return a single execution record by ID.
|
|
32
|
+
#
|
|
33
|
+
# @param id [String, Integer] adapter-specific identifier.
|
|
34
|
+
# @raise [RakeAudit::RecordNotFound] when no record matches.
|
|
35
|
+
# @raise [NotImplementedError] when not overridden.
|
|
36
|
+
def find(id)
|
|
37
|
+
raise NotImplementedError, "#{self.class}#find is not implemented"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Total number of stored executions.
|
|
41
|
+
#
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
# @raise [NotImplementedError] when not overridden.
|
|
44
|
+
def count
|
|
45
|
+
raise NotImplementedError, "#{self.class}#count is not implemented"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Number of executions whose +status+ equals +status+.
|
|
49
|
+
#
|
|
50
|
+
# @param status [String] e.g. <tt>"success"</tt> or <tt>"failure"</tt>.
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
# @raise [NotImplementedError] when not overridden.
|
|
53
|
+
def count_by_status(status)
|
|
54
|
+
raise NotImplementedError, "#{self.class}#count_by_status is not implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mean +duration_ms+ across all stored executions, or +nil+ when empty.
|
|
58
|
+
#
|
|
59
|
+
# @return [Float, nil]
|
|
60
|
+
# @raise [NotImplementedError] when not overridden.
|
|
61
|
+
def average_duration_ms
|
|
62
|
+
raise NotImplementedError, "#{self.class}#average_duration_ms is not implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The +limit+ task names with the most failures, ordered descending.
|
|
66
|
+
#
|
|
67
|
+
# @param limit [Integer]
|
|
68
|
+
# @return [Array<Array(String, Integer)>] e.g. +[["db:migrate", 5], ...]+
|
|
69
|
+
# @raise [NotImplementedError] when not overridden.
|
|
70
|
+
def top_failed_tasks(limit: 10)
|
|
71
|
+
raise NotImplementedError, "#{self.class}#top_failed_tasks is not implemented"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# All five dashboard metrics in a single call.
|
|
75
|
+
#
|
|
76
|
+
# The default implementation delegates to the five individual methods, which
|
|
77
|
+
# is efficient for SQL-backed adapters (five fast queries). Adapters whose
|
|
78
|
+
# individual methods are expensive (e.g. Redis, which reads the full list
|
|
79
|
+
# per call) should override this to compute everything in one pass.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] with keys +:total+, +:success_count+, +:failure_count+,
|
|
82
|
+
# +:average_duration_ms+, +:top_failed_tasks+.
|
|
83
|
+
def stats
|
|
84
|
+
{
|
|
85
|
+
total: count,
|
|
86
|
+
success_count: count_by_status('success'),
|
|
87
|
+
failure_count: count_by_status('failure'),
|
|
88
|
+
average_duration_ms: average_duration_ms,
|
|
89
|
+
top_failed_tasks: top_failed_tasks
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RakeAudit
|
|
4
|
+
module Adapters
|
|
5
|
+
# Read-side value object returned by non-ActiveRecord adapters.
|
|
6
|
+
#
|
|
7
|
+
# Carries every field the Web UI views access plus an +id+ and +to_param+
|
|
8
|
+
# so Rails route helpers (+execution_path(record)+) work without AR.
|
|
9
|
+
ExecutionRecord = Struct.new(
|
|
10
|
+
:id, :task_name, :arguments, :started_at, :finished_at,
|
|
11
|
+
:duration_ms, :status, :error_class, :error_message,
|
|
12
|
+
:hostname, :pid, :ruby_version, :rails_env,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def to_param
|
|
16
|
+
id.to_s
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'execution_record'
|
|
5
|
+
require_relative '../record_not_found'
|
|
6
|
+
|
|
7
|
+
module RakeAudit
|
|
8
|
+
module Adapters
|
|
9
|
+
# Persists and queries TaskExecutionRecord instances via MongoDB.
|
|
10
|
+
#
|
|
11
|
+
# Uses the MongoDB Ruby driver directly. Documents are stored in the
|
|
12
|
+
# +rake_task_executions+ collection and Mongo's native +_id+ (ObjectId) is
|
|
13
|
+
# used as the record identifier for Web UI links.
|
|
14
|
+
class MongoAdapter < Base
|
|
15
|
+
# @param client [Mongo::Client] a connected MongoDB client instance.
|
|
16
|
+
def initialize(client:)
|
|
17
|
+
super()
|
|
18
|
+
@client = client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Insert the record into the executions collection.
|
|
22
|
+
#
|
|
23
|
+
# @param record [TaskExecutionRecord]
|
|
24
|
+
def save(record)
|
|
25
|
+
collection.insert_one(record.to_h)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param filters [Hash] pre-validated non-blank filter values.
|
|
29
|
+
# @param page [Integer, String, nil]
|
|
30
|
+
# @param per_page [Integer]
|
|
31
|
+
# @return [Kaminari::PaginatableArray]
|
|
32
|
+
def query(filters: {}, page: nil, per_page: 25)
|
|
33
|
+
require 'kaminari'
|
|
34
|
+
filter = build_filter(filters)
|
|
35
|
+
total = collection.count_documents(filter)
|
|
36
|
+
current = [page.to_i, 1].max
|
|
37
|
+
cursor = collection.find(filter)
|
|
38
|
+
.sort(started_at: -1)
|
|
39
|
+
.skip((current - 1) * per_page)
|
|
40
|
+
.limit(per_page)
|
|
41
|
+
|
|
42
|
+
records = cursor.map { |doc| to_execution_record(doc) }
|
|
43
|
+
Kaminari
|
|
44
|
+
.paginate_array(records, total_count: total)
|
|
45
|
+
.page(current)
|
|
46
|
+
.per(per_page)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param id [String] string representation of a Mongo ObjectId.
|
|
50
|
+
# @return [RakeAudit::Adapters::ExecutionRecord]
|
|
51
|
+
# @raise [RakeAudit::RecordNotFound]
|
|
52
|
+
def find(id)
|
|
53
|
+
# Skip the require when BSON is already defined (e.g. stubbed in tests).
|
|
54
|
+
require 'bson' unless defined?(BSON)
|
|
55
|
+
object_id = BSON::ObjectId.from_string(id.to_s)
|
|
56
|
+
doc = collection.find(_id: object_id).first
|
|
57
|
+
raise RakeAudit::RecordNotFound, "Couldn't find execution with id=#{id}" unless doc
|
|
58
|
+
|
|
59
|
+
to_execution_record(doc)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
# BSON::ObjectId::Invalid inherits from ArgumentError — invalid id string.
|
|
62
|
+
raise RakeAudit::RecordNotFound, "Couldn't find execution with id=#{id}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
def count
|
|
67
|
+
collection.count_documents({})
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param status [String]
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def count_by_status(status)
|
|
73
|
+
collection.count_documents('status' => status)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Float, nil]
|
|
77
|
+
def average_duration_ms
|
|
78
|
+
result = collection.aggregate([
|
|
79
|
+
{ '$group' => { '_id' => nil, 'avg' => { '$avg' => '$duration_ms' } } }
|
|
80
|
+
]).first
|
|
81
|
+
result ? result['avg'] : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param limit [Integer]
|
|
85
|
+
# @return [Array<Array(String, Integer)>]
|
|
86
|
+
def top_failed_tasks(limit: 10)
|
|
87
|
+
collection.aggregate([
|
|
88
|
+
{ '$match' => { 'status' => 'failure' } },
|
|
89
|
+
{ '$group' => { '_id' => '$task_name', 'count' => { '$sum' => 1 } } },
|
|
90
|
+
{ '$sort' => { 'count' => -1 } },
|
|
91
|
+
{ '$limit' => limit }
|
|
92
|
+
]).map { |doc| [doc['_id'], doc['count']] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def collection
|
|
98
|
+
@client['rake_task_executions']
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_filter(filters)
|
|
102
|
+
filter = {}
|
|
103
|
+
|
|
104
|
+
%i[task_name status hostname rails_env].each do |col|
|
|
105
|
+
filter[col.to_s] = filters[col] if filters.key?(col)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if filters.key?(:from) || filters.key?(:to)
|
|
109
|
+
range = {}
|
|
110
|
+
range['$gte'] = filters[:from].to_s if filters.key?(:from)
|
|
111
|
+
range['$lte'] = filters[:to].to_s if filters.key?(:to)
|
|
112
|
+
filter['started_at'] = range
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
filter
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_execution_record(doc)
|
|
119
|
+
RakeAudit::Adapters::ExecutionRecord.new(
|
|
120
|
+
id: doc['_id']&.to_s,
|
|
121
|
+
task_name: doc['task_name'],
|
|
122
|
+
arguments: doc['arguments'],
|
|
123
|
+
started_at: doc['started_at'],
|
|
124
|
+
finished_at: doc['finished_at'],
|
|
125
|
+
duration_ms: doc['duration_ms'],
|
|
126
|
+
status: doc['status'],
|
|
127
|
+
error_class: doc['error_class'],
|
|
128
|
+
error_message: doc['error_message'],
|
|
129
|
+
hostname: doc['hostname'],
|
|
130
|
+
pid: doc['pid'],
|
|
131
|
+
ruby_version: doc['ruby_version'],
|
|
132
|
+
rails_env: doc['rails_env']
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|