rails_pulse 0.4.0.pre.4 → 0.4.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -87
  3. data/README.md +83 -111
  4. data/app/controllers/concerns/session_filters_concern.rb +4 -3
  5. data/app/controllers/concerns/time_range_concern.rb +29 -19
  6. data/app/controllers/rails_pulse/application_controller.rb +9 -5
  7. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
  8. data/app/controllers/rails_pulse/routes_controller.rb +46 -0
  9. data/app/helpers/rails_pulse/application_helper.rb +4 -0
  10. data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
  11. data/app/models/rails_pulse/cards/base.rb +61 -25
  12. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
  13. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
  14. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
  15. data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
  16. data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
  17. data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
  18. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
  19. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
  20. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
  21. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
  22. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
  23. data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
  24. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
  25. data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
  28. data/app/models/rails_pulse/route.rb +7 -1
  29. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
  30. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
  31. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
  32. data/app/models/rails_pulse/summary.rb +13 -18
  33. data/app/models/rails_pulse/time_range_preference.rb +28 -0
  34. data/app/models/rails_pulse/time_window.rb +61 -0
  35. data/app/services/rails_pulse/summary_service.rb +112 -162
  36. data/app/services/rails_pulse/tag_filter_service.rb +10 -1
  37. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
  38. data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
  39. data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
  40. data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
  41. data/app/views/rails_pulse/routes/show.html.erb +14 -2
  42. data/lib/generators/rails_pulse/install_generator.rb +5 -2
  43. data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
  44. data/lib/rails_pulse/cleanup_service.rb +7 -7
  45. data/lib/rails_pulse/configuration.rb +9 -1
  46. data/lib/rails_pulse/engine.rb +14 -42
  47. data/lib/rails_pulse/middleware/request_collector.rb +2 -17
  48. data/lib/rails_pulse/standalone.rb +12 -0
  49. data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
  50. data/lib/rails_pulse/tracker.rb +241 -25
  51. data/lib/rails_pulse/version.rb +1 -1
  52. data/lib/rails_pulse.rb +3 -16
  53. data/lib/rails_pulse_server.ru +37 -23
  54. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  55. metadata +7 -9
  56. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  57. data/app/assets/images/rails_pulse/query-show.png +0 -0
  58. data/app/assets/images/rails_pulse/request-show.png +0 -0
  59. data/app/assets/images/rails_pulse/request.png +0 -0
  60. data/app/assets/images/rails_pulse/route-show.png +0 -0
  61. data/lib/rails_pulse/extensions/active_record.rb +0 -124
@@ -98,11 +98,7 @@ module RailsPulse
98
98
  def grouped_counts(scope)
99
99
  scoped = scope.where(period_type: @period_type, period_start: current_window_start..now)
100
100
 
101
- if period_type_hours?
102
- scoped.group_by_hour(:period_start).sum("rails_pulse_summaries.count")
103
- else
104
- scoped.group_by_date(:period_start).sum("rails_pulse_summaries.count")
105
- end
101
+ bucket_by_period(scoped) { |relation| relation.sum("rails_pulse_summaries.count") }
106
102
  end
107
103
 
108
104
  def period_stat(exceptions, requests)
@@ -25,10 +25,10 @@ module RailsPulse
25
25
  .where(first_seen_at: current_window_start..now)
26
26
  .count
27
27
 
28
- grouped = RailsPulse::ExceptionGroup
29
- .where(first_seen_at: current_window_start..now)
30
- .group_by_date(:first_seen_at)
31
- .count
28
+ grouped = bucket_by_period(
29
+ RailsPulse::ExceptionGroup.where(first_seen_at: current_window_start..now),
30
+ column: :first_seen_at
31
+ ) { |relation| relation.count }
32
32
 
