rails_pulse 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -177
  3. data/Rakefile +77 -2
  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 -17
  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/chart_table_concern.rb +21 -4
  14. data/app/controllers/concerns/response_range_concern.rb +6 -3
  15. data/app/controllers/concerns/time_range_concern.rb +5 -10
  16. data/app/controllers/concerns/zoom_range_concern.rb +32 -1
  17. data/app/controllers/rails_pulse/application_controller.rb +13 -5
  18. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  19. data/app/controllers/rails_pulse/queries_controller.rb +111 -51
  20. data/app/controllers/rails_pulse/requests_controller.rb +37 -12
  21. data/app/controllers/rails_pulse/routes_controller.rb +98 -24
  22. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  23. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  24. data/app/helpers/rails_pulse/chart_helper.rb +21 -9
  25. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  26. data/app/javascript/rails_pulse/application.js +34 -3
  27. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  28. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  29. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  30. data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
  31. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  32. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  33. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  34. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  35. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  36. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  37. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  38. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
  39. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  40. data/app/models/rails_pulse/operation.rb +1 -1
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  45. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  46. data/app/models/rails_pulse/query.rb +47 -0
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  48. data/app/models/rails_pulse/route.rb +1 -6
  49. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
  50. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
  51. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
  52. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
  53. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  54. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  55. data/app/models/rails_pulse/summary.rb +143 -0
  56. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  57. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  58. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  59. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  60. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  61. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  62. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  63. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  64. data/app/services/rails_pulse/summary_service.rb +199 -0
  65. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  66. data/app/views/layouts/rails_pulse/application.html.erb +4 -6
  67. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  68. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  69. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  70. data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
  71. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  72. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  73. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  74. data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
  75. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  76. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  77. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  78. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  79. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  80. data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
  81. data/app/views/rails_pulse/queries/_table.html.erb +11 -13
  82. data/app/views/rails_pulse/queries/index.html.erb +32 -28
  83. data/app/views/rails_pulse/queries/show.html.erb +45 -34
  84. data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
  85. data/app/views/rails_pulse/requests/_table.html.erb +3 -3
  86. data/app/views/rails_pulse/requests/index.html.erb +33 -28
  87. data/app/views/rails_pulse/routes/_table.html.erb +14 -14
  88. data/app/views/rails_pulse/routes/index.html.erb +34 -29
  89. data/app/views/rails_pulse/routes/show.html.erb +43 -36
  90. data/config/initializers/rails_pulse.rb +0 -12
  91. data/config/routes.rb +5 -1
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  94. data/db/rails_pulse_schema.rb +130 -0
  95. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  96. data/lib/generators/rails_pulse/install_generator.rb +94 -4
  97. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  98. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  99. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  100. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  101. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  102. data/lib/rails_pulse/configuration.rb +0 -11
  103. data/lib/rails_pulse/engine.rb +0 -1
  104. data/lib/rails_pulse/version.rb +1 -1
  105. data/lib/tasks/rails_pulse.rake +77 -0
  106. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  107. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  108. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  109. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  110. data/public/rails-pulse-assets/search.svg +43 -0
  111. metadata +48 -14
  112. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  113. data/app/assets/images/rails_pulse/routes.png +0 -0
  114. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  115. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  116. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  117. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  118. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  119. data/db/migrate/20250227235904_create_routes.rb +0 -12
  120. data/db/migrate/20250227235915_create_requests.rb +0 -19
  121. data/db/migrate/20250228000000_create_queries.rb +0 -14
  122. data/db/migrate/20250228000056_create_operations.rb +0 -24
  123. data/lib/rails_pulse/migration.rb +0 -29
