rails_pulse 0.1.2 → 0.1.4

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 +66 -20
  3. data/Rakefile +169 -86
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  14. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  15. data/app/controllers/rails_pulse/queries_controller.rb +49 -10
  16. data/app/controllers/rails_pulse/requests_controller.rb +46 -20
  17. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +16 -8
  20. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  21. data/app/javascript/rails_pulse/application.js +34 -3
  22. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  23. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  24. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  25. data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
  26. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  27. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  28. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  29. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  30. data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
  31. data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
  32. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
  33. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  34. data/app/models/rails_pulse/query.rb +46 -0
  35. data/app/models/rails_pulse/request.rb +1 -1
  36. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  37. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  38. data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
  39. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
  40. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
  41. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
  42. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  43. data/app/models/rails_pulse/summary.rb +7 -7
  44. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  45. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  46. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  47. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  48. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  49. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
  50. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  51. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  52. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  53. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  54. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  55. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  56. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  57. data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
  58. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  59. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  60. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  61. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  62. data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
  63. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  64. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  65. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  66. data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
  67. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  68. data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
  69. data/app/views/rails_pulse/queries/_table.html.erb +4 -8
  70. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  71. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  72. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  73. data/app/views/rails_pulse/requests/_table.html.erb +31 -18
  74. data/app/views/rails_pulse/requests/index.html.erb +55 -50
  75. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  76. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  77. data/app/views/rails_pulse/routes/_table.html.erb +4 -10
  78. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  79. data/app/views/rails_pulse/routes/show.html.erb +6 -8
  80. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  81. data/config/routes.rb +5 -1
  82. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  83. data/db/rails_pulse_schema.rb +10 -1
  84. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
  85. data/lib/generators/rails_pulse/install_generator.rb +75 -18
  86. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  87. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
  88. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  89. data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
  90. data/lib/rails_pulse/engine.rb +21 -0
  91. data/lib/rails_pulse/version.rb +1 -1
  92. data/lib/tasks/rails_pulse.rake +27 -8
  93. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  94. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  96. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  97. metadata +25 -6
  98. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  99. data/app/assets/images/rails_pulse/routes.png +0 -0
  100. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  101. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -9,6 +9,13 @@ module RailsPulse
9
9
  # Validations
10
10
  validates :normalized_sql, presence: true, uniqueness: true
11
11
 
12
+ # JSON serialization for analysis columns
13
+ serialize :issues, type: Array, coder: JSON
14
+ serialize :metadata, type: Hash, coder: JSON
15
+ serialize :query_stats, type: Hash, coder: JSON
16
+ serialize :backtrace_analysis, type: Hash, coder: JSON
17
+ serialize :suggestions, type: Array, coder: JSON
18
+
12
19
  def self.ransackable_attributes(auth_object = nil)
13
20
  %w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
14
21
  end
@@ -52,6 +59,45 @@ module RailsPulse
52
59
  Arel.sql("MAX(rails_pulse_operations.occurred_at)")
53
60
  end
54
61
 
62
+ # Analysis helper methods
63
+ def analyzed?
64
+ analyzed_at.present?
65
+ end
66
+
67
+ def has_recent_operations?
68
+ operations.where("occurred_at > ?", 48.hours.ago).exists?
69
+ end
70
+
71
+ def needs_reanalysis?
72
+ return true unless analyzed?
73
+
74
+ # Check if there are new operations since analysis
75
+ last_operation_time = operations.maximum(:occurred_at)
76
+ return false unless last_operation_time
77
+
78
+ last_operation_time > analyzed_at
79
+ end
80
+
81
+ def analysis_status
82
+ return "not_analyzed" unless analyzed?
83
+ return "needs_update" if needs_reanalysis?
84
+ "current"
85
+ end
86
+
87
+ def issues_by_severity
88
+ return {} unless analyzed? && issues.present?
89
+
90
+ issues.group_by { |issue| issue["severity"] || "unknown" }
91
+ end
92
+
93
+ def critical_issues_count
94
+ issues_by_severity["critical"]&.count || 0
95
+ end
96
+
97
+ def warning_issues_count
98
+ issues_by_severity["warning"]&.count || 0
99
+ end
100
+
55
101
  def to_s
56
102
  id
57
103
  end
@@ -52,7 +52,7 @@ module RailsPulse
52
52
  end
53
53
 
54
54
  def to_s
55
- occurred_at.strftime("%b %d, %Y %l:%M %p")
55
+ occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
56
56
  end
