rails_pulse 0.2.3 → 0.2.5.pre.pre.2

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +270 -13
  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 +3 -3
  8. data/app/controllers/rails_pulse/application_controller.rb +20 -3
  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 -8
  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 +79 -3
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +32 -2
  20. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  21. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  22. data/app/javascript/rails_pulse/application.js +3 -54
  23. data/app/javascript/rails_pulse/controllers/chart_controller.js +333 -0
  24. data/app/javascript/rails_pulse/controllers/index_controller.js +9 -14
  25. data/app/javascript/rails_pulse/controllers/pagination_controller.js +27 -33
  26. data/app/jobs/rails_pulse/backfill_summaries_job.rb +0 -2
  27. data/app/jobs/rails_pulse/cleanup_job.rb +0 -2
  28. data/app/jobs/rails_pulse/summary_job.rb +0 -2
  29. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  30. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  31. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  32. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  33. data/app/models/rails_pulse/job.rb +85 -0
  34. data/app/models/rails_pulse/job_run.rb +76 -0
  35. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  36. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  37. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  38. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  39. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  40. data/app/models/rails_pulse/operation.rb +16 -3
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +1 -1
  45. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  46. data/app/models/rails_pulse/query.rb +10 -1
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +1 -1
  48. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  49. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  50. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  51. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  52. data/app/models/rails_pulse/routes/charts/average_response_times.rb +1 -1
  53. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  54. data/app/models/rails_pulse/summary.rb +10 -3
  55. data/app/services/rails_pulse/summary_service.rb +46 -0
  56. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  57. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  58. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  59. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  60. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  61. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  62. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  63. data/app/views/rails_pulse/components/_table_pagination.html.erb +8 -6
  64. data/app/views/rails_pulse/csp_test/show.html.erb +1 -1
  65. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -1
  66. data/app/views/rails_pulse/dashboard/index.html.erb +5 -4
  67. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  68. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  69. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  70. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  71. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  72. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  73. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  74. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  75. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  76. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  77. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  78. data/app/views/rails_pulse/queries/index.html.erb +2 -1
  79. data/app/views/rails_pulse/queries/show.html.erb +2 -1
  80. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  81. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  82. data/app/views/rails_pulse/routes/index.html.erb +2 -1
  83. data/app/views/rails_pulse/routes/show.html.erb +3 -2
  84. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  85. data/config/brakeman.ignore +213 -0
  86. data/config/brakeman.yml +68 -0
  87. data/config/importmap.rb +1 -1
  88. data/config/initializers/rails_pulse.rb +52 -0
  89. data/config/routes.rb +6 -0
  90. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  91. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  92. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  93. data/db/rails_pulse_schema.rb +186 -103
  94. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  95. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  96. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  97. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  98. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  99. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  100. data/lib/rails_pulse/cleanup_service.rb +65 -0
  101. data/lib/rails_pulse/configuration.rb +80 -7
  102. data/lib/rails_pulse/engine.rb +29 -28
  103. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  104. data/lib/rails_pulse/job_run_collector.rb +172 -0
  105. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  106. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  107. data/lib/rails_pulse/tracker.rb +82 -0
  108. data/lib/rails_pulse/version.rb +1 -1
  109. data/lib/rails_pulse.rb +2 -0
  110. data/lib/rails_pulse_server.ru +107 -0
  111. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  112. data/public/rails-pulse-assets/csp-test.js +10 -10
  113. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  114. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  115. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  116. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  117. data/public/rails-pulse-assets/rails-pulse.js +48 -48
  118. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  119. metadata +38 -30
  120. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  121. data/config/initializers/rails_charts_csp_patch.rb +0 -75
  122. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