@@ -0,0 +1,41 @@
1
+ module RailsPulse
2
+ class BackfillSummariesJob < ApplicationJob
3
+ queue_as :low_priority
4
+
5
+ def perform(start_date, end_date, period_types = [ "hour", "day" ])
6
+ start_date = start_date.to_datetime
7
+ end_date = end_date.to_datetime
8
+
9
+ period_types.each do |period_type|
10
+ backfill_period(period_type, start_date, end_date)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def backfill_period(period_type, start_date, end_date)
17
+ current = Summary.normalize_period_start(period_type, start_date)
18
+ period_end = Summary.calculate_period_end(period_type, end_date)
19
+
20
+ while current <= period_end
21
+ Rails.logger.info "[RailsPulse] Backfilling #{period_type} summary for #{current}"
22
+
23
+ SummaryService.new(period_type, current).perform
24
+
25
+ current = advance_period(current, period_type)
26
+
27
+ # Add small delay to avoid overwhelming the database
28
+ sleep 0.1
29
+ end
30
+ end
31
+
32
+ def advance_period(time, period_type)
33
+ case period_type
34
+ when "hour" then time + 1.hour
35
+ when "day" then time + 1.day
36
+ when "week" then time + 1.week
37
+ when "month" then time + 1.month
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ module RailsPulse
2
+ class SummaryJob < ApplicationJob
3
+ queue_as :low_priority
4
+
5
+ def perform(target_hour = nil)
6
+ target_hour ||= 1.hour.ago.beginning_of_hour
7
+
8
+ # Always run hourly summary
9
+ process_hourly_summary(target_hour)
10
+
11
+ # Check if we should run daily summary (at the start of a new day)
12
+ if target_hour.hour == 0
13
+ process_daily_summary(target_hour.to_date - 1.day)
14
+
15
+ # Check if we should run weekly summary (Monday at midnight)
16
+ if target_hour.wday == 1
17
+ process_weekly_summary((target_hour.to_date - 1.week).beginning_of_week)
18
+ end
19
+
20
+ # Check if we should run monthly summary (first day of month)
21
+ if target_hour.day == 1
22
+ process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month)
23
+ end
24
+ end
25
+ rescue => e
26
+ Rails.logger.error "[RailsPulse] Summary job failed: #{e.message}"
27
+ Rails.logger.error e.backtrace.join("\n")
28
+ raise
29
+ end
30
+
31
+ private
32
+
33
+ def process_hourly_summary(hour)
34
+ Rails.logger.info "[RailsPulse] Processing hourly summary for #{hour}"
35
+ SummaryService.new("hour", hour).perform
36
+ end
37
+
38
+ def process_daily_summary(date)
39
+ Rails.logger.info "[RailsPulse] Processing daily summary for #{date}"
40
+ SummaryService.new("day", date).perform
41
+ end
42
+
43
+ def process_weekly_summary(week_start)
44
+ Rails.logger.info "[RailsPulse] Processing weekly summary for week starting #{week_start}"
45
+ SummaryService.new("week", week_start).perform
46
+ end
47
+
48
+ def process_monthly_summary(month_start)
49
+ Rails.logger.info "[RailsPulse] Processing monthly summary for month starting #{month_start}"
50
+ SummaryService.new("month", month_start).perform
51
+ end
52
+ end
53
+ end
@@ -8,17 +8,38 @@ module RailsPulse
8
8
  end_date = Time.current.to_date
9
9
  date_range = (start_date..end_date)
10
10
 
11
- # Get the actual data
12
- requests = RailsPulse::Request.where("occurred_at >= ?", start_date.beginning_of_day)
13
- actual_data = requests
14
- .group_by_day(:occurred_at)
15
- .average(:duration)
11
+ # Get the actual data from Summary records (routes)
12
+ summaries = RailsPulse::Summary.where(
13
+ summarizable_type: "RailsPulse::Route",
14
+ period_type: "day",
15
+ period_start: start_date.beginning_of_day..end_date.end_of_day
16
+ )
17
+
18
+ # Group by day manually for cross-database compatibility
19
+ actual_data = {}
20
+ summaries.each do |summary|
21
+ date = summary.period_start.to_date
22
+
23
+ if actual_data[date]
24
+ actual_data[date][:total_weighted] += (summary.avg_duration || 0) * (summary.count || 0)
25
+ actual_data[date][:total_count] += (summary.count || 0)
26
+ else
27
+ actual_data[date] = {
28
+ total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
29
+ total_count: (summary.count || 0)
30
+ }
31
+ end
32
+ end
33
+
34
+ # Convert to final values
35
+ actual_data = actual_data.transform_values do |data|
36
+ data[:total_count] > 0 ? (data[:total_weighted] / data[:total_count]).round(0) : 0
37
+ end
16
38
 
17
39
  # Fill in all dates with zero values for missing days
18
40
  date_range.each_with_object({}) do |date, result|
19
41
  formatted_date = date.strftime("%b %-d")
20
- avg_duration = actual_data[date]
21
- result[formatted_date] = avg_duration&.round(0) || 0
42
+ result[formatted_date] = actual_data[date] || 0
22
43
  end
23
44
  end
24
45
  end
@@ -3,32 +3,28 @@ module RailsPulse
3
3
  module Charts
4
4
  class P95ResponseTime
5
5
  def to_chart_data