57
57
 
58
58
  private
@@ -13,11 +13,11 @@ module RailsPulse
13
13
 
14
14
  def to_rails_chart
15
15
  summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Route",
16
+ summarizable_type: "RailsPulse::Request",
17
+ summarizable_id: 0, # Overall request summaries
17
18
  period_type: @period_type
18
19
  )
19
20
 
20
- summaries = summaries.where(summarizable_id: @route.id) if @route
21
21
  summaries = summaries
22
22
  .group(:period_start)
23
23
  .having("AVG(avg_duration) > ?", @start_duration || 0)
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ module Requests
3
+ module Tables
4
+ class Index
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
6
+ @ransack_query = ransack_query
7
+ @period_type = period_type
8
+ @start_time = start_time
9
+ @params = params
10
+ end
11
+
12
+ def to_table
13
+ # Check if we have explicit ransack sorts
14
+ has_sorts = @ransack_query.sorts.any?
15
+
16
+ base_query = @ransack_query.result(distinct: false)
17
+ .where(
18
+ summarizable_type: "RailsPulse::Request",
19
+ summarizable_id: 0, # Overall request summaries
20
+ period_type: @period_type
21
+ )
22
+
23
+ # Apply grouping and aggregation for time periods
24
+ grouped_query = base_query
25
+ .group(
26
+ "rails_pulse_summaries.period_start",
27
+ "rails_pulse_summaries.period_end",
28
+ "rails_pulse_summaries.period_type"
29
+ )
30
+ .select(
31
+ "rails_pulse_summaries.period_start",
32
+ "rails_pulse_summaries.period_end",
33
+ "rails_pulse_summaries.period_type",
34
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
35
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
36
+ "MIN(rails_pulse_summaries.min_duration) as min_duration",
37
+ "SUM(rails_pulse_summaries.count) as count",
38
+ "SUM(rails_pulse_summaries.error_count) as error_count",
39
+ "SUM(rails_pulse_summaries.success_count) as success_count"
40
+ )
41
+
42
+ # Apply sorting based on ransack sorts or use default
43
+ if has_sorts
44
+ # Apply custom sorting based on ransack parameters
45
+ sort = @ransack_query.sorts.first
46
+ direction = sort.dir == "desc" ? :desc : :asc
47
+
48
+ case sort.name
49
+ when "avg_duration"
50
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
51
+ when "max_duration"
52
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
53
+ when "min_duration"
54
+ grouped_query = grouped_query.order(Arel.sql("MIN(rails_pulse_summaries.min_duration)").send(direction))
55
+ when "count"
56
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
57
+ when "requests_per_minute"
58
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
59
+ when "error_rate_percentage"
60
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
61
+ when "period_start"
62
+ grouped_query = grouped_query.order(period_start: direction)
63
+ else
64
+ # Unknown sort field, fallback to default
65
+ grouped_query = grouped_query.order(period_start: :desc)
66
+ end
67
+ else
68
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
69
+ grouped_query = grouped_query.order(period_start: :desc)
70
+ end
71
+
72
+ grouped_query
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -36,27 +36,25 @@ module RailsPulse
36
36
  trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
37
37
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
38
38
 
39
- # Separate query for sparkline data - manually calculate weighted averages by week
40
- sparkline_data = {}
41
- base_query.each do |summary|
42
- week_start = summary.period_start.beginning_of_week
43
- formatted_date = week_start.strftime("%b %-d")
39
+ # Sparkline data by day with zero-filled days over the last 14 days
40
+ grouped_weighted = base_query
41
+ .group_by_day(:period_start, time_zone: "UTC")
42
+ .sum(Arel.sql("avg_duration * count"))
44
43
 
45
- if sparkline_data[formatted_date]
46
- sparkline_data[formatted_date][:total_weighted] += (summary.avg_duration || 0) * (summary.count || 0)
47
- sparkline_data[formatted_date][:total_count] += (summary.count || 0)
48
- else
49
- sparkline_data[formatted_date] = {
50
- total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
51
- total_count: (summary.count || 0)
52
- }
53
- end
54
- end
44
+ grouped_counts = base_query
45
+ .group_by_day(:period_start, time_zone: "UTC")
46
+ .sum(:count)
55
47
 
