pg_reports 0.4.0 → 0.5.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +129 -4
  4. data/app/controllers/pg_reports/dashboard_controller.rb +188 -25
  5. data/app/views/layouts/pg_reports/application.html.erb +282 -0
  6. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +184 -23
  7. data/app/views/pg_reports/dashboard/_show_styles.html.erb +373 -0
  8. data/app/views/pg_reports/dashboard/index.html.erb +419 -0
  9. data/config/locales/en.yml +45 -0
  10. data/config/locales/ru.yml +45 -0
  11. data/config/routes.rb +8 -0
  12. data/lib/pg_reports/configuration.rb +13 -0
  13. data/lib/pg_reports/dashboard/reports_registry.rb +24 -1
  14. data/lib/pg_reports/definitions/connections/connection_churn.yml +49 -0
  15. data/lib/pg_reports/definitions/connections/pool_saturation.yml +42 -0
  16. data/lib/pg_reports/definitions/connections/pool_usage.yml +43 -0
  17. data/lib/pg_reports/definitions/connections/pool_wait_times.yml +44 -0
  18. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +3 -3
  19. data/lib/pg_reports/explain_analyzer.rb +338 -0
  20. data/lib/pg_reports/modules/schema_analysis.rb +4 -6
  21. data/lib/pg_reports/modules/system.rb +19 -2
  22. data/lib/pg_reports/query_monitor.rb +280 -0
  23. data/lib/pg_reports/sql/connections/connection_churn.sql +37 -0
  24. data/lib/pg_reports/sql/connections/pool_saturation.sql +90 -0
  25. data/lib/pg_reports/sql/connections/pool_usage.sql +31 -0
  26. data/lib/pg_reports/sql/connections/pool_wait_times.sql +19 -0
  27. data/lib/pg_reports/sql/queries/all_queries.sql +17 -15
  28. data/lib/pg_reports/sql/queries/expensive_queries.sql +9 -4
  29. data/lib/pg_reports/sql/queries/heavy_queries.sql +14 -12
  30. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +16 -14
  31. data/lib/pg_reports/sql/queries/missing_index_queries.sql +18 -16
  32. data/lib/pg_reports/sql/queries/slow_queries.sql +14 -12
  33. data/lib/pg_reports/sql/system/databases_list.sql +8 -0
  34. data/lib/pg_reports/version.rb +1 -1
  35. data/lib/pg_reports.rb +2 -0
  36. metadata +56 -3