6
- start_date = 2.weeks.ago.beginning_of_day
6
+ # Create a range of all dates in the past 2 weeks
7
+ start_date = 2.weeks.ago.beginning_of_day.to_date
8
+ end_date = Time.current.to_date
9
+ date_range = (start_date..end_date)
7
10
 
8
- # Performance optimization: Single query instead of N+1 queries (15 queries -> 1 query)
9
- # Fetch all requests for 2-week period, pre-sorted by date and duration
10
- # For optimal performance, ensure index exists: (occurred_at, duration)
11
- requests_by_day = RailsPulse::Request
12
- .where(occurred_at: start_date..)
13
- .select("occurred_at, duration, DATE(occurred_at) as request_date")
14
- .order("request_date, duration")
15
- .group_by { |r| r.request_date.to_date }
11
+ # Get the actual data from Summary records (queries for P95)
12
+ summaries = RailsPulse::Summary.where(
13
+ summarizable_type: "RailsPulse::Query",
14
+ period_type: "day",
15
+ period_start: start_date.beginning_of_day..end_date.end_of_day
16
+ )
16
17
 
17
- # Generate all dates in range and calculate P95 for each
18
- (start_date.to_date..Time.current.to_date).each_with_object({}) do |date, hash|
19
- day_requests = requests_by_day[date] || []
20
-
21
- if day_requests.empty?
22
- p95_value = 0
23
- else
24
- # Calculate P95 from in-memory sorted array (already sorted by DB)
25
- count = day_requests.length
26
- p95_index = (count * 0.95).ceil - 1
27
- p95_value = day_requests[p95_index].duration.round(0)
28
- end
18
+ actual_data = summaries
19
+ .group_by_day(:period_start, time_zone: Time.zone)
20
+ .average(:p95_duration)
21
+ .transform_keys { |date| date.to_date }
22
+ .transform_values { |avg| avg&.round(0) || 0 }
29
23
 
24
+ # Fill in all dates with zero values for missing days
25
+ date_range.each_with_object({}) do |date, result|
30
26
  formatted_date = date.strftime("%b %-d")
31
- hash[formatted_date] = p95_value
27
+ result[formatted_date] = actual_data[date] || 0
32
28
  end
33
29
  end
34
30
  end
@@ -8,11 +8,22 @@ module RailsPulse
8
8
  this_week_start = 1.week.ago.beginning_of_week
9
9
  this_week_end = Time.current.end_of_week
10
10
 
11
- # Fetch query data for this week
12
- query_data = RailsPulse::Operation.joins(:query)
13
- .where(occurred_at: this_week_start..this_week_end)
14
- .group("rails_pulse_queries.id, rails_pulse_queries.normalized_sql")
15
- .select("rails_pulse_queries.id, rails_pulse_queries.normalized_sql, AVG(rails_pulse_operations.duration) as avg_duration, COUNT(*) as request_count, MAX(rails_pulse_operations.occurred_at) as last_seen")
11
+ # Fetch query data from Summary records for this week
12
+ query_data = RailsPulse::Summary
13
+ .joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
14
+ .where(
15
+ summarizable_type: "RailsPulse::Query",
16
+ period_type: "day",
17
+ period_start: this_week_start..this_week_end
18
+ )
19
+ .group("rails_pulse_summaries.summarizable_id, rails_pulse_queries.normalized_sql")
20
+ .select(
21
+ "rails_pulse_summaries.summarizable_id as query_id",
22
+ "rails_pulse_queries.normalized_sql",
23
+ "SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
24
+ "SUM(rails_pulse_summaries.count) as request_count",
25
+ "MAX(rails_pulse_summaries.period_end) as last_seen"
26
+ )
16
27
  .order("avg_duration DESC")
17
28
  .limit(5)
18
29
 
@@ -20,8 +31,8 @@ module RailsPulse
20
31
  data_rows = query_data.map do |record|
