pg_reports 0.4.0 → 0.5.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -0
  3. data/README.md +170 -4
  4. data/app/controllers/pg_reports/dashboard_controller.rb +348 -47
  5. data/app/views/layouts/pg_reports/application.html.erb +370 -79
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -1
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +254 -29
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +373 -0
  9. data/app/views/pg_reports/dashboard/index.html.erb +485 -5
  10. data/config/locales/en.yml +45 -0
  11. data/config/locales/ru.yml +45 -0
  12. data/config/routes.rb +8 -0
  13. data/lib/pg_reports/configuration.rb +21 -0
  14. data/lib/pg_reports/dashboard/reports_registry.rb +24 -1
  15. data/lib/pg_reports/definitions/connections/connection_churn.yml +49 -0
  16. data/lib/pg_reports/definitions/connections/pool_saturation.yml +42 -0
  17. data/lib/pg_reports/definitions/connections/pool_usage.yml +43 -0
  18. data/lib/pg_reports/definitions/connections/pool_wait_times.yml +44 -0
  19. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +3 -3
  20. data/lib/pg_reports/explain_analyzer.rb +338 -0
  21. data/lib/pg_reports/modules/schema_analysis.rb +4 -6
  22. data/lib/pg_reports/modules/system.rb +26 -3
  23. data/lib/pg_reports/query_monitor.rb +293 -0
  24. data/lib/pg_reports/sql/connections/connection_churn.sql +37 -0
  25. data/lib/pg_reports/sql/connections/pool_saturation.sql +90 -0
  26. data/lib/pg_reports/sql/connections/pool_usage.sql +31 -0
  27. data/lib/pg_reports/sql/connections/pool_wait_times.sql +19 -0
  28. data/lib/pg_reports/sql/queries/all_queries.sql +17 -15
  29. data/lib/pg_reports/sql/queries/expensive_queries.sql +9 -4
  30. data/lib/pg_reports/sql/queries/heavy_queries.sql +14 -12
  31. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +16 -14
  32. data/lib/pg_reports/sql/queries/missing_index_queries.sql +18 -16
  33. data/lib/pg_reports/sql/queries/slow_queries.sql +14 -12
  34. data/lib/pg_reports/sql/system/databases_list.sql +8 -0
  35. data/lib/pg_reports/version.rb +1 -1
  36. data/lib/pg_reports.rb +2 -0
  37. metadata +55 -2
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "json"
6
+
7
+ module PgReports
8
+ class QueryMonitor
9
+ include Singleton
10
+
11
+ attr_reader :enabled, :session_id
12
+
13
+ def initialize
14
+ @enabled = false
15
+ @subscriber = nil
16
+ @mutex = Mutex.new
17
+ @session_id = nil
18
+ @queries = []
19
+ end
20
+
21
+ def start
22
+ @mutex.synchronize do
23
+ if @enabled
24
+ return {success: false, message: "Monitoring already active"}
25
+ end
26
+
27
+ @session_id = SecureRandom.uuid
28
+ @queries = []
29
+ @enabled = true
30
+
31
+ # Subscribe to sql.active_record events
32
+ @subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
33
+ handle_sql_event(name, started, finished, unique_id, payload)
34
+ end
35
+
36
+ # Write session start marker to file
37
+ write_session_marker("session_start")
38
+
39
+ {success: true, message: "Query monitoring started", session_id: @session_id}
40
+ end
41
+ rescue => e
42
+ @enabled = false
43
+ {success: false, error: e.message}
44
+ end
45
+
46
+ def stop
47
+ @mutex.synchronize do
48
+ unless @enabled
49
+ return {success: false, message: "Monitoring not active"}
50
+ end
51
+
52
+ # Unsubscribe from notifications
53
+ if @subscriber
54
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
55
+ @subscriber = nil
56
+ end
57
+
58
+ # Write session end marker to file
59
+ write_session_marker("session_end")
60
+
61
+ # Flush queries to file
62
+ flush_to_file
63
+
64
+ @enabled = false
65
+ @queries = []
66
+ session_id = @session_id
67
+ @session_id = nil
68
+
69
+ {success: true, message: "Query monitoring stopped", session_id: session_id}
70
+ end
71
+ rescue => e
72
+ {success: false, error: e.message}
73
+ end
74
+
75
+ def status
76
+ {
77
+ enabled: @enabled,
78
+ session_id: @session_id,
79
+ query_count: @queries.size
80
+ }
81
+ end
82
+
83
+ def queries(limit: nil, session_id: nil)
84
+ result = @queries.dup
85
+
86
+ # Filter by session_id if provided
87
+ if session_id
88
+ result = result.select { |q| q[:session_id] == session_id }
89
+ end
90
+
91
+ # Limit results if requested
92
+ if limit
93
+ result = result.last(limit)
94
+ end
95
+
96
+ result
97
+ end
98
+
99
+ def load_from_log(session_id: nil, limit: nil)
100
+ return [] unless log_file_enabled?
101
+ return [] unless File.exist?(log_file_path)
102
+
103
+ queries = []
104
+
105
+ begin
106
+ File.open(log_file_path, "r") do |f|
107
+ f.each_line do |line|
108
+ entry = JSON.parse(line.strip, symbolize_names: true)
109
+ next unless entry[:type] == "query"
110
+
111
+ # Filter by session_id if provided
112
+ next if session_id && entry[:session_id] != session_id
113
+
114
+ queries << entry
115
+ rescue JSON::ParserError
116
+ # Skip malformed lines
117
+ next
118
+ end
119
+ end
120
+
121
+ # Limit results if requested
122
+ queries = queries.last(limit) if limit
123
+
124
+ queries
125
+ rescue => e
126
+ Rails.logger.warn("PgReports: Failed to load queries from log: #{e.message}") if defined?(Rails)
127
+ []
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def handle_sql_event(name, started, finished, unique_id, payload)
134
+ return unless @enabled
135
+
136
+ # Skip if should be filtered
137
+ return if should_skip?(payload)
138
+
139
+ duration_ms = ((finished - started) * 1000).round(2)
140
+ sql = payload[:sql]
141
+ query_name = payload[:name]
142
+
143
+ # Extract source location
144
+ source_location = extract_source_location
145
+
146
+ # Build query entry
147
+ query_entry = {
148
+ type: "query",
149
+ session_id: @session_id,
150
+ sql: sql,
151
+ duration_ms: duration_ms,
152
+ name: query_name,
153
+ source_location: source_location,
154
+ timestamp: Time.current.iso8601
155
+ }
156
+
157
+ add_to_buffer(query_entry)
158
+ end
159
+
160
+ def should_skip?(payload)
161
+ sql = payload[:sql]
162
+ name = payload[:name]
163
+
164
+ # Skip if query is from pg_reports itself (check by name)
165
+ return true if name&.start_with?("PgReports")
166
+
167
+ # Skip if query is from pg_reports gem (check backtrace)
168
+ if query_from_pg_reports?
169
+ return true
170
+ end
171
+
172
+ # Skip SCHEMA queries
173
+ return true if name&.start_with?("SCHEMA")
174
+
175
+ # Skip CACHE queries
176
+ return true if name == "CACHE"
177
+
178
+ # Skip if cached
179
+ return true if payload[:cached]
180
+
181
+ # Skip EXPLAIN queries
182
+ return true if sql&.match?(/\bEXPLAIN\b/i)
183
+
184
+ # Skip DDL statements
185
+ return true if sql&.match?(/\b(CREATE|ALTER|DROP)\b/i)
186
+
187
+ false
188
+ end
189
+
190
+ def query_from_pg_reports?
191
+ # Check if query originates from pg_reports gem internal code
192
+ locations = caller_locations(0, 30)
193
+ return false unless locations
194
+
195
+ locations.any? do |location|
196
+ path = location.path
197
+ # Exclude test paths
198
+ next if path.include?("/spec/")
199
+
200
+ # Filter queries from pg_reports internal modules only:
201
+ # - Installed gem: /gems/pg_reports-X.Y.Z/lib/
202
+ # - Local gem: /pg_reports/lib/pg_reports/modules/
203
+ # - Dashboard controller: /pg_reports/app/controllers/pg_reports/
204
+ path.match?(%r{/gems/pg_reports[-\d.]+/lib/}) ||
205
+ path.match?(%r{/pg_reports/lib/pg_reports/modules/}) ||
206
+ path.match?(%r{/pg_reports/app/controllers/pg_reports/dashboard_controller\.rb})
207
+ end
208
+ end
209
+
210
+ def extract_source_location
211
+ # Get caller locations, skip first few frames (this file, active_support)
212
+ # Increase limit to 50 to capture more of the stack
213
+ locations = caller_locations(5, 50)
214
+
215
+ return nil unless locations
216
+
217
+ # Find first application code location
218
+ # Look for paths that are NOT from gems/ruby/railties
219
+ app_location = locations.find do |location|
220
+ path = location.path
221
+
222
+ # Skip framework and gem paths
223
+ next if path.match?(%r{/(gems|ruby|railties)/})
224
+
225
+ # Skip pg_reports internal paths
226
+ next if path.match?(%r{/pg_reports/lib/pg_reports/})
227
+ next if path.match?(%r{/pg_reports/app/controllers/pg_reports/})
228
+
229
+ # This is likely application code
230
+ true
231
+ end
232
+
233
+ return nil unless app_location
234
+
235
+ {
236
+ file: app_location.path,
237
+ line: app_location.lineno,
238
+ method: app_location.label
239
+ }
240
+ rescue
241
+ # If source extraction fails, return nil
242
+ nil
243
+ end
244
+
245
+ def add_to_buffer(query_entry)
246
+ @queries << query_entry
247
+
248
+ # Trim to max_queries to prevent memory bloat
249
+ max_queries = PgReports.config.query_monitor_max_queries
250
+ if @queries.size > max_queries
251
+ @queries = @queries.last(max_queries)
252
+ end
253
+ end
254
+
255
+ def write_session_marker(marker_type)
256
+ return unless log_file_enabled?
257
+
258
+ marker = {
259
+ type: marker_type,
260
+ session_id: @session_id,
261
+ timestamp: Time.current.iso8601
262
+ }
263
+
264
+ File.open(log_file_path, "a") do |f|
265
+ f.puts marker.to_json
266
+ end
267
+ rescue => e
268
+ # Silently fail - don't break monitoring if file write fails
269
+ Rails.logger.warn("PgReports: Failed to write session marker: #{e.message}")
270
+ end
271
+
272
+ def flush_to_file
273
+ return unless log_file_enabled?
274
+ return if @queries.empty?
275
+
276
+ File.open(log_file_path, "a") do |f|
277
+ @queries.each do |query|
278
+ f.puts query.to_json
279
+ end
280
+ end
281
+ rescue => e
282
+ Rails.logger.warn("PgReports: Failed to flush queries to file: #{e.message}")
283
+ end
284
+
285
+ def log_file_enabled?
286
+ PgReports.config.query_monitor_log_file.present?
287
+ end
288
+
289
+ def log_file_path
290
+ PgReports.config.query_monitor_log_file
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,37 @@
1
+ -- Connection Churn Analysis
2
+ -- Identifies applications with excessive connection turnover
3
+
4
+ WITH connection_ages AS (
5
+ SELECT
6
+ datname AS database,
7
+ COALESCE(application_name, 'unknown') AS application,
8
+ EXTRACT(EPOCH FROM (NOW() - backend_start)) AS connection_age_seconds
9
+ FROM pg_stat_activity
10
+ WHERE pid != pg_backend_pid()
11
+ AND datname IS NOT NULL
12
+ AND backend_type = 'client backend'
13
+ ),
14
+ connection_stats AS (
15
+ SELECT
16
+ database,
17
+ application,
18
+ COUNT(*) AS total_connections,
19
+ ROUND(AVG(connection_age_seconds)::numeric, 2) AS avg_connection_age_seconds,
20
+ ROUND(MIN(connection_age_seconds)::numeric, 2) AS min_connection_age_seconds,
21
+ ROUND(MAX(connection_age_seconds)::numeric, 2) AS max_connection_age_seconds,
22
+ COUNT(*) FILTER (WHERE connection_age_seconds < 10) AS short_lived_connections
23
+ FROM connection_ages
24
+ GROUP BY database, application
25
+ )
26
+ SELECT
27
+ database,
28
+ application,
29
+ total_connections,
30
+ avg_connection_age_seconds,
31
+ min_connection_age_seconds,
32
+ max_connection_age_seconds,
33
+ short_lived_connections,
34
+ ROUND((short_lived_connections::numeric / total_connections::numeric) * 100, 2) AS churn_rate_pct
35
+ FROM connection_stats
36
+ WHERE total_connections > 1 -- Filter out single connections
37
+ ORDER BY churn_rate_pct DESC, short_lived_connections DESC;
@@ -0,0 +1,90 @@
1
+ -- Connection Pool Saturation Analysis
2
+ -- Provides overall pool health metrics with warnings
3
+
4
+ WITH settings AS (
5
+ SELECT
6
+ (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_conn,
7
+ (SELECT setting::int FROM pg_settings WHERE name = 'superuser_reserved_connections') AS reserved_conn
8
+ ),
9
+ current_state AS (
10
+ SELECT
11
+ COUNT(*) AS total_connections,
12
+ COUNT(*) FILTER (WHERE state = 'active') AS active_conn,
13
+ COUNT(*) FILTER (WHERE state = 'idle') AS idle_conn,
14
+ COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn,
15
+ COUNT(*) FILTER (WHERE state = 'idle in transaction (aborted)') AS idle_in_txn_aborted,
16
+ COUNT(*) FILTER (WHERE wait_event IS NOT NULL) AS waiting_conn
17
+ FROM pg_stat_activity
18
+ WHERE pid != pg_backend_pid()
19
+ ),
20
+ metrics AS (
21
+ SELECT
22
+ 'Total Connections' AS metric,
23
+ cs.total_connections AS current_value,
24
+ s.max_conn - s.reserved_conn AS max_value,
25
+ ROUND((cs.total_connections::numeric / (s.max_conn - s.reserved_conn)::numeric) * 100, 2) AS utilization_pct
26
+ FROM current_state cs, settings s
27
+
28
+ UNION ALL
29
+
30
+ SELECT
31
+ 'Active Connections',
32
+ cs.active_conn,
33
+ s.max_conn - s.reserved_conn,
34
+ ROUND((cs.active_conn::numeric / (s.max_conn - s.reserved_conn)::numeric) * 100, 2)
35
+ FROM current_state cs, settings s
36
+
37
+ UNION ALL
38
+
39
+ SELECT
40
+ 'Idle Connections',
41
+ cs.idle_conn,
42
+ s.max_conn - s.reserved_conn,
43
+ ROUND((cs.idle_conn::numeric / (s.max_conn - s.reserved_conn)::numeric) * 100, 2)
44
+ FROM current_state cs, settings s
45
+
46
+ UNION ALL
47
+
48
+ SELECT
49
+ 'Idle in Transaction',
50
+ cs.idle_in_txn,
51
+ s.max_conn / 4, -- Should be max 25% of pool
52
+ ROUND((cs.idle_in_txn::numeric / (s.max_conn / 4)::numeric) * 100, 2)
53
+ FROM current_state cs, settings s
54
+
55
+ UNION ALL
56
+
57
+ SELECT
58
+ 'Waiting Connections',
59
+ cs.waiting_conn,
60
+ s.max_conn / 10, -- Should be max 10% of pool
61
+ ROUND((cs.waiting_conn::numeric / GREATEST(s.max_conn / 10, 1)::numeric) * 100, 2)
62
+ FROM current_state cs, settings s
63
+ )
64
+ SELECT
65
+ metric,
66
+ current_value,
67
+ max_value,
68
+ utilization_pct,
69
+ CASE
70
+ WHEN utilization_pct >= 85 THEN '🔴 Critical'
71
+ WHEN utilization_pct >= 70 THEN '🟡 Warning'
72
+ WHEN utilization_pct >= 50 THEN '🟡 Elevated'
73
+ ELSE '🟢 Normal'
74
+ END AS status,
75
+ CASE
76
+ WHEN metric = 'Total Connections' AND utilization_pct >= 85 THEN
77
+ 'CRITICAL: Pool near exhaustion. Scale up max_connections or implement connection pooling immediately.'
78
+ WHEN metric = 'Total Connections' AND utilization_pct >= 70 THEN
79
+ 'WARNING: High pool utilization. Consider increasing max_connections or adding connection pooler.'
80
+ WHEN metric = 'Idle in Transaction' AND utilization_pct >= 70 THEN
81
+ 'WARNING: Too many idle in transaction connections. Review application transaction handling and set idle_in_transaction_session_timeout.'
82
+ WHEN metric = 'Waiting Connections' AND utilization_pct >= 70 THEN
83
+ 'WARNING: Many connections waiting. Check for lock contention and long-running queries.'
84
+ WHEN metric = 'Active Connections' AND utilization_pct >= 85 THEN
85
+ 'High active connection count. Monitor query performance and consider read replicas.'
86
+ ELSE
87
+ 'Pool is healthy.'
88
+ END AS recommendation
89
+ FROM metrics
90
+ ORDER BY utilization_pct DESC;
@@ -0,0 +1,31 @@
1
+ -- Connection Pool Usage Statistics
2
+ -- Shows current pool utilization across databases
3
+
4
+ WITH connection_counts AS (
5
+ SELECT
6
+ COALESCE(datname, 'system') AS database,
7
+ COUNT(*) AS total_connections,
8
+ COUNT(*) FILTER (WHERE state = 'active') AS active_connections,
9
+ COUNT(*) FILTER (WHERE state = 'idle') AS idle_connections,
10
+ COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction
11
+ FROM pg_stat_activity
12
+ WHERE pid != pg_backend_pid()
13
+ GROUP BY datname
14
+ ),
15
+ limits AS (
16
+ SELECT setting::int AS max_connections
17
+ FROM pg_settings
18
+ WHERE name = 'max_connections'
19
+ )
20
+ SELECT
21
+ cc.database,
22
+ cc.total_connections,
23
+ cc.active_connections,
24
+ cc.idle_connections,
25
+ cc.idle_in_transaction,
26
+ l.max_connections,
27
+ ROUND((cc.total_connections::numeric / l.max_connections::numeric) * 100, 2) AS utilization_pct,
28
+ l.max_connections - cc.total_connections AS available_connections
29
+ FROM connection_counts cc
30
+ CROSS JOIN limits l
31
+ ORDER BY cc.total_connections DESC;
@@ -0,0 +1,19 @@
1
+ -- Connection Pool Wait Time Analysis
2
+ -- Shows queries currently waiting for resources
3
+
4
+ SELECT
5
+ pid,
6
+ datname AS database,
7
+ usename AS username,
8
+ wait_event_type,
9
+ wait_event,
10
+ state,
11
+ ROUND(EXTRACT(EPOCH FROM (NOW() - state_change))::numeric, 2) AS wait_duration_seconds,
12
+ state_change AS query_start,
13
+ LEFT(query, 500) AS query
14
+ FROM pg_stat_activity
15
+ WHERE wait_event IS NOT NULL
16
+ AND pid != pg_backend_pid()
17
+ AND datname IS NOT NULL
18
+ AND state != 'idle'
19
+ ORDER BY wait_duration_seconds DESC;
@@ -2,19 +2,21 @@
2
2
  -- Requires pg_stat_statements extension
3
3
 
4
4
  SELECT
5
- query,
6
- calls,
7
- ROUND((total_exec_time)::numeric, 2) AS total_time_ms,
8
- ROUND((mean_exec_time)::numeric, 2) AS mean_time_ms,
9
- ROUND((min_exec_time)::numeric, 2) AS min_time_ms,
10
- ROUND((max_exec_time)::numeric, 2) AS max_time_ms,
11
- ROUND((stddev_exec_time)::numeric, 2) AS stddev_time_ms,
12
- rows,
13
- shared_blks_hit,
14
- shared_blks_read,
15
- ROUND((shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
16
- FROM pg_stat_statements
17
- WHERE calls > 0
18
- AND query NOT LIKE '%pg_stat_statements%'
19
- ORDER BY total_exec_time DESC
5
+ s.query,
6
+ s.calls,
7
+ ROUND((s.total_exec_time)::numeric, 2) AS total_time_ms,
8
+ ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms,
9
+ ROUND((s.min_exec_time)::numeric, 2) AS min_time_ms,
10
+ ROUND((s.max_exec_time)::numeric, 2) AS max_time_ms,
11
+ ROUND((s.stddev_exec_time)::numeric, 2) AS stddev_time_ms,
12
+ s.rows,
13
+ s.shared_blks_hit,
14
+ s.shared_blks_read,
15
+ ROUND((s.shared_blks_hit * 100.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
16
+ FROM pg_stat_statements s
17
+ JOIN pg_database d ON s.dbid = d.oid
18
+ WHERE s.calls > 0
19
+ AND s.query NOT LIKE '%pg_stat_statements%'
20
+ AND d.datname = current_database()
21
+ ORDER BY s.total_exec_time DESC
20
22
  LIMIT 200;
@@ -2,9 +2,11 @@
2
2
  -- Requires pg_stat_statements extension
3
3
 
4
4
  WITH total AS (
5
- SELECT SUM(total_exec_time) AS total_time
6
- FROM pg_stat_statements
7
- WHERE calls > 0
5
+ SELECT SUM(s.total_exec_time) AS total_time
6
+ FROM pg_stat_statements s
7
+ JOIN pg_database d ON s.dbid = d.oid
8
+ WHERE s.calls > 0
9
+ AND d.datname = current_database()
8
10
  )
9
11
  SELECT
10
12
  s.query,
@@ -13,10 +15,13 @@ SELECT
13
15
  ROUND((s.total_exec_time * 100.0 / t.total_time)::numeric, 2) AS percent_of_total,
14
16
  ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms,
15
17
  s.rows
16
- FROM pg_stat_statements s, total t
18
+ FROM pg_stat_statements s
19
+ JOIN pg_database d ON s.dbid = d.oid
20
+ CROSS JOIN total t
17
21
  WHERE s.calls > 0
18
22
  AND s.query NOT LIKE '%pg_stat_statements%'
19
23
  AND s.query NOT LIKE 'COMMIT%'
20
24
  AND s.query NOT LIKE 'BEGIN%'
25
+ AND d.datname = current_database()
21
26
  ORDER BY s.total_exec_time DESC
22
27
  LIMIT 100;
@@ -2,16 +2,18 @@
2
2
  -- Requires pg_stat_statements extension
3
3
 
4
4
  SELECT
5
- query,
6
- calls,
7
- ROUND((total_exec_time)::numeric, 2) AS total_time_ms,
8
- ROUND((mean_exec_time)::numeric, 2) AS mean_time_ms,
9
- rows,
10
- ROUND((shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
11
- FROM pg_stat_statements
12
- WHERE calls > 0
13
- AND query NOT LIKE '%pg_stat_statements%'
14
- AND query NOT LIKE 'COMMIT%'
15
- AND query NOT LIKE 'BEGIN%'
16
- ORDER BY calls DESC
5
+ s.query,
6
+ s.calls,
7
+ ROUND((s.total_exec_time)::numeric, 2) AS total_time_ms,
8
+ ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms,
9
+ s.rows,
10
+ ROUND((s.shared_blks_hit * 100.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
11
+ FROM pg_stat_statements s
12
+ JOIN pg_database d ON s.dbid = d.oid
13
+ WHERE s.calls > 0
14
+ AND s.query NOT LIKE '%pg_stat_statements%'
15
+ AND s.query NOT LIKE 'COMMIT%'
16
+ AND s.query NOT LIKE 'BEGIN%'
17
+ AND d.datname = current_database()
18
+ ORDER BY s.calls DESC
17
19
  LIMIT 100;
@@ -2,18 +2,20 @@
2
2
  -- Requires pg_stat_statements extension
3
3
 
4
4
  SELECT
5
- query,
6
- calls,
7
- ROUND((shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio,
8
- shared_blks_hit,
9
- shared_blks_read,
10
- ROUND((total_exec_time)::numeric, 2) AS total_time_ms,
11
- ROUND((mean_exec_time)::numeric, 2) AS mean_time_ms
12
- FROM pg_stat_statements
13
- WHERE calls > 10
14
- AND (shared_blks_hit + shared_blks_read) > 0
15
- AND query NOT LIKE '%pg_stat_statements%'
16
- AND query NOT LIKE 'COMMIT%'
17
- AND query NOT LIKE 'BEGIN%'
18
- ORDER BY (shared_blks_hit * 1.0 / NULLIF(shared_blks_hit + shared_blks_read, 0)) ASC
5
+ s.query,
6
+ s.calls,
7
+ ROUND((s.shared_blks_hit * 100.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio,
8
+ s.shared_blks_hit,
9
+ s.shared_blks_read,
10
+ ROUND((s.total_exec_time)::numeric, 2) AS total_time_ms,
11
+ ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms
12
+ FROM pg_stat_statements s
13
+ JOIN pg_database d ON s.dbid = d.oid
14
+ WHERE s.calls > 10
15
+ AND (s.shared_blks_hit + s.shared_blks_read) > 0
16
+ AND s.query NOT LIKE '%pg_stat_statements%'
17
+ AND s.query NOT LIKE 'COMMIT%'
18
+ AND s.query NOT LIKE 'BEGIN%'
19
+ AND d.datname = current_database()
20
+ ORDER BY (s.shared_blks_hit * 1.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0)) ASC
19
21
  LIMIT 100;
@@ -3,23 +3,25 @@
3
3
  -- Requires pg_stat_statements extension
4
4
 
5
5
  SELECT
6
- query,
7
- calls,
8
- ROUND((total_exec_time)::numeric, 2) AS total_time_ms,
9
- ROUND((mean_exec_time)::numeric, 2) AS mean_time_ms,
10
- rows,
6
+ s.query,
7
+ s.calls,
8
+ ROUND((s.total_exec_time)::numeric, 2) AS total_time_ms,
9
+ ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms,
10
+ s.rows,
11
11
  -- Heuristic: high rows examined per call may indicate missing index
12
- ROUND((rows / NULLIF(calls, 0))::numeric, 0) AS rows_per_call,
12
+ ROUND((s.rows / NULLIF(s.calls, 0))::numeric, 0) AS rows_per_call,
13
13
  -- High read/hit ratio suggests disk access (possible seq scan)
14
- ROUND((shared_blks_read * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS disk_read_ratio
15
- FROM pg_stat_statements
16
- WHERE calls > 10
17
- AND (rows / NULLIF(calls, 0)) > 100
18
- AND mean_exec_time > 10
19
- AND query NOT LIKE '%pg_stat_statements%'
20
- AND query NOT LIKE 'COMMIT%'
21
- AND query NOT LIKE 'BEGIN%'
14
+ ROUND((s.shared_blks_read * 100.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 2) AS disk_read_ratio
15
+ FROM pg_stat_statements s
16
+ JOIN pg_database d ON s.dbid = d.oid
17
+ WHERE s.calls > 10
18
+ AND (s.rows / NULLIF(s.calls, 0)) > 100
19
+ AND s.mean_exec_time > 10
20
+ AND s.query NOT LIKE '%pg_stat_statements%'
21
+ AND s.query NOT LIKE 'COMMIT%'
22
+ AND s.query NOT LIKE 'BEGIN%'
22
23
  -- Focus on SELECT statements
23
- AND (query ILIKE 'SELECT%' OR query ILIKE '%WHERE%')
24
- ORDER BY (rows / NULLIF(calls, 0)) * calls DESC
24
+ AND (s.query ILIKE 'SELECT%' OR s.query ILIKE '%WHERE%')
25
+ AND d.datname = current_database()
26
+ ORDER BY (s.rows / NULLIF(s.calls, 0)) * s.calls DESC
25
27
  LIMIT 100;
@@ -2,16 +2,18 @@
2
2
  -- Requires pg_stat_statements extension
3
3
 
4
4
  SELECT
5
- query,
6
- calls,
7
- ROUND((mean_exec_time)::numeric, 2) AS mean_time_ms,
8
- ROUND((total_exec_time)::numeric, 2) AS total_time_ms,
9
- ROUND((rows / NULLIF(calls, 0))::numeric, 2) AS rows_per_call,
10
- ROUND((shared_blks_hit * 100.0 / NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
11
- FROM pg_stat_statements
12
- WHERE calls > 0
13
- AND query NOT LIKE '%pg_stat_statements%'
14
- AND query NOT LIKE 'COMMIT%'
15
- AND query NOT LIKE 'BEGIN%'
16
- ORDER BY mean_exec_time DESC
5
+ s.query,
6
+ s.calls,
7
+ ROUND((s.mean_exec_time)::numeric, 2) AS mean_time_ms,
8
+ ROUND((s.total_exec_time)::numeric, 2) AS total_time_ms,
9
+ ROUND((s.rows / NULLIF(s.calls, 0))::numeric, 2) AS rows_per_call,
10
+ ROUND((s.shared_blks_hit * 100.0 / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 2) AS cache_hit_ratio
11
+ FROM pg_stat_statements s
12
+ JOIN pg_database d ON s.dbid = d.oid
13
+ WHERE s.calls > 0
14
+ AND s.query NOT LIKE '%pg_stat_statements%'
15
+ AND s.query NOT LIKE 'COMMIT%'
16
+ AND s.query NOT LIKE 'BEGIN%'
17
+ AND d.datname = current_database()
18
+ ORDER BY s.mean_exec_time DESC
17
19
  LIMIT 100;