@@ -0,0 +1,49 @@
1
+ # Connection Churn Analysis
2
+ # Analyzes connection creation/destruction patterns and short-lived connections
3
+
4
+ report:
5
+ name: connection_churn
6
+ module: connections
7
+ description: "Connection churn and short-lived connection analysis"
8
+
9
+ sql:
10
+ category: connections
11
+ file: connection_churn
12
+
13
+ title: "Connection Churn Analysis"
14
+
15
+ columns:
16
+ - database
17
+ - application
18
+ - total_connections
19
+ - avg_connection_age_seconds
20
+ - min_connection_age_seconds
21
+ - max_connection_age_seconds
22
+ - short_lived_connections
23
+ - churn_rate_pct
24
+
25
+ thresholds:
26
+ churn_rate_pct:
27
+ warning: 50
28
+ critical: 75
29
+ short_lived_connections:
30
+ warning: 10
31
+ critical: 25
32
+
33
+ problem_fields:
34
+ - churn_rate_pct
35
+ - short_lived_connections
36
+
37
+ problem_explanations:
38
+ churn_rate_pct: high_connection_churn
39
+ short_lived_connections: too_many_short_connections
40
+
41
+ documentation:
42
+ what: "Analyzes connection lifecycle patterns to identify excessive connection churn (frequent connect/disconnect cycles)."
43
+ why: "High connection churn wastes resources on connection setup/teardown and indicates potential connection pooling issues. Short-lived connections suggest the application isn't reusing connections efficiently."
44
+ nuances:
45
+ - "Connections under 10 seconds old are considered short-lived"
46
+ - "High churn rate (>50%) suggests missing or misconfigured connection pooling"
47
+ - "Many short-lived connections increase CPU overhead and authentication load"
48
+ - "Consider using PgBouncer or similar pooler to reduce churn"
49
+ - "Web applications should maintain a connection pool, not create connections per request"
@@ -0,0 +1,42 @@
1
+ # Connection Pool Saturation Warnings
2
+ # Identifies connection pool saturation and potential exhaustion
3
+
4
+ report:
5
+ name: pool_saturation
6
+ module: connections
7
+ description: "Connection pool saturation analysis and warnings"
8
+
9
+ sql:
10
+ category: connections
11
+ file: pool_saturation
12
+
13
+ title: "Pool Saturation Warnings"
14
+
15
+ columns:
16
+ - metric
17
+ - current_value
18
+ - max_value
19
+ - utilization_pct
20
+ - status
21
+ - recommendation
22
+
23
+ thresholds:
24
+ utilization_pct:
25
+ warning: 70
26
+ critical: 85
27
+
28
+ problem_fields:
29
+ - utilization_pct
30
+
31
+ problem_explanations:
32
+ utilization_pct: pool_saturation
33
+
34
+ documentation:
35
+ what: "Overall connection pool health metrics with saturation warnings and recommendations."
36
+ why: "Pool saturation leads to connection exhaustion, causing 'too many connections' errors and application failures. Early detection prevents outages."
37
+ nuances:
38
+ - "Utilization consistently above 70% suggests need for pool tuning or scaling"
39
+ - "High idle in transaction connections waste resources and should be minimized"
40
+ - "Reserved connections (superuser_reserved_connections) reduce available pool"
41
+ - "Consider implementing connection pooling if not already in use"
42
+ - "Monitor trends over time - sudden spikes may indicate connection leaks"
@@ -0,0 +1,43 @@
1
+ # Connection Pool Usage Statistics
2
+ # Shows current pool utilization, limits, and capacity warnings
3
+
4
+ report:
5
+ name: pool_usage
6
+ module: connections
7
+ description: "Connection pool usage and capacity analysis"
8
+
9
+ sql:
10
+ category: connections
11
+ file: pool_usage
12
+
13
+ title: "Connection Pool Usage"
14
+
15
+ columns:
16
+ - database
17
+ - total_connections
18
+ - active_connections
19
+ - idle_connections
20
+ - idle_in_transaction
21
+ - max_connections
22
+ - utilization_pct
23
+ - available_connections
24
+
25
+ thresholds:
26
+ utilization_pct:
27
+ warning: 70
28
+ critical: 85
29
+
30
+ problem_fields:
31
+ - utilization_pct
32
+
33
+ problem_explanations:
34
+ utilization_pct: high_pool_usage
35
+
36
+ documentation:
37
+ what: "Current connection pool utilization across all databases, showing active, idle, and available connections."
38
+ why: "High pool utilization can lead to connection exhaustion, causing application errors and degraded performance. Monitoring pool usage helps prevent connection starvation."
39
+ nuances:
40
+ - "Utilization above 70% indicates you're approaching pool limits"
41
+ - "Idle in transaction connections hold locks and can block other queries"
42
+ - "max_connections is a database-wide setting, not per-database"
43
+ - "Consider using connection pooling (PgBouncer/pgpool) for better resource management"
@@ -0,0 +1,44 @@
1
+ # Connection Pool Wait Time Analysis
2
+ # Analyzes queries waiting for resources (locks, IO, etc.)
3
+
4
+ report:
5
+ name: pool_wait_times
6
+ module: connections
7
+ description: "Analysis of queries waiting for resources"
8
+
9
+ sql:
10
+ category: connections
11
+ file: pool_wait_times
12
+
13
+ title: "Pool Wait Time Analysis"
14
+
15
+ columns:
16
+ - pid
17
+ - database
18
+ - username
19
+ - wait_event_type
20
+ - wait_event
21
+ - state
22
+ - wait_duration_seconds
23
+ - query_start
24
+ - query
25
+
26
+ thresholds:
27
+ wait_duration_seconds:
28
+ warning: 10
29
+ critical: 60
30
+
31
+ problem_fields:
32
+ - wait_duration_seconds
33
+
34
+ problem_explanations:
35
+ wait_duration_seconds: long_wait_time
36
+
37
+ documentation:
38
+ what: "Queries currently waiting for resources like locks, I/O, or network operations."
39
+ why: "Long wait times indicate resource contention or bottlenecks. Understanding what queries are waiting for helps identify performance issues."
40
+ nuances:
41
+ - "ClientRead wait events indicate slow clients not consuming data fast enough"
42
+ - "Lock waits suggest contention between concurrent queries"
43
+ - "IO waits may indicate disk performance issues or need for more cache"
44
+ - "Wait times above 60 seconds are critical and should be investigated immediately"
@@ -16,9 +16,9 @@ report:
16
16
  - query
