active_job_dash 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 +23 -0
- data/MIT-LICENSE +21 -0
- data/README.md +81 -0
- data/app/controllers/active_job_dash/application_controller.rb +15 -0
- data/app/controllers/active_job_dash/dashboard_controller.rb +54 -0
- data/app/jobs/active_job_dash/record_execution_job.rb +9 -0
- data/app/models/active_job_dash/job_execution.rb +51 -0
- data/app/models/active_job_dash/record.rb +5 -0
- data/app/views/active_job_dash/dashboard/index.html.erb +126 -0
- data/app/views/active_job_dash/dashboard/jobs.html.erb +54 -0
- data/app/views/active_job_dash/dashboard/show.html.erb +77 -0
- data/app/views/layouts/active_job_dash/application.html.erb +207 -0
- data/config/routes.rb +10 -0
- data/lib/active_job_dash/engine.rb +44 -0
- data/lib/active_job_dash/generators/active_job_dash/install_generator.rb +49 -0
- data/lib/active_job_dash/generators/active_job_dash/templates/create_active_job_dash_executions.rb +29 -0
- data/lib/active_job_dash/generators/active_job_dash/templates/initializer.rb.erb +6 -0
- data/lib/active_job_dash/instrumentation.rb +214 -0
- data/lib/active_job_dash/log_subscriber.rb +24 -0
- data/lib/active_job_dash/stats.rb +103 -0
- data/lib/active_job_dash/tasks.rb +31 -0
- data/lib/active_job_dash/version.rb +3 -0
- data/lib/active_job_dash.rb +46 -0
- metadata +170 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>ActiveJob Dashboard</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
/* Light mode */
|
|
11
|
+
--bg: #f8fafc;
|
|
12
|
+
--bg-card: #ffffff;
|
|
13
|
+
--text: #1e293b;
|
|
14
|
+
--text-muted: #64748b;
|
|
15
|
+
--border: #e2e8f0;
|
|
16
|
+
--success: #16a34a;
|
|
17
|
+
--error: #dc2626;
|
|
18
|
+
--warning: #d97706;
|
|
19
|
+
--primary: #2563eb;
|
|
20
|
+
--primary-hover: #1d4ed8;
|
|
21
|
+
--hover-bg: rgba(0,0,0,0.04);
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
:root {
|
|
25
|
+
--bg: #030712;
|
|
26
|
+
--bg-card: #111827;
|
|
27
|
+
--text: #f9fafb;
|
|
28
|
+
--text-muted: #9ca3af;
|
|
29
|
+
--border: #1f2937;
|
|
30
|
+
--success: #22c55e;
|
|
31
|
+
--error: #ef4444;
|
|
32
|
+
--warning: #f59e0b;
|
|
33
|
+
--primary: #3b82f6;
|
|
34
|
+
--primary-hover: #60a5fa;
|
|
35
|
+
--hover-bg: rgba(255,255,255,0.05);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
39
|
+
body {
|
|
40
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
41
|
+
background: var(--bg);
|
|
42
|
+
color: var(--text);
|
|
43
|
+
font-size: 14px;
|
|
44
|
+
line-height: 1.5;
|
|
45
|
+
}
|
|
46
|
+
a { color: var(--primary); }
|
|
47
|
+
a:hover { color: var(--primary-hover); }
|
|
48
|
+
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
|
49
|
+
header {
|
|
50
|
+
display: flex;
|
|
51
|
+
justify-content: space-between;
|
|
52
|
+
align-items: center;
|
|
53
|
+
margin-bottom: 24px;
|
|
54
|
+
padding-bottom: 16px;
|
|
55
|
+
border-bottom: 1px solid var(--border);
|
|
56
|
+
}
|
|
57
|
+
h1 { font-size: 20px; font-weight: 600; }
|
|
58
|
+
h1 a { color: inherit; text-decoration: none; }
|
|
59
|
+
h1 a:hover { opacity: 0.8; }
|
|
60
|
+
h2 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
61
|
+
.stats-grid {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-columns: repeat(5, 1fr);
|
|
64
|
+
gap: 12px;
|
|
65
|
+
margin-bottom: 24px;
|
|
66
|
+
}
|
|
67
|
+
@media (max-width: 900px) {
|
|
68
|
+
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
|
69
|
+
}
|
|
70
|
+
@media (max-width: 600px) {
|
|
71
|
+
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
72
|
+
}
|
|
73
|
+
.stat-card {
|
|
74
|
+
background: var(--bg-card);
|
|
75
|
+
border: 1px solid var(--border);
|
|
76
|
+
border-radius: 8px;
|
|
77
|
+
padding: 16px 20px;
|
|
78
|
+
text-align: center;
|
|
79
|
+
}
|
|
80
|
+
.stat-value {
|
|
81
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
82
|
+
font-size: 28px;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
line-height: 1.2;
|
|
85
|
+
letter-spacing: -0.02em;
|
|
86
|
+
}
|
|
87
|
+
.stat-label {
|
|
88
|
+
font-size: 11px;
|
|
89
|
+
color: var(--text-muted);
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.5px;
|
|
92
|
+
margin-top: 4px;
|
|
93
|
+
}
|
|
94
|
+
.stat-card.success .stat-value { color: var(--success); }
|
|
95
|
+
.stat-card.error .stat-value { color: var(--error); }
|
|
96
|
+
table { width: 100%; border-collapse: collapse; }
|
|
97
|
+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
|
98
|
+
th { font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
|
|
99
|
+
tr:hover td { background: var(--hover-bg); }
|
|
100
|
+
.badge {
|
|
101
|
+
display: inline-block;
|
|
102
|
+
padding: 2px 8px;
|
|
103
|
+
border-radius: 4px;
|
|
104
|
+
font-size: 11px;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
}
|
|
108
|
+
.badge.success { background: rgba(22,163,74,0.15); color: var(--success); }
|
|
109
|
+
.badge.failed { background: rgba(220,38,38,0.15); color: var(--error); }
|
|
110
|
+
.badge.queued { background: rgba(107,114,128,0.15); color: var(--text-muted); }
|
|
111
|
+
.badge.running { background: rgba(37,99,235,0.15); color: var(--primary); }
|
|
112
|
+
.badge.retried { background: rgba(217,119,6,0.15); color: var(--warning); }
|
|
113
|
+
.card {
|
|
114
|
+
background: var(--bg-card);
|
|
115
|
+
border: 1px solid var(--border);
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
padding: 16px;
|
|
118
|
+
margin-bottom: 24px;
|
|
119
|
+
}
|
|
120
|
+
.btn {
|
|
121
|
+
display: inline-block;
|
|
122
|
+
padding: 6px 12px;
|
|
123
|
+
border-radius: 6px;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
text-decoration: none;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
border: 1px solid var(--border);
|
|
129
|
+
background: var(--bg-card);
|
|
130
|
+
color: var(--text);
|
|
131
|
+
}
|
|
132
|
+
.btn:hover { background: var(--hover-bg); border-color: var(--text-muted); }
|
|
133
|
+
.btn-primary {
|
|
134
|
+
background: var(--primary);
|
|
135
|
+
border-color: var(--primary);
|
|
136
|
+
color: #fff;
|
|
137
|
+
}
|
|
138
|
+
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
|
139
|
+
.btn-danger { background: var(--error); border-color: var(--error); color: #fff; }
|
|
140
|
+
.flash {
|
|
141
|
+
padding: 12px 16px;
|
|
142
|
+
border-radius: 6px;
|
|
143
|
+
margin-bottom: 16px;
|
|
144
|
+
}
|
|
145
|
+
.flash.notice { background: rgba(22,163,74,0.15); border: 1px solid var(--success); color: var(--success); }
|
|
146
|
+
.flash.alert { background: rgba(220,38,38,0.15); border: 1px solid var(--error); color: var(--error); }
|
|
147
|
+
.period-select { display: flex; gap: 8px; }
|
|
148
|
+
.period-select a {
|
|
149
|
+
padding: 6px 12px;
|
|
150
|
+
border-radius: 6px;
|
|
151
|
+
color: var(--text-muted);
|
|
152
|
+
text-decoration: none;
|
|
153
|
+
border: 1px solid var(--border);
|
|
154
|
+
}
|
|
155
|
+
.period-select a.active, .period-select a:hover {
|
|
156
|
+
background: var(--primary);
|
|
157
|
+
border-color: var(--primary);
|
|
158
|
+
color: #fff;
|
|
159
|
+
}
|
|
160
|
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
|
161
|
+
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
162
|
+
.mono { font-family: inherit; }
|
|
163
|
+
.text-muted { color: var(--text-muted); }
|
|
164
|
+
.text-success { color: var(--success); }
|
|
165
|
+
.text-error { color: var(--error); }
|
|
166
|
+
.text-sm { font-size: 12px; }
|
|
167
|
+
.mb-2 { margin-bottom: 8px; }
|
|
168
|
+
pre {
|
|
169
|
+
background: var(--bg);
|
|
170
|
+
border: 1px solid var(--border);
|
|
171
|
+
padding: 12px;
|
|
172
|
+
border-radius: 6px;
|
|
173
|
+
overflow-x: auto;
|
|
174
|
+
font-size: 13px;
|
|
175
|
+
}
|
|
176
|
+
/* Simple form styling for retry buttons */
|
|
177
|
+
form.inline { display: inline; }
|
|
178
|
+
form.inline button {
|
|
179
|
+
font-family: inherit;
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<div class="container">
|
|
186
|
+
<header>
|
|
187
|
+
<h1><a href="<%= active_job_dash.dashboard_path %>" style="color: inherit; text-decoration: none;">ActiveJob Dashboard</a></h1>
|
|
188
|
+
<div class="period-select">
|
|
189
|
+
<% [1, 6, 24, 168].each do |hours| %>
|
|
190
|
+
<%= link_to hours == 1 ? "1h" : hours == 6 ? "6h" : hours == 24 ? "24h" : "7d",
|
|
191
|
+
dashboard_path(period: hours),
|
|
192
|
+
class: (@period == hours.hours ? "active" : "") %>
|
|
193
|
+
<% end %>
|
|
194
|
+
</div>
|
|
195
|
+
</header>
|
|
196
|
+
|
|
197
|
+
<% if flash[:notice] %>
|
|
198
|
+
<div class="flash notice"><%= flash[:notice] %></div>
|
|
199
|
+
<% end %>
|
|
200
|
+
<% if flash[:alert] %>
|
|
201
|
+
<div class="flash alert"><%= flash[:alert] %></div>
|
|
202
|
+
<% end %>
|
|
203
|
+
|
|
204
|
+
<%= yield %>
|
|
205
|
+
</div>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ActiveJobDash::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#index"
|
|
3
|
+
|
|
4
|
+
get "/", to: "dashboard#index", as: :dashboard
|
|
5
|
+
get "/jobs", to: "dashboard#jobs", as: :jobs
|
|
6
|
+
get "/executions/:id", to: "dashboard#show", as: :execution
|
|
7
|
+
post "/executions/:id/retry", to: "dashboard#retry", as: :retry
|
|
8
|
+
post "/retry_all", to: "dashboard#retry_all", as: :retry_all
|
|
9
|
+
post "/purge", to: "dashboard#purge", as: :purge
|
|
10
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module ActiveJobDash
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace ActiveJobDash
|
|
6
|
+
|
|
7
|
+
config.active_job_dash = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer "active_job_dash.config", before: :run_prepare_callbacks do |app|
|
|
10
|
+
app.config.active_job_dash.each do |key, value|
|
|
11
|
+
ActiveJobDash.public_send("#{key}=", value)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "active_job_dash.app_executor", before: :run_prepare_callbacks do |app|
|
|
16
|
+
ActiveJobDash.on_thread_error = ->(exception) { Rails.error.report(exception, handled: false) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "active_job_dash.logger", before: :run_prepare_callbacks do
|
|
20
|
+
ActiveSupport.on_load(:active_job_dash) do
|
|
21
|
+
self.logger = Rails.logger
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ActiveJobDash::LogSubscriber.attach_to :active_job_dash
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer "active_job_dash.instrumentation", after: :load_config_initializers do
|
|
28
|
+
ActiveSupport.on_load(:active_job) do
|
|
29
|
+
unless ActiveJobDash.instance_variable_get(:@subscribed)
|
|
30
|
+
ActiveJobDash::Instrumentation.subscribe!
|
|
31
|
+
ActiveJobDash.instance_variable_set(:@subscribed, true)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rake_tasks do
|
|
37
|
+
load File.expand_path("tasks.rb", __dir__)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
generators do
|
|
41
|
+
require_relative "generators/active_job_dash/install_generator"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module ActiveJobDash
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Install ActiveJobDash configuration and migration"
|
|
9
|
+
|
|
10
|
+
def copy_migration
|
|
11
|
+
migration_file = Dir.glob("db/migrate/*_create_active_job_dash_executions.rb").first
|
|
12
|
+
if migration_file
|
|
13
|
+
say_status :skip, "Migration already exists", :yellow
|
|
14
|
+
else
|
|
15
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
16
|
+
copy_file "create_active_job_dash_executions.rb",
|
|
17
|
+
"db/migrate/#{timestamp}_create_active_job_dash_executions.rb"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_initializer
|
|
22
|
+
template "initializer.rb.erb", "config/initializers/active_job_dash.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mount_engine
|
|
26
|
+
route_line = "mount ActiveJobDash::Engine, at: '/admin/jobs'"
|
|
27
|
+
routes_file = "config/routes.rb"
|
|
28
|
+
|
|
29
|
+
if File.read(routes_file).include?("ActiveJobDash::Engine")
|
|
30
|
+
say_status :skip, "Engine already mounted", :yellow
|
|
31
|
+
else
|
|
32
|
+
route route_line
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_instructions
|
|
37
|
+
say ""
|
|
38
|
+
say "ActiveJobDash installed!", :green
|
|
39
|
+
say ""
|
|
40
|
+
say "Next steps:"
|
|
41
|
+
say " 1. Run: rails db:migrate"
|
|
42
|
+
say " 2. Visit: /admin/jobs"
|
|
43
|
+
say ""
|
|
44
|
+
say "Configure in config/initializers/active_job_dash.rb"
|
|
45
|
+
say ""
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/active_job_dash/generators/active_job_dash/templates/create_active_job_dash_executions.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class CreateActiveJobDashExecutions < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :active_job_dash_executions do |t|
|
|
4
|
+
t.string :job_class, null: false
|
|
5
|
+
t.string :job_id, null: false
|
|
6
|
+
t.string :queue_name
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.string :status
|
|
9
|
+
t.integer :duration_ms
|
|
10
|
+
t.integer :executions, default: 0
|
|
11
|
+
t.string :region
|
|
12
|
+
t.datetime :started_at
|
|
13
|
+
t.datetime :finished_at
|
|
14
|
+
t.datetime :scheduled_at
|
|
15
|
+
t.datetime :retried_at
|
|
16
|
+
t.string :error_class
|
|
17
|
+
t.text :error_message
|
|
18
|
+
t.jsonb :arguments
|
|
19
|
+
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :active_job_dash_executions, :job_id
|
|
24
|
+
add_index :active_job_dash_executions, %i[job_class created_at]
|
|
25
|
+
add_index :active_job_dash_executions, %i[status created_at]
|
|
26
|
+
add_index :active_job_dash_executions, %i[event_type created_at]
|
|
27
|
+
add_index :active_job_dash_executions, :created_at
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
module ActiveJobDash
|
|
2
|
+
class Instrumentation
|
|
3
|
+
EVENTS = %w[
|
|
4
|
+
perform.active_job
|
|
5
|
+
enqueue.active_job
|
|
6
|
+
enqueue_at.active_job
|
|
7
|
+
retry_stopped.active_job
|
|
8
|
+
discard.active_job
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def subscribe!
|
|
13
|
+
return unless ActiveJobDash.enabled?
|
|
14
|
+
return if @subscribed
|
|
15
|
+
|
|
16
|
+
subscribe_to_enqueue if ActiveJobDash.track_enqueue?
|
|
17
|
+
subscribe_to_perform_start
|
|
18
|
+
subscribe_to_perform
|
|
19
|
+
subscribe_to_retry_stopped
|
|
20
|
+
subscribe_to_discard
|
|
21
|
+
@subscribed = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def subscribed?
|
|
25
|
+
@subscribed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def subscribe_to_enqueue
|
|
31
|
+
ActiveSupport::Notifications.subscribe("enqueue.active_job") do |event|
|
|
32
|
+
record_enqueue(event)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def subscribe_to_perform_start
|
|
37
|
+
ActiveSupport::Notifications.subscribe("perform_start.active_job") do |event|
|
|
38
|
+
update_to_running(event)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subscribe_to_perform
|
|
43
|
+
ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
|
|
44
|
+
record_execution(event)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def subscribe_to_retry_stopped
|
|
49
|
+
ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |event|
|
|
50
|
+
record_retry_stopped(event)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def subscribe_to_discard
|
|
55
|
+
ActiveSupport::Notifications.subscribe("discard.active_job") do |event|
|
|
56
|
+
record_discard(event)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_enqueue(event)
|
|
61
|
+
return if excluded?(event.payload[:job])
|
|
62
|
+
|
|
63
|
+
job = event.payload[:job]
|
|
64
|
+
exception = event.payload[:exception_object]
|
|
65
|
+
|
|
66
|
+
attributes = {
|
|
67
|
+
job_class: job.class.name,
|
|
68
|
+
job_id: job.job_id,
|
|
69
|
+
queue_name: job.queue_name,
|
|
70
|
+
event_type: "perform",
|
|
71
|
+
status: exception ? "enqueue_failed" : "queued",
|
|
72
|
+
scheduled_at: job.scheduled_at,
|
|
73
|
+
region: ActiveJobDash.region,
|
|
74
|
+
error_class: exception&.class&.name,
|
|
75
|
+
error_message: exception&.message&.truncate(1000)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
attributes[:arguments] = serialize_arguments(job.arguments) if ActiveJobDash.store_arguments?
|
|
79
|
+
|
|
80
|
+
record(attributes)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_to_running(event)
|
|
84
|
+
return if excluded?(event.payload[:job])
|
|
85
|
+
|
|
86
|
+
job = event.payload[:job]
|
|
87
|
+
execution = JobExecution.find_by(job_id: job.job_id, status: "queued")
|
|
88
|
+
|
|
89
|
+
if execution
|
|
90
|
+
execution.update(status: "running", started_at: Time.current)
|
|
91
|
+
else
|
|
92
|
+
JobExecution.create!(
|
|
93
|
+
job_class: job.class.name,
|
|
94
|
+
job_id: job.job_id,
|
|
95
|
+
queue_name: job.queue_name,
|
|
96
|
+
event_type: "perform",
|
|
97
|
+
status: "running",
|
|
98
|
+
started_at: Time.current,
|
|
99
|
+
region: ActiveJobDash.region,
|
|
100
|
+
arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Rails.error.report(e, handled: true, context: { job_id: job.job_id, phase: "perform_start" })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def record_execution(event)
|
|
108
|
+
return if excluded?(event.payload[:job])
|
|
109
|
+
|
|
110
|
+
job = event.payload[:job]
|
|
111
|
+
exception = event.payload[:exception_object]
|
|
112
|
+
finished_at = Time.current
|
|
113
|
+
|
|
114
|
+
execution = JobExecution.find_by(job_id: job.job_id, status: %w[queued running])
|
|
115
|
+
|
|
116
|
+
if execution
|
|
117
|
+
execution.update!(
|
|
118
|
+
status: exception ? "failed" : "success",
|
|
119
|
+
duration_ms: event.duration.to_i,
|
|
120
|
+
finished_at: finished_at,
|
|
121
|
+
started_at: execution.started_at || (finished_at - (event.duration / 1000.0)),
|
|
122
|
+
executions: job.executions,
|
|
123
|
+
error_class: exception&.class&.name,
|
|
124
|
+
error_message: exception&.message&.truncate(1000)
|
|
125
|
+
)
|
|
126
|
+
else
|
|
127
|
+
attributes = {
|
|
128
|
+
job_class: job.class.name,
|
|
129
|
+
job_id: job.job_id,
|
|
130
|
+
queue_name: job.queue_name,
|
|
131
|
+
event_type: "perform",
|
|
132
|
+
status: exception ? "failed" : "success",
|
|
133
|
+
duration_ms: event.duration.to_i,
|
|
134
|
+
started_at: finished_at - (event.duration / 1000.0),
|
|
135
|
+
finished_at: finished_at,
|
|
136
|
+
executions: job.executions,
|
|
137
|
+
region: ActiveJobDash.region,
|
|
138
|
+
error_class: exception&.class&.name,
|
|
139
|
+
error_message: exception&.message&.truncate(1000)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
attributes[:arguments] = serialize_arguments(job.arguments) if ActiveJobDash.store_arguments?
|
|
143
|
+
|
|
144
|
+
record(attributes)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
ActiveJobDash.instrument(:record_execution, job_class: job.class.name, job_id: job.job_id, status: exception ? "failed" : "success")
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
Rails.error.report(e, handled: true, context: { job_id: job.job_id, phase: "perform" })
|
|
150
|
+
ActiveJobDash.instrument(:record_error, error: e.message, job_id: job.job_id)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def record_retry_stopped(event)
|
|
154
|
+
return if excluded?(event.payload[:job])
|
|
155
|
+
|
|
156
|
+
job = event.payload[:job]
|
|
157
|
+
error = event.payload[:error]
|
|
158
|
+
|
|
159
|
+
record(
|
|
160
|
+
job_class: job.class.name,
|
|
161
|
+
job_id: job.job_id,
|
|
162
|
+
queue_name: job.queue_name,
|
|
163
|
+
event_type: "retry_stopped",
|
|
164
|
+
status: "exhausted",
|
|
165
|
+
executions: job.executions,
|
|
166
|
+
region: ActiveJobDash.region,
|
|
167
|
+
error_class: error&.class&.name,
|
|
168
|
+
error_message: error&.message&.truncate(1000),
|
|
169
|
+
arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def record_discard(event)
|
|
174
|
+
return if excluded?(event.payload[:job])
|
|
175
|
+
|
|
176
|
+
job = event.payload[:job]
|
|
177
|
+
error = event.payload[:error]
|
|
178
|
+
|
|
179
|
+
record(
|
|
180
|
+
job_class: job.class.name,
|
|
181
|
+
job_id: job.job_id,
|
|
182
|
+
queue_name: job.queue_name,
|
|
183
|
+
event_type: "discard",
|
|
184
|
+
status: "discarded",
|
|
185
|
+
region: ActiveJobDash.region,
|
|
186
|
+
error_class: error&.class&.name,
|
|
187
|
+
error_message: error&.message&.truncate(1000),
|
|
188
|
+
arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def excluded?(job)
|
|
193
|
+
ActiveJobDash.excluded_jobs.include?(job.class.name)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def serialize_arguments(arguments)
|
|
197
|
+
arguments.to_json
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def record(attributes)
|
|
203
|
+
if ActiveJobDash.async_recording?
|
|
204
|
+
RecordExecutionJob.perform_later(attributes)
|
|
205
|
+
else
|
|
206
|
+
JobExecution.create!(attributes)
|
|
207
|
+
end
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
Rails.error.report(e, handled: true, context: { job_class: attributes[:job_class], job_id: attributes[:job_id] })
|
|
210
|
+
ActiveJobDash.instrument(:record_error, error: e.message, job_class: attributes[:job_class])
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ActiveJobDash
|
|
2
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
3
|
+
def record_execution(event)
|
|
4
|
+
info do
|
|
5
|
+
job_class = event.payload[:job_class]
|
|
6
|
+
job_id = event.payload[:job_id]
|
|
7
|
+
status = event.payload[:status]
|
|
8
|
+
"Recorded execution for #{job_class} (#{job_id}): #{status}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def record_error(event)
|
|
13
|
+
error do
|
|
14
|
+
"Failed to record job execution: #{event.payload[:error]}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def purge(event)
|
|
19
|
+
info do
|
|
20
|
+
"Purged #{event.payload[:count]} old job execution records"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module ActiveJobDash
|
|
2
|
+
class Stats
|
|
3
|
+
attr_reader :period
|
|
4
|
+
|
|
5
|
+
def initialize(period: 1.hour)
|
|
6
|
+
@period = period
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def summary
|
|
10
|
+
performs = scope.where(event_type: "perform")
|
|
11
|
+
{
|
|
12
|
+
total: performs.count,
|
|
13
|
+
queued: performs.where(status: "queued").count,
|
|
14
|
+
running: performs.where(status: "running").count,
|
|
15
|
+
success: performs.where(status: "success").count,
|
|
16
|
+
failed: performs.where(status: "failed").count,
|
|
17
|
+
avg_duration_ms: performs.where(status: "success").average(:duration_ms)&.round(2),
|
|
18
|
+
failure_rate: failure_rate
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def by_job_class
|
|
23
|
+
scope
|
|
24
|
+
.where(event_type: "perform")
|
|
25
|
+
.group(:job_class)
|
|
26
|
+
.select(
|
|
27
|
+
"job_class",
|
|
28
|
+
"COUNT(*) as total",
|
|
29
|
+
"SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
|
|
30
|
+
"SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count",
|
|
31
|
+
"AVG(duration_ms) as avg_duration_ms",
|
|
32
|
+
"MAX(duration_ms) as max_duration_ms"
|
|
33
|
+
)
|
|
34
|
+
.order("total DESC")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def by_queue
|
|
38
|
+
scope
|
|
39
|
+
.where(event_type: "perform")
|
|
40
|
+
.group(:queue_name)
|
|
41
|
+
.select(
|
|
42
|
+
"queue_name",
|
|
43
|
+
"COUNT(*) as total",
|
|
44
|
+
"SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
|
|
45
|
+
"SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count"
|
|
46
|
+
)
|
|
47
|
+
.order("total DESC")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def recent_failures(limit: 20)
|
|
51
|
+
JobExecution
|
|
52
|
+
.where(status: "failed")
|
|
53
|
+
.where(created_at: start_time..)
|
|
54
|
+
.order(created_at: :desc)
|
|
55
|
+
.limit(limit)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def timeline(interval: :hour)
|
|
59
|
+
group_clause = case interval
|
|
60
|
+
when :minute then "date_trunc('minute', created_at)"
|
|
61
|
+
when :hour then "date_trunc('hour', created_at)"
|
|
62
|
+
when :day then "date_trunc('day', created_at)"
|
|
63
|
+
else "date_trunc('hour', created_at)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
scope
|
|
67
|
+
.where(event_type: "perform")
|
|
68
|
+
.group(Arel.sql(group_clause))
|
|
69
|
+
.select(
|
|
70
|
+
Arel.sql("#{group_clause} as period"),
|
|
71
|
+
"COUNT(*) as total",
|
|
72
|
+
"SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
|
|
73
|
+
"SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count"
|
|
74
|
+
)
|
|
75
|
+
.order(Arel.sql(group_clause))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def slowest_jobs(limit: 10)
|
|
79
|
+
scope
|
|
80
|
+
.where(event_type: "perform")
|
|
81
|
+
.order(duration_ms: :desc)
|
|
82
|
+
.limit(limit)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def scope
|
|
88
|
+
JobExecution.where(created_at: start_time..)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def start_time
|
|
92
|
+
period.ago
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def failure_rate
|
|
96
|
+
total = scope.where(event_type: "perform").count
|
|
97
|
+
return 0.0 if total.zero?
|
|
98
|
+
|
|
99
|
+
failed = scope.where(event_type: "perform", status: "failed").count
|
|
100
|
+
(failed.to_f / total * 100).round(2)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|