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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -12
  3. data/Rakefile +142 -8
  4. data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
  5. data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
  6. data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
  7. data/app/controllers/concerns/chart_table_concern.rb +2 -1
  8. data/app/controllers/rails_pulse/application_controller.rb +11 -1
  9. data/app/controllers/rails_pulse/assets_controller.rb +18 -2
  10. data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
  11. data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
  12. data/app/controllers/rails_pulse/operations_controller.rb +43 -31
  13. data/app/controllers/rails_pulse/queries_controller.rb +1 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +3 -9
  15. data/app/controllers/rails_pulse/routes_controller.rb +1 -1
  16. data/app/controllers/rails_pulse/tags_controller.rb +31 -5
  17. data/app/helpers/rails_pulse/application_helper.rb +32 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  20. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  21. data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
  22. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  23. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  24. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  26. data/app/models/rails_pulse/job.rb +85 -0
  27. data/app/models/rails_pulse/job_run.rb +76 -0
  28. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  29. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  30. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  31. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  32. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  33. data/app/models/rails_pulse/operation.rb +16 -3
  34. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  35. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  36. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  37. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  38. data/app/models/rails_pulse/query.rb +10 -1
  39. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  40. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  41. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  42. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  43. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  44. data/app/models/rails_pulse/summary.rb +10 -3
  45. data/app/services/rails_pulse/summary_service.rb +46 -0
  46. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  47. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  48. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  49. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  50. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  51. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  52. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  53. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  54. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  55. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  56. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  57. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  58. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  59. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  60. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  61. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  62. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  63. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  64. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  65. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  66. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  67. data/config/brakeman.ignore +213 -0
  68. data/config/brakeman.yml +68 -0
  69. data/config/initializers/rails_pulse.rb +52 -0
  70. data/config/routes.rb +6 -0
  71. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  72. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  73. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  74. data/db/rails_pulse_schema.rb +186 -103
  75. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  76. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  77. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  78. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  79. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  80. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  81. data/lib/rails_pulse/cleanup_service.rb +65 -0
  82. data/lib/rails_pulse/configuration.rb +80 -7
  83. data/lib/rails_pulse/engine.rb +34 -3
  84. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  85. data/lib/rails_pulse/job_run_collector.rb +172 -0
  86. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  87. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  88. data/lib/rails_pulse/tracker.rb +82 -0
  89. data/lib/rails_pulse/version.rb +1 -1
  90. data/lib/rails_pulse.rb +2 -0
  91. data/lib/rails_pulse_server.ru +107 -0
  92. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  93. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  94. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  96. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  97. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  98. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  99. metadata +37 -9
  100. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  101. 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, parse it
164
+ // If it's a function string, use safe formatter registry instead of eval()
165
165
  if (cleanString.trim().startsWith('function')) {
166
- try {
167
- // eslint-disable-next-line no-eval
168
- return eval(`(${cleanString})`)
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
- scope :with_tag, ->(tag) { where("#{table_name}.tags LIKE ?", "%#{tag}%") }
11
- scope :without_tag, ->(tag) { where.not("#{table_name}.tags LIKE ?", "%#{tag}%") }
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
- .group_by_day(:period_start, time_zone: Time.zone)
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-auto" },
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