rails_pulse 0.1.0 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -178
  3. data/Rakefile +75 -173
  4. data/app/assets/stylesheets/rails_pulse/application.css +0 -12
  5. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  6. data/app/controllers/concerns/response_range_concern.rb +6 -3
  7. data/app/controllers/concerns/time_range_concern.rb +5 -10
  8. data/app/controllers/concerns/zoom_range_concern.rb +1 -1
  9. data/app/controllers/rails_pulse/application_controller.rb +8 -4
  10. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  11. data/app/controllers/rails_pulse/queries_controller.rb +65 -50
  12. data/app/controllers/rails_pulse/requests_controller.rb +24 -12
  13. data/app/controllers/rails_pulse/routes_controller.rb +59 -24
  14. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  15. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  16. data/app/helpers/rails_pulse/chart_helper.rb +6 -2
  17. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  18. data/app/javascript/rails_pulse/controllers/index_controller.js +117 -33
  19. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  20. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  21. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  22. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  23. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  24. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +20 -9
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +19 -7
  26. data/app/models/rails_pulse/operation.rb +1 -1
  27. data/app/models/rails_pulse/queries/cards/average_query_times.rb +47 -23
  28. data/app/models/rails_pulse/queries/cards/execution_rate.rb +33 -26
  29. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +34 -45
  30. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  31. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  32. data/app/models/rails_pulse/query.rb +1 -0
  33. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  34. data/app/models/rails_pulse/route.rb +1 -6
  35. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -23
  36. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +38 -45
  37. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +34 -47
  38. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +30 -25
  39. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  40. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  41. data/app/models/rails_pulse/summary.rb +143 -0
  42. data/app/services/rails_pulse/summary_service.rb +199 -0
  43. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  44. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  45. data/app/views/rails_pulse/components/_metric_card.html.erb +10 -24
  46. data/app/views/rails_pulse/dashboard/index.html.erb +54 -36
  47. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  48. data/app/views/rails_pulse/queries/_table.html.erb +10 -12
  49. data/app/views/rails_pulse/queries/index.html.erb +41 -34
  50. data/app/views/rails_pulse/queries/show.html.erb +38 -31
  51. data/app/views/rails_pulse/requests/_operations.html.erb +32 -26
  52. data/app/views/rails_pulse/requests/_table.html.erb +1 -3
  53. data/app/views/rails_pulse/requests/index.html.erb +42 -34
  54. data/app/views/rails_pulse/routes/_table.html.erb +13 -13
  55. data/app/views/rails_pulse/routes/index.html.erb +43 -35
  56. data/app/views/rails_pulse/routes/show.html.erb +42 -35
  57. data/config/initializers/rails_pulse.rb +0 -12
  58. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  59. data/db/rails_pulse_schema.rb +121 -0
  60. data/lib/generators/rails_pulse/install_generator.rb +41 -4
  61. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  62. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  63. data/lib/rails_pulse/configuration.rb +6 -12
  64. data/lib/rails_pulse/engine.rb +0 -1
  65. data/lib/rails_pulse/version.rb +1 -1
  66. data/lib/tasks/rails_pulse.rake +58 -0
  67. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  68. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  69. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  70. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  71. data/public/rails-pulse-assets/search.svg +43 -0
  72. metadata +28 -12
  73. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  74. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  75. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  76. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  77. data/db/migrate/20250227235904_create_routes.rb +0 -12
  78. data/db/migrate/20250227235915_create_requests.rb +0 -19
  79. data/db/migrate/20250228000000_create_queries.rb +0 -14
  80. data/db/migrate/20250228000056_create_operations.rb +0 -24
  81. data/lib/rails_pulse/migration.rb +0 -29
@@ -7,44 +7,49 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- requests = if @route
11
- RailsPulse::Request.where(route: @route)
12
- else
13
- RailsPulse::Request.all
14
- end
10
+ last_7_days = 7.days.ago.beginning_of_day
11
+ previous_7_days = 14.days.ago.beginning_of_day
15
12
 
16
- requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
13
+ # Single query to get all count metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Route",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @route.id) if @route
17
20
 