@@ -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
@@ -0,0 +1,74 @@
1
+ module RailsPulse
2
+ module Jobs
3
+ module Cards
4
+ class TotalJobs < Base
5
+ def initialize(job: nil)
6
+ @job = job
7
+ end
8
+
9
+ def to_metric_card
10
+ # When scoped to a job, show runs count instead of job count
11
+ if @job
12
+ base_query = RailsPulse::Summary
13
+ .where(
14
+ summarizable_type: "RailsPulse::Job",
15
+ summarizable_id: @job.id,
16
+ period_type: "day",
17
+ period_start: range_start..now
18
+ )
19
+
20
+ metrics = base_query.select(
21
+ "SUM(count) AS total_count",
22
+ "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
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
+ ).take
25
+
26
+ total_runs = metrics&.total_count.to_i
27
+ current_runs = metrics&.current_count.to_i
28
+ previous_runs = metrics&.previous_count.to_i
29
+
30
+ trend_icon, trend_amount = trend_for(current_runs, previous_runs)
31
+
32
+ grouped_runs = base_query
33
+ .group_by_date(:period_start)
34
+ .sum(:count)
35
+
36
+ {
37
+ id: "jobs_total_jobs",
38
+ context: "jobs",
39
+ title: "Total Runs",
40
+ summary: "#{format_number(total_runs)} runs",
41
+ chart_data: sparkline_from(grouped_runs),
42
+ trend_icon: trend_icon,
43
+ trend_amount: trend_amount,
44
+ trend_text: "Compared to previous week"
45
+ }
46
+ else
47
+ total_jobs = RailsPulse::Job.count
48
+
49
+ current_new_jobs = RailsPulse::Job.where(created_at: current_window_start..now).count
50
+ previous_new_jobs = RailsPulse::Job.where(created_at: range_start...current_window_start).count
51
+
52
+ trend_icon, trend_amount = trend_for(current_new_jobs, previous_new_jobs)
53
+
54
+ grouped_new_jobs = RailsPulse::Job
55
+ .where(created_at: range_start..now)
56
+ .group_by_date(:created_at)
57
+ .count
58
+
59
+ {
60
+ id: "jobs_total_jobs",
61
+ context: "jobs",
62
+ title: "Total Jobs",
63
+ summary: "#{format_number(total_jobs)} jobs",
64
+ chart_data: sparkline_from(grouped_new_jobs),
65
+ trend_icon: trend_icon,
66
+ trend_amount: trend_amount,
67
+ trend_text: "New jobs vs previous week"
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ module RailsPulse
2
+ module Jobs
3
+ module Cards
4
+ class TotalRuns < 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(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
21
+ "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
22
+ ).take
23
+
24
+ total_runs = metrics&.total_count.to_i
25
+ current_runs = metrics&.current_count.to_i
26
+ previous_runs = metrics&.previous_count.to_i
27
+
28
+ trend_icon, trend_amount = trend_for(current_runs, previous_runs)
29
+
30
+ grouped_runs = base_query
31
+ .group_by_date(:period_start)
32
+ .sum(:count)
33
+
34
+ {
35
+ id: "jobs_total_runs",
36
+ context: "jobs",
37
+ title: "Job Runs",
38
+ summary: "#{format_number(total_runs)} runs",
39
+ chart_data: sparkline_from(grouped_runs),
40
+ trend_icon: trend_icon,
41
+ trend_amount: trend_amount,
42
+ trend_text: "Compared to previous week"
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,3 +1,5 @@
1
+ require "digest"
2
+
1
3
  module RailsPulse
2
4
  class Operation < RailsPulse::ApplicationRecord
3
5
  self.table_name = "rails_pulse_operations"
@@ -18,15 +20,16 @@ module RailsPulse
18
20
  ].freeze
19
21
 
20
22
  # Associations
21
- belongs_to :request, class_name: "RailsPulse::Request"
23
+ belongs_to :request, class_name: "RailsPulse::Request", optional: true
24
+ belongs_to :job_run, class_name: "RailsPulse::JobRun", optional: true
22
25
  belongs_to :query, class_name: "RailsPulse::Query", optional: true
23
26
 
24
27
  # Validations
25
- validates :request_id, presence: true
26
28
  validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
27
29
  validates :label, presence: true
28
30
  validates :occurred_at, presence: true
29
31
  validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
32
+ validate :has_request_or_job_run
30
33
 
31
34
  # Scopes (optional, for convenience)
32
35
  scope :by_type, ->(type) { where(operation_type: type) }
@@ -72,11 +75,21 @@ module RailsPulse
72
75
 
73
76
  private
74
77
 
78
+ def has_request_or_job_run
79
+ return if request_id.present? || job_run_id.present?
80
+
81
+ errors.add(:base, "Operation must belong to a request or a job run")
82
+ end
83
+
75
84
  def associate_query
76
85
  return unless operation_type == "sql" && label.present?
77
86
 
78
87
  normalized = normalize_query_label(label)
79
- self.query = RailsPulse::Query.find_or_create_by(normalized_sql: normalized)
88
+ hashed = Digest::MD5.hexdigest(normalized)
89
+
90
+ self.query = RailsPulse::Query.find_or_create_by(hashed_sql: hashed) do |q|
91
+ q.normalized_sql = normalized
92
+ end
80
93
  end
81
94
 
82
95
  # Normalize SQL query using the dedicated service