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