56
- # Convert to final format
57
- sparkline_data = sparkline_data.transform_values do |data|
58
- weighted_avg = data[:total_count] > 0 ? (data[:total_weighted] / data[:total_count]).round(0) : 0
59
- { value: weighted_avg }
48
+ start_day = 2.weeks.ago.beginning_of_day.to_date
49
+ end_day = Time.current.to_date
50
+
51
+ sparkline_data = {}
52
+ (start_day..end_day).each do |day|
53
+ weighted_sum = grouped_weighted[day] || 0
54
+ count_sum = grouped_counts[day] || 0
55
+ avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
56
+ label = day.strftime("%b %-d")
57
+ sparkline_data[label] = { value: avg }
60
58
  end
61
59
 
62
60
  {
@@ -64,7 +62,7 @@ module RailsPulse
64
62
  context: "routes",
65
63
  title: "Average Response Time",
66
64
  summary: "#{average_response_time} ms",
67
- line_chart_data: sparkline_data,
65
+ chart_data: sparkline_data,
68
66
  trend_icon: trend_icon,
69
67
  trend_amount: trend_amount,
70
68
  trend_text: "Compared to last week"
@@ -39,22 +39,27 @@ module RailsPulse
39
39
  trend_icon = percentage < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
40
40
  trend_amount = previous_period_errors.zero? ? "0%" : "#{percentage}%"
41
41
 
42
- # Separate query for sparkline data - group by week using Rails
43
- sparkline_data = base_query
44
- .group_by_week(:period_start, time_zone: "UTC")
42
+ # Sparkline data by day with zero-filled days over the last 14 days
43
+ grouped_daily = base_query
44
+ .group_by_day(:period_start, time_zone: "UTC")
45
45
  .sum(:error_count)
46
- .each_with_object({}) do |(week_start, total_errors), hash|
47
- formatted_date = week_start.strftime("%b %-d")
48
- value = total_errors || 0
49
- hash[formatted_date] = { value: value }
50
- end
46
+
47
+ start_day = 2.weeks.ago.beginning_of_day.to_date
48
+ end_day = Time.current.to_date
49
+
50
+ sparkline_data = {}
51
+ (start_day..end_day).each do |day|
52
+ total = grouped_daily[day] || 0
53
+ label = day.strftime("%b %-d")
54
+ sparkline_data[label] = { value: total }
55
+ end
51
56
 
52
57
  {
53
58
  id: "error_rate_per_route",
54
59
  context: "routes",
55
60
  title: "Error Rate Per Route",
56
61
  summary: "#{overall_error_rate}%",
57
- line_chart_data: sparkline_data,
62
+ chart_data: sparkline_data,
58
63
  trend_icon: trend_icon,
59
64
  trend_amount: trend_amount,
60
65
  trend_text: "Compared to last week"
@@ -33,22 +33,27 @@ module RailsPulse
33
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_p95 < previous_period_p95 ? "trending-down" : "trending-up"
34
34
  trend_amount = previous_period_p95.zero? ? "0%" : "#{percentage}%"
35
35
 
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")
36
+ # Sparkline data by day with zero-filled days over the last 14 days
37
+ grouped_daily = base_query
38
+ .group_by_day(:period_start, time_zone: "UTC")
39
39
  .average(:p95_duration)
40
- .each_with_object({}) do |(week_start, avg_p95), hash|
41
- formatted_date = week_start.strftime("%b %-d")
42
- value = (avg_p95 || 0).round(0)
43
- hash[formatted_date] = { value: value }
44
- end
40
+
41
+ start_day = 2.weeks.ago.beginning_of_day.to_date
42
+ end_day = Time.current.to_date
43
+
44
+ sparkline_data = {}
45
+ (start_day..end_day).each do |day|
46
+ avg = grouped_daily[day]&.round(0) || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: avg }
49
+ end
45
50
 
46
51
  {
47
52
  id: "percentile_response_times",
48
53
  context: "routes",
49
54
  title: "95th Percentile Response Time",
50
55
  summary: "#{p95_response_time} ms",
51
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
52
57
  trend_icon: trend_icon,
53
58
  trend_amount: trend_amount,
54
59
  trend_text: "Compared to last week"
@@ -33,26 +33,42 @@ module RailsPulse
33
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
34
34
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
35
35
 
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")
36
+ # Sparkline data by day with zero-filled days over the last 14 days
37
+ grouped_daily = base_query
38
+ .group_by_day(:period_start, time_zone: "UTC")
39
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 }
44
- end
45
40
 
46
- # Calculate average requests per minute over 2-week period
47
- total_minutes = 2.weeks / 1.minute
48
- average_requests_per_minute = total_request_count / total_minutes
41
+ start_day = 2.weeks.ago.beginning_of_day.to_date
42
+ end_day = Time.current.to_date
43
+
44
+ sparkline_data = {}
45
+ (start_day..end_day).each do |day|
46
+ total = grouped_daily[day] || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: total }
49
+ end
50
+
51
+ # Calculate appropriate rate display based on frequency
52
+ total_minutes = 2.weeks / 1.minute.to_f
53
+ requests_per_minute = total_request_count.to_f / total_minutes
54
+
55
+ # Choose appropriate time unit for display
56
+ if requests_per_minute >= 1
57
+ summary = "#{requests_per_minute.round(2)} / min"
58
+ elsif requests_per_minute * 60 >= 1
59
+ requests_per_hour = requests_per_minute * 60
60
+ summary = "#{requests_per_hour.round(2)} / hour"
61
+ else
62
+ requests_per_day = requests_per_minute * 60 * 24
63
+ summary = "#{requests_per_day.round(2)} / day"
64
+ end
49
65
 