21
32
  {
22
33
  query_text: truncate_query(record.normalized_sql),
23
- query_id: record.id,
24
- query_link: "/rails_pulse/queries/#{record.id}",
34
+ query_id: record.query_id,
35
+ query_link: "/rails_pulse/queries/#{record.query_id}",
25
36
  average_time: record.avg_duration.to_f.round(0),
26
37
  request_count: record.request_count,
27
38
  last_request: time_ago_in_words(record.last_seen)
@@ -5,58 +5,51 @@ module RailsPulse
5
5
  include RailsPulse::FormattingHelper
6
6
 
7
7
  def to_table_data
8
- # Get data for this week and last week
8
+ # Get data for this week
9
9
  this_week_start = 1.week.ago.beginning_of_week
10
10
  this_week_end = Time.current.end_of_week
11
- last_week_start = 2.weeks.ago.beginning_of_week
12
- last_week_end = 1.week.ago.beginning_of_week
13
11
 
14
- # Get this week's data
15
- this_week_data = RailsPulse::Request.joins(:route)
16
- .where(occurred_at: this_week_start..this_week_end)
17
- .group("rails_pulse_routes.path, rails_pulse_routes.id")
18
- .select("rails_pulse_routes.path, rails_pulse_routes.id, AVG(rails_pulse_requests.duration) as avg_duration, COUNT(*) as request_count")
12
+ # Fetch route data from Summary records for this week
13
+ route_data = RailsPulse::Summary
14
+ .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
15
+ .where(
16
+ summarizable_type: "RailsPulse::Route",
17
+ period_type: "day",
18
+ period_start: this_week_start..this_week_end
19
+ )
20
+ .group("rails_pulse_summaries.summarizable_id, rails_pulse_routes.path")
21
+ .select(
22
+ "rails_pulse_summaries.summarizable_id as route_id",
23
+ "rails_pulse_routes.path",
24
+ "SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
25
+ "SUM(rails_pulse_summaries.count) as request_count",
26
+ "MAX(rails_pulse_summaries.period_end) as last_seen"
27
+ )
19
28
  .order("avg_duration DESC")
20
29
  .limit(5)
21
30
 
22
- # Get last week's data for comparison
23
- last_week_averages = RailsPulse::Request.joins(:route)
24
- .where(occurred_at: last_week_start..last_week_end)
25
- .group("rails_pulse_routes.path")
26
- .average("rails_pulse_requests.duration")
27
-
28
- # Build result array matching test expectations
29
- this_week_data.map do |record|
30
- this_week_avg = record.avg_duration.to_f.round(0)
31
- last_week_avg = last_week_averages[record.path]&.round(0) || 0
32
-
33
- # Calculate percentage change
34
- percentage_change = if last_week_avg == 0
35
- this_week_avg > 0 ? 100.0 : 0.0
36
- else
37
- ((this_week_avg - last_week_avg) / last_week_avg.to_f * 100).round(1)
38
- end
39
-
40
- # Determine trend (worse = slower response times)
41
- trend = if last_week_avg == 0
42
- this_week_avg > 0 ? "worse" : "stable"
43
- elsif this_week_avg > last_week_avg
44
- "worse" # Slower = worse
45
- elsif this_week_avg < last_week_avg
46
- "better" # Faster = better
47
- else
48
- "stable"
49
- end
50
-
31
+ # Build data rows
32
+ data_rows = route_data.map do |record|
51
33
  {
52
34
  route_path: record.path,
53
- this_week_avg: this_week_avg,
54
- last_week_avg: last_week_avg,
55
- percentage_change: percentage_change,
35
+ route_id: record.route_id,
36
+ route_link: "/rails_pulse/routes/#{record.route_id}",
37
+ average_time: record.avg_duration.to_f.round(0),
56
38
  request_count: record.request_count,
57
- trend: trend
39
+ last_request: time_ago_in_words(record.last_seen)
58
40
  }
59
41
  end
42
+
43
+ # Return new structure with columns and data
44
+ {
45
+ columns: [
46
+ { field: :route_path, label: "Route", link_to: :route_link, class: "w-auto" },
47
+ { field: :average_time, label: "Average Time", class: "w-32" },
48
+ { field: :request_count, label: "Requests", class: "w-24" },
49
+ { field: :last_request, label: "Last Request", class: "w-32" }
50
+ ],
51
+ data: data_rows
52
+ }
60
53
  end
61
54
  end
62
55
  end
@@ -34,7 +34,7 @@ module RailsPulse
34
34
  before_validation :associate_query
35
35
 
36
36
  def self.ransackable_attributes(auth_object = nil)
37
- %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type]
37
+ %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type query_id]
38
38
  end
39
39
 
40
40
  def self.ransackable_associations(auth_object = nil)
@@ -2,42 +2,66 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class AverageQueryTimes
5
- def initialize(query:)
5
+ def initialize(query: nil)
6
6
  @query = query
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- operations = if @query
11
- RailsPulse::Operation.where(query: @query)
12
- else
13
- RailsPulse::Operation.all
14
- end
15
-
16
- # Calculate overall average response time
17
- average_query_time = operations.average(:duration)&.round(0) || 0
18
-
19
- # Calculate trend by comparing last 7 days vs previous 7 days
20
10
  last_7_days = 7.days.ago.beginning_of_day