33
33
  {
34
34
  id: "exceptions_open_groups",
@@ -36,10 +36,9 @@ module RailsPulse
36
36
  # what it means on a throughput card.
37
37
  trend_icon, trend_amount = trend_for(current, previous) if show_trend?
38
38
 
39
- grouped = base_query
40
- .where(period_start: current_window_start..now)
41
- .group_by_date(:period_start)
42
- .sum("rails_pulse_summaries.count")
39
+ grouped = bucket_by_period(base_query.where(period_start: current_window_start..now)) do |relation|
40
+ relation.sum("rails_pulse_summaries.count")
41
+ end
43
42
 
44
43
  {
45
44
  id: "exceptions_total_occurrences",
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class FailureRate < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
5
+ def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
6
  @job = job
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -52,13 +54,8 @@ module RailsPulse
52
54
  )
53
55
  sparkline_query = sparkline_query.where(summarizable_id: @job.id) if @job
54
56
 
55
- if @period_type == "hour"
56
- grouped_errors = sparkline_query.group_by_hour(:period_start).sum("rails_pulse_summaries.error_count")
57
- grouped_counts = sparkline_query.group_by_hour(:period_start).sum("rails_pulse_summaries.count")
58
- else
59
- grouped_errors = sparkline_query.group_by_date(:period_start).sum("rails_pulse_summaries.error_count")
60
- grouped_counts = sparkline_query.group_by_date(:period_start).sum("rails_pulse_summaries.count")
61
- end
57
+ grouped_errors = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.error_count") }
58
+ grouped_counts = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.count") }
62
59
 
63
60
  sparkline_data = sparkline_from_failure_rates(grouped_errors, grouped_counts)
64
61
 
@@ -88,25 +85,15 @@ module RailsPulse
88
85
 
89
86
  def sparkline_from_failure_rates(errors_by_period, counts_by_period)
90
87
  if @period_type == "hour"
91
- start_time = current_window_start.beginning_of_hour
92
- end_time = now.beginning_of_hour
93
- result = {}
94
-
95
- current_time = start_time
96
- while current_time <= end_time
88
+ sparkline_hours.each_with_object({}) do |current_time, result|
97
89
  errors = errors_by_period[current_time].to_f
98
90
  total = counts_by_period[current_time].to_f
99
91
  rate = total.zero? ? 0.0 : (errors / total * 100).round(1)
100
92
  # Use timestamp in milliseconds as key to preserve uniqueness across days
101
93
  result[current_time.to_i * 1000] = { value: rate }
102
- current_time += 1.hour
103
94
  end
104
- result
105
95
  else
106
- start_date = current_window_start.to_date
107
- end_date = now.to_date
108
-
109
- (start_date..end_date).each_with_object({}) do |day, hash|
96
+ sparkline_dates.each_with_object({}) do |day, hash|
110
97
  errors = errors_by_period[day].to_f
111
98
  total = counts_by_period[day].to_f
112
99
  rate = total.zero? ? 0.0 : (errors / total * 100).round(1)
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class P95Duration < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
5
+ def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
6
  @job = job
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -48,13 +50,8 @@ module RailsPulse
48
50
  trend_icon, trend_amount = trend_for(current_p95, previous_p95)
49
51
  end
50
52
 
51
- grouped_weighted = base_query
52
- .group_by_date(:period_start)
53
- .sum(Arel.sql("rails_pulse_summaries.p95_duration * rails_pulse_summaries.count"))
54
-
55
- grouped_counts = base_query
56
- .group_by_date(:period_start)
57
- .sum("rails_pulse_summaries.count")
53
+ grouped_weighted = bucket_by_period(base_query) { |relation| relation.sum(Arel.sql("rails_pulse_summaries.p95_duration * rails_pulse_summaries.count")) }
54
+ grouped_counts = bucket_by_period(base_query) { |relation| relation.sum("rails_pulse_summaries.count") }
58
55
 
59
56
  sparkline_data = sparkline_from_averages(grouped_weighted, grouped_counts)
60
57
 
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class TotalRuns < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
5
+ def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
6
  @job = job
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -32,9 +34,7 @@ module RailsPulse
32
34
 
33
35
  trend_icon, trend_amount = trend_for(current_runs, previous_runs) if show_trend?
34
36
 
35
- grouped_runs = base_query
36
- .group_by_date(:period_start)
37
- .sum("rails_pulse_summaries.count")
37
+ grouped_runs = bucket_by_period(base_query) { |relation| relation.sum("rails_pulse_summaries.count") }
38
38
 
39
39
  {
40
40
  id: "jobs_total_runs",
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class AverageQueryTimes < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @query = query
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -32,16 +34,11 @@ module RailsPulse
32
34
  trend_icon, trend_amount = trend_for(current_period_avg, previous_period_avg) if show_trend?
33
35
 
34
36
  # Sparkline data with zero-filled periods
35
- grouped_weighted = base_query
36
- .group_by_date(:period_start)
37
- .sum(Arel.sql("avg_duration * count"))
38
-
39
- grouped_counts = base_query
40
- .group_by_date(:period_start)
41
- .sum(:count)
37
+ grouped_weighted = bucket_by_period(base_query) { |relation| relation.sum(Arel.sql("avg_duration * count")) }
38
+ grouped_counts = bucket_by_period(base_query) { |relation| relation.sum(:count) }
42
39
 
43
40
  # Calculate weighted averages for each period
44
- averages_by_period = grouped_weighted.transform_keys(&:to_date).transform_values.with_index do |(weighted, day), _|
41
+ averages_by_period = grouped_weighted.transform_values.with_index do |(weighted, day), _|
45
42
  count = grouped_counts[day] || 0
46
43
  count > 0 ? (weighted.to_f / count).round(0) : 0
47
44
  end
@@ -2,11 +2,13 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class DatabaseLoad < RailsPulse::Cards::Base
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
+ @start_time = start_time
11
+ @end_time = end_time
10
12
  end
11
13
 
12
14
  def to_metric_card
@@ -63,8 +65,8 @@ module RailsPulse
63
65
 
64
66
  # Sparkline data - group by hour or day depending on period_type
65
67
  if period_type_hours?
66
- start_time = current_window_start
67
- end_time = now.beginning_of_hour
68
+ start_time = sparkline_hours.first
69
+ end_time = sparkline_hours.last
68
70
 
69
71
  # Create separate queries for sparkline data using only the current period
70
72
  sparkline_query_summaries = RailsPulse::Summary
@@ -91,8 +93,7 @@ module RailsPulse
91
93
  .transform_values { |summaries| summaries.sum { |s| s.total_duration || 0 } }
92
94
 
93
95
  sparkline_data = {}
94
- current_time = start_time
95
- while current_time <= end_time
96
+ sparkline_hours.each do |current_time|
96
97
  query_time = hourly_query_time[current_time] || 0
97
98
  request_time = hourly_request_time[current_time] || 0
98
99
  percentage = request_time > 0 ? (query_time.to_f / request_time * 100).round(1) : 0
@@ -111,12 +112,8 @@ module RailsPulse
111
112
  value: percentage,
112
113
  itemStyle: { color: color }
113
114
  }
114
- current_time += 1.hour
115
115
  end
116
116
  else
117
- start_day = current_window_start.to_date
118
- end_day = now.to_date
119
-
120
117
  # Group by day
121
118
  daily_query_time = query_summaries.group_by { |s| s.period_start.to_date }
122
119
  .transform_values { |summaries| summaries.sum { |s| s.total_duration || 0 } }
@@ -125,7 +122,7 @@ module RailsPulse
125
122
  .transform_values { |summaries| summaries.sum { |s| s.total_duration || 0 } }
126
123
 
127
124
  sparkline_data = {}
128
- (start_day..end_day).each do |day|
125
+ sparkline_dates.each do |day|
129
126
  query_time = daily_query_time[day] || 0
130
127
  request_time = daily_request_time[day] || 0
131
128
  percentage = request_time > 0 ? (query_time.to_f / request_time * 100).round(1) : 0
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class ExecutionRate < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @query = query
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -38,11 +40,7 @@ module RailsPulse
38
40
  )
