pg_peek 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/MIT-LICENSE +20 -0
- data/README.md +217 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/pg_peek/application.js +147 -0
- data/app/assets/stylesheets/pg_peek/application.css +362 -0
- data/app/controllers/pg_peek/activity_controller.rb +16 -0
- data/app/controllers/pg_peek/application_controller.rb +36 -0
- data/app/controllers/pg_peek/databases_controller.rb +96 -0
- data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
- data/app/controllers/pg_peek/indexes_controller.rb +7 -0
- data/app/controllers/pg_peek/jobs_controller.rb +44 -0
- data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
- data/app/controllers/pg_peek/queries_controller.rb +13 -0
- data/app/controllers/pg_peek/tables_controller.rb +6 -0
- data/app/helpers/pg_peek/application_helper.rb +179 -0
- data/app/helpers/pg_peek/report_helper.rb +58 -0
- data/app/jobs/pg_peek/application_job.rb +4 -0
- data/app/mailers/pg_peek/application_mailer.rb +6 -0
- data/app/models/pg_peek/application_record.rb +3 -0
- data/app/models/pg_peek/database.rb +131 -0
- data/app/models/pg_peek/pg_stat_statements.rb +132 -0
- data/app/models/pg_peek/sql_comment.rb +25 -0
- data/app/models/pg_peek/table.rb +266 -0
- data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
- data/app/reports/pg_peek/column.rb +27 -0
- data/app/reports/pg_peek/report.rb +63 -0
- data/app/reports/pg_peek/reports/blocked.rb +60 -0
- data/app/reports/pg_peek/reports/databases.rb +47 -0
- data/app/reports/pg_peek/reports/endpoints.rb +35 -0
- data/app/reports/pg_peek/reports/indexes.rb +44 -0
- data/app/reports/pg_peek/reports/job_queries.rb +14 -0
- data/app/reports/pg_peek/reports/jobs.rb +21 -0
- data/app/reports/pg_peek/reports/queries.rb +23 -0
- data/app/reports/pg_peek/reports/sessions.rb +88 -0
- data/app/reports/pg_peek/reports/tables.rb +25 -0
- data/app/views/layouts/pg_peek/application.html.erb +56 -0
- data/app/views/pg_peek/activity/show.html.erb +70 -0
- data/app/views/pg_peek/authentication_required.html.erb +31 -0
- data/app/views/pg_peek/databases/index.html.erb +43 -0
- data/app/views/pg_peek/databases/show.html.erb +169 -0
- data/app/views/pg_peek/endpoints/index.html.erb +24 -0
- data/app/views/pg_peek/indexes/index.html.erb +31 -0
- data/app/views/pg_peek/jobs/index.html.erb +51 -0
- data/app/views/pg_peek/jobs/show.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
- data/app/views/pg_peek/queries/index.html.erb +10 -0
- data/app/views/pg_peek/reports/_report.html.erb +32 -0
- data/app/views/pg_peek/tables/show.html.erb +128 -0
- data/config/routes.rb +16 -0
- data/lib/generators/pg_peek/install/install_generator.rb +40 -0
- data/lib/generators/pg_peek/install/templates/README +31 -0
- data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
- data/lib/pg_peek/configuration.rb +59 -0
- data/lib/pg_peek/engine.rb +7 -0
- data/lib/pg_peek/query_loader.rb +59 -0
- data/lib/pg_peek/version.rb +3 -0
- data/lib/pg_peek.rb +10 -0
- data/lib/tasks/pg_peek_tasks.rake +4 -0
- metadata +192 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Tables whose dead-tuple share is at or above the warning threshold.
|
|
2
|
+
SELECT
|
|
3
|
+
relname,
|
|
4
|
+
n_live_tup,
|
|
5
|
+
n_dead_tup,
|
|
6
|
+
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
|
|
7
|
+
last_autovacuum
|
|
8
|
+
FROM pg_stat_user_tables
|
|
9
|
+
WHERE n_live_tup + n_dead_tup > 0
|
|
10
|
+
AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
|
|
11
|
+
ORDER BY dead_ratio DESC
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
SELECT
|
|
2
|
+
t.relname AS table_name,
|
|
3
|
+
t.seq_scan,
|
|
4
|
+
t.seq_tup_read,
|
|
5
|
+
t.idx_scan,
|
|
6
|
+
t.idx_tup_fetch,
|
|
7
|
+
t.n_tup_ins,
|
|
8
|
+
t.n_tup_upd,
|
|
9
|
+
t.n_tup_del,
|
|
10
|
+
t.n_live_tup,
|
|
11
|
+
t.n_dead_tup,
|
|
12
|
+
t.last_vacuum,
|
|
13
|
+
t.last_autovacuum,
|
|
14
|
+
t.last_analyze,
|
|
15
|
+
t.last_autoanalyze,
|
|
16
|
+
t.vacuum_count,
|
|
17
|
+
t.autovacuum_count,
|
|
18
|
+
t.analyze_count,
|
|
19
|
+
t.autoanalyze_count,
|
|
20
|
+
s.heap_blks_read,
|
|
21
|
+
s.heap_blks_hit,
|
|
22
|
+
s.idx_blks_read,
|
|
23
|
+
s.idx_blks_hit,
|
|
24
|
+
s.toast_blks_read,
|
|
25
|
+
s.toast_blks_hit,
|
|
26
|
+
s.tidx_blks_read,
|
|
27
|
+
s.tidx_blks_hit,
|
|
28
|
+
c.reltuples AS row_estimate
|
|
29
|
+
FROM pg_stat_user_tables t
|
|
30
|
+
LEFT JOIN pg_statio_user_tables s ON t.relid = s.relid
|
|
31
|
+
LEFT JOIN pg_class c ON t.relid = c.oid
|
|
32
|
+
WHERE t.relname = $1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# One column of a report: what to read from a row, how to label it, and how the
|
|
2
|
+
# value should be drawn. Renderers decide what those choices mean -- the HTML
|
|
3
|
+
# table right-aligns, a terminal renderer pads -- so a column stays free of
|
|
4
|
+
# either.
|
|
5
|
+
class PgPeek::Column
|
|
6
|
+
ALIGNMENTS = %i[left right].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :key, :align, :format, :title
|
|
9
|
+
|
|
10
|
+
def initialize(key, header: nil, align: :left, format: :text, title: nil)
|
|
11
|
+
raise ArgumentError, "unknown alignment #{align.inspect}" unless ALIGNMENTS.include?(align)
|
|
12
|
+
|
|
13
|
+
@key = key
|
|
14
|
+
@header = header
|
|
15
|
+
@align = align
|
|
16
|
+
@format = format
|
|
17
|
+
@title = title
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def header
|
|
21
|
+
@header || key.to_s.tr("_", " ")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def numeric?
|
|
25
|
+
align == :right
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# A report is columns plus rows, and nothing about how either is drawn.
|
|
2
|
+
#
|
|
3
|
+
# Every angle pg_peek offers -- queries, endpoints, jobs, tables -- is one of
|
|
4
|
+
# these, so a new angle is a subclass and a .sql file rather than a controller,
|
|
5
|
+
# a view and its own styling. It also means a terminal renderer can draw the
|
|
6
|
+
# same report as the HTML one, from the same declaration.
|
|
7
|
+
class PgPeek::Report
|
|
8
|
+
class Row
|
|
9
|
+
def initialize(attributes) = @attributes = attributes
|
|
10
|
+
|
|
11
|
+
def value(column) = @attributes[column.key.to_s]
|
|
12
|
+
def [](key) = @attributes[key.to_s]
|
|
13
|
+
def to_h = @attributes
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_attribute :columns, instance_writer: false, default: []
|
|
17
|
+
|
|
18
|
+
attr_reader :database
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def title(value = nil)
|
|
22
|
+
@title = value if value
|
|
23
|
+
@title || name.demodulize.titleize
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def column(key, **options)
|
|
27
|
+
# Subclasses get their own array rather than appending to the parent's.
|
|
28
|
+
self.columns = columns + [ PgPeek::Column.new(key, **options) ]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Most reports describe one database. A few -- the database list itself --
|
|
33
|
+
# describe the set of them, and pass nothing.
|
|
34
|
+
def initialize(database: nil)
|
|
35
|
+
@database = database
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rows
|
|
39
|
+
@rows ||= fetch_rows.map { |attributes| Row.new(attributes) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty? = rows.empty?
|
|
43
|
+
|
|
44
|
+
def title = self.class.title
|
|
45
|
+
|
|
46
|
+
# Relative formats -- an intensity bar, a share of total -- need the extent of
|
|
47
|
+
# the column, not just the cell.
|
|
48
|
+
def max_for(column)
|
|
49
|
+
@maxima ||= {}
|
|
50
|
+
@maxima[column.key] ||= rows.filter_map { |row| row.value(column)&.to_f }.max
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
# Subclasses return an array of hashes keyed by column name, which is what
|
|
55
|
+
# PG::Result already yields.
|
|
56
|
+
def fetch_rows
|
|
57
|
+
raise NotImplementedError, "#{self.class} must define #fetch_rows"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pg_stat_statements
|
|
61
|
+
@pg_stat_statements ||= PgPeek::PgStatStatements.new(database: database)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Sessions waiting for a lock, and who holds it. Built from the sessions
|
|
2
|
+
# report rather than a second look at pg_stat_activity, so both tables on the
|
|
3
|
+
# page describe the same instant.
|
|
4
|
+
class PgPeek::Reports::Blocked < PgPeek::Report
|
|
5
|
+
title "Blocked sessions"
|
|
6
|
+
|
|
7
|
+
column :pid, align: :right, title: "The waiting session"
|
|
8
|
+
column :waiting_for
|
|
9
|
+
column :duration_ms, header: "for", align: :right, format: :duration_ms, title: "How long the waiting query has run"
|
|
10
|
+
column :source, title: "Endpoint or job of the waiting session"
|
|
11
|
+
column :blocked_by, align: :right, title: "The session holding the lock"
|
|
12
|
+
column :blocker, title: "State and source of the session holding the lock"
|
|
13
|
+
column :blocker_query, header: "blocker's query", format: :sql
|
|
14
|
+
|
|
15
|
+
def initialize(database:, sessions: nil)
|
|
16
|
+
super(database: database)
|
|
17
|
+
@sessions = sessions || PgPeek::Reports::Sessions.new(database: database)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def any? = rows.any?
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
attr_reader :sessions
|
|
24
|
+
|
|
25
|
+
def fetch_rows
|
|
26
|
+
blocked = sessions.blocked
|
|
27
|
+
return [] if blocked.empty?
|
|
28
|
+
|
|
29
|
+
by_pid = sessions.rows.index_by { |row| row["pid"].to_i }
|
|
30
|
+
locks = database.waiting_locks.index_by { |row| row["pid"].to_i }
|
|
31
|
+
|
|
32
|
+
blocked.map do |row|
|
|
33
|
+
blockers = row["blocked_by"].map { |pid| by_pid[pid] }.compact
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"pid" => row["pid"],
|
|
37
|
+
"waiting_for" => describe(locks[row["pid"].to_i]),
|
|
38
|
+
"duration_ms" => row["query_ms"],
|
|
39
|
+
"source" => row["source"],
|
|
40
|
+
"blocked_by" => row["blocked_by"].join(", "),
|
|
41
|
+
"blocker" => blockers.map { |b| [ b["state"], b["source"] ].compact_blank.join(" · ") }.join("; ").presence || "a session on another database",
|
|
42
|
+
"blocker_query" => blockers.map { |b| b["query"] }.compact_blank.join("\n")
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# "access exclusive lock on posts", "advisory lock (exclusive)",
|
|
48
|
+
# "transactionid lock (share)" -- the last is what a row-level conflict
|
|
49
|
+
# looks like from pg_locks.
|
|
50
|
+
def describe(lock)
|
|
51
|
+
return "a lock" unless lock
|
|
52
|
+
|
|
53
|
+
mode = lock["mode"].to_s.delete_suffix("Lock").underscore.tr("_", " ")
|
|
54
|
+
if lock["relation"].present?
|
|
55
|
+
"#{mode} lock on #{lock["relation"]}"
|
|
56
|
+
else
|
|
57
|
+
"#{lock["locktype"]} lock (#{mode})"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# The databases pg_peek can open, and whether each is actually collecting
|
|
2
|
+
# statistics. Whether a database is worth visiting is the question this page
|
|
3
|
+
# answers, so the extension's state belongs in the list rather than behind a
|
|
4
|
+
# click.
|
|
5
|
+
class PgPeek::Reports::Databases < PgPeek::Report
|
|
6
|
+
title "Databases"
|
|
7
|
+
|
|
8
|
+
column :name, header: "database"
|
|
9
|
+
column :role
|
|
10
|
+
column :version, align: :right
|
|
11
|
+
column :statistics, title: "State of the pg_stat_statements extension"
|
|
12
|
+
column :tables, align: :right, format: :number
|
|
13
|
+
|
|
14
|
+
def initialize(databases:)
|
|
15
|
+
super(database: nil)
|
|
16
|
+
@databases = databases
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
def fetch_rows
|
|
21
|
+
@databases.map do |database|
|
|
22
|
+
pg_stat = PgPeek::PgStatStatements.new(database: database)
|
|
23
|
+
|
|
24
|
+
{ "name" => database.name,
|
|
25
|
+
"role" => role_for(database),
|
|
26
|
+
"version" => database.version,
|
|
27
|
+
"statistics" => statistics_for(pg_stat),
|
|
28
|
+
"tables" => database.tables.size,
|
|
29
|
+
"database" => database }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def role_for(database)
|
|
34
|
+
return "primary" if database.primary
|
|
35
|
+
return "replica" if database.replica
|
|
36
|
+
|
|
37
|
+
""
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def statistics_for(pg_stat)
|
|
41
|
+
return "not available" unless pg_stat.available?
|
|
42
|
+
return "not installed" unless pg_stat.installed?
|
|
43
|
+
return "not preloaded" unless pg_stat.preloaded?
|
|
44
|
+
|
|
45
|
+
"collecting · #{pg_stat.installed_version}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Database cost per controller#action, attributed through SQLcommenter tags.
|
|
2
|
+
class PgPeek::Reports::Endpoints < PgPeek::Report
|
|
3
|
+
title "Endpoints"
|
|
4
|
+
|
|
5
|
+
# Above this, a query is running many times per request rather than a few:
|
|
6
|
+
# the shape of an N+1 rather than a page that legitimately asks twice.
|
|
7
|
+
N_PLUS_ONE_THRESHOLD = 5
|
|
8
|
+
|
|
9
|
+
column :endpoint
|
|
10
|
+
column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
|
|
11
|
+
column :share, align: :right, format: :intensity, title: "Share of database time across endpoints"
|
|
12
|
+
column :request_count, header: "requests", align: :right, format: :number,
|
|
13
|
+
title: "Estimated from the least-called query of this endpoint"
|
|
14
|
+
column :total_calls, header: "queries", align: :right, format: :number
|
|
15
|
+
column :query_count, header: "shapes", align: :right, format: :number
|
|
16
|
+
column :total_rows, header: "rows", align: :right, format: :number
|
|
17
|
+
column :max_queries_per_request, header: "q/req", align: :right, format: :ratio,
|
|
18
|
+
title: "Worst queries-per-request in this endpoint. An estimate, not a measurement."
|
|
19
|
+
|
|
20
|
+
def self.n_plus_one?(row)
|
|
21
|
+
row["max_queries_per_request"].to_f >= N_PLUS_ONE_THRESHOLD
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
def fetch_rows
|
|
26
|
+
rows = pg_stat_statements.endpoints
|
|
27
|
+
return [] if rows.blank?
|
|
28
|
+
|
|
29
|
+
rows.each do |row|
|
|
30
|
+
# namespaced_controller arrives url-encoded: staff%2Fprocesses#show.
|
|
31
|
+
row["endpoint"] = CGI.unescape(row["endpoint"].to_s)
|
|
32
|
+
row["share"] = row["total_exec_time_ms"]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Every index in the database, unused ones first. "Unused" means never scanned
|
|
2
|
+
# since the statistics were last reset and not enforcing a constraint --
|
|
3
|
+
# primary keys and unique indexes do their work on writes, whether or not a
|
|
4
|
+
# query ever reads them.
|
|
5
|
+
class PgPeek::Reports::Indexes < PgPeek::Report
|
|
6
|
+
title "Indexes"
|
|
7
|
+
|
|
8
|
+
column :table_name, header: "table"
|
|
9
|
+
column :index_name, header: "index"
|
|
10
|
+
column :kind
|
|
11
|
+
column :size, align: :right
|
|
12
|
+
column :scans, align: :right, format: :number, title: "Scans since statistics were last reset"
|
|
13
|
+
column :note
|
|
14
|
+
|
|
15
|
+
def unused
|
|
16
|
+
rows.select { |row| row["unused"] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def unused_bytes
|
|
20
|
+
unused.sum { |row| row["size_bytes"].to_i }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
def fetch_rows
|
|
25
|
+
rows = database.indexes.reject { |row| PgPeek::Table.excluded?(row["table_name"]) }
|
|
26
|
+
|
|
27
|
+
rows.each do |row|
|
|
28
|
+
primary = truthy?(row["primary_key"])
|
|
29
|
+
unique = truthy?(row["unique_index"])
|
|
30
|
+
|
|
31
|
+
row["kind"] = primary ? "primary key" : (unique ? "unique" : "")
|
|
32
|
+
row["unused"] = row["scans"].to_i.zero? && !primary && !unique
|
|
33
|
+
row["note"] = row["unused"] ? "unused" : ""
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rows.sort_by { |row| [ row["unused"] ? 0 : 1, -row["size_bytes"].to_i, row["table_name"], row["index_name"] ] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raw results hand booleans over as "t"/"f" or true/false depending on
|
|
40
|
+
# the adapter's type map.
|
|
41
|
+
def truthy?(value)
|
|
42
|
+
ActiveModel::Type::Boolean.new.cast(value) == true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The query shapes one job class issues, most expensive first.
|
|
2
|
+
class PgPeek::Reports::JobQueries < PgPeek::Reports::Queries
|
|
3
|
+
attr_reader :job_class
|
|
4
|
+
|
|
5
|
+
def initialize(database:, job_class:)
|
|
6
|
+
super(database: database)
|
|
7
|
+
@job_class = job_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def source_rows
|
|
12
|
+
pg_stat_statements.outliers_by_job(job_class)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Database cost per ActiveJob class, attributed through the `job` SQLcommenter
|
|
2
|
+
# tag. Queue depth, retries and failures belong to the job backend; this is the
|
|
3
|
+
# database's view of the same jobs.
|
|
4
|
+
class PgPeek::Reports::Jobs < PgPeek::Report
|
|
5
|
+
title "Jobs"
|
|
6
|
+
|
|
7
|
+
column :job_class, header: "job"
|
|
8
|
+
column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
|
|
9
|
+
column :share, align: :right, format: :intensity, title: "Share of database time across jobs"
|
|
10
|
+
column :total_calls, header: "queries", align: :right, format: :number
|
|
11
|
+
column :query_count, header: "shapes", align: :right, format: :number,
|
|
12
|
+
title: "Distinct query shapes issued by this job"
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def fetch_rows
|
|
16
|
+
rows = pg_stat_statements.jobs
|
|
17
|
+
return [] if rows.blank?
|
|
18
|
+
|
|
19
|
+
rows.each { |row| row["share"] = row["total_exec_time_ms"] }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# The most expensive query shapes in the database.
|
|
2
|
+
class PgPeek::Reports::Queries < PgPeek::Report
|
|
3
|
+
title "Queries"
|
|
4
|
+
|
|
5
|
+
column :calls, align: :right, format: :intensity, title: "Call count relative to the other queries listed"
|
|
6
|
+
column :total_exec_time, header: "db time", align: :right, format: :duration
|
|
7
|
+
column :prop_exec_time, header: "share", align: :right
|
|
8
|
+
column :avg_exec_ms, header: "avg", align: :right, format: :duration_ms
|
|
9
|
+
column :query, format: :sql
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
def fetch_rows
|
|
13
|
+
rows = source_rows
|
|
14
|
+
return [] if rows.blank?
|
|
15
|
+
|
|
16
|
+
# ncalls arrives already formatted with separators; the bar needs a number.
|
|
17
|
+
rows.each { |row| row["calls"] = row["ncalls"].to_s.delete(",").to_i }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def source_rows
|
|
21
|
+
pg_stat_statements.outliers
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Every other client session on the database right now, the busiest first:
|
|
2
|
+
# blocked sessions, then running queries by age, then open transactions by
|
|
3
|
+
# age, then idle connections. Unlike every other report this is a snapshot,
|
|
4
|
+
# not a sum since the last reset.
|
|
5
|
+
class PgPeek::Reports::Sessions < PgPeek::Report
|
|
6
|
+
title "Sessions"
|
|
7
|
+
|
|
8
|
+
HIDDEN_QUERY = "<insufficient privilege>".freeze
|
|
9
|
+
|
|
10
|
+
column :pid, align: :right
|
|
11
|
+
column :state
|
|
12
|
+
column :duration_ms, header: "for", align: :right, format: :duration_ms,
|
|
13
|
+
title: "Active: how long the current query has run. Idle in transaction: age of the open transaction. Idle: time since the last query finished."
|
|
14
|
+
column :wait, title: "What the session is waiting on: another session's lock, IO, its client -- or nothing, which means CPU"
|
|
15
|
+
column :source, title: "Endpoint or job from the query's SQLcommenter tags, otherwise the application name"
|
|
16
|
+
column :query, format: :sql
|
|
17
|
+
|
|
18
|
+
def active = rows.select { |row| row["state"] == "active" }
|
|
19
|
+
def idle = rows.select { |row| row["state"] == "idle" }
|
|
20
|
+
def in_transaction = rows.select { |row| row["state"].to_s.start_with?("idle in transaction") }
|
|
21
|
+
def blocked = rows.select { |row| row["blocked_by"].any? }
|
|
22
|
+
|
|
23
|
+
# Everything an operator would look at; idle connections are a pool doing
|
|
24
|
+
# its job.
|
|
25
|
+
def busy = rows.reject { |row| row["state"] == "idle" }
|
|
26
|
+
|
|
27
|
+
def long_running(threshold_ms)
|
|
28
|
+
active.select { |row| row["duration_ms"].to_i >= threshold_ms }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stale_transactions(threshold_ms)
|
|
32
|
+
in_transaction.select { |row| row["duration_ms"].to_i >= threshold_ms }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# PostgreSQL shows a session's query text only to its own user, superusers
|
|
36
|
+
# and members of pg_read_all_stats. Anything else reads as a placeholder.
|
|
37
|
+
def hidden_queries?
|
|
38
|
+
rows.any? { |row| row["query"] == HIDDEN_QUERY }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
def fetch_rows
|
|
43
|
+
rows = database.sessions
|
|
44
|
+
|
|
45
|
+
rows.each do |row|
|
|
46
|
+
row["blocked_by"] = row["blocked_by"].to_s.split(",").map(&:to_i)
|
|
47
|
+
row["duration_ms"] = duration_for(row)
|
|
48
|
+
row["wait"] = wait_for(row)
|
|
49
|
+
row["source"] = source_for(row)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
rows.sort_by { |row| [ priority(row), -row["duration_ms"].to_i, row["pid"].to_i ] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def priority(row)
|
|
56
|
+
return 0 if row["blocked_by"].any?
|
|
57
|
+
|
|
58
|
+
case row["state"]
|
|
59
|
+
when "active" then 1
|
|
60
|
+
when "idle" then 3
|
|
61
|
+
else 2
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The figure that matters depends on what the session is doing: a running
|
|
66
|
+
# query's age, an open transaction's age, or how long a connection has
|
|
67
|
+
# sat unused.
|
|
68
|
+
def duration_for(row)
|
|
69
|
+
value = case row["state"]
|
|
70
|
+
when "active" then row["query_ms"]
|
|
71
|
+
when "idle" then row["state_ms"]
|
|
72
|
+
else row["xact_ms"] || row["state_ms"]
|
|
73
|
+
end
|
|
74
|
+
value&.to_i
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_for(row)
|
|
78
|
+
return "blocked by #{row["blocked_by"].join(", ")}" if row["blocked_by"].any?
|
|
79
|
+
return "" if row["wait_event_type"].blank?
|
|
80
|
+
|
|
81
|
+
"#{row["wait_event_type"].downcase}: #{row["wait_event"]}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def source_for(row)
|
|
85
|
+
tags = PgPeek::SqlComment.tags(row["query"])
|
|
86
|
+
PgPeek::SqlComment.label(tags) || row["application_name"].presence || ""
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Every table in the database with the cache ratios already fetched in one
|
|
2
|
+
# round trip by Table.inline_stats_for.
|
|
3
|
+
class PgPeek::Reports::Tables < PgPeek::Report
|
|
4
|
+
title "Tables"
|
|
5
|
+
|
|
6
|
+
column :name, header: "table"
|
|
7
|
+
column :heap_hit_ratio, header: "heap cache", align: :right, format: :percent,
|
|
8
|
+
title: "Share of heap block reads served from cache"
|
|
9
|
+
column :index_hit_ratio, header: "index cache", align: :right, format: :percent,
|
|
10
|
+
title: "Share of index block reads served from cache"
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
def fetch_rows
|
|
14
|
+
names = database.tables
|
|
15
|
+
stats = PgPeek::Table.inline_stats_for(database, names)
|
|
16
|
+
|
|
17
|
+
names.map do |name|
|
|
18
|
+
row = stats[name] || {}
|
|
19
|
+
|
|
20
|
+
{ "name" => name,
|
|
21
|
+
"heap_hit_ratio" => row[:heap_hit_ratio],
|
|
22
|
+
"index_hit_ratio" => row[:index_hit_ratio] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<title>pg_peek</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
|
|
10
|
+
<%= yield :head %>
|
|
11
|
+
|
|
12
|
+
<%= stylesheet_link_tag "pg_peek/application", media: "all" %>
|
|
13
|
+
<%= javascript_include_tag "pg_peek/application", defer: true %>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main>
|
|
17
|
+
<% if flash[:notice] %><p class="notice"><%= flash[:notice] %></p><% end %>
|
|
18
|
+
<nav>
|
|
19
|
+
<ul>
|
|
20
|
+
<li><%= link_to "pg_peek", databases_path, class: "wordmark" %></li>
|
|
21
|
+
</ul>
|
|
22
|
+
<ul>
|
|
23
|
+
<% if @database %>
|
|
24
|
+
<li><%= link_to "overview", database_path(@database) %></li>
|
|
25
|
+
<li><%= link_to "queries", database_queries_path(@database) %></li>
|
|
26
|
+
<li><%= link_to "endpoints", database_endpoints_path(@database) %></li>
|
|
27
|
+
<li><%= link_to "jobs", database_jobs_path(@database) %></li>
|
|
28
|
+
<li><%= link_to "indexes", database_indexes_path(@database) %></li>
|
|
29
|
+
<li><%= link_to "activity", database_activity_path(@database) %></li>
|
|
30
|
+
<li class="current-database">
|
|
31
|
+
<% if switchable_databases.many? %>
|
|
32
|
+
<span class="switcher">
|
|
33
|
+
<select data-switcher aria-label="Switch database">
|
|
34
|
+
<% switchable_databases.each do |candidate| %>
|
|
35
|
+
<option value="<%= switch_database_path(candidate) %>" <%= "selected" if candidate.name == @database.name %>><%= candidate.name %></option>
|
|
36
|
+
<% end %>
|
|
37
|
+
</select>
|
|
38
|
+
</span>
|
|
39
|
+
<% else %>
|
|
40
|
+
<%= link_to @database.name, database_path(@database) %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</li>
|
|
43
|
+
<% end %>
|
|
44
|
+
</ul>
|
|
45
|
+
</nav>
|
|
46
|
+
|
|
47
|
+
<%= yield %>
|
|
48
|
+
</main>
|
|
49
|
+
|
|
50
|
+
<footer class="status">
|
|
51
|
+
<span>pg_peek <%= PgPeek::VERSION %></span>
|
|
52
|
+
<a href="https://github.com/trix/pg_peek" rel="noopener noreferrer">source</a>
|
|
53
|
+
<button type="button" data-theme-toggle aria-label="Toggle theme" title="Press t to toggle">theme: light</button>
|
|
54
|
+
</footer>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<% if @refresh %>
|
|
2
|
+
<% content_for :head do %><meta http-equiv="refresh" content="<%= @refresh %>"><% end %>
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<div class="toolbar">
|
|
6
|
+
<h1>activity</h1>
|
|
7
|
+
<small class="dim">
|
|
8
|
+
captured <%= @captured_at.strftime("%H:%M:%S") %>
|
|
9
|
+
· refresh:
|
|
10
|
+
<% PgPeek::ActivityController::REFRESH_INTERVALS.each do |seconds| %>
|
|
11
|
+
<%= link_to_unless @refresh == seconds, "#{seconds}s", database_activity_path(@database, refresh: seconds, idle: @show_idle ? 1 : nil) do |name| %><b><%= name %></b><% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= link_to_unless @refresh.nil?, "off", database_activity_path(@database, idle: @show_idle ? 1 : nil) do |name| %><b><%= name %></b><% end %>
|
|
14
|
+
· <kbd>r</kbd> reloads
|
|
15
|
+
</small>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<p class="vitals dim">
|
|
19
|
+
<span><%= @summary["client_connections"] %> of <%= @summary["max_connections"] %> server connections in use</span>
|
|
20
|
+
<span>this database: <%= @sessions.active.size %> active</span>
|
|
21
|
+
<span><%= @sessions.in_transaction.size %> idle in transaction</span>
|
|
22
|
+
<span><%= @sessions.idle.size %> idle</span>
|
|
23
|
+
<% if @blocked.any? %><span><%= @blocked.rows.size %> blocked</span><% end %>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<% if @blocked.any? %>
|
|
27
|
+
<article aria-label="Blocked sessions">
|
|
28
|
+
<header><%= pluralize(@blocked.rows.size, "session") %> waiting for a lock</header>
|
|
29
|
+
<%= render "pg_peek/reports/report", report: @blocked %>
|
|
30
|
+
<footer>
|
|
31
|
+
<small>
|
|
32
|
+
pg_peek only looks. To end a query: <code>SELECT pg_cancel_backend(pid)</code>;
|
|
33
|
+
to drop the whole session: <code>SELECT pg_terminate_backend(pid)</code>.
|
|
34
|
+
</small>
|
|
35
|
+
</footer>
|
|
36
|
+
</article>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<% if @sessions.hidden_queries? %>
|
|
40
|
+
<article aria-label="Query text hidden">
|
|
41
|
+
<header>queries of other users are hidden</header>
|
|
42
|
+
<p>
|
|
43
|
+
PostgreSQL shows a session's query only to its own user, superusers and
|
|
44
|
+
members of <code>pg_read_all_stats</code>. pg_peek connects as
|
|
45
|
+
<code><%= @summary["username"] %></code>:
|
|
46
|
+
</p>
|
|
47
|
+
<pre><code>GRANT pg_read_all_stats TO <%= @summary["username"] %>;</code></pre>
|
|
48
|
+
</article>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<% visible = @show_idle ? @sessions.rows : @sessions.busy %>
|
|
52
|
+
|
|
53
|
+
<h2>
|
|
54
|
+
sessions
|
|
55
|
+
<small class="dim">
|
|
56
|
+
<% if @show_idle %>
|
|
57
|
+
<%= link_to "hide idle", database_activity_path(@database, refresh: @refresh) %>
|
|
58
|
+
<% elsif @sessions.idle.any? %>
|
|
59
|
+
<%= @sessions.idle.size %> idle hidden · <%= link_to "show idle", database_activity_path(@database, refresh: @refresh, idle: 1) %>
|
|
60
|
+
<% end %>
|
|
61
|
+
</small>
|
|
62
|
+
</h2>
|
|
63
|
+
|
|
64
|
+
<% if @sessions.empty? %>
|
|
65
|
+
<p class="dim">No other session is connected to <%= @database.name %>.</p>
|
|
66
|
+
<% elsif visible.empty? %>
|
|
67
|
+
<p class="dim">Nothing running. Every other session is idle.</p>
|
|
68
|
+
<% else %>
|
|
69
|
+
<%= render "pg_peek/reports/report", report: @sessions, rows: visible %>
|
|
70
|
+
<% end %>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<h1>pg_peek</h1>
|
|
2
|
+
|
|
3
|
+
<article aria-label="Authentication is not configured">
|
|
4
|
+
<header>Authentication is not configured</header>
|
|
5
|
+
<p>
|
|
6
|
+
pg_peek exposes your schema, every normalized query in the database, and an
|
|
7
|
+
endpoint that resets query statistics. It refuses to serve
|
|
8
|
+
<code><%= Rails.env %></code> until you say how it should be protected.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p>Either give it credentials, and it will ask for them:</p>
|
|
12
|
+
<pre><code>PG_PEEK_USERNAME=... PG_PEEK_PASSWORD=...</code></pre>
|
|
13
|
+
<p>or, equivalently, in <code>config/credentials.yml.enc</code>:</p>
|
|
14
|
+
<pre><code>pg_peek:
|
|
15
|
+
username: ...
|
|
16
|
+
password: ...</code></pre>
|
|
17
|
+
|
|
18
|
+
<p>
|
|
19
|
+
Or, if the engine is already mounted behind your own authentication, say so
|
|
20
|
+
in <code>config/initializers/pg_peek.rb</code>:
|
|
21
|
+
</p>
|
|
22
|
+
<pre><code>config.public_dashboard = true</code></pre>
|
|
23
|
+
|
|
24
|
+
<footer>
|
|
25
|
+
<small>
|
|
26
|
+
Development and test are exempt, so this never interrupts local work.
|
|
27
|
+
Credentials, once set, apply everywhere — including a development
|
|
28
|
+
server exposed through a tunnel.
|
|
29
|
+
</small>
|
|
30
|
+
</footer>
|
|
31
|
+
</article>
|