21
11
  previous_7_days = 14.days.ago.beginning_of_day
22
- current_period_avg = operations.where("occurred_at >= ?", last_7_days).average(:duration) || 0
23
- previous_period_avg = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
24
12
 
25
- percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
26
- trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
13
+ # Single query to get all aggregated metrics with conditional sums
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Query",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @query.id) if @query
20
+
21
+ metrics = base_query.select(
22
+ "SUM(avg_duration * count) AS total_weighted_duration",
23
+ "SUM(count) AS total_requests",
24
+ "SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN avg_duration * count ELSE 0 END) AS current_weighted_duration",
25
+ "SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS current_requests",
26
+ "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 avg_duration * count ELSE 0 END) AS previous_weighted_duration",
27
+ "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_requests"
28
+ ).take
29
+
30
+ # Calculate metrics from single query result
31
+ average_query_time = metrics.total_requests.to_i > 0 ? (metrics.total_weighted_duration / metrics.total_requests).round(0) : 0
32
+ current_period_avg = metrics.current_requests.to_i > 0 ? (metrics.current_weighted_duration / metrics.current_requests) : 0
33
+ previous_period_avg = metrics.previous_requests.to_i > 0 ? (metrics.previous_weighted_duration / metrics.previous_requests) : 0
34
+
35
+ percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
36
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
27
37
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
28
38
 
29
- sparkline_data = operations
30
- .group_by_week(:occurred_at, time_zone: "UTC")
31
- .average(:duration)
32
- .each_with_object({}) do |(date, avg), hash|
33
- formatted_date = date.strftime("%b %-d")
34
- value = avg&.round(0) || 0
35
- hash[formatted_date] = {
36
- value: value
37
- }
38
- end
39
+ # Sparkline data by day with zero-filled days over the last 14 days
40
+ # Use Groupdate to get grouped sums and compute weighted averages per day
41
+ grouped_weighted = base_query
42
+ .group_by_day(:period_start, time_zone: "UTC")
43
+ .sum(Arel.sql("avg_duration * count"))
44
+
45
+ grouped_counts = base_query
46
+ .group_by_day(:period_start, time_zone: "UTC")
47
+ .sum(:count)
48
+
49
+ # Build a continuous 14-day range, fill missing days with 0
50
+ start_day = 2.weeks.ago.beginning_of_day.to_date
51
+ end_day = Time.current.to_date
52
+
53
+ sparkline_data = {}
54
+ (start_day..end_day).each do |day|
55
+ weighted_sum = grouped_weighted[day] || 0
56
+ count_sum = grouped_counts[day] || 0
57
+ avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
58
+ label = day.strftime("%b %-d")
59
+ sparkline_data[label] = { value: avg }
60
+ end
39
61
 
40
62
  {
63
+ id: "average_query_times",
64
+ context: "queries",
41
65
  title: "Average Query Time",
42
66
  summary: "#{average_query_time} ms",
43
67
  line_chart_data: sparkline_data,
@@ -7,44 +7,56 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- operations = if @query
11
- RailsPulse::Operation.where(query: @query)
12
- else
13
- RailsPulse::Operation.all
14
- end
15
-
16
- # Calculate total request count
17
- total_request_count = operations.count
18
-
19
- # Calculate trend by comparing last 7 days vs previous 7 days
20
10
  last_7_days = 7.days.ago.beginning_of_day
21
11
  previous_7_days = 14.days.ago.beginning_of_day
22
- current_period_count = operations.where("occurred_at >= ?", last_7_days).count
23
- previous_period_count = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
12
+
13
+ # Single query to get all count metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Query",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @query.id) if @query
20
+
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
26
+
27
+ # Calculate metrics from single query result
28
+ total_execution_count = metrics.total_count || 0
29
+ current_period_count = metrics.current_count || 0
30
+ previous_period_count = metrics.previous_count || 0
24
31
 
25
32
  percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
26
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
27
34
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
28
35
 
29
- sparkline_data = operations
30
- .group_by_week(:occurred_at, time_zone: "UTC")
31
- .count
32
- .each_with_object({}) do |(date, count), hash|
33
- formatted_date = date.strftime("%b %-d")
34
- hash[formatted_date] = {
35
- value: count
36
- }
37
- end
38
-
39
- # Calculate average operations per minute
40
- min_time = operations.minimum(:occurred_at)
41
- max_time = operations.maximum(:occurred_at)
42
- total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
43
- average_operations_per_minute = total_request_count / total_minutes
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
+ .sum(:count)
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
+ total = grouped_daily[day] || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: total }
49
+ end
50
+
51
+ # Calculate average executions per minute over 2-week period
52
+ total_minutes = 2.weeks / 1.minute
53
+ average_executions_per_minute = total_execution_count / total_minutes
44
54
 