39
41
  sparkline_query = sparkline_query.where(summarizable_id: @query.id) if @query
40
42
 
41
- if period_type_hours?
42
- grouped_data = sparkline_query.group_by_hour(:period_start).sum(:count)
43
- else
44
- grouped_data = sparkline_query.group_by_date(:period_start).sum(:count)
45
- end
43
+ grouped_data = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
46
44
 
47
45
  # Use base class sparkline generation (handles hour vs day automatically)
48
46
  sparkline_data = sparkline_from(grouped_data)
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class PercentileQueryTimes < RailsPulse::Cards::Base
5
- def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @query = query
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -44,13 +46,8 @@ module RailsPulse
44
46
  )
45
47
  sparkline_query = sparkline_query.where(summarizable_id: @query.id) if @query
46
48
 
47
- if period_type_hours?
48
- weighted_sums = sparkline_query.group_by_hour(:period_start).sum("p95_duration * count")
49
- period_counts = sparkline_query.group_by_hour(:period_start).sum(:count)
50
- else
51
- weighted_sums = sparkline_query.group_by_date(:period_start).sum("p95_duration * count")
52
- period_counts = sparkline_query.group_by_date(:period_start).sum(:count)
53
- end
49
+ weighted_sums = bucket_by_period(sparkline_query) { |relation| relation.sum("p95_duration * count") }
50
+ period_counts = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
54
51
 
55
52
  # Calculate weighted averages for each period
56
53
  averages_by_period = weighted_sums.each_with_object({}) do |(period, weighted), hash|
