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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +129 -4
- data/app/controllers/pg_reports/dashboard_controller.rb +246 -28
- data/app/views/layouts/pg_reports/application.html.erb +283 -1
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +8 -1
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +240 -41
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +495 -1
- data/app/views/pg_reports/dashboard/index.html.erb +419 -0
- data/app/views/pg_reports/dashboard/show.html.erb +89 -47
- data/config/locales/en.yml +58 -0
- data/config/locales/ru.yml +58 -0
- data/config/locales/uk.yml +13 -0
- data/config/routes.rb +8 -0
- data/lib/pg_reports/configuration.rb +13 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +38 -1
- data/lib/pg_reports/definitions/connections/active_connections.yml +23 -0
- data/lib/pg_reports/definitions/connections/blocking_queries.yml +20 -0
- data/lib/pg_reports/definitions/connections/connection_churn.yml +49 -0
- data/lib/pg_reports/definitions/connections/connection_stats.yml +18 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +21 -0
- data/lib/pg_reports/definitions/connections/locks.yml +22 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +43 -0
- data/lib/pg_reports/definitions/connections/pool_saturation.yml +42 -0
- data/lib/pg_reports/definitions/connections/pool_usage.yml +43 -0
- data/lib/pg_reports/definitions/connections/pool_wait_times.yml +44 -0
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +43 -0
- data/lib/pg_reports/definitions/indexes/duplicate_indexes.yml +19 -0
- data/lib/pg_reports/definitions/indexes/index_sizes.yml +29 -0
- data/lib/pg_reports/definitions/indexes/index_usage.yml +27 -0
- data/lib/pg_reports/definitions/indexes/invalid_indexes.yml +19 -0
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +27 -0
- data/lib/pg_reports/definitions/indexes/unused_indexes.yml +41 -0
- data/lib/pg_reports/definitions/queries/all_queries.yml +35 -0
- data/lib/pg_reports/definitions/queries/expensive_queries.yml +43 -0
- data/lib/pg_reports/definitions/queries/heavy_queries.yml +49 -0
- data/lib/pg_reports/definitions/queries/low_cache_hit_queries.yml +47 -0
- data/lib/pg_reports/definitions/queries/missing_index_queries.yml +31 -0
- data/lib/pg_reports/definitions/queries/slow_queries.yml +48 -0
- data/lib/pg_reports/definitions/system/activity_overview.yml +17 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +18 -0
- data/lib/pg_reports/definitions/system/database_sizes.yml +18 -0
- data/lib/pg_reports/definitions/system/extensions.yml +19 -0
- data/lib/pg_reports/definitions/system/settings.yml +20 -0
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +43 -0
- data/lib/pg_reports/definitions/tables/cache_hit_ratios.yml +26 -0
- data/lib/pg_reports/definitions/tables/recently_modified.yml +27 -0
- data/lib/pg_reports/definitions/tables/row_counts.yml +29 -0
- data/lib/pg_reports/definitions/tables/seq_scans.yml +31 -0
- data/lib/pg_reports/definitions/tables/table_sizes.yml +31 -0
- data/lib/pg_reports/definitions/tables/vacuum_needed.yml +39 -0
- data/lib/pg_reports/explain_analyzer.rb +338 -0
- data/lib/pg_reports/filter.rb +58 -0
- data/lib/pg_reports/module_generator.rb +44 -0
- data/lib/pg_reports/modules/connections.rb +8 -73
- data/lib/pg_reports/modules/indexes.rb +9 -94
- data/lib/pg_reports/modules/queries.rb +9 -100
- data/lib/pg_reports/modules/schema_analysis.rb +154 -0
- data/lib/pg_reports/modules/system.rb +26 -61
- data/lib/pg_reports/modules/tables.rb +9 -96
- data/lib/pg_reports/query_monitor.rb +280 -0
- data/lib/pg_reports/report_definition.rb +161 -0
- data/lib/pg_reports/report_loader.rb +38 -0
- data/lib/pg_reports/sql/connections/connection_churn.sql +37 -0
- data/lib/pg_reports/sql/connections/pool_saturation.sql +90 -0
- data/lib/pg_reports/sql/connections/pool_usage.sql +31 -0
- data/lib/pg_reports/sql/connections/pool_wait_times.sql +19 -0
- data/lib/pg_reports/sql/queries/all_queries.sql +17 -15
- data/lib/pg_reports/sql/queries/expensive_queries.sql +9 -4
- data/lib/pg_reports/sql/queries/heavy_queries.sql +14 -12
- data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +16 -14
- data/lib/pg_reports/sql/queries/missing_index_queries.sql +18 -16
- data/lib/pg_reports/sql/queries/slow_queries.sql +14 -12
- data/lib/pg_reports/sql/schema_analysis/unique_indexes.sql +35 -0
- data/lib/pg_reports/sql/system/databases_list.sql +8 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +26 -0
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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;
|