rails_pulse 0.2.4 → 0.2.5.pre.pre.3
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 +4 -4
- data/README.md +269 -12
- data/Rakefile +142 -8
- data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
- data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
- data/app/controllers/concerns/chart_table_concern.rb +2 -1
- data/app/controllers/rails_pulse/application_controller.rb +11 -1
- data/app/controllers/rails_pulse/assets_controller.rb +18 -2
- data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
- data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
- data/app/controllers/rails_pulse/operations_controller.rb +43 -31
- data/app/controllers/rails_pulse/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +3 -9
- data/app/controllers/rails_pulse/routes_controller.rb +1 -1
- data/app/controllers/rails_pulse/tags_controller.rb +31 -5
- data/app/helpers/rails_pulse/application_helper.rb +32 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
- data/app/helpers/rails_pulse/status_helper.rb +16 -0
- data/app/helpers/rails_pulse/tags_helper.rb +39 -1
- data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
- data/app/models/concerns/rails_pulse/taggable.rb +25 -2
- data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/job.rb +85 -0
- data/app/models/rails_pulse/job_run.rb +76 -0
- data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
- data/app/models/rails_pulse/operation.rb +16 -3
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/tables/index.rb +2 -1
- data/app/models/rails_pulse/query.rb +10 -1
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
- data/app/models/rails_pulse/routes/tables/index.rb +2 -1
- data/app/models/rails_pulse/summary.rb +10 -3
- data/app/services/rails_pulse/summary_service.rb +46 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/application.html.erb +23 -0
- data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
- data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
- data/app/views/rails_pulse/components/_table.html.erb +7 -4
- data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
- data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
- data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
- data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
- data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
- data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
- data/app/views/rails_pulse/jobs/index.html.erb +34 -0
- data/app/views/rails_pulse/jobs/show.html.erb +49 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
- data/app/views/rails_pulse/operations/show.html.erb +10 -8
- data/app/views/rails_pulse/queries/_table.html.erb +3 -3
- data/app/views/rails_pulse/requests/_table.html.erb +6 -6
- data/app/views/rails_pulse/routes/_table.html.erb +3 -3
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
- data/config/brakeman.ignore +213 -0
- data/config/brakeman.yml +68 -0
- data/config/initializers/rails_pulse.rb +52 -0
- data/config/routes.rb +6 -0
- data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
- data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
- data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
- data/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
- data/lib/rails_pulse/active_job_extensions.rb +13 -0
- data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
- data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
- data/lib/rails_pulse/cleanup_service.rb +65 -0
- data/lib/rails_pulse/configuration.rb +80 -7
- data/lib/rails_pulse/engine.rb +34 -3
- data/lib/rails_pulse/extensions/active_record.rb +82 -0
- data/lib/rails_pulse/job_run_collector.rb +172 -0
- data/lib/rails_pulse/middleware/request_collector.rb +20 -43
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
- data/lib/rails_pulse/tracker.rb +82 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +2 -0
- data/lib/rails_pulse_server.ru +107 -0
- data/lib/tasks/rails_pulse_benchmark.rake +382 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
- metadata +37 -9
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
|
@@ -161,19 +161,123 @@ export default class extends Controller {
|
|
|
161
161
|
// Remove function markers if present
|
|
162
162
|
const cleanString = formatterString.replace(/__FUNCTION_START__|__FUNCTION_END__/g, '')
|
|
163
163
|
|
|
164
|
-
// If it's a function string,
|
|
164
|
+
// If it's a function string, use safe formatter registry instead of eval()
|
|
165
165
|
if (cleanString.trim().startsWith('function')) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
} catch (error) {
|
|
170
|
-
console.error('[RailsPulse] Error parsing formatter function:', error)
|
|
171
|
-
return cleanString
|
|
172
|
-
}
|
|
166
|
+
// Extract formatter logic using safe parsing
|
|
167
|
+
// Rather than eval(), we match against known safe patterns
|
|
168
|
+
return this.getSafeFormatter(cleanString)
|
|
173
169
|
}
|
|
174
170
|
return cleanString
|
|
175
171
|
}
|
|
176
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Returns a safe formatter function based on the formatter string.
|
|
175
|
+
* This prevents arbitrary code execution by using a whitelist approach.
|
|
176
|
+
*
|
|
177
|
+
* Security: Replaces eval() to prevent XSS and code injection attacks.
|
|
178
|
+
*/
|
|
179
|
+
getSafeFormatter(formatterString) {
|
|
180
|
+
// Whitelist of safe formatter patterns
|
|
181
|
+
// Each pattern maps to a safe implementation
|
|
182
|
+
const SAFE_FORMATTERS = {
|
|
183
|
+
// Duration formatter (milliseconds)
|
|
184
|
+
'duration_ms': (value) => {
|
|
185
|
+
if (typeof value === 'number') {
|
|
186
|
+
return value.toFixed(2) + ' ms'
|
|
187
|
+
}
|
|
188
|
+
return value
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// Percentage formatter
|
|
192
|
+
'percentage': (value) => {
|
|
193
|
+
if (typeof value === 'number') {
|
|
194
|
+
return value.toFixed(1) + '%'
|
|
195
|
+
}
|
|
196
|
+
return value
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Number with commas
|
|
200
|
+
'number_delimited': (value) => {
|
|
201
|
+
if (typeof value === 'number') {
|
|
202
|
+
return value.toLocaleString()
|
|
203
|
+
}
|
|
204
|
+
return value
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Timestamp formatter
|
|
208
|
+
'timestamp': (value) => {
|
|
209
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
210
|
+
const date = new Date(value)
|
|
211
|
+
return date.toLocaleString()
|
|
212
|
+
}
|
|
213
|
+
return value
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// Date only
|
|
217
|
+
'date': (value) => {
|
|
218
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
219
|
+
const date = new Date(value)
|
|
220
|
+
return date.toLocaleDateString()
|
|
221
|
+
}
|
|
222
|
+
return value
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// Time only
|
|
226
|
+
'time': (value) => {
|
|
227
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
228
|
+
const date = new Date(value)
|
|
229
|
+
return date.toLocaleTimeString()
|
|
230
|
+
}
|
|
231
|
+
return value
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// Bytes formatter
|
|
235
|
+
'bytes': (value) => {
|
|
236
|
+
if (typeof value !== 'number') return value
|
|
237
|
+
|
|
238
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
239
|
+
let size = value
|
|
240
|
+
let unitIndex = 0
|
|
241
|
+
|
|
242
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
243
|
+
size /= 1024
|
|
244
|
+
unitIndex++
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return size.toFixed(2) + ' ' + units[unitIndex]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Try to match the formatter string to a known safe pattern
|
|
252
|
+
for (const [key, formatter] of Object.entries(SAFE_FORMATTERS)) {
|
|
253
|
+
if (formatterString.includes(key) ||
|
|
254
|
+
formatterString.includes(key.replace('_', ''))) {
|
|
255
|
+
return formatter
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for specific safe patterns in the function string
|
|
260
|
+
if (formatterString.includes('toFixed(2)') && formatterString.includes('ms')) {
|
|
261
|
+
return SAFE_FORMATTERS.duration_ms
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (formatterString.includes('toLocaleString')) {
|
|
265
|
+
return SAFE_FORMATTERS.number_delimited
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (formatterString.includes('toLocaleDateString')) {
|
|
269
|
+
return SAFE_FORMATTERS.date
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (formatterString.includes('toLocaleTimeString')) {
|
|
273
|
+
return SAFE_FORMATTERS.time
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Default: return a safe identity function that just returns the value
|
|
277
|
+
console.warn('[RailsPulse] Unknown formatter pattern, using identity function:', formatterString)
|
|
278
|
+
return (value) => value
|
|
279
|
+
}
|
|
280
|
+
|
|
177
281
|
showError() {
|
|
178
282
|
this.element.classList.add('chart-error')
|
|
179
283
|
this.element.innerHTML = '<p class="text-subtle p-4">Chart failed to load</p>'
|
|
@@ -2,13 +2,24 @@ module RailsPulse
|
|
|
2
2
|
module Taggable
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
# Tag validation constants
|
|
6
|
+
TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
|
|
7
|
+
MAX_TAG_LENGTH = 50
|
|
8
|
+
|
|
5
9
|
included do
|
|
6
10
|
# Callbacks
|
|
7
11
|
before_save :ensure_tags_is_array
|
|
8
12
|
|
|
9
13
|
# Scopes with table name qualification to avoid ambiguity
|
|
10
|
-
|
|
11
|
-
scope :
|
|
14
|
+
# Note: LIKE patterns are sanitized to prevent SQL injection via wildcards
|
|
15
|
+
scope :with_tag, ->(tag) {
|
|
16
|
+
sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
|
|
17
|
+
where("#{table_name}.tags LIKE ?", "%#{sanitized_tag}%")
|
|
18
|
+
}
|
|
19
|
+
scope :without_tag, ->(tag) {
|
|
20
|
+
sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
|
|
21
|
+
where.not("#{table_name}.tags LIKE ?", "%#{sanitized_tag}%")
|
|
22
|
+
}
|
|
12
23
|
scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
|
|
13
24
|
end
|
|
14
25
|
|
|
@@ -26,11 +37,16 @@ module RailsPulse
|
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
def add_tag(tag)
|
|
40
|
+
# Validate tag format and length
|
|
41
|
+
return false unless valid_tag_name?(tag)
|
|
42
|
+
|
|
29
43
|
current_tags = tag_list
|
|
30
44
|
unless current_tags.include?(tag.to_s)
|
|
31
45
|
current_tags << tag.to_s
|
|
32
46
|
self.tag_list = current_tags
|
|
33
47
|
save
|
|
48
|
+
else
|
|
49
|
+
true # Tag already exists, return success
|
|
34
50
|
end
|
|
35
51
|
end
|
|
36
52
|
|
|
@@ -45,6 +61,13 @@ module RailsPulse
|
|
|
45
61
|
|
|
46
62
|
private
|
|
47
63
|
|
|
64
|
+
def valid_tag_name?(tag)
|
|
65
|
+
return false if tag.blank?
|
|
66
|
+
return false if tag.to_s.length > MAX_TAG_LENGTH
|
|
67
|
+
return false unless tag.to_s.match?(TAG_NAME_REGEX)
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def parsed_tags
|
|
49
72
|
return [] if tags.nil? || tags.empty?
|
|
50
73
|
JSON.parse(tags)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Charts
|
|
3
|
+
class OperationsChart
|
|
4
|
+
OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct)
|
|
5
|
+
|
|
6
|
+
attr_reader :bars, :min_start, :max_end, :total_duration
|
|
7
|
+
|
|
8
|
+
HORIZONTAL_OFFSET_PX = 20
|
|
9
|
+
|
|
10
|
+
def initialize(operations)
|
|
11
|
+
@operations = operations
|
|
12
|
+
@min_start = @operations.map(&:start_time).min || 0
|
|
13
|
+
@max_end = @operations.map { |op| op.start_time + op.duration }.max || 1
|
|
14
|
+
@total_duration = (@max_end - @min_start).nonzero? || 1
|
|
15
|
+
@bars = build_bars
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_bars
|
|
21
|
+
@operations.map do |operation|
|
|
22
|
+
left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2
|
|
23
|
+
width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct)
|
|
24
|
+
OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def px_to_pct
|
|
29
|
+
(HORIZONTAL_OFFSET_PX.to_f / 1000) * 100
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -23,9 +23,8 @@ module RailsPulse
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
actual_data = summaries
|
|
26
|
-
.
|
|
26
|
+
.group_by_date(:period_start)
|
|
27
27
|
.average(:p95_duration)
|
|
28
|
-
.transform_keys { |date| date.to_date }
|
|
29
28
|
.transform_values { |avg| avg&.round(0) || 0 }
|
|
30
29
|
|
|
31
30
|
# Fill in all dates with zero values for missing days
|
|
@@ -49,7 +49,7 @@ module RailsPulse
|
|
|
49
49
|
# Return new structure with columns and data
|
|
50
50
|
{
|
|
51
51
|
columns: [
|
|
52
|
-
{ field: :route_path, label: "Route", link_to: :route_link, class: "w-
|
|
52
|
+
{ field: :route_path, label: "Route", link_to: :route_link, class: "w-48", cell_class: "truncate-cell" },
|
|
53
53
|
{ field: :average_time, label: "Average Time", class: "w-32" },
|
|
54
54
|
{ field: :request_count, label: "Requests", class: "w-24" },
|
|
55
55
|
{ field: :last_request, label: "Last Request", class: "w-32" }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class Job < RailsPulse::ApplicationRecord
|
|
3
|
+
include Taggable
|
|
4
|
+
|
|
5
|
+
self.table_name = "rails_pulse_jobs"
|
|
6
|
+
|
|
7
|
+
has_many :runs,
|
|
8
|
+
class_name: "RailsPulse::JobRun",
|
|
9
|
+
foreign_key: :job_id,
|
|
10
|
+
inverse_of: :job,
|
|
11
|
+
dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :name, presence: true, uniqueness: true
|
|
14
|
+
|
|
15
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
16
|
+
%w[id name queue_name runs_count failures_count retries_count avg_duration]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.ransackable_associations(auth_object = nil)
|
|
20
|
+
%w[runs]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
scope :by_queue, ->(queue) { where(queue_name: queue) }
|
|
24
|
+
scope :with_failures, -> { where("failures_count > 0") }
|
|
25
|
+
scope :ordered_by_runs, -> { order(runs_count: :desc) }
|
|
26
|
+
|
|
27
|
+
def apply_run!(run)
|
|
28
|
+
return unless run.duration
|
|
29
|
+
|
|
30
|
+
duration = run.duration.to_f
|
|
31
|
+
|
|
32
|
+
with_lock do
|
|
33
|
+
reload
|
|
34
|
+
total_runs = runs_count.to_i
|
|
35
|
+
previous_total = [ total_runs - 1, 0 ].max
|
|
36
|
+
previous_average = avg_duration.to_f
|
|
37
|
+
|
|
38
|
+
new_average = if previous_total.zero?
|
|
39
|
+
duration
|
|
40
|
+
else
|
|
41
|
+
((previous_average * previous_total) + duration) / (previous_total + 1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
updates = { avg_duration: new_average }
|
|
45
|
+
if run.failure_like_status?
|
|
46
|
+
updates[:failures_count] = failures_count + 1
|
|
47
|
+
end
|
|
48
|
+
if run.status == "retried"
|
|
49
|
+
updates[:retries_count] = retries_count + 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
update!(updates)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failure_rate
|
|
57
|
+
return 0.0 if runs_count.zero?
|
|
58
|
+
|
|
59
|
+
((failures_count.to_f / runs_count) * 100).round(2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def performance_status
|
|
63
|
+
thresholds = RailsPulse.configuration.job_thresholds
|
|
64
|
+
duration = avg_duration.to_f
|
|
65
|
+
|
|
66
|
+
if duration < thresholds[:slow]
|
|
67
|
+
:fast
|
|
68
|
+
elsif duration < thresholds[:very_slow]
|
|
69
|
+
:slow
|
|
70
|
+
elsif duration < thresholds[:critical]
|
|
71
|
+
:very_slow
|
|
72
|
+
else
|
|
73
|
+
:critical
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_param
|
|
78
|
+
id.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_breadcrumb
|
|
82
|
+
name
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class JobRun < RailsPulse::ApplicationRecord
|
|
3
|
+
include Taggable
|
|
4
|
+
|
|
5
|
+
self.table_name = "rails_pulse_job_runs"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[enqueued running success failed discarded retried].freeze
|
|
8
|
+
FINAL_STATUSES = %w[success failed discarded retried].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :job,
|
|
11
|
+
class_name: "RailsPulse::Job",
|
|
12
|
+
counter_cache: :runs_count,
|
|
13
|
+
inverse_of: :runs
|
|
14
|
+
has_many :operations,
|
|
15
|
+
class_name: "RailsPulse::Operation",
|
|
16
|
+
foreign_key: :job_run_id,
|
|
17
|
+
inverse_of: :job_run,
|
|
18
|
+
dependent: :destroy
|
|
19
|
+
|
|
20
|
+
validates :run_id, presence: true, uniqueness: true
|
|
21
|
+
validates :status, inclusion: { in: STATUSES }
|
|
22
|
+
validates :occurred_at, presence: true
|
|
23
|
+
|
|
24
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
25
|
+
%w[id job_id run_id status occurred_at duration attempts adapter]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.ransackable_associations(auth_object = nil)
|
|
29
|
+
%w[job operations]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
scope :successful, -> { where(status: "success") }
|
|
33
|
+
scope :failed, -> { where(status: %w[failed discarded]) }
|
|
34
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
35
|
+
scope :by_adapter, ->(adapter) { where(adapter: adapter) }
|
|
36
|
+
|
|
37
|
+
after_commit :apply_to_job_caches, on: %i[create update], if: :finalized?
|
|
38
|
+
|
|
39
|
+
def all_tags
|
|
40
|
+
(job.tag_list + tag_list).uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def performance_status
|
|
44
|
+
thresholds = RailsPulse.configuration.job_thresholds
|
|
45
|
+
duration = self.duration.to_f
|
|
46
|
+
|
|
47
|
+
if duration < thresholds[:slow]
|
|
48
|
+
:fast
|
|
49
|
+
elsif duration < thresholds[:very_slow]
|
|
50
|
+
:slow
|
|
51
|
+
elsif duration < thresholds[:critical]
|
|
52
|
+
:very_slow
|
|
53
|
+
else
|
|
54
|
+
:critical
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def failure_like_status?
|
|
59
|
+
FINAL_STATUSES.include?(status) && status != "success"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def finalized?
|
|
63
|
+
change = previous_changes["status"]
|
|
64
|
+
return false unless change
|
|
65
|
+
|
|
66
|
+
previous_state, new_state = change
|
|
67
|
+
FINAL_STATUSES.include?(new_state) && !FINAL_STATUSES.include?(previous_state)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def apply_to_job_caches
|
|
73
|
+
job.apply_run!(self)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class AverageDuration < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
base_query = RailsPulse::Summary
|
|
11
|
+
.where(
|
|
12
|
+
summarizable_type: "RailsPulse::Job",
|
|
13
|
+
period_type: "day",
|
|
14
|
+
period_start: range_start..now
|
|
15
|
+
)
|
|
16
|
+
base_query = base_query.where(summarizable_id: @job.id) if @job
|
|
17
|
+
|
|
18
|
+
metrics = base_query.select(
|
|
19
|
+
"SUM(avg_duration * count) AS total_weighted_duration",
|
|
20
|
+
"SUM(count) AS total_runs",
|
|
21
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS current_weighted_duration",
|
|
22
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_runs",
|
|
23
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS previous_weighted_duration",
|
|
24
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_runs"
|
|
25
|
+
).take
|
|
26
|
+
|
|
27
|
+
total_runs = metrics&.total_runs.to_i
|
|
28
|
+
total_weighted_duration = metrics&.total_weighted_duration.to_f
|
|
29
|
+
current_runs = metrics&.current_runs.to_i
|
|
30
|
+
current_weighted_duration = metrics&.current_weighted_duration.to_f
|
|
31
|
+
previous_runs = metrics&.previous_runs.to_i
|
|
32
|
+
previous_weighted_duration = metrics&.previous_weighted_duration.to_f
|
|
33
|
+
|
|
34
|
+
average_duration = average_for(total_weighted_duration, total_runs)
|
|
35
|
+
current_average = average_for(current_weighted_duration, current_runs)
|
|
36
|
+
previous_average = average_for(previous_weighted_duration, previous_runs)
|
|
37
|
+
|
|
38
|
+
trend_icon, trend_amount = trend_for(current_average, previous_average)
|
|
39
|
+
|
|
40
|
+
grouped_weighted = base_query
|
|
41
|
+
.group_by_date(:period_start)
|
|
42
|
+
.sum(Arel.sql("avg_duration * count"))
|
|
43
|
+
|
|
44
|
+
grouped_counts = base_query
|
|
45
|
+
.group_by_date(:period_start)
|
|
46
|
+
.sum(:count)
|
|
47
|
+
|
|
48
|
+
sparkline_data = sparkline_from_averages(grouped_weighted, grouped_counts)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
id: "jobs_average_duration",
|
|
52
|
+
context: "jobs",
|
|
53
|
+
title: "Average Duration",
|
|
54
|
+
summary: format_duration(average_duration),
|
|
55
|
+
chart_data: sparkline_data,
|
|
56
|
+
trend_icon: trend_icon,
|
|
57
|
+
trend_amount: trend_amount,
|
|
58
|
+
trend_text: "Compared to previous week"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def average_for(weighted_duration, total_runs)
|
|
65
|
+
return 0.0 if total_runs.zero?
|
|
66
|
+
|
|
67
|
+
(weighted_duration.to_f / total_runs).round(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sparkline_from_averages(weighted_by_day, counts_by_day)
|
|
71
|
+
start_date = range_start.to_date
|
|
72
|
+
end_date = now.to_date
|
|
73
|
+
|
|
74
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
75
|
+
weighted = weighted_by_day[day].to_f
|
|
76
|
+
count = counts_by_day[day].to_f
|
|
77
|
+
avg = count.zero? ? 0.0 : (weighted / count).round(1)
|
|
78
|
+
label = day.strftime("%b %-d")
|
|
79
|
+
hash[label] = { value: avg }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "active_support/number_helper"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module Jobs
|
|
5
|
+
module Cards
|
|
6
|
+
class Base
|
|
7
|
+
RANGE_DAYS = 14
|
|
8
|
+
WINDOW_DAYS = 7
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def now
|
|
13
|
+
@now ||= Time.current
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def previous_window_start
|
|
17
|
+
(now - (WINDOW_DAYS * 2).days).beginning_of_day
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_window_start
|
|
21
|
+
(now - WINDOW_DAYS.days).beginning_of_day
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def range_start
|
|
25
|
+
previous_window_start
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def quote(time)
|
|
29
|
+
RailsPulse::Summary.connection.quote(time)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sparkline_from(grouped_values)
|
|
33
|
+
start_date = range_start.to_date
|
|
34
|
+
end_date = now.to_date
|
|
35
|
+
|
|
36
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
37
|
+
label = day.strftime("%b %-d")
|
|
38
|
+
hash[label] = { value: grouped_values[day] || 0 }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def trend_for(current_value, previous_value, precision: 1)
|
|
43
|
+
percentage = previous_value.zero? ? 0.0 : ((current_value - previous_value) / previous_value.to_f * 100).round(precision)
|
|
44
|
+
|
|
45
|
+
icon = if percentage.abs < 0.1
|
|
46
|
+
"move-right"
|
|
47
|
+
elsif percentage.positive?
|
|
48
|
+
"trending-up"
|
|
49
|
+
else
|
|
50
|
+
"trending-down"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
[ icon, format_percentage(percentage.abs, precision) ]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_percentage(value, precision)
|
|
57
|
+
"#{value.round(precision)}%"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def format_number(value)
|
|
61
|
+
ActiveSupport::NumberHelper.number_to_delimited(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_duration(value)
|
|
65
|
+
"#{value.round(0)} ms"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class FailureRate < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
base_query = RailsPulse::Summary
|
|
11
|
+
.where(
|
|
12
|
+
summarizable_type: "RailsPulse::Job",
|
|
13
|
+
period_type: "day",
|
|
14
|
+
period_start: range_start..now
|
|
15
|
+
)
|
|
16
|
+
base_query = base_query.where(summarizable_id: @job.id) if @job
|
|
17
|
+
|
|
18
|
+
metrics = base_query.select(
|
|
19
|
+
"SUM(count) AS total_count",
|
|
20
|
+
"SUM(error_count) AS total_errors",
|
|
21
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
|
|
22
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN error_count ELSE 0 END) AS current_errors",
|
|
23
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count",
|
|
24
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN error_count ELSE 0 END) AS previous_errors"
|
|
25
|
+
).take
|
|
26
|
+
|
|
27
|
+
total_runs = metrics&.total_count.to_i
|
|
28
|
+
total_errors = metrics&.total_errors.to_i
|
|
29
|
+
current_runs = metrics&.current_count.to_i
|
|
30
|
+
current_errors = metrics&.current_errors.to_i
|
|
31
|
+
previous_runs = metrics&.previous_count.to_i
|
|
32
|
+
previous_errors = metrics&.previous_errors.to_i
|
|
33
|
+
|
|
34
|
+
failure_rate = rate_for(total_errors, total_runs)
|
|
35
|
+
current_rate = rate_for(current_errors, current_runs)
|
|
36
|
+
previous_rate = rate_for(previous_errors, previous_runs)
|
|
37
|
+
|
|
38
|
+
trend_icon, trend_amount = trend_for(current_rate, previous_rate)
|
|
39
|
+
|
|
40
|
+
grouped_errors = base_query
|
|
41
|
+
.group_by_date(:period_start)
|
|
42
|
+
.sum(:error_count)
|
|
43
|
+
|
|
44
|
+
grouped_counts = base_query
|
|
45
|
+
.group_by_date(:period_start)
|
|
46
|
+
.sum(:count)
|
|
47
|
+
|
|
48
|
+
sparkline_data = sparkline_from_failure_rates(grouped_errors, grouped_counts)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
id: "jobs_failure_rate",
|
|
52
|
+
context: "jobs",
|
|
53
|
+
title: "Failure Rate",
|
|
54
|
+
summary: "#{format_percentage(failure_rate, 1)}",
|
|
55
|
+
chart_data: sparkline_data,
|
|
56
|
+
trend_icon: trend_icon,
|
|
57
|
+
trend_amount: trend_amount,
|
|
58
|
+
trend_text: "Compared to previous week"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def rate_for(errors, total)
|
|
65
|
+
return 0.0 if total.zero?
|
|
66
|
+
|
|
67
|
+
(errors.to_f / total * 100).round(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sparkline_from_failure_rates(errors_by_day, counts_by_day)
|
|
71
|
+
start_date = range_start.to_date
|
|
72
|
+
end_date = now.to_date
|
|
73
|
+
|
|
74
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
75
|
+
errors = errors_by_day[day].to_f
|
|
76
|
+
total = counts_by_day[day].to_f
|
|
77
|
+
rate = total.zero? ? 0.0 : (errors / total * 100).round(1)
|
|
78
|
+
label = day.strftime("%b %-d")
|
|
79
|
+
hash[label] = { value: rate }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|