@@ -70,11 +70,17 @@ module RailsPulse
70
70
  # True when at least one stored route still has no action but the live
71
71
  # host router would assign one — the usual sign that schema migrated
72
72
  # without `rails rails_pulse:migrate_routes`.
73
+ # Runs on every dashboard page, so the common case (every route already
74
+ # has an action) must be one indexed query; router recognition only
75
+ # happens when there is something to recognise.
73
76
  def self.needs_action_backfill?
74
77
  return false unless column_names.include?("controller_action")
75
78
  return false unless column_names.include?("http_methods")
76
79
 
77
- where(controller_action: [ nil, "" ]).limit(25).any? do |route|
80
+ blank_action = where(controller_action: [ nil, "" ])
81
+ return false unless blank_action.exists?
82
+
83
+ blank_action.limit(25).any? do |route|
78
84
  method = route.http_methods_list.first
79
85
  next false if method.blank?
80
86
 
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class ErrorRates < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @route = route
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -76,22 +78,14 @@ module RailsPulse
76
78
 
77
79
  def sparkline_from_error_rates(errors_by_period, client_errors_by_period, counts_by_period)
78
80
  if @period_type == "hour"
79
- start_time = current_window_start.beginning_of_hour
80
- end_time = now.beginning_of_hour
81
- result = {}
82
- current_time = start_time
83
- while current_time <= end_time
81
+ sparkline_hours.each_with_object({}) do |current_time, result|
84
82
  errors = errors_by_period[current_time].to_f + client_errors_by_period[current_time].to_f
85
83
  total = counts_by_period[current_time].to_f
86
84
  rate = total.zero? ? 0.0 : (errors / total * 100).round(2)
87
85
  result[current_time.to_i * 1000] = { value: rate }
88
- current_time += 1.hour
89
86
  end
90
- result
91
87
  else
92
- start_date = current_window_start.to_date
93
- end_date = now.to_date
94
- (start_date..end_date).each_with_object({}) do |day, hash|
88
+ sparkline_dates.each_with_object({}) do |day, hash|
95
89
  errors = errors_by_period[day].to_f + client_errors_by_period[day].to_f
96
90
  total = counts_by_period[day].to_f
97
91
  rate = total.zero? ? 0.0 : (errors / total * 100).round(2)
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class PercentileResponseTimes < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @route = route
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -2,12 +2,14 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class RequestCountTotals < RailsPulse::Cards::Base
5
- def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(route: nil, disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @route = route
7
7
  @disabled_tags = disabled_tags
8
8
  @show_non_tagged = show_non_tagged
9
9
  @period = period
10
10
  @period_type = period_type
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  end
12
14
 
13
15
  def to_metric_card
@@ -42,11 +44,7 @@ module RailsPulse
42
44
  )
43
45
  sparkline_query = sparkline_query.where(summarizable_id: @route.id) if @route
44
46
 
45
- if period_type_hours?
46
- grouped_data = sparkline_query.group_by_hour(:period_start).sum(:count)
47
- else
48
- grouped_data = sparkline_query.group_by_date(:period_start).sum(:count)
49
- end
47
+ grouped_data = bucket_by_period(sparkline_query) { |relation| relation.sum(:count) }
50
48
 
51
49
  # Use base class sparkline generation (handles hour vs day automatically)
52
50
  sparkline_data = sparkline_from(grouped_data)
@@ -55,26 +55,21 @@ module RailsPulse
55
55
  # Return early if no filters are applied
56
56
  return all if actual_disabled_tags.empty? && show_non_tagged
57
57
 
58
- # Get filtered IDs from TagFilterService
59
- filtered_ids = TagFilterService.filter_all(disabled_tags, show_non_tagged)
60
-
61
- route_ids = filtered_ids[:route_ids].presence || [ -1 ]
62
- query_ids = filtered_ids[:query_ids].presence || [ -1 ]
63
- job_ids = filtered_ids[:job_ids].presence || [ -1 ]
58
+ # One subquery per summarizable kind. This scope runs on every card and
59
+ # chart query, so the alternative (plucking every matching route, query
60
+ # and job id and inlining the lists) cost three extra statements per
61
+ # call and grew each statement with the size of the tables.
62
+ table = arel_table
63
+ keep_for = lambda do |type, model|
64
+ matching = TagFilterService.scope(model, actual_disabled_tags, show_non_tagged).select(:id).arel
65
+ table[:summarizable_type].eq(type).and(table[:summarizable_id].in(matching))
66
+ end
64
67
 
