pg_reports 0.3.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +129 -4
  4. data/app/controllers/pg_reports/dashboard_controller.rb +246 -28
  5. data/app/views/layouts/pg_reports/application.html.erb +283 -1
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +8 -1
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +240 -41
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +495 -1
  9. data/app/views/pg_reports/dashboard/index.html.erb +419 -0
  10. data/app/views/pg_reports/dashboard/show.html.erb +89 -47
  11. data/config/locales/en.yml +58 -0
  12. data/config/locales/ru.yml +58 -0
  13. data/config/locales/uk.yml +13 -0
  14. data/config/routes.rb +8 -0
  15. data/lib/pg_reports/configuration.rb +13 -0
  16. data/lib/pg_reports/dashboard/reports_registry.rb +38 -1
  17. data/lib/pg_reports/definitions/connections/active_connections.yml +23 -0
  18. data/lib/pg_reports/definitions/connections/blocking_queries.yml +20 -0
  19. data/lib/pg_reports/definitions/connections/connection_churn.yml +49 -0
  20. data/lib/pg_reports/definitions/connections/connection_stats.yml +18 -0
  21. data/lib/pg_reports/definitions/connections/idle_connections.yml +21 -0
  22. data/lib/pg_reports/definitions/connections/locks.yml +22 -0
  23. data/lib/pg_reports/definitions/connections/long_running_queries.yml +43 -0
  24. data/lib/pg_reports/definitions/connections/pool_saturation.yml +42 -0
  25. data/lib/pg_reports/definitions/connections/pool_usage.yml +43 -0
  26. data/lib/pg_reports/definitions/connections/pool_wait_times.yml +44 -0
  27. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +43 -0
  28. data/lib/pg_reports/definitions/indexes/duplicate_indexes.yml +19 -0
  29. data/lib/pg_reports/definitions/indexes/index_sizes.yml +29 -0
  30. data/lib/pg_reports/definitions/indexes/index_usage.yml +27 -0
  31. data/lib/pg_reports/definitions/indexes/invalid_indexes.yml +19 -0
  32. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +27 -0
  33. data/lib/pg_reports/definitions/indexes/unused_indexes.yml +41 -0
  34. data/lib/pg_reports/definitions/queries/all_queries.yml +35 -0
  35. data/lib/pg_reports/definitions/queries/expensive_queries.yml +43 -0
  36. data/lib/pg_reports/definitions/queries/heavy_queries.yml +49 -0
  37. data/lib/pg_reports/definitions/queries/low_cache_hit_queries.yml +47 -0
  38. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +31 -0
  39. data/lib/pg_reports/definitions/queries/slow_queries.yml +48 -0
  40. data/lib/pg_reports/definitions/system/activity_overview.yml +17 -0
  41. data/lib/pg_reports/definitions/system/cache_stats.yml +18 -0
  42. data/lib/pg_reports/definitions/system/database_sizes.yml +18 -0
  43. data/lib/pg_reports/definitions/system/extensions.yml +19 -0
  44. data/lib/pg_reports/definitions/system/settings.yml +20 -0
  45. data/lib/pg_reports/definitions/tables/bloated_tables.yml +43 -0
  46. data/lib/pg_reports/definitions/tables/cache_hit_ratios.yml +26 -0
  47. data/lib/pg_reports/definitions/tables/recently_modified.yml +27 -0
  48. data/lib/pg_reports/definitions/tables/row_counts.yml +29 -0
  49. data/lib/pg_reports/definitions/tables/seq_scans.yml +31 -0
  50. data/lib/pg_reports/definitions/tables/table_sizes.yml +31 -0
  51. data/lib/pg_reports/definitions/tables/vacuum_needed.yml +39 -0
  52. data/lib/pg_reports/explain_analyzer.rb +338 -0
  53. data/lib/pg_reports/filter.rb +58 -0
  54. data/lib/pg_reports/module_generator.rb +44 -0
  55. data/lib/pg_reports/modules/connections.rb +8 -73
  56. data/lib/pg_reports/modules/indexes.rb +9 -94
  57. data/lib/pg_reports/modules/queries.rb +9 -100
  58. data/lib/pg_reports/modules/schema_analysis.rb +154 -0
  59. data/lib/pg_reports/modules/system.rb +26 -61
  60. data/lib/pg_reports/modules/tables.rb +9 -96
  61. data/lib/pg_reports/query_monitor.rb +280 -0
  62. data/lib/pg_reports/report_definition.rb +161 -0
  63. data/lib/pg_reports/report_loader.rb +38 -0
  64. data/lib/pg_reports/sql/connections/connection_churn.sql +37 -0
  65. data/lib/pg_reports/sql/connections/pool_saturation.sql +90 -0
  66. data/lib/pg_reports/sql/connections/pool_usage.sql +31 -0
  67. data/lib/pg_reports/sql/connections/pool_wait_times.sql +19 -0
  68. data/lib/pg_reports/sql/queries/all_queries.sql +17 -15
  69. data/lib/pg_reports/sql/queries/expensive_queries.sql +9 -4
  70. data/lib/pg_reports/sql/queries/heavy_queries.sql +14 -12
  71. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +16 -14
  72. data/lib/pg_reports/sql/queries/missing_index_queries.sql +18 -16
  73. data/lib/pg_reports/sql/queries/slow_queries.sql +14 -12
  74. data/lib/pg_reports/sql/schema_analysis/unique_indexes.sql +35 -0
  75. data/lib/pg_reports/sql/system/databases_list.sql +8 -0
  76. data/lib/pg_reports/version.rb +1 -1
  77. data/lib/pg_reports.rb +26 -0
  78. metadata +93 -3