17
17
  - source
18
18
  - calls
19
- - seq_scan_count
20
- - rows_examined
21
- - table_name
19
+ - total_time_ms
20
+ - rows_per_call
21
+ - disk_read_ratio
22
22
 
23
23
  parameters:
24
24
  limit:
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Analyzes EXPLAIN ANALYZE output and extracts insights
5
+ class ExplainAnalyzer
6
+ # Node types and their characteristics
7
+ NODE_TYPES = {
8
+ "Seq Scan" => {color: "warning", description: "Full table scan - potentially slow for large tables"},
9
+ "Index Scan" => {color: "good", description: "Using an index efficiently"},
10
+ "Index Only Scan" => {color: "good", description: "Most efficient - reading only from index"},
11
+ "Bitmap Index Scan" => {color: "ok", description: "First step of bitmap scan"},
12
+ "Bitmap Heap Scan" => {color: "ok", description: "Using multiple indexes combined"},
13
+ "Nested Loop" => {color: "neutral", description: "Joining tables in a loop"},
14
+ "Hash Join" => {color: "good", description: "Efficient join using hash table"},
15
+ "Merge Join" => {color: "good", description: "Efficient join on sorted data"},
16
+ "Sort" => {color: "warning", description: "Sorting data in memory or disk"},
17
+ "HashAggregate" => {color: "ok", description: "Grouping using hash table"},
18
+ "GroupAggregate" => {color: "ok", description: "Grouping on sorted data"},
19
+ "Aggregate" => {color: "ok", description: "Computing aggregate functions"},
20
+ "Limit" => {color: "good", description: "Limiting result set"},
21
+ "Subquery Scan" => {color: "neutral", description: "Scanning a subquery result"},
22
+ "CTE Scan" => {color: "neutral", description: "Scanning a Common Table Expression"},
23
+ "Materialize" => {color: "warning", description: "Caching intermediate results"},
24
+ "Gather" => {color: "ok", description: "Parallel query coordination"},
25
+ "Gather Merge" => {color: "ok", description: "Parallel query with merge"}
26
+ }.freeze
27
+
28
+ attr_reader :raw_output, :lines, :problems, :summary
29
+
30
+ def initialize(explain_output)
31
+ @raw_output = explain_output
32
+ @lines = explain_output.split("\n")
33
+ @problems = []
34
+ @summary = {}
35
+ analyze
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ raw_output: @raw_output,
41
+ annotated_lines: annotate_lines,
42
+ problems: @problems,
43
+ summary: @summary,
44
+ stats: extract_stats
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def analyze
51
+ detect_sequential_scans
52
+ detect_high_cost_operations
53
+ detect_sort_operations
54
+ detect_low_row_accuracy
55
+ detect_timing_issues
56
+ build_summary
57
+ end
58
+
59
+ # Annotate each line with metadata for rendering
60
+ def annotate_lines
61
+ @lines.map.with_index do |line, idx|
62
+ node_type = extract_node_type(line)
63
+ metrics = extract_metrics(line)
64
+
65
+ {
66
+ line_number: idx + 1,
67
+ text: line,
68
+ node_type: node_type,
69
+ node_info: NODE_TYPES[node_type],
70
+ metrics: metrics,
71
+ indent_level: line[/^ */].length / 2,
72
+ is_planning: line.include?("Planning"),
73
+ is_execution: line.include?("Execution"),
74
+ is_timing: line.match?(/Planning Time|Execution Time/)
75
+ }
76
+ end
77
+ end
78
+
79
+ def extract_node_type(line)
80
+ NODE_TYPES.keys.find { |type| line.include?(type) }
81
+ end
82
+
83
+ def extract_metrics(line)
84
+ metrics = {}
85
+
86
+ # Extract cost
87
+ if (match = line.match(/cost=([\d.]+)\.\.([\d.]+)/))
88
+ metrics[:startup_cost] = match[1].to_f
89
+ metrics[:total_cost] = match[2].to_f
90
+ end
91
+
92
+ # Extract rows
93
+ if (match = line.match(/rows=(\d+)/))
94
+ metrics[:rows_estimated] = match[1].to_i
95
+ end
96
+
97
+ # Extract actual rows
98
+ if (match = line.match(/rows=(\d+).*actual.*rows=(\d+)/))
99
+ metrics[:rows_estimated] = match[1].to_i
100
+ metrics[:rows_actual] = match[2].to_i
101
+ elsif (match = line.match(/actual.*rows=(\d+)/))
102
+ metrics[:rows_actual] = match[1].to_i
103
+ end
104
+
105
+ # Extract actual time
106
+ if (match = line.match(/actual time=([\d.]+)\.\.([\d.]+)/))
107
+ metrics[:actual_time_start] = match[1].to_f
108
+ metrics[:actual_time_end] = match[2].to_f
109
+ end
110
+
111
+ # Extract loops
112
+ if (match = line.match(/loops=(\d+)/))
113
+ metrics[:loops] = match[1].to_i
114
+ end
115
+
116
+ # Extract buffers
117
+ if (match = line.match(/Buffers: shared hit=(\d+)/))
118
+ metrics[:buffers_hit] = match[1].to_i
119
+ end
120
+ if (match = line.match(/read=(\d+)/))
121
+ metrics[:buffers_read] = match[1].to_i
122
+ end
123
+
124
+ metrics
125
+ end
126
+
127
+ def extract_stats
128
+ stats = {}
129
+
130
+ @lines.each do |line|
131
+ if (match = line.match(/Planning Time: ([\d.]+) ms/))
132
+ stats[:planning_time] = match[1].to_f
133
+ elsif (match = line.match(/Execution Time: ([\d.]+) ms/))
134
+ stats[:execution_time] = match[1].to_f
135
+ end
136
+ end
137
+
138
+ # Extract top-level cost and rows from first line with cost
139
+ first_cost_line = @lines.find { |l| l.include?("cost=") }
140
+ if first_cost_line
141
+ if (match = first_cost_line.match(/cost=[\d.]+\.\.([\d.]+)/))
142
+ stats[:total_cost] = match[1].to_f
143
+ end
144
+ if (match = first_cost_line.match(/rows=(\d+)/))
145
+ stats[:rows_estimated] = match[1].to_i
146
+ end
147
+ end
148
+
149
+ stats
150
+ end
151
+
152
+ def detect_sequential_scans
153
+ seq_scans = []
154
+
155
+ @lines.each_with_index do |line, idx|
156
+ next unless line.include?("Seq Scan")
157
+
158
+ table_name = extract_table_name(line)
159
+ metrics = extract_metrics(line)
160
+
161
+ # Consider it a problem if:
162
+ # 1. High cost (> 1000)
163
+ # 2. Many rows (> 1000)
164
+ # 3. Significant actual time (> 100ms per loop)
165
+ is_problem = false
166
+ reasons = []
167
+
168
+ if metrics[:total_cost] && metrics[:total_cost] > 1000
169
+ is_problem = true
170
+ reasons << "high cost (#{metrics[:total_cost].round(2)})"
171
+ end
172
+
173
+ if metrics[:rows_estimated] && metrics[:rows_estimated] > 1000
174
+ is_problem = true
175
+ reasons << "many rows (#{metrics[:rows_estimated]})"
176
+ end
177
+
178
+ if metrics[:actual_time_end] && metrics[:actual_time_end] > 100
179
+ is_problem = true
180
+ reasons << "slow execution (#{metrics[:actual_time_end].round(2)}ms)"
181
+ end
182
+
183
+ if is_problem
184
+ @problems << {
185
+ type: :sequential_scan,
186
+ severity: :warning,
187
+ line_number: idx + 1,
188
+ table: table_name,
189
+ message: "Sequential scan on #{table_name || "table"}",
190
+ details: reasons.join(", "),
191
+ recommendation: "Consider adding an index on frequently filtered columns"
192
+ }
193
+ end
194
+
195
+ seq_scans << {table: table_name, line: idx + 1, is_problem: is_problem}
196
+ end
197
+
198
+ seq_scans
199
+ end
200
+
201
+ def detect_high_cost_operations
202
+ @lines.each_with_index do |line, idx|
203
+ metrics = extract_metrics(line)
204
+ next unless metrics[:total_cost]
205
+
206
+ # Flag operations with very high cost (> 10000)
207
+ if metrics[:total_cost] > 10000
208
+ node_type = extract_node_type(line)
209
+ @problems << {
210
+ type: :high_cost,
211
+ severity: :warning,
212
+ line_number: idx + 1,
213
+ node_type: node_type,
214
+ cost: metrics[:total_cost],
215
+ message: "Very high cost operation (#{metrics[:total_cost].round(2)})",
216
+ recommendation: "This operation is expensive - review if it can be optimized"
217
+ }
218
+ end
219
+ end
220
+ end
221
+
222
+ def detect_sort_operations
223
+ @lines.each_with_index do |line, idx|
224
+ next unless line.include?("Sort")
225
+
226
+ # Check if sort spilled to disk
227
+ if line.match?(/external.*sort/i) || line.include?("Disk:")
228
+ @problems << {
229
+ type: :sort_spill,
230
+ severity: :critical,
231
+ line_number: idx + 1,
232
+ message: "Sort operation spilled to disk",
233
+ recommendation: "Increase work_mem or optimize query to reduce sort size"
234
+ }
235
+ elsif line.include?("Sort")
236
+ # Just a regular sort, note it but not necessarily a problem
237
+ metrics = extract_metrics(line)
238
+ if metrics[:actual_time_end] && metrics[:actual_time_end] > 1000
239
+ @problems << {
240
+ type: :slow_sort,
241
+ severity: :warning,
242
+ line_number: idx + 1,
243
+ message: "Slow sort operation (#{metrics[:actual_time_end].round(2)}ms)",
244
+ recommendation: "Consider reducing the dataset before sorting or using an index"
245
+ }
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ def detect_low_row_accuracy
252
+ @lines.each_with_index do |line, idx|
253
+ metrics = extract_metrics(line)
254
+ next unless metrics[:rows_estimated] && metrics[:rows_actual]
255
+
256
+ estimated = metrics[:rows_estimated].to_f
257
+ actual = metrics[:rows_actual].to_f
258
+
259
+ # Skip if very small numbers
260
+ next if estimated < 10 && actual < 10
261
+
262
+ # Calculate ratio (avoid division by zero)
263
+ max_val = [estimated, actual].max
264
+ min_val = [estimated, actual].min
265
+ next if max_val == 0
266
+
267
+ ratio = max_val / min_val
268
+
269
+ # If estimation is off by more than 10x, it's a problem
270
+ if ratio > 10
271
+ @problems << {
272
+ type: :estimation_error,
273
+ severity: :warning,
274
+ line_number: idx + 1,
275
+ message: "Row estimation is significantly off (estimated: #{estimated.to_i}, actual: #{actual.to_i})",
276
+ recommendation: "Run ANALYZE on the involved tables to update statistics"
277
+ }
278
+ end
279
+ end
280
+ end
281
+
282
+ def detect_timing_issues
283
+ stats = extract_stats
284
+
285
+ if stats[:execution_time] && stats[:execution_time] > 1000
286
+ @problems << {
287
+ type: :slow_query,
288
+ severity: :critical,
289
+ message: "Query execution is very slow (#{stats[:execution_time].round(2)}ms)",
290
+ recommendation: "Review the execution plan for optimization opportunities"
291
+ }
292
+ end
293
+
294
+ if stats[:planning_time] && stats[:planning_time] > 100
295
+ @problems << {
296
+ type: :slow_planning,
297
+ severity: :info,
298
+ message: "Query planning is slow (#{stats[:planning_time].round(2)}ms)",
299
+ recommendation: "Consider simplifying the query or using prepared statements"
300
+ }
301
+ end
302
+ end
303
+
304
+ def build_summary
305
+ @summary = {
306
+ total_problems: @problems.length,
307
+ critical_problems: @problems.count { |p| p[:severity] == :critical },
308
+ warnings: @problems.count { |p| p[:severity] == :warning },
309
+ info: @problems.count { |p| p[:severity] == :info }
310
+ }
311
+
312
+ # Add overall assessment
313
+ if @summary[:critical_problems] > 0
314
+ @summary[:status] = "critical"
315
+ @summary[:status_text] = "Critical issues detected"
316
+ @summary[:status_icon] = "🔴"
317
+ elsif @summary[:warnings] > 0
318
+ @summary[:status] = "warning"
319
+ @summary[:status_text] = "Potential issues detected"
320
+ @summary[:status_icon] = "🟡"
321
+ else
322
+ @summary[:status] = "good"
323
+ @summary[:status_text] = "No issues detected"
324
+ @summary[:status_icon] = "🟢"
325
+ end
326
+
327
+ # Group problems by type for summary
328
+ problem_types = @problems.group_by { |p| p[:type] }
329
+ @summary[:problem_breakdown] = problem_types.transform_values(&:count)
330
+ end
331
+
332
+ def extract_table_name(line)
333
+ if (match = line.match(/on (\w+)/))
334
+ match[1]
335
+ end
336
+ end
337
+ end
338
+ end
@@ -89,12 +89,10 @@ module PgReports
89
89
  ].uniq