18
- # Calculate total request count
19
- total_request_count = requests.count
21
+ metrics = base_query.select(
22
+ "SUM(count) AS total_count",
23
+ "SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS current_count",
24
+ "SUM(CASE WHEN period_start >= '#{previous_7_days.strftime('%Y-%m-%d %H:%M:%S')}' AND period_start < '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS previous_count"
25
+ ).take
20
26
 
21
- # Calculate trend by comparing last 7 days vs previous 7 days
22
- last_7_days = 7.days.ago.beginning_of_day
23
- previous_7_days = 14.days.ago.beginning_of_day
24
- current_period_count = requests.where("occurred_at >= ?", last_7_days).count
25
- previous_period_count = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
27
+ # Calculate metrics from single query result
28
+ total_request_count = metrics.total_count || 0
29
+ current_period_count = metrics.current_count || 0
30
+ previous_period_count = metrics.previous_count || 0
26
31
 
27
32
  percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
28
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
29
34
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
30
35
 
31
- sparkline_data = requests
32
- .group_by_week(:occurred_at, time_zone: "UTC")
33
- .count
34
- .each_with_object({}) do |(date, count), hash|
35
- formatted_date = date.strftime("%b %-d")
36
- hash[formatted_date] = {
37
- value: count
38
- }
36
+ # Separate query for sparkline data - group by week using Rails
37
+ sparkline_data = base_query
38
+ .group_by_week(:period_start, time_zone: "UTC")
39
+ .sum(:count)
40
+ .each_with_object({}) do |(week_start, total_count), hash|
41
+ formatted_date = week_start.strftime("%b %-d")
42
+ value = total_count || 0
43
+ hash[formatted_date] = { value: value }
39
44
  end
40
45
 
41
- # Calculate average requests per minute
42
- min_time = requests.minimum(:occurred_at)
43
- max_time = requests.maximum(:occurred_at)
44
- total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
46
+ # Calculate average requests per minute over 2-week period
47
+ total_minutes = 2.weeks / 1.minute
45
48
  average_requests_per_minute = total_request_count / total_minutes
46
49
 
47
50
  {
51
+ id: "request_count_totals",
52
+ context: "routes",
48
53
  title: "Request Count Total",
49
54
  summary: "#{average_requests_per_minute.round(2)} / min",
50
55
  line_chart_data: sparkline_data,
@@ -2,112 +2,35 @@ module RailsPulse
2
2
  module Routes
3
3
  module Charts
4
4
  class AverageResponseTimes
5
- def initialize(ransack_query:, group_by: :group_by_day, route: nil)
5
+ def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil)
6
6
  @ransack_query = ransack_query
7
- @group_by = group_by
7
+ @period_type = period_type
8
8
  @route = route
9
+ @start_time = start_time
10
+ @end_time = end_time
11
+ @start_duration = start_duration
9
12
  end
10
13
 
11
14
  def to_rails_chart