45
55
  {
56
+ id: "execution_rate",
57
+ context: "queries",
46
58
  title: "Execution Rate",
47
- summary: "#{average_operations_per_minute.round(2)} / min",
59
+ summary: "#{average_executions_per_minute.round(2)} / min",
48
60
  line_chart_data: sparkline_data,
49
61
  trend_icon: trend_icon,
50
62
  trend_amount: trend_amount,
@@ -7,58 +7,52 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- operations = if @query
11
- RailsPulse::Operation.where(query: @query)
12
- else
13
- RailsPulse::Operation.all
14
- end
15
-
16
- # Calculate overall 95th percentile response time
17
- count = operations.count
18
- percentile_95th = if count > 0
19
- operations.select("duration").order("duration").limit(1).offset((count * 0.95).floor).pluck(:duration).first || 0
20
- else
21
- 0
22
- end
23
-
24
- # Calculate trend by comparing last 7 days vs previous 7 days for 95th percentile
25
10
  last_7_days = 7.days.ago.beginning_of_day
26
11
  previous_7_days = 14.days.ago.beginning_of_day
27
12
 
28
- current_period = operations.where("occurred_at >= ?", last_7_days)
29
- current_count = current_period.count
30
- current_period_95th = if current_count > 0
31
- current_period.select("duration").order("duration").limit(1).offset((current_count * 0.95).floor).pluck(:duration).first || 0
32
- else
33
- 0
34
- end
13
+ # Single query to get all P95 metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Query",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @query.id) if @query
35
20
 
36
- previous_period = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days)
37
- previous_count = previous_period.count
38
- previous_period_95th = if previous_count > 0
39
- previous_period.select("duration").order("duration").limit(1).offset((previous_count * 0.95).floor).pluck(:duration).first || 0
40
- else
41
- 0
42
- end
21
+ metrics = base_query.select(
22
+ "AVG(p95_duration) AS overall_p95",
23
+ "AVG(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN p95_duration ELSE NULL END) AS current_p95",
24
+ "AVG(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 p95_duration ELSE NULL END) AS previous_p95"
25
+ ).take
26
+
27
+ # Calculate metrics from single query result
28
+ p95_query_time = (metrics.overall_p95 || 0).round(0)
29
+ current_period_p95 = metrics.current_p95 || 0
30
+ previous_period_p95 = metrics.previous_p95 || 0
31
+
32
+ percentage = previous_period_p95.zero? ? 0 : ((previous_period_p95 - current_period_p95) / previous_period_p95 * 100).abs.round(1)
33
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_p95 < previous_period_p95 ? "trending-down" : "trending-up"
34
+ trend_amount = previous_period_p95.zero? ? "0%" : "#{percentage}%"
43
35
 
44
- percentage = previous_period_95th.zero? ? 0 : ((previous_period_95th - current_period_95th) / previous_period_95th * 100).abs.round(1)
45
- trend_icon = percentage < 0.1 ? "move-right" : current_period_95th < previous_period_95th ? "trending-down" : "trending-up"
46
- trend_amount = previous_period_95th.zero? ? "0%" : "#{percentage}%"
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
+ .average(:p95_duration)
47
40
 
48
- sparkline_data = operations
49
- .group_by_week(:occurred_at, time_zone: "UTC")
50
- .average(:duration)
51
- .each_with_object({}) do |(date, avg), hash|
52
- formatted_date = date.strftime("%b %-d")
53
- value = avg&.round(0) || 0
54
- hash[formatted_date] = {
55
- value: value
56
- }
57
- end
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
58
50
 
59
51
  {
52
+ id: "percentile_query_times",
53
+ context: "queries",
60
54
  title: "95th Percentile Query Time",
61
- summary: "#{percentile_95th} ms",
55
+ summary: "#{p95_query_time} ms",
62
56
  line_chart_data: sparkline_data,
63
57
  trend_icon: trend_icon,
64
58
  trend_amount: trend_amount,