90
90
 
91
91
  possible_names.each do |model_name|
92
- begin
93
- model = model_name.constantize
94
- return model if model.is_a?(Class) && model < ActiveRecord::Base
95
- rescue NameError
96
- # Model doesn't exist, try next one
97
- end
92
+ model = model_name.constantize
93
+ return model if model.is_a?(Class) && model < ActiveRecord::Base
94
+ rescue NameError
95
+ # Model doesn't exist, try next one
98
96
  end
99
97
 
100
98
  nil
@@ -50,8 +50,7 @@ module PgReports
50
50
  # @return [Hash] Metrics data
51
51
  def live_metrics(long_query_threshold: 60)
52
52
  data = executor.execute_from_file(:system, :live_metrics,
53
- long_query_threshold: long_query_threshold
54
- )
53
+ long_query_threshold: long_query_threshold)
55
54
 
56
55
  row = data.first || {}
57
56
 
@@ -116,6 +115,24 @@ module PgReports
116
115
  end
117
116
  end
118
117
 
118
+ # Get list of all databases
119
+ # @return [Array<Hash>] List of databases with sizes
120
+ def databases_list
121
+ executor.execute_from_file(:system, :databases_list)
122
+ rescue
123
+ # Fallback to empty array if query fails
124
+ []
125
+ end
126
+
127
+ # Get current database name
128
+ # @return [String] Current database name
129
+ def current_database
130
+ result = executor.execute("SELECT current_database() AS database")
131
+ result.first&.fetch("database", "unknown") || "unknown"
132
+ rescue
133
+ "unknown"
134
+ end
135
+
119
136
  private
120
137
 
121
138
  def executor