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
@@ -2,109 +2,35 @@ module RailsPulse
2
2
  module Queries
3
3
  module Charts
4
4
  class AverageQueryTimes
5
- def initialize(ransack_query:, group_by: :group_by_day, query: nil)
5
+ def initialize(ransack_query:, period_type: nil, query: nil, start_time: nil, end_time: nil, start_duration: nil)
6
6
  @ransack_query = ransack_query
7
- @group_by = group_by
7
+ @period_type = period_type
8
8
  @query = query
9
+ @start_time = start_time
10
+ @end_time = end_time
11
+ @start_duration = start_duration
9
12
  end
10
13
 
11
14
  def to_rails_chart
12
- # Get actual data using existing logic
13
- actual_data = if @query
14
- @ransack_query.result(distinct: false)
15
- .public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
16
- .average(:duration)
17
- else
18
- @ransack_query.result(distinct: false)
19
- .left_joins(:operations)
20
- .public_send(
21
- @group_by,
22
- "rails_pulse_operations.occurred_at",
23
- series: true,
24
- time_zone: "UTC"
25
- )
26
- .average("rails_pulse_operations.duration")
27
- end
28
-
29
- # Create full time range and fill in missing periods
30
- fill_missing_periods(actual_data)
31
- end
32
-
33
- private
34
-
35
- def fill_missing_periods(actual_data)
36
- # Extract actual time range from ransack query conditions
37
- start_time, end_time = extract_time_range_from_ransack
38
-
39
- # Create time range based on grouping type
40
- case @group_by
41
- when :group_by_hour
42
- time_range = generate_hour_range(start_time, end_time)
43
- else # :group_by_day
44
- time_range = generate_day_range(start_time, end_time)
45
- end
46
-
47
- # Fill in all periods with zero values for missing periods
48
- time_range.each_with_object({}) do |period, result|
49
- occurred_at = period.is_a?(String) ? Time.parse(period) : period
50
- occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
51
-
52
- normalized_occurred_at =
53
- case @group_by
54
- when :group_by_hour
55
- occurred_at&.beginning_of_hour || occurred_at
56
- when :group_by_day
57
- occurred_at&.beginning_of_day || occurred_at
58
- else
59
- occurred_at
60
- end
61
-
62
- # Use actual data if available, otherwise default to 0
63
- average_duration = actual_data[period] || 0
64
- result[normalized_occurred_at.to_i] = {
65
- value: average_duration.to_f
66
- }
67
- end
68
- end
69
-
70
- def generate_day_range(start_time, end_time)
71
- (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
72
- end
73
-
74
- def generate_hour_range(start_time, end_time)
75
- current = start_time
76
- hours = []
77
- while current <= end_time
78
- hours << current
79
- current += 1.hour
80
- end
81
- hours
82
- end
83
-
84
- def extract_time_range_from_ransack
85
- # Extract time range from ransack conditions
86
- conditions = @ransack_query.conditions
87
-
88
- if @query
89
- # For specific query, look for occurred_at conditions
90
- start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
91
- end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
92
- else
93
- # For general operations, look for rails_pulse_operations_occurred_at conditions
94
- start_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "gteq" }
95
- end_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "lt" }
96
- end
97
-
98
- start_time = start_condition&.v || 2.weeks.ago
99
- end_time = end_condition&.v || Time.current
100
-
101
- # Normalize time boundaries based on grouping
102
- case @group_by
103
- when :group_by_hour
104
- [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
105
- else
106
- [ start_time.beginning_of_day, end_time.beginning_of_day ]
15
+ summaries = @ransack_query.result(distinct: false).where(
16
+ summarizable_type: "RailsPulse::Query",
17
+ period_type: @period_type
18
+ )
19
+
20
+ summaries = summaries.where(summarizable_id: @query.id) if @query
21
+ summaries = summaries
22
+ .group(:period_start)
23
+ .having("AVG(avg_duration) > ?", @start_duration || 0)
24
+ .average(:avg_duration)
25
+ .transform_keys(&:to_i)
26
+
27
+ # Pad missing data points with zeros
28
+ step = @period_type == :hour ? 1.hour : 1.day
29
+ data = {}
30
+ (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
31
+ data[timestamp.to_i] = summaries[timestamp.to_i].to_f.round(2)
107
32
  end
33
+ data
108
34
  end
109
35
  end
110
36
  end
@@ -0,0 +1,74 @@
1
+ module RailsPulse
2
+ module Queries
3
+ module Tables
4
+ class Index
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:, query: nil)
6
+ @ransack_query = ransack_query
7
+ @period_type = period_type
8
+ @start_time = start_time
9
+ @params = params
10
+ @query = query
11
+ end
12
+
13
+ def to_table
14
+ # Check if we have explicit ransack sorts
15
+ has_sorts = @ransack_query.sorts.any?
16
+
17
+ base_query = @ransack_query.result(distinct: false)
18
+ .joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
19
+ .where(
20
+ summarizable_type: "RailsPulse::Query",
21
+ period_type: @period_type
22
+ )
23
+
24
+ base_query = base_query.where(summarizable_id: @query.id) if @query
25
+
26
+ # Apply grouping and aggregation
27
+ grouped_query = base_query
28
+ .group(
29
+ "rails_pulse_summaries.summarizable_id",
30
+ "rails_pulse_summaries.summarizable_type",
31
+ "rails_pulse_queries.id",
32
+ "rails_pulse_queries.normalized_sql"
33
+ )
34
+ .select(
35
+ "rails_pulse_summaries.summarizable_id",
36
+ "rails_pulse_summaries.summarizable_type",
37
+ "rails_pulse_queries.id as query_id",
38
+ "rails_pulse_queries.normalized_sql",
39
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
40
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
41
+ "SUM(rails_pulse_summaries.count) as execution_count",
42
+ "SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration) as total_time_consumed"
43
+ )
44
+
45
+ # Apply sorting based on ransack sorts or use default
46
+ if has_sorts
47
+ # Apply custom sorting based on ransack parameters
48
+ sort = @ransack_query.sorts.first
49
+ direction = sort.dir == "desc" ? :desc : :asc
50
+
51
+ case sort.name
52
+ when "avg_duration_sort"
53
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
54
+ when "execution_count_sort"
55
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
56
+ when "total_time_consumed_sort"
57
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)").send(direction))
58
+ when "normalized_sql"
59
+ grouped_query = grouped_query.order(Arel.sql("rails_pulse_queries.normalized_sql").send(direction))
60
+ else
61
+ # Unknown sort field, fallback to default
62
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
63
+ end
64
+ else
65
+ # Apply default sort when no explicit sort is provided
66
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
67
+ end
68
+
69
+ grouped_query
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -4,10 +4,18 @@ module RailsPulse
4
4
 
