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
@@ -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)
@@ -47,8 +58,8 @@ module RailsPulse
47
58
 
48
59
  # Remove extra whitespace and truncate
49
60
  cleaned_sql = sql.gsub(/\s+/, " ").strip
50
- if cleaned_sql.length > 38
51
- "#{cleaned_sql[0..35]}..."
61
+ if cleaned_sql.length > 80
62
+ "#{cleaned_sql[0..79]}..."
52
63
  else
53
64
  cleaned_sql
54
65
  end
@@ -3,16 +3,28 @@ module RailsPulse
3
3
  module Tables
4
4
  class SlowRoutes
5
5
  include RailsPulse::FormattingHelper
6
+
6
7
  def to_table_data
7
8
  # Get data for this week
8
9
  this_week_start = 1.week.ago.beginning_of_week
9
10
  this_week_end = Time.current.end_of_week
10
11
 
11
- # Fetch route data for this week
12
- route_data = RailsPulse::Request.joins(:route)
13
- .where(occurred_at: this_week_start..this_week_end)
14
- .group("rails_pulse_routes.path, rails_pulse_routes.id")
15
- .select("rails_pulse_routes.path, rails_pulse_routes.id, AVG(rails_pulse_requests.duration) as avg_duration, COUNT(*) as request_count, MAX(rails_pulse_requests.occurred_at) as last_seen")
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
+ )
16
28
  .order("avg_duration DESC")
17
29
  .limit(5)
18
30
 
@@ -20,8 +32,8 @@ module RailsPulse
20
32
  data_rows = route_data.map do |record|
21
33
  {
22
34
  route_path: record.path,
23
- route_id: record.id,
24
- route_link: "/rails_pulse/routes/#{record.id}",
35
+ route_id: record.route_id,
36
+ route_link: "/rails_pulse/routes/#{record.route_id}",
25
37
  average_time: record.avg_duration.to_f.round(0),
26
38
  request_count: record.request_count,
27
39
  last_request: time_ago_in_words(record.last_seen)
@@ -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
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")
44
+
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)
37
52
  }
38
53
  end
54
+ end
55
+
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 }
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,51 @@ 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
- }
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 }
37
44
  end
38
45
 
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
46
+ # Calculate average executions per minute over 2-week period
47
+ total_minutes = 2.weeks / 1.minute
48
+ average_executions_per_minute = total_execution_count / total_minutes
44
49
 
45
50
  {
51
+ id: "execution_rate",
52
+ context: "queries",
46
53
  title: "Execution Rate",
47
- summary: "#{average_operations_per_minute.round(2)} / min",
54
+ summary: "#{average_executions_per_minute.round(2)} / min",
48
55
  line_chart_data: sparkline_data,
49
56
  trend_icon: trend_icon,
50
57
  trend_amount: trend_amount,
@@ -7,58 +7,47 @@ 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
35
-
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
43
-
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}%"
47
-
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
- }
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
20
+
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}%"
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")
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 }
57
44
  end
58
45
 
59
46
  {
47
+ id: "percentile_query_times",
48
+ context: "queries",
60
49
  title: "95th Percentile Query Time",
61
- summary: "#{percentile_95th} ms",
50
+ summary: "#{p95_query_time} ms",
62
51
  line_chart_data: sparkline_data,
63
52
  trend_icon: trend_icon,
64
53
  trend_amount: trend_amount,