@@ -0,0 +1,280 @@
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}")
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 code (not tests)
192
+ locations = caller_locations(0, 30)
193
+ return false unless locations
194
+
195
+ locations.any? do |location|
196
+ path = location.path
197
+ # Match gem paths: /gems/pg_reports-X.Y.Z/lib/ or local /lib/pg_reports/
198
+ # But exclude test paths: /spec/
199
+ next if path.include?("/spec/")
200
+
201
+ # Match both gem installation and local development lib paths
202
+ path.match?(%r{/gems/pg_reports[-\d.]+/lib/}) ||
203
+ path.match?(%r{/lib/pg_reports/})
204
+ end
205
+ end
206
+
207
+ def extract_source_location
208
+ filter_proc = PgReports.config.query_monitor_backtrace_filter
209
+
210
+ # Get caller locations, skip first few frames (this file, active_support)
211
+ locations = caller_locations(5, 20)
212
+
213
+ return nil unless locations
214
+
215
+ # Find first application code location
216
+ app_location = locations.find do |location|
217
+ filter_proc.call(location)
218
+ end
219
+
220
+ return nil unless app_location
221
+
222
+ {
223
+ file: app_location.path,
224
+ line: app_location.lineno,
225
+ method: app_location.label
226
+ }
227
+ rescue
228
+ # If source extraction fails, return nil
229
+ nil
230
+ end
231
+
232
+ def add_to_buffer(query_entry)
233
+ @queries << query_entry
234
+
235
+ # Trim to max_queries to prevent memory bloat
236
+ max_queries = PgReports.config.query_monitor_max_queries
237
+ if @queries.size > max_queries
238
+ @queries = @queries.last(max_queries)
239
+ end
240
+ end
241
+
242
+ def write_session_marker(marker_type)
243
+ return unless log_file_enabled?
244
+
245
+ marker = {
246
+ type: marker_type,
247
+ session_id: @session_id,
248
+ timestamp: Time.current.iso8601
249
+ }
250
+
251
+ File.open(log_file_path, "a") do |f|
252
+ f.puts marker.to_json
253
+ end
254
+ rescue => e
255
+ # Silently fail - don't break monitoring if file write fails
256
+ Rails.logger.warn("PgReports: Failed to write session marker: #{e.message}")
257
+ end
258
+
259
+ def flush_to_file
260
+ return unless log_file_enabled?
261
+ return if @queries.empty?
262
+
263
+ File.open(log_file_path, "a") do |f|
264
+ @queries.each do |query|
265
+ f.puts query.to_json
266
+ end
267
+ end
268
+ rescue => e
269
+ Rails.logger.warn("PgReports: Failed to flush queries to file: #{e.message}")
270
+ end
271
+
272
+ def log_file_enabled?
273
+ PgReports.config.query_monitor_log_file.present?
274
+ end
275
+
276
+ def log_file_path
277
+ PgReports.config.query_monitor_log_file
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Parses YAML report definitions and generates Report objects
5
+ class ReportDefinition
6
+ attr_reader :config
7
+
8
+ def initialize(yaml_path)
9
+ @config = YAML.load_file(yaml_path)["report"]
10
+ @yaml_path = yaml_path
11
+ end
12
+
13
+ def generate_report(**params)
14
+ # 1. Execute SQL
15
+ data = execute_sql(**params)
16
+
17
+ # 2. Apply filters
18
+ data = apply_filters(data, params)
19
+
20
+ # 3. Apply enrichment
21
+ data = apply_enrichment(data) if enrichment?
22
+
23
+ # 4. Apply limit
24
+ limit = params[:limit] || default_limit
25
+ data = data.first(limit) if limit && data.respond_to?(:first)
26
+
27
+ # 5. Create Report
28
+ Report.new(
29
+ title: interpolate_title(params),
30
+ data: data,
31
+ columns: config["columns"]
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def execute_sql(**params)
38
+ sql_config = config["sql"]
39
+ sql_params = extract_sql_params(params)
40
+
41
+ executor = Executor.new
42
+ executor.execute_from_file(
43
+ sql_config["category"].to_sym,
44
+ sql_config["file"].to_sym,
45
+ **sql_params
46
+ )
47
+ end
48
+
49
+ def extract_sql_params(params)
50
+ return {} unless config["sql"]["params"]
51
+
52
+ config["sql"]["params"].each_with_object({}) do |(key, value_config), result|
53
+ result[key.to_sym] = resolve_value(value_config, params)
54
+ end
55
+ end
56
+
57
+ def apply_filters(data, params)
58
+ return data unless config["filters"]
59
+
60
+ config["filters"].reduce(data) do |filtered, filter_config|
61
+ Filter.new(filter_config).apply(filtered, params)
62
+ end
63
+ end
64
+
65
+ def enrichment?
66
+ config["enrichment"].present?
67
+ end
68
+
69
+ def apply_enrichment(data)
70
+ enrichment = config["enrichment"]
71
+ module_name = enrichment["module"]
72
+ hook_name = enrichment["hook"]
73
+
74
+ # Call private method from module
75
+ module_class = PgReports::Modules.const_get(module_name.capitalize)
76
+ module_class.send(hook_name, data)
77
+ end
78
+
79
+ def interpolate_title(params)
80
+ title = config["title"]
81
+ return title unless config["title_vars"]
82
+
83
+ config["title_vars"].each do |var_name, var_config|
84
+ value = resolve_value(var_config, params)
85
+ title = title.gsub("${#{var_name}}", value.to_s)
86
+ end
87
+
88
+ title
89
+ end
90
+
91
+ def resolve_value(value_config, params)
92
+ case value_config["source"]
93
+ when "config"
94
+ PgReports.config.public_send(value_config["key"])
95
+ when "param"
96
+ key = value_config["key"].to_sym
97
+ # Try to get from params, fallback to default value
98
+ params[key] || get_default_param_value(key)
99
+ else
100
+ raise ArgumentError, "Unknown value source: #{value_config["source"]}"
101
+ end
102
+ end
103
+
104
+ def get_default_param_value(param_key)
105
+ return nil unless config["parameters"]&.dig(param_key.to_s)
106
+
107
+ config["parameters"][param_key.to_s]["default"]
108
+ end
109
+
110
+ def default_limit
111
+ return nil unless config["parameters"]&.dig("limit")
112
+
113
+ config["parameters"]["limit"]["default"]
114
+ end
115
+
116
+ public
117
+
118
+ # Extract filter parameters for UI
119
+ def filter_parameters
120
+ params = {}
121
+
122
+ # Parameters from parameters section
123
+ if config["parameters"]
124
+ config["parameters"].each do |name, param_config|
125
+ params[name] = {
126
+ type: param_config["type"],
127
+ default: param_config["default"],
128
+ description: param_config["description"],
129
+ label: name.to_s.titleize
130
+ }
131
+ end
132
+ end
133
+
134
+ # Add threshold parameters from filters (config-based)
135
+ if config["filters"]
136
+ config["filters"].each do |filter|
137
+ if filter["value"]["source"] == "config"
138
+ config_key = filter["value"]["key"]
139
+ field_name = filter["field"]
140
+
141
+ params["#{field_name}_threshold"] = {
142
+ type: filter["cast"] || "integer",
143
+ default: PgReports.config.public_send(config_key),
144
+ description: "Override threshold for #{field_name}",
145
+ label: "#{field_name.titleize} Threshold",
146
+ current_config: PgReports.config.public_send(config_key),
147
+ is_threshold: true
148
+ }
149
+ end
150
+ end
151
+ end
152
+
153
+ params
154
+ end
155
+
156
+ # Extract problem explanations mapping from YAML
157
+ def problem_explanations
158
+ config["problem_explanations"] || {}
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module PgReports
6
+ # Loads YAML report definitions from the definitions directory
7
+ class ReportLoader
8
+ def self.load_all
9
+ @definitions ||= begin
10
+ definitions = {}
11
+
12
+ Dir.glob(definitions_path.join("**/*.yml")).each do |yaml_file|
13
+ definition = ReportDefinition.new(yaml_file)
14
+ module_name = definition.config["module"]
15
+ report_name = definition.config["name"]
16
+
17
+ definitions[module_name] ||= {}
18
+ definitions[module_name][report_name] = definition
19
+ end
20
+
21
+ definitions
22
+ end
23
+ end
24
+
25
+ def self.get(module_name, report_name)
26
+ load_all.dig(module_name.to_s, report_name.to_s)
27
+ end
28
+
29
+ def self.definitions_path
30
+ Pathname.new(__dir__).join("definitions")
31
+ end
32
+
33
+ def self.reload!
34
+ @definitions = nil
35
+ load_all
36
+ end
37
+ end
38
+ 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;