50
66
  {
51
67
  id: "request_count_totals",
52
68
  context: "routes",
53
69
  title: "Request Count Total",
54
- summary: "#{average_requests_per_minute.round(2)} / min",
55
- line_chart_data: sparkline_data,
70
+ summary: summary,
71
+ chart_data: sparkline_data,
56
72
  trend_icon: trend_icon,
57
73
  trend_amount: trend_amount,
58
74
  trend_text: "Compared to last week"
@@ -13,7 +13,9 @@ module RailsPulse
13
13
  # Check if we have explicit ransack sorts
14
14
  has_sorts = @ransack_query.sorts.any?
15
15
 
16
- base_query = @ransack_query.result(distinct: false)
16
+ # Store sorts for later and get result without ordering
17
+ # This prevents PostgreSQL GROUP BY issues with ORDER BY columns
18
+ base_query = @ransack_query.result(distinct: false).reorder(nil)
17
19
  .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
18
20
  .where(
19
21
  summarizable_type: "RailsPulse::Route",
@@ -55,7 +57,7 @@ module RailsPulse
55
57
  grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
56
58
  when "max_duration_sort"
57
59
  grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
58
- when "count_sort"
60
+ when "count_sort", "request_count_sort"
59
61
  grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
60
62
  when "requests_per_minute"
61
63
  grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
@@ -36,9 +36,10 @@ module RailsPulse
36
36
  # Ransack configuration
37
37
  def self.ransackable_attributes(auth_object = nil)
38
38
  %w[
39
- period_start period_end avg_duration max_duration count error_count
39
+ period_start period_end avg_duration min_duration max_duration count error_count
40
40
  requests_per_minute error_rate_percentage route_path_cont
41
41
  execution_count total_time_consumed normalized_sql
42
+ summarizable_id summarizable_type
42
43
  ]
43
44
  end
44
45
 
@@ -46,17 +47,16 @@ module RailsPulse
46
47
  %w[route query]
47
48
  end
48
49
 
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
50
+ # Note: Basic fields like count, avg_duration, min_duration, max_duration
51
+ # are handled automatically by Ransack using actual database columns
53
52
 
53
+ # Custom ransackers for calculated fields only
54
54
  ransacker :requests_per_minute do
55
- Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
55
+ Arel.sql("rails_pulse_summaries.count / 60.0")
56
56
  end
57
57
 
58
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
59
+ Arel.sql("(rails_pulse_summaries.error_count * 100.0) / rails_pulse_summaries.count")
60
60
  end
61
61
 
62
62
 
@@ -0,0 +1,256 @@
1
+ # Analyzes execution backtraces to identify code hotspots and execution patterns.
2
+ # Tracks most common execution locations, controller/model usage, and framework layer distribution.
3
+ module RailsPulse
4
+ module Analysis
5
+ class BacktraceAnalyzer < BaseAnalyzer
6
+ def analyze
7
+ backtraces = extract_backtraces
8
+
9
+ {
10
+ total_executions: operations.count,
11
+ unique_locations: backtraces.uniq.count,
12
+ most_common_location: find_most_common_location(backtraces),
13
+ potential_n_plus_one: detect_simple_n_plus_one_pattern,
14
+ execution_frequency: calculate_execution_frequency,
15
+ location_distribution: calculate_location_distribution(backtraces),
16
+ code_hotspots: identify_code_hotspots(backtraces),
17
+ execution_contexts: analyze_execution_contexts(backtraces)
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def extract_backtraces
24
+ operations.filter_map(&:codebase_location).compact
25
+ end
26
+
27
+ def find_most_common_location(backtraces)
28
+ return nil if backtraces.empty?
29
+
30
+ frequency = backtraces.tally
31
+ most_common = frequency.max_by { |_, count| count }
32
+
33
+ return nil unless most_common
34
+
35
+ {
36
+ location: most_common[0],
37
+ count: most_common[1],
38
+ percentage: (most_common[1].to_f / backtraces.length * 100).round(1)
39
+ }
40
+ end
41
+
42
+ def detect_simple_n_plus_one_pattern
43
+ # Simple N+1 detection: many operations with same query in short time
44
+ time_window = 1.minute
45
+ groups = operations.group_by { |op| op.occurred_at.beginning_of_minute }
46
+
47
+ suspicious_groups = groups.select { |_, ops| ops.count > 10 }
48
+
49
+ {
50
+ detected: suspicious_groups.any?,
51
+ suspicious_periods: suspicious_groups.map do |time, ops|
52
+ {
53
+ period: time.strftime("%Y-%m-%d %H:%M"),
54
+ count: ops.count,
55
+ avg_duration: ops.sum(&:duration) / ops.count
56
+ }
57
+ end
58
+ }
59
+ end
60
+
61
+ def calculate_execution_frequency
62
+ return 0 if operations.empty? || operations.count < 2
63
+
64
+ time_span = operations.last.occurred_at - operations.first.occurred_at
65
+ return operations.count if time_span <= 0
66
+
67
+ (operations.count / time_span.in_hours).round(2)
68
+ end
69
+
70
+ def calculate_location_distribution(backtraces)
71
+ return {} if backtraces.empty?
72
+
73
+ total = backtraces.length
74
+ distribution = backtraces.tally.transform_values { |count| (count.to_f / total * 100).round(1) }
75
+
76
+ # Sort by frequency and return top locations
77
+ distribution.sort_by { |_, percentage| -percentage }.first(10).to_h
78
+ end
79
+
80
+ def identify_code_hotspots(backtraces)
81
+ return [] if backtraces.empty?
82
+
83
+ # Group by file/method to identify hotspots
84
+ hotspots = []
85
+
86
+ # Group by controller actions
87
+ controller_hotspots = group_by_controller_actions(backtraces)
88
+ hotspots.concat(controller_hotspots)
89
+
90
+ # Group by model methods
91
+ model_hotspots = group_by_model_methods(backtraces)
92
+ hotspots.concat(model_hotspots)
93
+
94
+ # Group by file
95
+ file_hotspots = group_by_files(backtraces)
96
+ hotspots.concat(file_hotspots)
97
+
98
+ # Sort by frequency and return top hotspots
99
+ hotspots.sort_by { |hotspot| -hotspot[:count] }.first(10)
100
+ end
101
+
102
+ def group_by_controller_actions(backtraces)
103
+ controller_traces = backtraces.select { |trace| trace.include?("app/controllers/") }
104
+
105
+ controller_actions = controller_traces.filter_map do |trace|
106
+ match = trace.match(%r{app/controllers/(.+?)\.rb.*in `(.+?)'})
107
+ next unless match
108
+
109
+ controller = match[1].gsub("_controller", "").humanize
110
+ action = match[2]
111
+ "#{controller}##{action}"
112
+ end
113
+
114
+ build_hotspot_data(controller_actions, "controller_action")
115
+ end
116
+
117
+ def group_by_model_methods(backtraces)
118
+ model_traces = backtraces.select { |trace| trace.include?("app/models/") }
119
+
120
+ model_methods = model_traces.filter_map do |trace|
121
+ match = trace.match(%r{app/models/(.+?)\.rb.*in `(.+?)'})
122
+ next unless match
123
+
124
+ model = match[1].classify
125
+ method = match[2]
126
+ "#{model}.#{method}"
127
+ end
128
+
129
+ build_hotspot_data(model_methods, "model_method")
130
+ end
131
+
132
+ def group_by_files(backtraces)
133
+ files = backtraces.filter_map do |trace|
134
+ match = trace.match(%r{(app/[^:]+)})
135
+ match[1] if match
136
+ end
137
+
138
+ build_hotspot_data(files, "file")
139
+ end
140
+
141
+ def build_hotspot_data(items, type)
142
+ return [] if items.empty?
143
+
144
+ item_counts = items.tally
145
+ total_operations = operations.count
146
+
147
+ item_counts.map do |item, count|
148
+ {
149
+ type: type,
150
+ location: item,
151
+ count: count,
152
+ percentage: (count.to_f / total_operations * 100).round(1),
153
+ operations_per_execution: (count.to_f / item_counts.values.sum * total_operations).round(2)
154
+ }
155
+ end
156
+ end
157
+
158
+ def analyze_execution_contexts(backtraces)
159
+ return {} if backtraces.empty?
160
+
161
+ contexts = {
162
+ framework_layers: analyze_framework_layers(backtraces),
163
+ application_layers: analyze_application_layers(backtraces),
164
+ gem_usage: analyze_gem_usage(backtraces),
165
+ database_access_patterns: analyze_database_access_patterns(backtraces)
166
+ }
167
+
168
+ contexts
169
+ end
170
+
171
+ def analyze_framework_layers(backtraces)
172
+ layers = {
173
+ controller: backtraces.count { |trace| trace.include?("app/controllers/") },
174
+ model: backtraces.count { |trace| trace.include?("app/models/") },
175
+ view: backtraces.count { |trace| trace.include?("app/views/") },
176
+ service: backtraces.count { |trace| trace.include?("app/services/") },
177
+ job: backtraces.count { |trace| trace.include?("app/jobs/") },
178
+ rails_framework: backtraces.count { |trace| trace.include?("railties") || trace.include?("actionpack") },
179
+ activerecord: backtraces.count { |trace| trace.include?("activerecord") }
180
+ }
181
+
182
+ total = backtraces.count
183
+ layers.transform_values { |count| { count: count, percentage: (count.to_f / total * 100).round(1) } }
184
+ end
185
+
186
+ def analyze_application_layers(backtraces)
187
+ app_traces = backtraces.select { |trace| trace.include?("app/") }
188
+
189
+ layers = {}
190
+ app_traces.each do |trace|
191
+ layer = extract_app_layer(trace)
192
+ layers[layer] ||= 0
193
+ layers[layer] += 1
194
+ end
195
+
196
+ total = app_traces.count
197
+ layers.transform_values { |count| { count: count, percentage: (count.to_f / total * 100).round(1) } }
198
+ end
199
+
200
+ def extract_app_layer(trace)
201
+ case trace
202
+ when /app\/controllers/ then :controllers
203
+ when /app\/models/ then :models
204
+ when /app\/services/ then :services
205
+ when /app\/jobs/ then :jobs
206
+ when /app\/mailers/ then :mailers
207
+ when /app\/helpers/ then :helpers
208
+ when /app\/views/ then :views
209
+ when /app\/lib/ then :lib
210
+ else :other
211
+ end
212
+ end
213
+
214
+ def analyze_gem_usage(backtraces)
215
+ gem_traces = backtraces.reject { |trace| trace.include?("app/") || trace.include?("config/") }
216
+
217
+ gems = gem_traces.filter_map do |trace|
218
+ # Extract gem name from path like "/gems/gem_name-version/lib/..."
219
+ match = trace.match(%r{/gems/([^/]+)/})
220
+ match[1].split("-").first if match
221
+ end
222
+
223
+ gem_counts = gems.tally
224
+ total = gem_traces.count
225
+
226
+ gem_counts.transform_values { |count| { count: count, percentage: (count.to_f / total * 100).round(1) } }
227
+ .sort_by { |_, data| -data[:count] }
228
+ .first(5)
229
+ .to_h
230
+ end
231
+
232
+ def analyze_database_access_patterns(backtraces)
233
+ db_traces = backtraces.select { |trace|
234
+ trace.include?("activerecord") ||
235
+ trace.include?("execute_query") ||
236
+ trace.include?("adapter")
237
+ }
238
+
239
+ {
240
+ total_db_operations: db_traces.count,
241
+ percentage_db_operations: (db_traces.count.to_f / backtraces.count * 100).round(1),
242
+ common_db_methods: extract_common_db_methods(db_traces)
243
+ }
244
+ end
245
+
246
+ def extract_common_db_methods(db_traces)
247
+ methods = db_traces.filter_map do |trace|
248
+ match = trace.match(/in `(.+?)'/)
249
+ match[1] if match
250
+ end
251
+
252
+ methods.tally.sort_by { |_, count| -count }.first(5).to_h
253
+ end
254
+ end
255
+ end
256
+ end