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.
- checksums.yaml +4 -4
- data/README.md +79 -177
- data/Rakefile +77 -2
- 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 -17
- 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/chart_table_concern.rb +21 -4
- data/app/controllers/concerns/response_range_concern.rb +6 -3
- data/app/controllers/concerns/time_range_concern.rb +5 -10
- data/app/controllers/concerns/zoom_range_concern.rb +32 -1
- data/app/controllers/rails_pulse/application_controller.rb +13 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
- data/app/controllers/rails_pulse/queries_controller.rb +111 -51
- data/app/controllers/rails_pulse/requests_controller.rb +37 -12
- data/app/controllers/rails_pulse/routes_controller.rb +98 -24
- data/app/helpers/rails_pulse/application_helper.rb +0 -1
- data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
- data/app/helpers/rails_pulse/chart_helper.rb +21 -9
- data/app/helpers/rails_pulse/status_helper.rb +10 -4
- 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 +353 -39
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
- 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/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
- data/app/jobs/rails_pulse/summary_job.rb +53 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
- data/app/models/rails_pulse/operation.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
- data/app/models/rails_pulse/queries/tables/index.rb +74 -0
- data/app/models/rails_pulse/query.rb +47 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
- data/app/models/rails_pulse/route.rb +1 -6
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
- data/app/models/rails_pulse/routes/tables/index.rb +57 -40
- data/app/models/rails_pulse/summary.rb +143 -0
- 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 +146 -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/services/rails_pulse/summary_service.rb +199 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +4 -6
- 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 +11 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
- 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 +55 -37
- 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 +87 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
- data/app/views/rails_pulse/queries/_table.html.erb +11 -13
- data/app/views/rails_pulse/queries/index.html.erb +32 -28
- data/app/views/rails_pulse/queries/show.html.erb +45 -34
- data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
- data/app/views/rails_pulse/requests/_table.html.erb +3 -3
- data/app/views/rails_pulse/requests/index.html.erb +33 -28
- data/app/views/rails_pulse/routes/_table.html.erb +14 -14
- data/app/views/rails_pulse/routes/index.html.erb +34 -29
- data/app/views/rails_pulse/routes/show.html.erb +43 -36
- data/config/initializers/rails_pulse.rb +0 -12
- data/config/routes.rb +5 -1
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +130 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +94 -4
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
- data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/configuration.rb +0 -11
- data/lib/rails_pulse/engine.rb +0 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +77 -0
- 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
- data/public/rails-pulse-assets/search.svg +43 -0
- metadata +48 -14
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/controllers/rails_pulse/caches_controller.rb +0 -115
- data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
- data/app/models/rails_pulse/component_cache_key.rb +0 -33
- data/app/views/rails_pulse/caches/show.html.erb +0 -9
- data/db/migrate/20250227235904_create_routes.rb +0 -12
- data/db/migrate/20250227235915_create_requests.rb +0 -19
- data/db/migrate/20250228000000_create_queries.rb +0 -14
- data/db/migrate/20250228000056_create_operations.rb +0 -24
- data/lib/rails_pulse/migration.rb +0 -29
@@ -0,0 +1,217 @@
|
|
1
|
+
# Consolidates analysis results into prioritized, actionable optimization suggestions.
|
2
|
+
# Combines insights from all analyzers and categorizes suggestions by impact and implementation complexity.
|
3
|
+
module RailsPulse
|
4
|
+
module Analysis
|
5
|
+
class SuggestionGenerator
|
6
|
+
attr_reader :analysis_results
|
7
|
+
|
8
|
+
def initialize(analysis_results)
|
9
|
+
@analysis_results = analysis_results
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate
|
13
|
+
suggestions = []
|
14
|
+
|
15
|
+
# Suggestions from pattern issues
|
16
|
+
suggestions.concat(generate_issue_suggestions)
|
17
|
+
|
18
|
+
# Suggestions from index recommendations
|
19
|
+
suggestions.concat(generate_index_suggestions)
|
20
|
+
|
21
|
+
# Suggestions from N+1 analysis
|
22
|
+
suggestions.concat(generate_n_plus_one_suggestions)
|
23
|
+
|
24
|
+
# Suggestions from query characteristics
|
25
|
+
suggestions.concat(generate_query_characteristic_suggestions)
|
26
|
+
|
27
|
+
# Suggestions from explain plan issues
|
28
|
+
suggestions.concat(generate_explain_plan_suggestions)
|
29
|
+
|
30
|
+
# Prioritize and deduplicate suggestions
|
31
|
+
prioritize_suggestions(suggestions)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def generate_issue_suggestions
|
37
|
+
issues = analysis_results.dig(:query_characteristics, :pattern_issues) || []
|
38
|
+
|
39
|
+
issues.map do |issue|
|
40
|
+
case issue[:type]
|
41
|
+
when "select_star"
|
42
|
+
{
|
43
|
+
type: "optimization",
|
44
|
+
action: "Specify only needed columns instead of SELECT *",
|
45
|
+
benefit: "Reduced memory usage and faster data transfer",
|
46
|
+
priority: "medium",
|
47
|
+
category: "sql_optimization"
|
48
|
+
}
|
49
|
+
when "missing_limit"
|
50
|
+
{
|
51
|
+
type: "optimization",
|
52
|
+
action: "Add LIMIT clause to prevent large result sets",
|
53
|
+
benefit: "Controlled memory usage and faster response times",
|
54
|
+
priority: "high",
|
55
|
+
category: "sql_optimization"
|
56
|
+
}
|
57
|
+
when "missing_where_clause"
|
58
|
+
{
|
59
|
+
type: "optimization",
|
60
|
+
action: "Add WHERE clause to filter results",
|
61
|
+
benefit: "Avoid full table scans and reduce data transfer",
|
62
|
+
priority: "high",
|
63
|
+
category: "sql_optimization"
|
64
|
+
}
|
65
|
+
when "complex_where_clause"
|
66
|
+
{
|
67
|
+
type: "refactoring",
|
68
|
+
action: "Simplify WHERE clause by breaking into multiple queries or using views",
|
69
|
+
benefit: "Easier maintenance and potentially better performance",
|
70
|
+
priority: "medium",
|
71
|
+
category: "code_quality"
|
72
|
+
}
|
73
|
+
end
|
74
|
+
end.compact
|
75
|
+
end
|
76
|
+
|
77
|
+
def generate_index_suggestions
|
78
|
+
recommendations = analysis_results[:index_recommendations] || []
|
79
|
+
|
80
|
+
recommendations.map do |rec|
|
81
|
+
{
|
82
|
+
type: "index",
|
83
|
+
action: "Add #{rec[:type]} index: #{rec[:migration_code]}",
|
84
|
+
benefit: rec[:estimated_benefit],
|
85
|
+
priority: rec[:priority],
|
86
|
+
migration_code: rec[:migration_code],
|
87
|
+
table: rec[:table],
|
88
|
+
columns: rec[:columns],
|
89
|
+
category: "database_optimization"
|
90
|
+
}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def generate_n_plus_one_suggestions
|
95
|
+
n_plus_one = analysis_results[:n_plus_one_analysis] || {}
|
96
|
+
return [] unless n_plus_one[:is_likely_n_plus_one]
|
97
|
+
|
98
|
+
suggestions = n_plus_one[:suggested_fixes] || []
|
99
|
+
|
100
|
+
suggestions.map do |fix|
|
101
|
+
{
|
102
|
+
type: "n_plus_one",
|
103
|
+
action: fix[:description],
|
104
|
+
benefit: "Eliminate N+1 queries and reduce database load",
|
105
|
+
priority: "high",
|
106
|
+
code_example: fix[:code_example],
|
107
|
+
confidence: n_plus_one[:confidence_score],
|
108
|
+
category: "performance_critical"
|
109
|
+
}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def generate_query_characteristic_suggestions
|
114
|
+
stats = analysis_results[:query_characteristics] || {}
|
115
|
+
suggestions = []
|
116
|
+
|
117
|
+
if stats[:join_count] && stats[:join_count] > 3
|
118
|
+
suggestions << {
|
119
|
+
type: "optimization",
|
120
|
+
action: "Review if all #{stats[:join_count]} JOINs are necessary",
|
121
|
+
benefit: "Simplified query execution and better performance",
|
122
|
+
priority: "medium",
|
123
|
+
category: "sql_optimization"
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
if stats[:estimated_complexity] && stats[:estimated_complexity] > 10
|
128
|
+
suggestions << {
|
129
|
+
type: "refactoring",
|
130
|
+
action: "Consider breaking complex query (complexity: #{stats[:estimated_complexity]}) into smaller parts",
|
131
|
+
benefit: "Easier maintenance and potentially better performance",
|
132
|
+
priority: "medium",
|
133
|
+
category: "code_quality"
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
if stats[:has_subqueries] && stats[:join_count] && stats[:join_count] > 1
|
138
|
+
suggestions << {
|
139
|
+
type: "optimization",
|
140
|
+
action: "Consider converting subqueries to JOINs for better performance",
|
141
|
+
benefit: "More efficient query execution in most databases",
|
142
|
+
priority: "medium",
|
143
|
+
category: "sql_optimization"
|
144
|
+
}
|
145
|
+
end
|
146
|
+
|
147
|
+
suggestions
|
148
|
+
end
|
149
|
+
|
150
|
+
def generate_explain_plan_suggestions
|
151
|
+
explain_issues = analysis_results.dig(:explain_plan, :issues) || []
|
152
|
+
|
153
|
+
explain_issues.map do |issue|
|
154
|
+
case issue[:type]
|
155
|
+
when "sequential_scan"
|
156
|
+
{
|
157
|
+
type: "index",
|
158
|
+
action: "Consider adding database indexes for WHERE clause columns",
|
159
|
+
benefit: "Dramatically faster query execution",
|
160
|
+
priority: "high",
|
161
|
+
category: "database_optimization"
|
162
|
+
}
|
163
|
+
when "temporary_table"
|
164
|
+
{
|
165
|
+
type: "optimization",
|
166
|
+
action: "Optimize query to avoid temporary tables and filesort operations",
|
167
|
+
benefit: "Reduced memory usage and faster execution",
|
168
|
+
priority: "medium",
|
169
|
+
category: "sql_optimization"
|
170
|
+
}
|
171
|
+
when "high_cost_operation"
|
172
|
+
{
|
173
|
+
type: "optimization",
|
174
|
+
action: "Review query execution plan for high-cost operations",
|
175
|
+
benefit: "Identify specific bottlenecks for targeted optimization",
|
176
|
+
priority: "high",
|
177
|
+
category: "performance_critical"
|
178
|
+
}
|
179
|
+
when "where_without_index"
|
180
|
+
{
|
181
|
+
type: "index",
|
182
|
+
action: "Add indexes to support WHERE clause conditions",
|
183
|
+
benefit: "Eliminate row-by-row filtering during query execution",
|
184
|
+
priority: "high",
|
185
|
+
category: "database_optimization"
|
186
|
+
}
|
187
|
+
end
|
188
|
+
end.compact
|
189
|
+
end
|
190
|
+
|
191
|
+
def prioritize_suggestions(suggestions)
|
192
|
+
# Remove duplicates based on action
|
193
|
+
unique_suggestions = suggestions.uniq { |s| s[:action] }
|
194
|
+
|
195
|
+
# Sort by priority and category
|
196
|
+
unique_suggestions.sort_by do |suggestion|
|
197
|
+
priority_score = case suggestion[:priority]
|
198
|
+
when "high" then 3
|
199
|
+
when "medium" then 2
|
200
|
+
when "low" then 1
|
201
|
+
else 0
|
202
|
+
end
|
203
|
+
|
204
|
+
category_score = case suggestion[:category]
|
205
|
+
when "performance_critical" then 4
|
206
|
+
when "database_optimization" then 3
|
207
|
+
when "sql_optimization" then 2
|
208
|
+
when "code_quality" then 1
|
209
|
+
else 0
|
210
|
+
end
|
211
|
+
|
212
|
+
[ -priority_score, -category_score, suggestion[:action] ]
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# Orchestrates comprehensive query analysis using modular analyzers.
|
2
|
+
# Coordinates multiple specialized analyzers and consolidates results into actionable insights.
|
3
|
+
module RailsPulse
|
4
|
+
class QueryAnalysisService
|
5
|
+
def self.analyze_query(query_id)
|
6
|
+
query = RailsPulse::Query.find(query_id)
|
7
|
+
new(query).analyze
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(query)
|
11
|
+
@query = query
|
12
|
+
@operations = fetch_recent_operations
|
13
|
+
end
|
14
|
+
|
15
|
+
def analyze
|
16
|
+
# Run all analyzers
|
17
|
+
results = {
|
18
|
+
analyzed_at: Time.current,
|
19
|
+
query_characteristics: analyze_query_characteristics,
|
20
|
+
index_recommendations: analyze_index_recommendations,
|
21
|
+
n_plus_one_analysis: analyze_n_plus_one,
|
22
|
+
explain_plan: analyze_explain_plan,
|
23
|
+
backtrace_analysis: analyze_backtraces
|
24
|
+
}
|
25
|
+
|
26
|
+
# Generate consolidated suggestions
|
27
|
+
results[:suggestions] = generate_suggestions(results)
|
28
|
+
|
29
|
+
# Build compatible format for query model
|
30
|
+
compatible_results = build_compatible_results(results)
|
31
|
+
|
32
|
+
# Save results to query
|
33
|
+
save_results_to_query(compatible_results)
|
34
|
+
|
35
|
+
results
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def fetch_recent_operations
|
41
|
+
@query.operations
|
42
|
+
.where("occurred_at > ?", 48.hours.ago)
|
43
|
+
.order(occurred_at: :desc)
|
44
|
+
.limit(50)
|
45
|
+
end
|
46
|
+
|
47
|
+
def analyze_query_characteristics
|
48
|
+
Analysis::QueryCharacteristicsAnalyzer.new(@query, @operations).analyze
|
49
|
+
end
|
50
|
+
|
51
|
+
def analyze_index_recommendations
|
52
|
+
Analysis::IndexRecommendationEngine.new(@query, @operations).analyze
|
53
|
+
end
|
54
|
+
|
55
|
+
def analyze_n_plus_one
|
56
|
+
Analysis::NPlusOneDetector.new(@query, @operations).analyze
|
57
|
+
end
|
58
|
+
|
59
|
+
def analyze_explain_plan
|
60
|
+
return { explain_plan: nil, issues: [] } if @operations.empty?
|
61
|
+
Analysis::ExplainPlanAnalyzer.new(@query, @operations).analyze
|
62
|
+
end
|
63
|
+
|
64
|
+
def analyze_backtraces
|
65
|
+
return {} if @operations.empty?
|
66
|
+
Analysis::BacktraceAnalyzer.new(@query, @operations).analyze
|
67
|
+
end
|
68
|
+
|
69
|
+
def generate_suggestions(analysis_results)
|
70
|
+
Analysis::SuggestionGenerator.new(analysis_results).generate
|
71
|
+
end
|
72
|
+
|
73
|
+
# Build compatible format for query model storage
|
74
|
+
def build_compatible_results(results)
|
75
|
+
characteristics = results[:query_characteristics]
|
76
|
+
explain_result = results[:explain_plan]
|
77
|
+
|
78
|
+
{
|
79
|
+
analyzed_at: results[:analyzed_at],
|
80
|
+
explain_plan: explain_result[:explain_plan],
|
81
|
+
issues: extract_all_issues(characteristics, explain_result),
|
82
|
+
metadata: build_metadata(results),
|
83
|
+
query_stats: extract_query_stats(characteristics),
|
84
|
+
backtrace_analysis: results[:backtrace_analysis],
|
85
|
+
index_recommendations: results[:index_recommendations],
|
86
|
+
n_plus_one_analysis: results[:n_plus_one_analysis],
|
87
|
+
suggestions: results[:suggestions]
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
def extract_all_issues(characteristics, explain_result)
|
92
|
+
issues = []
|
93
|
+
issues.concat(characteristics[:pattern_issues] || [])
|
94
|
+
issues.concat(explain_result[:issues] || [])
|
95
|
+
issues
|
96
|
+
end
|
97
|
+
|
98
|
+
def extract_query_stats(characteristics)
|
99
|
+
characteristics.except(:pattern_issues)
|
100
|
+
end
|
101
|
+
|
102
|
+
def build_metadata(results)
|
103
|
+
{
|
104
|
+
analyzers_used: results.keys.reject { |k| k.in?([ :analyzed_at, :suggestions ]) },
|
105
|
+
analysis_version: "2.0",
|
106
|
+
total_recommendations: results[:index_recommendations]&.count || 0,
|
107
|
+
n_plus_one_detected: results.dig(:n_plus_one_analysis, :is_likely_n_plus_one) || false
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
def save_results_to_query(results)
|
112
|
+
@query.update!(
|
113
|
+
analyzed_at: results[:analyzed_at],
|
114
|
+
explain_plan: results[:explain_plan],
|
115
|
+
issues: results[:issues],
|
116
|
+
metadata: results[:metadata],
|
117
|
+
query_stats: results[:query_stats],
|
118
|
+
backtrace_analysis: results[:backtrace_analysis],
|
119
|
+
index_recommendations: results[:index_recommendations],
|
120
|
+
n_plus_one_analysis: results[:n_plus_one_analysis],
|
121
|
+
suggestions: results[:suggestions]
|
122
|
+
)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
|
2
|
+
module RailsPulse
|
3
|
+
class SummaryService
|
4
|
+
attr_reader :period_type, :start_time, :end_time
|
5
|
+
|
6
|
+
def initialize(period_type, start_time)
|
7
|
+
@period_type = period_type
|
8
|
+
@start_time = Summary.normalize_period_start(period_type, start_time)
|
9
|
+
@end_time = Summary.calculate_period_end(period_type, @start_time)
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform
|
13
|
+
Rails.logger.info "[RailsPulse] Starting #{period_type} summary for #{start_time}"
|
14
|
+
|
15
|
+
ActiveRecord::Base.transaction do
|
16
|
+
aggregate_requests # Overall system metrics
|
17
|
+
aggregate_routes # Per-route metrics
|
18
|
+
aggregate_queries # Per-query metrics
|
19
|
+
end
|
20
|
+
|
21
|
+
Rails.logger.info "[RailsPulse] Completed #{period_type} summary"
|
22
|
+
rescue => e
|
23
|
+
Rails.logger.error "[RailsPulse] Summary failed: #{e.message}"
|
24
|
+
raise
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def aggregate_requests
|
30
|
+
# Create a single summary for ALL requests in this period
|
31
|
+
requests = Request.where(occurred_at: start_time...end_time)
|
32
|
+
|
33
|
+
return if requests.empty?
|
34
|
+
|
35
|
+
# Get all durations and statuses for percentile calculations
|
36
|
+
request_data = requests.pluck(:duration, :status)
|
37
|
+
durations = request_data.map(&:first).compact.sort
|
38
|
+
statuses = request_data.map(&:second)
|
39
|
+
|
40
|
+
# Find or create the overall request summary
|
41
|
+
summary = Summary.find_or_initialize_by(
|
42
|
+
summarizable_type: "RailsPulse::Request",
|
43
|
+
summarizable_id: 0, # Use 0 as a special ID for overall summaries
|
44
|
+
period_type: period_type,
|
45
|
+
period_start: start_time
|
46
|
+
)
|
47
|
+
|
48
|
+
summary.assign_attributes(
|
49
|
+
period_end: end_time,
|
50
|
+
count: durations.size,
|
51
|
+
avg_duration: durations.any? ? durations.sum.to_f / durations.size : 0,
|
52
|
+
min_duration: durations.min,
|
53
|
+
max_duration: durations.max,
|
54
|
+
total_duration: durations.sum,
|
55
|
+
p50_duration: calculate_percentile(durations, 0.5),
|
56
|
+
p95_duration: calculate_percentile(durations, 0.95),
|
57
|
+
p99_duration: calculate_percentile(durations, 0.99),
|
58
|
+
stddev_duration: calculate_stddev(durations, durations.sum.to_f / durations.size),
|
59
|
+
error_count: statuses.count { |s| s >= 400 },
|
60
|
+
success_count: statuses.count { |s| s < 400 },
|
61
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
62
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
63
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
64
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
65
|
+
)
|
66
|
+
|
67
|
+
summary.save!
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def aggregate_routes
|
73
|
+
# Use ActiveRecord for cross-database compatibility
|
74
|
+
route_groups = Request
|
75
|
+
.where(occurred_at: start_time...end_time)
|
76
|
+
.where.not(route_id: nil)
|
77
|
+
.group(:route_id)
|
78
|
+
|
79
|
+
# Calculate basic aggregates
|
80
|
+
basic_stats = route_groups.pluck(
|
81
|
+
:route_id,
|
82
|
+
Arel.sql("COUNT(*) as request_count"),
|
83
|
+
Arel.sql("AVG(duration) as avg_duration"),
|
84
|
+
Arel.sql("MIN(duration) as min_duration"),
|
85
|
+
Arel.sql("MAX(duration) as max_duration"),
|
86
|
+
Arel.sql("SUM(duration) as total_duration")
|
87
|
+
)
|
88
|
+
|
89
|
+
basic_stats.each do |stats|
|
90
|
+
route_id = stats[0]
|
91
|
+
|
92
|
+
# Calculate percentiles and status counts separately for cross-DB compatibility
|
93
|
+
durations = Request
|
94
|
+
.where(occurred_at: start_time...end_time)
|
95
|
+
.where(route_id: route_id)
|
96
|
+
.pluck(:duration, :status)
|
97
|
+
|
98
|
+
sorted_durations = durations.map(&:first).compact.sort
|
99
|
+
statuses = durations.map(&:last)
|
100
|
+
|
101
|
+
summary = Summary.find_or_initialize_by(
|
102
|
+
summarizable_type: "RailsPulse::Route",
|
103
|
+
summarizable_id: route_id,
|
104
|
+
period_type: period_type,
|
105
|
+
period_start: start_time
|
106
|
+
)
|
107
|
+
|
108
|
+
summary.assign_attributes(
|
109
|
+
period_end: end_time,
|
110
|
+
count: stats[1],
|
111
|
+
avg_duration: stats[2],
|
112
|
+
min_duration: stats[3],
|
113
|
+
max_duration: stats[4],
|
114
|
+
total_duration: stats[5],
|
115
|
+
p50_duration: calculate_percentile(sorted_durations, 0.5),
|
116
|
+
p95_duration: calculate_percentile(sorted_durations, 0.95),
|
117
|
+
p99_duration: calculate_percentile(sorted_durations, 0.99),
|
118
|
+
stddev_duration: calculate_stddev(sorted_durations, stats[2]),
|
119
|
+
error_count: statuses.count { |s| s >= 400 },
|
120
|
+
success_count: statuses.count { |s| s < 400 },
|
121
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
122
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
123
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
124
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
125
|
+
)
|
126
|
+
|
127
|
+
summary.save!
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def aggregate_queries
|
132
|
+
query_groups = Operation
|
133
|
+
.where(occurred_at: start_time...end_time)
|
134
|
+
.where.not(query_id: nil)
|
135
|
+
.group(:query_id)
|
136
|
+
|
137
|
+
basic_stats = query_groups.pluck(
|
138
|
+
:query_id,
|
139
|
+
Arel.sql("COUNT(*) as execution_count"),
|
140
|
+
Arel.sql("AVG(duration) as avg_duration"),
|
141
|
+
Arel.sql("MIN(duration) as min_duration"),
|
142
|
+
Arel.sql("MAX(duration) as max_duration"),
|
143
|
+
Arel.sql("SUM(duration) as total_duration")
|
144
|
+
)
|
145
|
+
|
146
|
+
basic_stats.each do |stats|
|
147
|
+
query_id = stats[0]
|
148
|
+
|
149
|
+
# Calculate percentiles separately
|
150
|
+
durations = Operation
|
151
|
+
.where(occurred_at: start_time...end_time)
|
152
|
+
.where(query_id: query_id)
|
153
|
+
.pluck(:duration)
|
154
|
+
.compact
|
155
|
+
.sort
|
156
|
+
|
157
|
+
summary = Summary.find_or_initialize_by(
|
158
|
+
summarizable_type: "RailsPulse::Query",
|
159
|
+
summarizable_id: query_id,
|
160
|
+
period_type: period_type,
|
161
|
+
period_start: start_time
|
162
|
+
)
|
163
|
+
|
164
|
+
summary.assign_attributes(
|
165
|
+
period_end: end_time,
|
166
|
+
count: stats[1],
|
167
|
+
avg_duration: stats[2],
|
168
|
+
min_duration: stats[3],
|
169
|
+
max_duration: stats[4],
|
170
|
+
total_duration: stats[5],
|
171
|
+
p50_duration: calculate_percentile(durations, 0.5),
|
172
|
+
p95_duration: calculate_percentile(durations, 0.95),
|
173
|
+
p99_duration: calculate_percentile(durations, 0.99),
|
174
|
+
stddev_duration: calculate_stddev(durations, stats[2])
|
175
|
+
)
|
176
|
+
|
177
|
+
summary.save!
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def calculate_percentile(sorted_array, percentile)
|
182
|
+
return nil if sorted_array.empty?
|
183
|
+
|
184
|
+
k = (percentile * (sorted_array.length - 1)).floor
|
185
|
+
f = (percentile * (sorted_array.length - 1)) - k
|
186
|
+
|
187
|
+
return sorted_array[k] if f == 0 || k + 1 >= sorted_array.length
|
188
|
+
|
189
|
+
sorted_array[k] + (sorted_array[k + 1] - sorted_array[k]) * f
|
190
|
+
end
|
191
|
+
|
192
|
+
def calculate_stddev(values, mean)
|
193
|
+
return nil if values.empty? || values.size == 1
|
194
|
+
|
195
|
+
sum_of_squares = values.sum { |v| (v - mean) ** 2 }
|
196
|
+
Math.sqrt(sum_of_squares / (values.size - 1))
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
@@ -22,18 +22,16 @@
|
|
22
22
|
<div class="hide@md" data-controller="rails-pulse--dialog">
|
23
23
|
<button type="button" class="btn btn--icon" data-action="rails-pulse--dialog#showModal">
|
24
24
|
<%= rails_pulse_icon 'menu', width: '20' %>
|
25
|
-
<span class="sr-only">Open menu</span>
|
26
25
|
</button>
|
27
26
|
|
28
27
|
<dialog class="sheet sheet--left" style="--sheet-size: 288px;" data-rails-pulse--dialog-target="menu" data-action="click->rails-pulse--dialog#closeOnClickOutside">
|
29
28
|
<div class="sheet__content p-2">
|
30
29
|
<div class="sidebar-menu">
|
31
|
-
|
30
|
+
<%= link_to rails_pulse.root_path, class: "btn sidebar-menu__button" do %>
|
32
31
|
<div class="flex flex-col text-start leading-tight overflow-hidden">
|
33
32
|
<span class="overflow-ellipsis font-semibold">Rails Pulse</span>
|
34
|
-
<span class="overflow-ellipsis text-xs">Open Source</span>
|
35
33
|
</div>
|
36
|
-
|
34
|
+
<% end %>
|
37
35
|
<div class="sidebar-menu__content">
|
38
36
|
<div class="sidebar-menu__group">
|
39
37
|
<nav class="sidebar-menu__items">
|
@@ -47,9 +45,9 @@
|
|
47
45
|
</div>
|
48
46
|
|
49
47
|
<div class="flex items-center gap">
|
50
|
-
|
48
|
+
<%= link_to rails_pulse.root_path, class: "flex items-center gap mie-2" do %>
|
51
49
|
<span class="font-bold">Rails Pulse</span>
|
52
|
-
|
50
|
+
<% end %>
|
53
51
|
<nav class="flex items-center gap text-sm text-subtle show@md" style="--column-gap: 1rem">
|
54
52
|
<%= render 'layouts/rails_pulse/menu_items' %>
|
55
53
|
</nav>
|
@@ -1,4 +1,4 @@
|
|
1
|
-
<nav class="breadcrumb" aria-label="Breadcrumb">
|
1
|
+
<nav class="breadcrumb mis-2" aria-label="Breadcrumb">
|
2
2
|
<% breadcrumbs.each_with_index do |crumb, index| %>
|
3
3
|
<% if crumb[:current] %>
|
4
4
|
<span class="text-primary" aria-disabled="true" aria-current="page" role="link"><%= crumb[:title] %></span>
|
@@ -2,11 +2,25 @@
|
|
2
2
|
title ||= nil
|
3
3
|
%>
|
4
4
|
|
5
|
-
<div
|
5
|
+
<div
|
6
|
+
data-controller="rails-pulse--collapsible"
|
7
|
+
data-rails-pulse--collapsible-collapsed-class="collapsed"
|
8
|
+
class="collapsible-code"
|
9
|
+
>
|
6
10
|
<% if title %>
|
7
|
-
<h2 class="grow font-semibold leading-none mbe-1 uppercase text-xs"
|
11
|
+
<h2 class="grow font-semibold leading-none mbe-1 uppercase text-xs">
|
12
|
+
<%= title %>
|
13
|
+
<button
|
14
|
+
type="button"
|
15
|
+
class="collapsible-toggle"
|
16
|
+
data-rails-pulse--collapsible-target="toggle"
|
17
|
+
data-action="click->rails-pulse--collapsible#toggle"
|
18
|
+
>
|
19
|
+
show more
|
20
|
+
</button>
|
21
|
+
</h2>
|
8
22
|
<% end %>
|
9
|
-
<div class="prose max-i-none">
|
23
|
+
<div class="prose max-i-none" data-rails-pulse--collapsible-target="content">
|
10
24
|
<pre class="mbs-0" style="white-space: normal; margin-block-end: 0; margin-block-start: 0;"><code><%= html_escape(value) %></code></pre>
|
11
25
|
</div>
|
12
26
|
</div>
|
@@ -0,0 +1,11 @@
|
|
1
|
+
<div class="flex items-center justify-center pbs-12 pbe-12 pis-6 pie-6 mb-8">
|
2
|
+
<div class="flex items-center gap">
|
3
|
+
<div class="shrink-0">
|
4
|
+
<img src="<%= asset_path('search.svg') %>" class="w-48 h-48" alt="No data available" />
|
5
|
+
</div>
|
6
|
+
<div class="text-subtle pis-8">
|
7
|
+
<p class="text-lg font-semibold mbe-2"><%= title %></p>
|
8
|
+
<p class="text-sm"><%= description %></p>
|
9
|
+
</div>
|
10
|
+
</div>
|
11
|
+
</div>
|