65
- # Apply filters: include only summaries for filtered routes/queries/jobs
66
- # Use -1 as an impossible ID when no items match the filter
67
68
  where(
68
- "(" \
69
- " (summarizable_type = 'RailsPulse::Route' AND summarizable_id IN (?)) OR " \
70
- " (summarizable_type = 'RailsPulse::Query' AND summarizable_id IN (?)) OR " \
71
- " (summarizable_type = 'RailsPulse::Job' AND summarizable_id IN (?)) OR " \
72
- " (summarizable_type = 'RailsPulse::Request') OR " \
73
- " (summarizable_type = 'RailsPulse::ExceptionGroup')" \
74
- ")",
75
- route_ids,
76
- query_ids,
77
- job_ids
69
+ keep_for.call("RailsPulse::Route", Route)
70
+ .or(keep_for.call("RailsPulse::Query", Query))
71
+ .or(keep_for.call("RailsPulse::Job", Job))
72
+ .or(table[:summarizable_type].in([ "RailsPulse::Request", "RailsPulse::ExceptionGroup" ]))
78
73
  )
79
74
  }
80
75
 
@@ -0,0 +1,28 @@
1
+ module RailsPulse
2
+ # Normalizes the time range preference stored in the session so every
3
+ # reader sees the same shape regardless of which session store wrote it.
4
+ #
5
+ # The cookie store round-trips through JSON and stringifies hash keys, but a
6
+ # Marshal-backed store (activerecord-session_store's default) hands back the
7
+ # symbol keys the writer used. Readers that only knew one shape raised
8
+ # NoMethodError on the other and locked the dashboard into 500s for the rest
9
+ # of the browser session.
10
+ module TimeRangePreference
11
+ # A preset name as a String, a custom range as a string-keyed Hash, or nil
12
+ # for anything unrecognized so callers fall back to their defaults.
13
+ def self.normalize(value)
14
+ case value
15
+ when Hash
16
+ value.to_h.transform_keys(&:to_s)
17
+ when String, Symbol
18
+ value.to_s
19
+ else
20
+ nil
21
+ end
22
+ end
23
+
24
+ def self.custom?(preference)
25
+ preference.is_a?(Hash) && preference["type"] == "custom"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,61 @@
1
+ module RailsPulse
2
+ # A resolved start/end range plus the bucketing helpers charts and cards
3
+ # need: day count, calendar dates/hour-starts inside it, and the
4
+ # equal-length prior window for comparisons.
5
+ class TimeWindow
6
+ attr_reader :start_time, :end_time
7
+
8
+ # nil (not raised) when either bound is missing — callers already have
9
+ # a "no range selected" fallback to use instead.
10
+ def self.build(start_time, end_time)
11
+ return nil if start_time.nil? || end_time.nil?
12
+
13
+ new(start_time, end_time)
14
+ end
15
+
16
+ def initialize(start_time, end_time)
17
+ @start_time = coerce(start_time)
18
+ @end_time = coerce(end_time)
19
+ end
20
+
21
+ # Whole days spanned, rounded rather than truncated (a range a few
22
+ # seconds short of N days still counts as N). Always at least 1.
23
+ def days
24
+ [ ((end_time - start_time) / 1.day).round, 1 ].max
25
+ end
26
+
27
+ # Every full calendar day inside the window, ascending. Skips a leading
28
+ # day whose midnight falls before the window start.
29
+ def dates
30
+ first_date = start_time.beginning_of_day == start_time ? start_time.to_date : (start_time.to_date + 1)
31
+ return [] if first_date > end_time.to_date
32
+
33
+ (first_date..end_time.to_date).to_a
34
+ end
35
+
36
+ # Every hour-start inside the window, ascending.
37
+ def hour_starts
38
+ starts = []
39
+ current = start_time.beginning_of_hour
40
+ current += 1.hour if current < start_time
41
+ while current <= end_time
42
+ starts << current
43
+ current += 1.hour
44
+ end
45
+ starts
46
+ end
47
+
48
+ # The window of the same length immediately preceding this one, for
49
+ # period-over-period comparisons. `unit` is "day" or "hour".
50
+ def previous(unit)
51
+ span = unit == "hour" ? (end_time - start_time) : days.days
52
+ self.class.new(start_time - span, start_time)
53
+ end
54
+
55
+ private
56
+
57
+ def coerce(value)
58
+ value.is_a?(Numeric) ? Time.zone.at(value) : value.in_time_zone
59
+ end
60
+ end
61
+ end