5
5
  # Associations
6
6
  has_many :operations, class_name: "RailsPulse::Operation", inverse_of: :query
7
+ has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
7
8
 
8
9
  # Validations
9
10
  validates :normalized_sql, presence: true, uniqueness: true
10
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
+
11
19
  def self.ransackable_attributes(auth_object = nil)
12
20
  %w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
13
21
  end
@@ -51,6 +59,45 @@ module RailsPulse
51
59
  Arel.sql("MAX(rails_pulse_operations.occurred_at)")
52
60
  end
53
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
+
54
101
  def to_s
55
102
  id
56
103
  end
@@ -2,96 +2,35 @@ module RailsPulse
2
2
  module Requests
3
3
  module Charts
4
4
  class AverageResponseTimes
5
- def initialize(ransack_query:, group_by: :group_by_day, route: nil)
5
+ def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil)
6
6
  @ransack_query = ransack_query
7
- @group_by = group_by
7
+ @period_type = period_type
8
8
  @route = route
9
+ @start_time = start_time
10
+ @end_time = end_time
11
+ @start_duration = start_duration
9
12
  end
10
13
 
11
14
  def to_rails_chart
12
- # Get actual data using existing logic
13
- actual_data = @ransack_query.result(distinct: false)
14
- .public_send(
15
- @group_by,
16
- "rails_pulse_requests.occurred_at",
17
- series: true,
18
- time_zone: "UTC"
19
- )
20
- .average("rails_pulse_requests.duration")
21
-
22
- # Create full time range and fill in missing periods
23
- fill_missing_periods(actual_data)
24
- end
25
-
26
- private
27
-
28
- def fill_missing_periods(actual_data)
29
- # Extract actual time range from ransack query conditions
30
- start_time, end_time = extract_time_range_from_ransack
31
-
32
- # Create time range based on grouping type
33
- case @group_by
34
- when :group_by_hour
35
- time_range = generate_hour_range(start_time, end_time)
36
- else # :group_by_day
37
- time_range = generate_day_range(start_time, end_time)
38
- end
39
-
40
- # Fill in all periods with zero values for missing periods
41
- time_range.each_with_object({}) do |period, result|
42
- occurred_at = period.is_a?(String) ? Time.parse(period) : period
43
- occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
44
-
45
- normalized_occurred_at =
46
- case @group_by
47
- when :group_by_hour
48
- occurred_at&.beginning_of_hour || occurred_at
49
- when :group_by_day
50
- occurred_at&.beginning_of_day || occurred_at
51
- else
52
- occurred_at
53
- end
54
-
55
- # Use actual data if available, otherwise default to 0
56
- average_duration = actual_data[period] || 0
57
- result[normalized_occurred_at.to_i] = {
58
- value: average_duration.to_f
59
- }
60
- end
61
- end
62
-
63
- def generate_day_range(start_time, end_time)
64
- (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
65
- end
66
-
67
- def generate_hour_range(start_time, end_time)
68
- current = start_time
69
- hours = []
70
- while current <= end_time
71
- hours << current
72
- current += 1.hour
73
- end
74
- hours
75
- end
76
-
77
- def extract_time_range_from_ransack
78
- # Extract time range from ransack conditions
79
- conditions = @ransack_query.conditions
80
-
81
- # For requests, look for occurred_at conditions on rails_pulse_requests
82
- start_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "gteq" }
83
- end_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "lt" }
84
-
85
- start_time = start_condition&.v || 2.weeks.ago
86
- end_time = end_condition&.v || Time.current
87
-
88
- # Normalize time boundaries based on grouping
89
- case @group_by
90
- when :group_by_hour
91
- [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
92
- else
93
- [ start_time.beginning_of_day, end_time.beginning_of_day ]
15
+ summaries = @ransack_query.result(distinct: false).where(
16
+ summarizable_type: "RailsPulse::Route",
17
+ period_type: @period_type
18
+ )
19
+
20
+ summaries = summaries.where(summarizable_id: @route.id) if @route
21
+ summaries = summaries
22
+ .group(:period_start)
23
+ .having("AVG(avg_duration) > ?", @start_duration || 0)
24
+ .average(:avg_duration)
25
+ .transform_keys(&:to_i)
26
+
27
+ # Pad missing data points with zeros
28
+ step = @period_type == :hour ? 1.hour : 1.day
29
+ data = {}
30
+ (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
31
+ data[timestamp.to_i] = summaries[timestamp.to_i].to_f.round(2)
94
32
  end
33
+ data
95
34
  end
96
35
  end
97
36
  end
@@ -4,6 +4,7 @@ module RailsPulse
4
4
 
5
5
  # Associations
6
6
  has_many :requests, class_name: "RailsPulse::Request", foreign_key: "route_id", dependent: :restrict_with_exception
7
+ has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
7
8
 
8
9
  # Validations
9
10
  validates :method, presence: true
@@ -56,12 +57,6 @@ module RailsPulse
56
57
  Arel.sql("COUNT(rails_pulse_requests.id)")
57
58
  end
58
59
 
59
- # Remove the problematic ransacker that causes SQL syntax errors
60
- # The status_indicator will be handled differently or removed from filtering
61
- # ransacker :status_indicator do
62
- # # Removed to fix SQL generation issues in tests
63
- # end
64
-
65
60
  def to_breadcrumb
66
61
  path
67
62
  end
@@ -7,39 +7,59 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- requests = if @route
11
- RailsPulse::Request.where(route: @route)
12
- else
13
- RailsPulse::Request.all
14
- end
10
+ last_7_days = 7.days.ago.beginning_of_day
11
+ previous_7_days = 14.days.ago.beginning_of_day
15
12
 
16
- requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
13
+ # Single query to get all aggregated metrics with conditional sums
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Route",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @route.id) if @route
17
20
 
18
- # Calculate overall average response time
19
- average_response_time = requests.average(:duration)&.round(0) || 0
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
20
29
 
21
- # Calculate trend by comparing last 7 days vs previous 7 days
22
- last_7_days = 7.days.ago.beginning_of_day
23
- previous_7_days = 14.days.ago.beginning_of_day
24
- current_period_avg = requests.where("occurred_at >= ?", last_7_days).average(:duration) || 0
25
- previous_period_avg = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
30
+ # Calculate metrics from single query result
31
+ average_response_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
26
34
 
27
- percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
28
- trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
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"
29
37
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
30
38
 
31
- sparkline_data = requests
32
- .group_by_week(:occurred_at, time_zone: "UTC")
33
- .average(:duration)
34
- .each_with_object({}) do |(date, avg), hash|
35
- formatted_date = date.strftime("%b %-d")
36
- value = avg&.round(0) || 0
37
- hash[formatted_date] = {
38
- value: value
39
- }
40
- end
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"))
43
+
44
+ grouped_counts = base_query
45
+ .group_by_day(:period_start, time_zone: "UTC")
46
+ .sum(:count)
47
+
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 }
58
+ end
41
59
 
42
60
  {
61
+ id: "average_response_times",
62
+ context: "routes",
43
63
  title: "Average Response Time",
44
64
  summary: "#{average_response_time} ms",
45
65
  line_chart_data: sparkline_data,
@@ -7,60 +7,58 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- # Calculate error rate for each route or a specific route
11
- routes = if @route
12
- RailsPulse::Route.where(id: @route)
13
- else
14
- RailsPulse::Route.all
15
- end
10
+ last_7_days = 7.days.ago.beginning_of_day
11
+ previous_7_days = 14.days.ago.beginning_of_day
16
12
 
17
- routes = routes.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
13
+ # Single query to get all error metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Route",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @route.id) if @route
18
20
 
19
- error_rates = routes.joins(:requests)
20
- .select("rails_pulse_routes.id, rails_pulse_routes.path, COUNT(rails_pulse_requests.id) as total_requests, SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END) as error_count")
21
- .group("rails_pulse_routes.id, rails_pulse_routes.path")
22
- .map do |route|
23
- error_rate = route.error_count.to_f / route.total_requests * 100
24
- {
25
- path: route.path,
26
- error_rate: error_rate.round(2)
27
- }
28
- end
21
+ metrics = base_query.select(
22
+ "SUM(error_count) AS total_errors",
23
+ "SUM(count) AS total_requests",
24
+ "SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN error_count ELSE 0 END) AS current_errors",
25
+ "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 error_count ELSE 0 END) AS previous_errors"
26
+ ).take
29
27
 
30
- # Calculate overall error rate summary as errors per day
31
- requests = @route ? RailsPulse::Request.where(route: @route) : RailsPulse::Request.all
32
- total_errors = requests.where(is_error: true).count
33
- min_time = requests.minimum(:occurred_at)
34
- max_time = requests.maximum(:occurred_at)
35
- total_days = min_time && max_time && min_time != max_time ? (max_time - min_time) / 1.day : 1
36
- errors_per_day = total_errors / total_days
37
- error_rate_summary = "#{errors_per_day.round(2)} / day"
28
+ # Calculate metrics from single query result
29
+ total_errors = metrics.total_errors || 0
30
+ total_requests = metrics.total_requests || 0
31
+ current_period_errors = metrics.current_errors || 0
32
+ previous_period_errors = metrics.previous_errors || 0
38
33
 
39
- # Generate sparkline data
40
- sparkline_data = requests
41
- .where(is_error: true)
42
- .group_by_week(:occurred_at, time_zone: "UTC")
43
- .count
44
- .each_with_object({}) do |(date, count), hash|
45
- formatted_date = date.strftime("%b %-d")
46
- hash[formatted_date] = {
47
- value: count
48
- }
49
- end
34
+ # Calculate overall error rate percentage
35
+ overall_error_rate = total_requests > 0 ? (total_errors.to_f / total_requests * 100).round(2) : 0
50
36
 
51
- # Determine trend direction and amount
52
- last_7_days = 7.days.ago.beginning_of_day
53
- previous_7_days = 14.days.ago.beginning_of_day
54
- current_period_errors = requests.where("occurred_at >= ? AND is_error = ?", last_7_days, true).count
55
- previous_period_errors = requests.where("occurred_at >= ? AND occurred_at < ? AND is_error = ?", previous_7_days, last_7_days, true).count
37
+ # Calculate trend
38
+ percentage = previous_period_errors.zero? ? 0 : ((previous_period_errors - current_period_errors) / previous_period_errors.to_f * 100).abs.round(1)
39
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
40
+ trend_amount = previous_period_errors.zero? ? "0%" : "#{percentage}%"
41
+
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
+ .sum(:error_count)
56
46
 
57
- trend_amount = previous_period_errors.zero? ? "0%" : "#{((current_period_errors - previous_period_errors) / previous_period_errors.to_f * 100).round(1)}%"
58
- trend_icon = trend_amount.to_f < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
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
59
56
 
60
57
  {
58
+ id: "error_rate_per_route",
59
+ context: "routes",
61
60
  title: "Error Rate Per Route",
62
- data: error_rates,
63
- summary: error_rate_summary,
61
+ summary: "#{overall_error_rate}%",
64
62
  line_chart_data: sparkline_data,
65
63
  trend_icon: trend_icon,
66
64
  trend_amount: trend_amount,