pg_reports 0.1.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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +335 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +133 -0
- data/app/views/layouts/pg_reports/application.html.erb +594 -0
- data/app/views/pg_reports/dashboard/index.html.erb +435 -0
- data/app/views/pg_reports/dashboard/show.html.erb +481 -0
- data/config/routes.rb +13 -0
- data/lib/pg_reports/annotation_parser.rb +114 -0
- data/lib/pg_reports/configuration.rb +83 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +89 -0
- data/lib/pg_reports/engine.rb +22 -0
- data/lib/pg_reports/error.rb +15 -0
- data/lib/pg_reports/executor.rb +51 -0
- data/lib/pg_reports/modules/connections.rb +106 -0
- data/lib/pg_reports/modules/indexes.rb +111 -0
- data/lib/pg_reports/modules/queries.rb +140 -0
- data/lib/pg_reports/modules/system.rb +148 -0
- data/lib/pg_reports/modules/tables.rb +113 -0
- data/lib/pg_reports/report.rb +228 -0
- data/lib/pg_reports/sql/connections/active_connections.sql +20 -0
- data/lib/pg_reports/sql/connections/blocking_queries.sql +35 -0
- data/lib/pg_reports/sql/connections/connection_stats.sql +13 -0
- data/lib/pg_reports/sql/connections/idle_connections.sql +19 -0
- data/lib/pg_reports/sql/connections/locks.sql +20 -0
- data/lib/pg_reports/sql/connections/long_running_queries.sql +21 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +36 -0
- data/lib/pg_reports/sql/indexes/duplicate_indexes.sql +38 -0
- data/lib/pg_reports/sql/indexes/index_sizes.sql +14 -0
- data/lib/pg_reports/sql/indexes/index_usage.sql +19 -0
- data/lib/pg_reports/sql/indexes/invalid_indexes.sql +15 -0
- data/lib/pg_reports/sql/indexes/missing_indexes.sql +27 -0
- data/lib/pg_reports/sql/indexes/unused_indexes.sql +18 -0
- data/lib/pg_reports/sql/queries/all_queries.sql +20 -0
- data/lib/pg_reports/sql/queries/expensive_queries.sql +22 -0
- data/lib/pg_reports/sql/queries/heavy_queries.sql +17 -0
- data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +19 -0
- data/lib/pg_reports/sql/queries/missing_index_queries.sql +25 -0
- data/lib/pg_reports/sql/queries/slow_queries.sql +17 -0
- data/lib/pg_reports/sql/system/activity_overview.sql +29 -0
- data/lib/pg_reports/sql/system/cache_stats.sql +19 -0
- data/lib/pg_reports/sql/system/database_sizes.sql +10 -0
- data/lib/pg_reports/sql/system/extensions.sql +12 -0
- data/lib/pg_reports/sql/system/settings.sql +33 -0
- data/lib/pg_reports/sql/tables/bloated_tables.sql +23 -0
- data/lib/pg_reports/sql/tables/cache_hit_ratios.sql +24 -0
- data/lib/pg_reports/sql/tables/recently_modified.sql +20 -0
- data/lib/pg_reports/sql/tables/row_counts.sql +18 -0
- data/lib/pg_reports/sql/tables/seq_scans.sql +26 -0
- data/lib/pg_reports/sql/tables/table_sizes.sql +16 -0
- data/lib/pg_reports/sql/tables/vacuum_needed.sql +22 -0
- data/lib/pg_reports/sql_loader.rb +35 -0
- data/lib/pg_reports/telegram_sender.rb +83 -0
- data/lib/pg_reports/version.rb +5 -0
- data/lib/pg_reports.rb +114 -0
- metadata +184 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Registry of all available reports for the dashboard
|
|
6
|
+
module ReportsRegistry
|
|
7
|
+
REPORTS = {
|
|
8
|
+
queries: {
|
|
9
|
+
name: "Queries",
|
|
10
|
+
icon: "⚡",
|
|
11
|
+
color: "#6366f1",
|
|
12
|
+
reports: {
|
|
13
|
+
slow_queries: {name: "Slow Queries", description: "Queries with high mean execution time"},
|
|
14
|
+
heavy_queries: {name: "Heavy Queries", description: "Most frequently called queries"},
|
|
15
|
+
expensive_queries: {name: "Expensive Queries", description: "Queries consuming most total time"},
|
|
16
|
+
missing_index_queries: {name: "Missing Index Queries", description: "Queries potentially missing indexes"},
|
|
17
|
+
low_cache_hit_queries: {name: "Low Cache Hit", description: "Queries with poor cache utilization"},
|
|
18
|
+
all_queries: {name: "All Queries", description: "All query statistics"}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
indexes: {
|
|
22
|
+
name: "Indexes",
|
|
23
|
+
icon: "📇",
|
|
24
|
+
color: "#10b981",
|
|
25
|
+
reports: {
|
|
26
|
+
unused_indexes: {name: "Unused Indexes", description: "Indexes rarely or never scanned"},
|
|
27
|
+
duplicate_indexes: {name: "Duplicate Indexes", description: "Redundant indexes"},
|
|
28
|
+
invalid_indexes: {name: "Invalid Indexes", description: "Indexes that failed to build"},
|
|
29
|
+
missing_indexes: {name: "Missing Indexes", description: "Tables potentially missing indexes"},
|
|
30
|
+
index_usage: {name: "Index Usage", description: "Index scan statistics"},
|
|
31
|
+
bloated_indexes: {name: "Bloated Indexes", description: "Indexes with high bloat"},
|
|
32
|
+
index_sizes: {name: "Index Sizes", description: "Index disk usage"}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
tables: {
|
|
36
|
+
name: "Tables",
|
|
37
|
+
icon: "📊",
|
|
38
|
+
color: "#f59e0b",
|
|
39
|
+
reports: {
|
|
40
|
+
table_sizes: {name: "Table Sizes", description: "Table disk usage"},
|
|
41
|
+
bloated_tables: {name: "Bloated Tables", description: "Tables with high dead tuple ratio"},
|
|
42
|
+
vacuum_needed: {name: "Vacuum Needed", description: "Tables needing vacuum"},
|
|
43
|
+
row_counts: {name: "Row Counts", description: "Table row counts"},
|
|
44
|
+
cache_hit_ratios: {name: "Cache Hit Ratios", description: "Table cache statistics"},
|
|
45
|
+
seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
|
|
46
|
+
recently_modified: {name: "Recently Modified", description: "Tables with recent activity"}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
connections: {
|
|
50
|
+
name: "Connections",
|
|
51
|
+
icon: "🔌",
|
|
52
|
+
color: "#ec4899",
|
|
53
|
+
reports: {
|
|
54
|
+
active_connections: {name: "Active Connections", description: "Current database connections"},
|
|
55
|
+
connection_stats: {name: "Connection Stats", description: "Connection statistics by state"},
|
|
56
|
+
long_running_queries: {name: "Long Running", description: "Queries running for extended period"},
|
|
57
|
+
blocking_queries: {name: "Blocking Queries", description: "Queries blocking others"},
|
|
58
|
+
locks: {name: "Locks", description: "Current database locks"},
|
|
59
|
+
idle_connections: {name: "Idle Connections", description: "Idle connections"}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
system: {
|
|
63
|
+
name: "System",
|
|
64
|
+
icon: "🖥️",
|
|
65
|
+
color: "#8b5cf6",
|
|
66
|
+
reports: {
|
|
67
|
+
database_sizes: {name: "Database Sizes", description: "Size of all databases"},
|
|
68
|
+
settings: {name: "Settings", description: "PostgreSQL configuration"},
|
|
69
|
+
extensions: {name: "Extensions", description: "Installed extensions"},
|
|
70
|
+
activity_overview: {name: "Activity Overview", description: "Current activity summary"},
|
|
71
|
+
cache_stats: {name: "Cache Stats", description: "Database cache statistics"}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def self.all
|
|
77
|
+
REPORTS
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.find(category, report)
|
|
81
|
+
REPORTS.dig(category.to_sym, :reports, report.to_sym)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.category(category)
|
|
85
|
+
REPORTS[category.to_sym]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace PgReports
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "pg_reports.assets" do |_app|
|
|
12
|
+
# Assets are inline in views, no precompilation needed
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "pg_reports.append_routes" do |app|
|
|
16
|
+
# Allow mounting at custom path
|
|
17
|
+
app.routes.append do
|
|
18
|
+
# Routes are mounted by the user in their routes.rb
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
# Base error class for PgReports
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when Telegram is not configured
|
|
8
|
+
class TelegramNotConfiguredError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when SQL file is not found
|
|
11
|
+
class SqlFileNotFoundError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when database connection fails
|
|
14
|
+
class ConnectionError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
# Executes SQL queries and returns results
|
|
5
|
+
class Executor
|
|
6
|
+
def initialize(connection: nil)
|
|
7
|
+
@connection = connection || PgReports.config.connection
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Execute SQL from a file and return results as array of hashes
|
|
11
|
+
def execute_from_file(category, name, **params)
|
|
12
|
+
sql = SqlLoader.load(category, name)
|
|
13
|
+
execute(sql, **params)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Execute raw SQL and return results as array of hashes
|
|
17
|
+
def execute(sql, **params)
|
|
18
|
+
processed_sql = interpolate_params(sql, params)
|
|
19
|
+
result = @connection.exec_query(processed_sql)
|
|
20
|
+
result.to_a
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Simple parameter interpolation for SQL
|
|
26
|
+
# Replaces :param_name with quoted values
|
|
27
|
+
def interpolate_params(sql, params)
|
|
28
|
+
return sql if params.empty?
|
|
29
|
+
|
|
30
|
+
params.reduce(sql) do |query, (key, value)|
|
|
31
|
+
quoted_value = quote_value(value)
|
|
32
|
+
query.gsub(":#{key}", quoted_value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def quote_value(value)
|
|
37
|
+
case value
|
|
38
|
+
when nil
|
|
39
|
+
"NULL"
|
|
40
|
+
when Integer, Float
|
|
41
|
+
value.to_s
|
|
42
|
+
when String
|
|
43
|
+
@connection.quote(value)
|
|
44
|
+
when Array
|
|
45
|
+
"(#{value.map { |v| quote_value(v) }.join(", ")})"
|
|
46
|
+
else
|
|
47
|
+
@connection.quote(value.to_s)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Modules
|
|
5
|
+
# Connection and lock analysis module
|
|
6
|
+
module Connections
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# Active connections
|
|
10
|
+
# @return [Report] Report with active connections
|
|
11
|
+
def active_connections
|
|
12
|
+
data = executor.execute_from_file(:connections, :active_connections)
|
|
13
|
+
|
|
14
|
+
Report.new(
|
|
15
|
+
title: "Active Connections",
|
|
16
|
+
data: data,
|
|
17
|
+
columns: %w[pid database username application state query_start state_change query]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Connection statistics by state
|
|
22
|
+
# @return [Report] Report with connection counts by state
|
|
23
|
+
def connection_stats
|
|
24
|
+
data = executor.execute_from_file(:connections, :connection_stats)
|
|
25
|
+
|
|
26
|
+
Report.new(
|
|
27
|
+
title: "Connection Statistics",
|
|
28
|
+
data: data,
|
|
29
|
+
columns: %w[database state count]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Long running queries
|
|
34
|
+
# @return [Report] Report with long running queries
|
|
35
|
+
def long_running_queries(min_duration_seconds: 60)
|
|
36
|
+
data = executor.execute_from_file(:connections, :long_running_queries)
|
|
37
|
+
|
|
38
|
+
filtered = data.select { |row| row["duration_seconds"].to_f >= min_duration_seconds }
|
|
39
|
+
|
|
40
|
+
Report.new(
|
|
41
|
+
title: "Long Running Queries (>= #{min_duration_seconds}s)",
|
|
42
|
+
data: filtered,
|
|
43
|
+
columns: %w[pid database username duration_seconds state query]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Blocking queries - queries that are blocking others
|
|
48
|
+
# @return [Report] Report with blocking queries
|
|
49
|
+
def blocking_queries
|
|
50
|
+
data = executor.execute_from_file(:connections, :blocking_queries)
|
|
51
|
+
|
|
52
|
+
Report.new(
|
|
53
|
+
title: "Blocking Queries",
|
|
54
|
+
data: data,
|
|
55
|
+
columns: %w[blocked_pid blocking_pid blocked_query blocking_query blocked_duration]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Lock statistics
|
|
60
|
+
# @return [Report] Report with lock statistics
|
|
61
|
+
def locks
|
|
62
|
+
data = executor.execute_from_file(:connections, :locks)
|
|
63
|
+
|
|
64
|
+
Report.new(
|
|
65
|
+
title: "Current Locks",
|
|
66
|
+
data: data,
|
|
67
|
+
columns: %w[pid database relation locktype mode granted waiting]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Idle connections
|
|
72
|
+
# @return [Report] Report with idle connections
|
|
73
|
+
def idle_connections
|
|
74
|
+
data = executor.execute_from_file(:connections, :idle_connections)
|
|
75
|
+
|
|
76
|
+
Report.new(
|
|
77
|
+
title: "Idle Connections",
|
|
78
|
+
data: data,
|
|
79
|
+
columns: %w[pid database username application idle_duration state_change]
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Kill a specific backend process
|
|
84
|
+
# @param pid [Integer] Process ID to terminate
|
|
85
|
+
# @return [Boolean] Success status
|
|
86
|
+
def kill_connection(pid)
|
|
87
|
+
result = executor.execute("SELECT pg_terminate_backend(:pid)", pid: pid)
|
|
88
|
+
result.first&.fetch("pg_terminate_backend", false) || false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Cancel a specific query (softer than kill)
|
|
92
|
+
# @param pid [Integer] Process ID to cancel
|
|
93
|
+
# @return [Boolean] Success status
|
|
94
|
+
def cancel_query(pid)
|
|
95
|
+
result = executor.execute("SELECT pg_cancel_backend(:pid)", pid: pid)
|
|
96
|
+
result.first&.fetch("pg_cancel_backend", false) || false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def executor
|
|
102
|
+
@executor ||= Executor.new
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Modules
|
|
5
|
+
# Index analysis module
|
|
6
|
+
module Indexes
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# Unused indexes - indexes that are rarely or never scanned
|
|
10
|
+
# @return [Report] Report with unused indexes
|
|
11
|
+
def unused_indexes(limit: 50)
|
|
12
|
+
data = executor.execute_from_file(:indexes, :unused_indexes)
|
|
13
|
+
threshold = PgReports.config.unused_index_threshold_scans
|
|
14
|
+
|
|
15
|
+
filtered = data.select { |row| row["idx_scan"].to_i <= threshold }
|
|
16
|
+
.first(limit)
|
|
17
|
+
|
|
18
|
+
Report.new(
|
|
19
|
+
title: "Unused Indexes (scans <= #{threshold})",
|
|
20
|
+
data: filtered,
|
|
21
|
+
columns: %w[schema table_name index_name idx_scan index_size_mb]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Duplicate indexes - indexes that may be redundant
|
|
26
|
+
# @return [Report] Report with duplicate indexes
|
|
27
|
+
def duplicate_indexes
|
|
28
|
+
data = executor.execute_from_file(:indexes, :duplicate_indexes)
|
|
29
|
+
|
|
30
|
+
Report.new(
|
|
31
|
+
title: "Duplicate Indexes",
|
|
32
|
+
data: data,
|
|
33
|
+
columns: %w[table_name index_name duplicate_of index_size_mb]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Invalid indexes - indexes that are not valid (e.g., failed to build)
|
|
38
|
+
# @return [Report] Report with invalid indexes
|
|
39
|
+
def invalid_indexes
|
|
40
|
+
data = executor.execute_from_file(:indexes, :invalid_indexes)
|
|
41
|
+
|
|
42
|
+
Report.new(
|
|
43
|
+
title: "Invalid Indexes",
|
|
44
|
+
data: data,
|
|
45
|
+
columns: %w[schema table_name index_name index_definition]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Missing indexes - tables with high sequential scans
|
|
50
|
+
# @return [Report] Report suggesting missing indexes
|
|
51
|
+
def missing_indexes(limit: 20)
|
|
52
|
+
data = executor.execute_from_file(:indexes, :missing_indexes)
|
|
53
|
+
.first(limit)
|
|
54
|
+
|
|
55
|
+
Report.new(
|
|
56
|
+
title: "Tables Potentially Missing Indexes",
|
|
57
|
+
data: data,
|
|
58
|
+
columns: %w[schema table_name seq_scan seq_tup_read idx_scan table_size_mb]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Index usage statistics
|
|
63
|
+
# @return [Report] Report with index usage statistics
|
|
64
|
+
def index_usage(limit: 50)
|
|
65
|
+
data = executor.execute_from_file(:indexes, :index_usage)
|
|
66
|
+
.first(limit)
|
|
67
|
+
|
|
68
|
+
Report.new(
|
|
69
|
+
title: "Index Usage Statistics",
|
|
70
|
+
data: data,
|
|
71
|
+
columns: %w[schema table_name index_name idx_scan idx_tup_read index_size_mb]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Bloated indexes - indexes with high bloat
|
|
76
|
+
# @return [Report] Report with bloated indexes
|
|
77
|
+
def bloated_indexes(limit: 20)
|
|
78
|
+
data = executor.execute_from_file(:indexes, :bloated_indexes)
|
|
79
|
+
threshold = PgReports.config.bloat_threshold_percent
|
|
80
|
+
|
|
81
|
+
filtered = data.select { |row| row["bloat_percent"].to_f >= threshold }
|
|
82
|
+
.first(limit)
|
|
83
|
+
|
|
84
|
+
Report.new(
|
|
85
|
+
title: "Bloated Indexes (bloat >= #{threshold}%)",
|
|
86
|
+
data: filtered,
|
|
87
|
+
columns: %w[schema table_name index_name index_size_mb bloat_size_mb bloat_percent]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Index sizes
|
|
92
|
+
# @return [Report] Report with index sizes
|
|
93
|
+
def index_sizes(limit: 50)
|
|
94
|
+
data = executor.execute_from_file(:indexes, :index_sizes)
|
|
95
|
+
.first(limit)
|
|
96
|
+
|
|
97
|
+
Report.new(
|
|
98
|
+
title: "Index Sizes (top #{limit})",
|
|
99
|
+
data: data,
|
|
100
|
+
columns: %w[schema table_name index_name index_size_mb]
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def executor
|
|
107
|
+
@executor ||= Executor.new
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Modules
|
|
5
|
+
# Query analysis module - analyzes pg_stat_statements data
|
|
6
|
+
module Queries
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# Slow queries - queries with high mean execution time
|
|
10
|
+
# @return [Report] Report with slow queries
|
|
11
|
+
def slow_queries(limit: 20)
|
|
12
|
+
data = executor.execute_from_file(:queries, :slow_queries)
|
|
13
|
+
threshold = PgReports.config.slow_query_threshold_ms
|
|
14
|
+
|
|
15
|
+
filtered = data.select { |row| row["mean_time_ms"].to_f >= threshold }
|
|
16
|
+
.first(limit)
|
|
17
|
+
|
|
18
|
+
enriched = enrich_with_annotations(filtered)
|
|
19
|
+
|
|
20
|
+
Report.new(
|
|
21
|
+
title: "Slow Queries (mean time >= #{threshold}ms)",
|
|
22
|
+
data: enriched,
|
|
23
|
+
columns: %w[query source calls mean_time_ms total_time_ms rows_per_call]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Heavy queries - queries called most frequently
|
|
28
|
+
# @return [Report] Report with heavy queries
|
|
29
|
+
def heavy_queries(limit: 20)
|
|
30
|
+
data = executor.execute_from_file(:queries, :heavy_queries)
|
|
31
|
+
threshold = PgReports.config.heavy_query_threshold_calls
|
|
32
|
+
|
|
33
|
+
filtered = data.select { |row| row["calls"].to_i >= threshold }
|
|
34
|
+
.first(limit)
|
|
35
|
+
|
|
36
|
+
enriched = enrich_with_annotations(filtered)
|
|
37
|
+
|
|
38
|
+
Report.new(
|
|
39
|
+
title: "Heavy Queries (calls >= #{threshold})",
|
|
40
|
+
data: enriched,
|
|
41
|
+
columns: %w[query source calls total_time_ms mean_time_ms cache_hit_ratio]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Expensive queries - queries consuming most total time
|
|
46
|
+
# @return [Report] Report with expensive queries
|
|
47
|
+
def expensive_queries(limit: 20)
|
|
48
|
+
data = executor.execute_from_file(:queries, :expensive_queries)
|
|
49
|
+
threshold = PgReports.config.expensive_query_threshold_ms
|
|
50
|
+
|
|
51
|
+
filtered = data.select { |row| row["total_time_ms"].to_f >= threshold }
|
|
52
|
+
.first(limit)
|
|
53
|
+
|
|
54
|
+
enriched = enrich_with_annotations(filtered)
|
|
55
|
+
|
|
56
|
+
Report.new(
|
|
57
|
+
title: "Expensive Queries (total time >= #{threshold}ms)",
|
|
58
|
+
data: enriched,
|
|
59
|
+
columns: %w[query source calls total_time_ms percent_of_total mean_time_ms]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Queries missing indexes - sequential scans on large tables
|
|
64
|
+
# @return [Report] Report with queries likely missing indexes
|
|
65
|
+
def missing_index_queries(limit: 20)
|
|
66
|
+
data = executor.execute_from_file(:queries, :missing_index_queries)
|
|
67
|
+
.first(limit)
|
|
68
|
+
|
|
69
|
+
enriched = enrich_with_annotations(data)
|
|
70
|
+
|
|
71
|
+
Report.new(
|
|
72
|
+
title: "Queries Potentially Missing Indexes",
|
|
73
|
+
data: enriched,
|
|
74
|
+
columns: %w[query source calls seq_scan_count rows_examined table_name]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Queries with low cache hit ratio
|
|
79
|
+
# @return [Report] Report with queries having poor cache utilization
|
|
80
|
+
def low_cache_hit_queries(limit: 20, min_calls: 100)
|
|
81
|
+
data = executor.execute_from_file(:queries, :low_cache_hit_queries)
|
|
82
|
+
|
|
83
|
+
filtered = data.select { |row| row["calls"].to_i >= min_calls }
|
|
84
|
+
.first(limit)
|
|
85
|
+
|
|
86
|
+
enriched = enrich_with_annotations(filtered)
|
|
87
|
+
|
|
88
|
+
Report.new(
|
|
89
|
+
title: "Queries with Low Cache Hit Ratio (min #{min_calls} calls)",
|
|
90
|
+
data: enriched,
|
|
91
|
+
columns: %w[query source calls cache_hit_ratio shared_blks_hit shared_blks_read]
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# All query statistics ordered by total time
|
|
96
|
+
# @return [Report] Report with all query statistics
|
|
97
|
+
def all_queries(limit: 50)
|
|
98
|
+
data = executor.execute_from_file(:queries, :all_queries)
|
|
99
|
+
.first(limit)
|
|
100
|
+
|
|
101
|
+
enriched = enrich_with_annotations(data)
|
|
102
|
+
|
|
103
|
+
Report.new(
|
|
104
|
+
title: "All Query Statistics (top #{limit})",
|
|
105
|
+
data: enriched,
|
|
106
|
+
columns: %w[query source calls total_time_ms mean_time_ms rows]
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reset pg_stat_statements statistics
|
|
111
|
+
def reset_statistics!
|
|
112
|
+
executor.execute("SELECT pg_stat_statements_reset()")
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def executor
|
|
119
|
+
@executor ||= Executor.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Enrich query data with parsed annotations (Marginalia, Rails QueryLogs, etc.)
|
|
123
|
+
def enrich_with_annotations(data)
|
|
124
|
+
data.map do |row|
|
|
125
|
+
query = row["query"].to_s
|
|
126
|
+
annotation = AnnotationParser.parse(query)
|
|
127
|
+
|
|
128
|
+
if annotation.any?
|
|
129
|
+
row.merge(
|
|
130
|
+
"source" => AnnotationParser.format_for_display(annotation),
|
|
131
|
+
"query" => AnnotationParser.strip_annotations(query)
|
|
132
|
+
)
|
|
133
|
+
else
|
|
134
|
+
row.merge("source" => nil)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Modules
|
|
5
|
+
# System-level database statistics
|
|
6
|
+
module System
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# Database sizes
|
|
10
|
+
# @return [Report] Report with database sizes
|
|
11
|
+
def database_sizes
|
|
12
|
+
data = executor.execute_from_file(:system, :database_sizes)
|
|
13
|
+
|
|
14
|
+
Report.new(
|
|
15
|
+
title: "Database Sizes",
|
|
16
|
+
data: data,
|
|
17
|
+
columns: %w[database size_mb size_pretty]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# PostgreSQL settings
|
|
22
|
+
# @return [Report] Report with important PostgreSQL settings
|
|
23
|
+
def settings
|
|
24
|
+
data = executor.execute_from_file(:system, :settings)
|
|
25
|
+
|
|
26
|
+
Report.new(
|
|
27
|
+
title: "PostgreSQL Settings",
|
|
28
|
+
data: data,
|
|
29
|
+
columns: %w[name setting unit category description]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Extension information
|
|
34
|
+
# @return [Report] Report with installed extensions
|
|
35
|
+
def extensions
|
|
36
|
+
data = executor.execute_from_file(:system, :extensions)
|
|
37
|
+
|
|
38
|
+
Report.new(
|
|
39
|
+
title: "Installed Extensions",
|
|
40
|
+
data: data,
|
|
41
|
+
columns: %w[name version schema description]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Database activity overview
|
|
46
|
+
# @return [Report] Report with current activity
|
|
47
|
+
def activity_overview
|
|
48
|
+
data = executor.execute_from_file(:system, :activity_overview)
|
|
49
|
+
|
|
50
|
+
Report.new(
|
|
51
|
+
title: "Database Activity Overview",
|
|
52
|
+
data: data,
|
|
53
|
+
columns: %w[metric value]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cache hit ratio for the entire database
|
|
58
|
+
# @return [Report] Report with cache statistics
|
|
59
|
+
def cache_stats
|
|
60
|
+
data = executor.execute_from_file(:system, :cache_stats)
|
|
61
|
+
|
|
62
|
+
Report.new(
|
|
63
|
+
title: "Database Cache Statistics",
|
|
64
|
+
data: data,
|
|
65
|
+
columns: %w[database heap_hit_ratio index_hit_ratio]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# pg_stat_statements availability check
|
|
70
|
+
# @return [Boolean] Whether pg_stat_statements is available
|
|
71
|
+
def pg_stat_statements_available?
|
|
72
|
+
result = executor.execute(<<~SQL)
|
|
73
|
+
SELECT EXISTS (
|
|
74
|
+
SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
|
|
75
|
+
) AS available
|
|
76
|
+
SQL
|
|
77
|
+
result.first&.fetch("available", false) || false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if pg_stat_statements is in shared_preload_libraries
|
|
81
|
+
# @return [Boolean] Whether pg_stat_statements is preloaded
|
|
82
|
+
def pg_stat_statements_preloaded?
|
|
83
|
+
result = executor.execute(<<~SQL)
|
|
84
|
+
SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'
|
|
85
|
+
SQL
|
|
86
|
+
setting = result.first&.fetch("setting", "") || ""
|
|
87
|
+
setting.include?("pg_stat_statements")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get pg_stat_statements status details
|
|
91
|
+
# @return [Hash] Status information
|
|
92
|
+
def pg_stat_statements_status
|
|
93
|
+
{
|
|
94
|
+
extension_installed: pg_stat_statements_available?,
|
|
95
|
+
preloaded: pg_stat_statements_preloaded?,
|
|
96
|
+
ready: pg_stat_statements_available? && pg_stat_statements_preloaded?
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Enable pg_stat_statements extension
|
|
101
|
+
# Tries to create extension, returns helpful error if it fails
|
|
102
|
+
# @return [Hash] Result with success status and message
|
|
103
|
+
def enable_pg_stat_statements!
|
|
104
|
+
# Check if already enabled
|
|
105
|
+
if pg_stat_statements_available?
|
|
106
|
+
return {success: true, message: "pg_stat_statements is already enabled"}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Try to create extension
|
|
110
|
+
begin
|
|
111
|
+
executor.execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
|
|
112
|
+
|
|
113
|
+
# Verify it worked
|
|
114
|
+
if pg_stat_statements_available?
|
|
115
|
+
{success: true, message: "pg_stat_statements extension created successfully"}
|
|
116
|
+
else
|
|
117
|
+
{
|
|
118
|
+
success: false,
|
|
119
|
+
message: "Extension created but not working. Check shared_preload_libraries in postgresql.conf",
|
|
120
|
+
requires_restart: true
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
rescue => e
|
|
124
|
+
error_message = e.message
|
|
125
|
+
|
|
126
|
+
# Provide helpful message for common errors
|
|
127
|
+
if error_message.include?("could not open extension control file") ||
|
|
128
|
+
error_message.include?("extension \"pg_stat_statements\" is not available")
|
|
129
|
+
{
|
|
130
|
+
success: false,
|
|
131
|
+
message: "pg_stat_statements is not installed. Add to postgresql.conf: " \
|
|
132
|
+
"shared_preload_libraries = 'pg_stat_statements' and restart PostgreSQL.",
|
|
133
|
+
requires_restart: true
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
{success: false, message: "Failed to create extension: #{error_message}"}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def executor
|
|
144
|
+
@executor ||= Executor.new
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|