12
- # Get actual data using existing logic
13
- actual_data = if @route
14
- # These are the requests for the specific route so it will just be a collection of Requests that we can
15
- # filter and sort using the attributes on each Request
16
- @ransack_query.result(distinct: false)
17
- .public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
18
- .average(:duration)
19
- else
20
- # Use the existing query structure with left_joins from ransack
21
- @ransack_query.result(distinct: false)
22
- .left_joins(:requests)
23
- .public_send(
24
- @group_by,
25
- "rails_pulse_requests.occurred_at",
26
- series: true,
27
- time_zone: "UTC"
28
- )
29
- .average("rails_pulse_requests.duration")
30
- end
31
-
32
- # Create full time range and fill in missing periods
33
- fill_missing_periods(actual_data)
34
- end
35
-
36
- private
37
-
38
- def fill_missing_periods(actual_data)
39
- # Extract actual time range from ransack query conditions
40
- start_time, end_time = extract_time_range_from_ransack
41
-
42
- # Create time range based on grouping type
43
- case @group_by
44
- when :group_by_hour
45
- time_range = generate_hour_range(start_time, end_time)
46
- else # :group_by_day
47
- time_range = generate_day_range(start_time, end_time)
48
- end
49
-
50
- # Fill in all periods with zero values for missing periods
51
- time_range.each_with_object({}) do |period, result|
52
- occurred_at = period.is_a?(String) ? Time.parse(period) : period
53
- occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
54
-
55
- normalized_occurred_at =
56
- case @group_by
57
- when :group_by_hour
58
- occurred_at&.beginning_of_hour || occurred_at
59
- when :group_by_day
60
- occurred_at&.beginning_of_day || occurred_at
61
- else
62
- occurred_at
63
- end
64
-
65
- # Use actual data if available, otherwise default to 0
66
- average_duration = actual_data[period] || 0
67
- result[normalized_occurred_at.to_i] = {
68
- value: average_duration.to_f
69
- }
70
- end
71
- end
72
-
73
- def generate_day_range(start_time, end_time)
74
- (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
75
- end
76
-
77
- def generate_hour_range(start_time, end_time)
78
- current = start_time
79
- hours = []
80
- while current <= end_time
81
- hours << current
82
- current += 1.hour
83
- end
84
- hours
85
- end
86
-
87
- def extract_time_range_from_ransack
88
- # Extract time range from ransack conditions
89
- conditions = @ransack_query.conditions
90
-
91
- if @route
92
- # For specific route queries, look for occurred_at conditions
93
- start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
94
- end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
95
- else
96
- # For general route queries, look for requests_occurred_at conditions
97
- start_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "gteq" }
98
- end_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "lt" }
99
- end
100
-
101
- start_time = start_condition&.v || 2.weeks.ago
102
- end_time = end_condition&.v || Time.current
103
-
104
- # Normalize time boundaries based on grouping
105
- case @group_by
106
- when :group_by_hour
107
- [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
108
- else
109
- [ start_time.beginning_of_day, end_time.beginning_of_day ]
15
+ summaries = @ransack_query.result(distinct: false).where(
16
+ summarizable_type: "RailsPulse::Route",
17
+ period_type: @period_type
18
+ )
19
+
20
+ summaries = summaries.where(summarizable_id: @route.id) if @route
21
+ summaries = summaries
22
+ .group(:period_start)
23
+ .having("AVG(avg_duration) > ?", @start_duration || 0)
24
+ .average(:avg_duration)
25
+ .transform_keys(&:to_i)
26
+
27
+ # Pad missing data points with zeros
28
+ step = @period_type == :hour ? 1.hour : 1.day
29
+ data = {}
30
+ (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
31
+ data[timestamp.to_i] = summaries[timestamp.to_i].to_f.round(2)
110
32
  end
33
+ data
111
34
  end
112
35
  end
113
36
  end
@@ -2,60 +2,77 @@ module RailsPulse
2
2
  module Routes
3
3
  module Tables
4
4
  class Index
5
- def initialize(ransack_query:, start_time:, params:)
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
6
6
  @ransack_query = ransack_query
7
+ @period_type = period_type
7
8
  @start_time = start_time
8
9
  @params = params
9
10
  end
10
11
 
11
12
  def to_table
12
- # Pre-calculate values to avoid SQL injection and improve readability
13
- minutes_elapsed = calculate_minutes_elapsed
13
+ # Check if we have explicit ransack sorts
14
+ has_sorts = @ransack_query.sorts.any?
14
15
 
15
- # Get thresholds with safe defaults to avoid nil access errors
16
- config = RailsPulse.configuration rescue nil
17
- thresholds = config&.route_thresholds || { slow: 500, very_slow: 1500, critical: 3000 }
18
-
19
- requests_per_minute_divisor = minutes_elapsed > 0 ? minutes_elapsed : 1
16
+ base_query = @ransack_query.result(distinct: false)
17
+ .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
18
+ .where(
19
+ summarizable_type: "RailsPulse::Route",
20
+ period_type: @period_type
21
+ )
20
22
 
21
- status_sql = build_status_sql(thresholds)
23
+ base_query = base_query.where(summarizable_id: @route.id) if @route
22
24
 
23
- @ransack_query.result(distinct: false)
24
- .left_joins(:requests)
25
- .group("rails_pulse_routes.id")
25
+ # Apply grouping and aggregation
26
+ grouped_query = base_query
27
+ .group(
28
+ "rails_pulse_summaries.summarizable_id",
29
+ "rails_pulse_summaries.summarizable_type",
30
+ "rails_pulse_routes.id",
31
+ "rails_pulse_routes.path",
32
+ "rails_pulse_routes.method"
33
+ )
26
34
  .select(
27
- "rails_pulse_routes.*",
28
- "COALESCE(AVG(rails_pulse_requests.duration), 0) AS average_response_time_ms",
29
- "COUNT(rails_pulse_requests.id) AS request_count",
30
- "COALESCE(COUNT(rails_pulse_requests.id) / #{requests_per_minute_divisor}, 0) AS requests_per_minute",
31
- "COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) AS error_count",
32
- "CASE WHEN COUNT(rails_pulse_requests.id) > 0 THEN ROUND((COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) * 100.0) / COUNT(rails_pulse_requests.id), 2) ELSE 0 END AS error_rate_percentage",
33
- "COALESCE(MAX(rails_pulse_requests.duration), 0) AS max_response_time_ms",
34
- "#{status_sql} AS status_indicator"
35
+ "rails_pulse_summaries.summarizable_id",
36
+ "rails_pulse_summaries.summarizable_type",
37
+ "rails_pulse_routes.id as route_id",
38
+ "rails_pulse_routes.path",
39
+ "rails_pulse_routes.method as route_method",
40
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
41
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
42
+ "SUM(rails_pulse_summaries.count) as count",
43
+ "SUM(rails_pulse_summaries.error_count) as error_count",
44
+ "SUM(rails_pulse_summaries.success_count) as success_count"
35
45
  )
36
- end
37
-
38
- private
39
46
 
40
- def calculate_minutes_elapsed
41
- start_timestamp = Time.at(@start_time.to_i).utc
42
- ((Time.current.utc - start_timestamp) / 60.0).round(2)
43
- end
47
+ # Apply sorting based on ransack sorts or use default
48
+ if has_sorts
49
+ # Apply custom sorting based on ransack parameters
50
+ sort = @ransack_query.sorts.first
51
+ direction = sort.dir == "desc" ? :desc : :asc
44
52
 
45
- def build_status_sql(thresholds)
46
- # Ensure all thresholds have default values
47
- slow = thresholds[:slow] || 500
48
- very_slow = thresholds[:very_slow] || 1500
49
- critical = thresholds[:critical] || 3000
53
+ case sort.name
54
+ when "avg_duration_sort"
55
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
56
+ when "max_duration_sort"
57
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
58
+ when "count_sort"
59
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
60
+ when "requests_per_minute"
61
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
62
+ when "error_rate_percentage"
63
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
64
+ when "route_path"
65
+ grouped_query = grouped_query.order(Arel.sql("rails_pulse_routes.path").send(direction))
66
+ else
67
+ # Unknown sort field, fallback to default
68
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
69
+ end
70
+ else
71
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
72
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
73
+ end
50
74
 
51
- <<-SQL.squish
52
- CASE
53
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{critical} THEN 3
54
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{very_slow} THEN 2
55
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{slow} THEN 1
56
- ELSE 0
57
- END
58
- SQL
75
+ grouped_query
59
76
  end
60
77
  end
61
78
  end
@@ -0,0 +1,143 @@
1
+ module RailsPulse
2
+ class Summary < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_summaries"
4
+
5
+ PERIOD_TYPES = %w[hour day week month].freeze
6
+
7
+ # Polymorphic association
8
+ belongs_to :summarizable, polymorphic: true, optional: true # Optional for Request summaries
9
+
10
+ # Convenience associations for easier querying
11
+ belongs_to :route, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Route" }) },
12
+ foreign_key: "summarizable_id", class_name: "RailsPulse::Route", optional: true
13
+ belongs_to :query, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Query" }) },
14
+ foreign_key: "summarizable_id", class_name: "RailsPulse::Query", optional: true
15
+
16
+ # Validations
17
+ validates :period_type, inclusion: { in: PERIOD_TYPES }
18
+ validates :period_start, presence: true
19
+ validates :period_end, presence: true
20
+
21
+ # Scopes
22
+ scope :for_period_type, ->(type) { where(period_type: type) }
23
+ scope :for_date_range, ->(start_date, end_date) {
24
+ where(period_start: start_date..end_date)
25
+ }
26
+ scope :for_requests, -> { where(summarizable_type: "RailsPulse::Request") }
27
+ scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") }
28
+ scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") }
29
+ scope :recent, -> { order(period_start: :desc) }
30
+
31
+ # Special scope for overall request summaries
32
+ scope :overall_requests, -> {
33
+ where(summarizable_type: "RailsPulse::Request", summarizable_id: 0)
34
+ }
35
+
36
+ # Ransack configuration
37
+ def self.ransackable_attributes(auth_object = nil)
38
+ %w[
39
+ period_start period_end avg_duration max_duration count error_count
40
+ requests_per_minute error_rate_percentage route_path_cont
41
+ execution_count total_time_consumed normalized_sql
42
+ ]
43
+ end
44
+
45
+ def self.ransackable_associations(auth_object = nil)
46
+ %w[route query]
47
+ end
48
+
49
+ # Custom ransackers for calculated fields (designed to work with GROUP BY queries)
50
+ ransacker :count do
51
+ Arel.sql("SUM(rails_pulse_summaries.count)") # Use SUM for proper grouping
52
+ end
53
+
54
+ ransacker :requests_per_minute do
55
+ Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
56
+ end
57
+
58
+ ransacker :error_rate_percentage do
59
+ Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)") # Use SUM for both
60
+ end
61
+
62
+
63
+ # Ransacker for route path sorting (when joined with routes table)
64
+ ransacker :route_path do
65
+ Arel.sql("rails_pulse_routes.path")
66
+ end
67
+
68
+ # Ransacker for route path filtering using subquery (works without JOIN)
69
+ ransacker :route_path_cont do |parent|
70
+ Arel.sql(<<-SQL)
71
+ rails_pulse_summaries.summarizable_id IN (
72
+ SELECT id FROM rails_pulse_routes
73
+ WHERE rails_pulse_routes.path LIKE '%' || ? || '%'
74
+ )
75
+ SQL
76
+ end
77
+
78
+ # Sorting-specific ransackers for GROUP BY compatibility (used only in ORDER BY)
79
+ # These use different names to avoid conflicts with filtering
80
+ ransacker :avg_duration_sort do
81
+ Arel.sql("AVG(rails_pulse_summaries.avg_duration)")
82
+ end
83
+
84
+ ransacker :max_duration_sort do
85
+ Arel.sql("MAX(rails_pulse_summaries.max_duration)")
86
+ end
87
+
88
+ ransacker :count_sort do
89
+ Arel.sql("SUM(rails_pulse_summaries.count)")
90
+ end
91
+
92
+ ransacker :error_count_sort do
93
+ Arel.sql("SUM(rails_pulse_summaries.error_count)")
94
+ end
95
+
96
+ ransacker :success_count_sort do
97
+ Arel.sql("SUM(rails_pulse_summaries.success_count)")
98
+ end
99
+
100
+ ransacker :total_time_consumed_sort do
101
+ Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)")
102
+ end
103
+
104
+ # Alias execution_count_sort to count_sort for queries table compatibility
105
+ ransacker :execution_count_sort do
106
+ Arel.sql("SUM(rails_pulse_summaries.count)")
107
+ end
108
+
109
+ # Ransackers for queries table calculated fields
110
+ ransacker :execution_count do
111
+ Arel.sql("SUM(rails_pulse_summaries.count)") # Total executions
112
+ end
113
+
114
+ ransacker :total_time_consumed do
115
+ Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)") # Total time consumed
116
+ end
117
+
118
+ # Ransacker for query SQL sorting (when joined with queries table)
119
+ ransacker :normalized_sql do
120
+ Arel.sql("rails_pulse_queries.normalized_sql")
121
+ end
122
+
123
+ class << self
124
+ def calculate_period_end(period_type, start_time)
125
+ case period_type
126
+ when "hour" then start_time.end_of_hour
127
+ when "day" then start_time.end_of_day
128
+ when "week" then start_time.end_of_week
129
+ when "month" then start_time.end_of_month
130
+ end
131
+ end
132
+
133
+ def normalize_period_start(period_type, time)
134
+ case period_type
135
+ when "hour" then time.beginning_of_hour
136
+ when "day" then time.beginning_of_day
137
+ when "week" then time.beginning_of_week
138
+ when "month" then time.beginning_of_month
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,199 @@
1
+
2
+ module RailsPulse
3
+ class SummaryService
4
+ attr_reader :period_type, :start_time, :end_time
5
+
6
+ def initialize(period_type, start_time)
7
+ @period_type = period_type
8
+ @start_time = Summary.normalize_period_start(period_type, start_time)
9
+ @end_time = Summary.calculate_period_end(period_type, @start_time)
10
+ end
11
+
12
+ def perform
13
+ Rails.logger.info "[RailsPulse] Starting #{period_type} summary for #{start_time}"
14
+
15
+ ActiveRecord::Base.transaction do
16
+ aggregate_requests # Overall system metrics
17
+ aggregate_routes # Per-route metrics
18
+ aggregate_queries # Per-query metrics
19
+ end
20
+
21
+ Rails.logger.info "[RailsPulse] Completed #{period_type} summary"
22
+ rescue => e
23
+ Rails.logger.error "[RailsPulse] Summary failed: #{e.message}"
24
+ raise
25
+ end
26
+
27
+ private
28
+
29
+ def aggregate_requests
30
+ # Create a single summary for ALL requests in this period
31
+ requests = Request.where(occurred_at: start_time...end_time)
32
+
33
+ return if requests.empty?
34
+
35
+ # Get all durations and statuses for percentile calculations
36
+ request_data = requests.pluck(:duration, :status)
37
+ durations = request_data.map(&:first).compact.sort
38
+ statuses = request_data.map(&:second)
39
+
40
+ # Find or create the overall request summary
41
+ summary = Summary.find_or_initialize_by(
42
+ summarizable_type: "RailsPulse::Request",
43
+ summarizable_id: 0, # Use 0 as a special ID for overall summaries
44
+ period_type: period_type,
45
+ period_start: start_time
46
+ )
47
+
48
+ summary.assign_attributes(
49
+ period_end: end_time,
50
+ count: durations.size,
51
+ avg_duration: durations.any? ? durations.sum.to_f / durations.size : 0,
52
+ min_duration: durations.min,
53
+ max_duration: durations.max,
54
+ total_duration: durations.sum,
55
+ p50_duration: calculate_percentile(durations, 0.5),
56
+ p95_duration: calculate_percentile(durations, 0.95),
57
+ p99_duration: calculate_percentile(durations, 0.99),
58
+ stddev_duration: calculate_stddev(durations, durations.sum.to_f / durations.size),
59
+ error_count: statuses.count { |s| s >= 400 },
60
+ success_count: statuses.count { |s| s < 400 },
61
+ status_2xx: statuses.count { |s| s.between?(200, 299) },
62
+ status_3xx: statuses.count { |s| s.between?(300, 399) },
63
+ status_4xx: statuses.count { |s| s.between?(400, 499) },
64
+ status_5xx: statuses.count { |s| s >= 500 }
65
+ )
66
+
67
+ summary.save!
68
+ end
69
+
70
+ private
71
+
72
+ def aggregate_routes
73
+ # Use ActiveRecord for cross-database compatibility
74
+ route_groups = Request
75
+ .where(occurred_at: start_time...end_time)
76
+ .where.not(route_id: nil)
77
+ .group(:route_id)
78
+
79
+ # Calculate basic aggregates
80
+ basic_stats = route_groups.pluck(
81
+ :route_id,
82
+ Arel.sql("COUNT(*) as request_count"),
83
+ Arel.sql("AVG(duration) as avg_duration"),
84
+ Arel.sql("MIN(duration) as min_duration"),
85
+ Arel.sql("MAX(duration) as max_duration"),
86
+ Arel.sql("SUM(duration) as total_duration")
87
+ )
88
+
89
+ basic_stats.each do |stats|
90
+ route_id = stats[0]
91
+
92
+ # Calculate percentiles and status counts separately for cross-DB compatibility
93
+ durations = Request
94
+ .where(occurred_at: start_time...end_time)
95
+ .where(route_id: route_id)
96
+ .pluck(:duration, :status)
97
+
98
+ sorted_durations = durations.map(&:first).compact.sort
99
+ statuses = durations.map(&:last)
100
+
101
+ summary = Summary.find_or_initialize_by(
102
+ summarizable_type: "RailsPulse::Route",
103
+ summarizable_id: route_id,
104
+ period_type: period_type,
105
+ period_start: start_time
106
+ )
107
+
108
+ summary.assign_attributes(
109
+ period_end: end_time,
110
+ count: stats[1],
111
+ avg_duration: stats[2],
112
+ min_duration: stats[3],
113
+ max_duration: stats[4],
114
+ total_duration: stats[5],
115
+ p50_duration: calculate_percentile(sorted_durations, 0.5),
116
+ p95_duration: calculate_percentile(sorted_durations, 0.95),
117
+ p99_duration: calculate_percentile(sorted_durations, 0.99),
118
+ stddev_duration: calculate_stddev(sorted_durations, stats[2]),
119
+ error_count: statuses.count { |s| s >= 400 },
120
+ success_count: statuses.count { |s| s < 400 },
121
+ status_2xx: statuses.count { |s| s.between?(200, 299) },
122
+ status_3xx: statuses.count { |s| s.between?(300, 399) },
123
+ status_4xx: statuses.count { |s| s.between?(400, 499) },
124
+ status_5xx: statuses.count { |s| s >= 500 }
125
+ )
126
+
127
+ summary.save!
128
+ end
129
+ end
130
+
131
+ def aggregate_queries
132
+ query_groups = Operation
133
+ .where(occurred_at: start_time...end_time)
134
+ .where.not(query_id: nil)
135
+ .group(:query_id)
136
+
137
+ basic_stats = query_groups.pluck(
138
+ :query_id,
139
+ Arel.sql("COUNT(*) as execution_count"),
140
+ Arel.sql("AVG(duration) as avg_duration"),
141
+ Arel.sql("MIN(duration) as min_duration"),
142
+ Arel.sql("MAX(duration) as max_duration"),
143
+ Arel.sql("SUM(duration) as total_duration")
144
+ )
145
+
146
+ basic_stats.each do |stats|
147
+ query_id = stats[0]
148
+
149
+ # Calculate percentiles separately
150
+ durations = Operation
151
+ .where(occurred_at: start_time...end_time)
152
+ .where(query_id: query_id)
153
+ .pluck(:duration)
154
+ .compact
155
+ .sort
156
+
157
+ summary = Summary.find_or_initialize_by(
158
+ summarizable_type: "RailsPulse::Query",
159
+ summarizable_id: query_id,
160
+ period_type: period_type,
161
+ period_start: start_time
162
+ )
163
+
164
+ summary.assign_attributes(
165
+ period_end: end_time,
166
+ count: stats[1],
167
+ avg_duration: stats[2],
168
+ min_duration: stats[3],
169
+ max_duration: stats[4],
170
+ total_duration: stats[5],
171
+ p50_duration: calculate_percentile(durations, 0.5),
172
+ p95_duration: calculate_percentile(durations, 0.95),
173
+ p99_duration: calculate_percentile(durations, 0.99),
174
+ stddev_duration: calculate_stddev(durations, stats[2])
175
+ )
176
+
177
+ summary.save!
178
+ end
179
+ end
180
+
181
+ def calculate_percentile(sorted_array, percentile)
182
+ return nil if sorted_array.empty?
183
+
184
+ k = (percentile * (sorted_array.length - 1)).floor
185
+ f = (percentile * (sorted_array.length - 1)) - k
186
+
187
+ return sorted_array[k] if f == 0 || k + 1 >= sorted_array.length
188
+
189
+ sorted_array[k] + (sorted_array[k + 1] - sorted_array[k]) * f
190
+ end
191
+
192
+ def calculate_stddev(values, mean)
193
+ return nil if values.empty? || values.size == 1
194
+
195
+ sum_of_squares = values.sum { |v| (v - mean) ** 2 }
196
+ Math.sqrt(sum_of_squares / (values.size - 1))
197
+ end
198
+ end
199
+ end