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.
Files changed (120) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +217 -0
  4. data/Rakefile +16 -0
  5. data/app/assets/javascripts/pg_peek/application.js +147 -0
  6. data/app/assets/stylesheets/pg_peek/application.css +362 -0
  7. data/app/controllers/pg_peek/activity_controller.rb +16 -0
  8. data/app/controllers/pg_peek/application_controller.rb +36 -0
  9. data/app/controllers/pg_peek/databases_controller.rb +96 -0
  10. data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
  11. data/app/controllers/pg_peek/indexes_controller.rb +7 -0
  12. data/app/controllers/pg_peek/jobs_controller.rb +44 -0
  13. data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
  14. data/app/controllers/pg_peek/queries_controller.rb +13 -0
  15. data/app/controllers/pg_peek/tables_controller.rb +6 -0
  16. data/app/helpers/pg_peek/application_helper.rb +179 -0
  17. data/app/helpers/pg_peek/report_helper.rb +58 -0
  18. data/app/jobs/pg_peek/application_job.rb +4 -0
  19. data/app/mailers/pg_peek/application_mailer.rb +6 -0
  20. data/app/models/pg_peek/application_record.rb +3 -0
  21. data/app/models/pg_peek/database.rb +131 -0
  22. data/app/models/pg_peek/pg_stat_statements.rb +132 -0
  23. data/app/models/pg_peek/sql_comment.rb +25 -0
  24. data/app/models/pg_peek/table.rb +266 -0
  25. data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
  26. data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
  27. data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
  28. data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
  29. data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
  30. data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
  31. data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
  32. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
  33. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
  34. data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
  35. data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
  36. data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
  37. data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
  38. data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
  39. data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
  40. data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
  41. data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
  42. data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
  43. data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
  44. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
  45. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
  46. data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
  47. data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
  48. data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
  49. data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
  50. data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
  51. data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
  52. data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
  53. data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
  54. data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
  55. data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
  56. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
  57. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
  58. data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
  59. data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
  60. data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
  61. data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
  62. data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
  63. data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
  64. data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
  65. data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
  66. data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
  67. data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
  68. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
  69. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
  70. data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
  71. data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
  72. data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
  73. data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
  74. data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
  75. data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
  76. data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
  77. data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
  78. data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
  79. data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
  80. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
  81. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
  82. data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
  83. data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
  84. data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
  85. data/app/reports/pg_peek/column.rb +27 -0
  86. data/app/reports/pg_peek/report.rb +63 -0
  87. data/app/reports/pg_peek/reports/blocked.rb +60 -0
  88. data/app/reports/pg_peek/reports/databases.rb +47 -0
  89. data/app/reports/pg_peek/reports/endpoints.rb +35 -0
  90. data/app/reports/pg_peek/reports/indexes.rb +44 -0
  91. data/app/reports/pg_peek/reports/job_queries.rb +14 -0
  92. data/app/reports/pg_peek/reports/jobs.rb +21 -0
  93. data/app/reports/pg_peek/reports/queries.rb +23 -0
  94. data/app/reports/pg_peek/reports/sessions.rb +88 -0
  95. data/app/reports/pg_peek/reports/tables.rb +25 -0
  96. data/app/views/layouts/pg_peek/application.html.erb +56 -0
  97. data/app/views/pg_peek/activity/show.html.erb +70 -0
  98. data/app/views/pg_peek/authentication_required.html.erb +31 -0
  99. data/app/views/pg_peek/databases/index.html.erb +43 -0
  100. data/app/views/pg_peek/databases/show.html.erb +169 -0
  101. data/app/views/pg_peek/endpoints/index.html.erb +24 -0
  102. data/app/views/pg_peek/indexes/index.html.erb +31 -0
  103. data/app/views/pg_peek/jobs/index.html.erb +51 -0
  104. data/app/views/pg_peek/jobs/show.html.erb +17 -0
  105. data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
  106. data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
  107. data/app/views/pg_peek/queries/index.html.erb +10 -0
  108. data/app/views/pg_peek/reports/_report.html.erb +32 -0
  109. data/app/views/pg_peek/tables/show.html.erb +128 -0
  110. data/config/routes.rb +16 -0
  111. data/lib/generators/pg_peek/install/install_generator.rb +40 -0
  112. data/lib/generators/pg_peek/install/templates/README +31 -0
  113. data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
  114. data/lib/pg_peek/configuration.rb +59 -0
  115. data/lib/pg_peek/engine.rb +7 -0
  116. data/lib/pg_peek/query_loader.rb +59 -0
  117. data/lib/pg_peek/version.rb +3 -0
  118. data/lib/pg_peek.rb +10 -0
  119. data/lib/tasks/pg_peek_tasks.rake +4 -0
  120. metadata +192 -0
@@ -0,0 +1,179 @@
1
+ module PgPeek::ApplicationHelper
2
+ # Where the current section lives in another database. Detail pages fall back
3
+ # to their section, since the other database may not have that table or job.
4
+ def switch_database_path(database)
5
+ case controller_name
6
+ when "queries" then database_queries_path(database)
7
+ when "endpoints" then database_endpoints_path(database)
8
+ when "jobs" then database_jobs_path(database)
9
+ when "indexes" then database_indexes_path(database)
10
+ when "activity" then database_activity_path(database)
11
+ else database_path(database)
12
+ end
13
+ end
14
+
15
+ # example sql comment: /*action='show',application='Trmz',namespaced_controller='staff%2Fprocesses%2Fprocesses'*/
16
+
17
+ SQLCOMMENTER_PATTERN = %r{
18
+ \s* # optional leading whitespace
19
+ /\* # opening /*
20
+ (?: # one or more key:value pairs
21
+ [a-zA-Z_][a-zA-Z0-9_]* # key (starts with letter/underscore)
22
+ = # colon
23
+ [^:*]* # value (anything except * or : to avoid breaking */
24
+ \s*)+
25
+ \*/ # closing */
26
+ \s* # optional trailing whitespace
27
+ }xi
28
+
29
+ def strip_sqlcommenter(sql)
30
+ sql.gsub(SQLCOMMENTER_PATTERN, "").strip
31
+ end
32
+
33
+ # Where a query's SQLcommenter tags point, if anywhere: a job's own page,
34
+ # or the endpoints list for a controller/action (there is no per-endpoint
35
+ # page to link into yet, so this lands on the list rather than one row of it).
36
+ def sqlcommenter_href(database, tags)
37
+ return database_job_path(database, tags["job"]) if tags["job"].present?
38
+
39
+ controller = tags["namespaced_controller"] || tags["controller"]
40
+ return database_endpoints_path(database) if controller.present?
41
+
42
+ nil
43
+ end
44
+
45
+ # Format a rate with auto-scaled units based on magnitude
46
+ def format_rate(count, seconds_elapsed)
47
+ return "N/A" if seconds_elapsed.nil? || seconds_elapsed.zero? || count.nil?
48
+
49
+ per_second = count.to_f / seconds_elapsed
50
+
51
+ formatted = case per_second
52
+ when 0...0.017 # < 1/min
53
+ "#{format_number((per_second * 86400).round(1))}/day"
54
+ when 0.017...1 # < 1/sec
55
+ "#{format_number((per_second * 3600).round(1))}/hr"
56
+ when 1...60 # < 60/sec
57
+ "#{format_number((per_second * 60).round(1))}/min"
58
+ else
59
+ "#{format_number(per_second.round(1))}/s"
60
+ end
61
+
62
+ "(~#{formatted})"
63
+ end
64
+
65
+ # Format a count with its rate
66
+ def format_count_with_rate(count, seconds_elapsed)
67
+ return "N/A" if count.nil?
68
+
69
+ formatted_count = format_number(count)
70
+ rate = format_rate(count, seconds_elapsed)
71
+
72
+ if rate == "N/A"
73
+ formatted_count
74
+ else
75
+ "#{formatted_count} #{rate}"
76
+ end
77
+ end
78
+
79
+ # Format a number with thousands separators
80
+ def format_number(number)
81
+ return "N/A" if number.nil?
82
+ number_with_delimiter(number)
83
+ end
84
+
85
+ # Format a percentage, returning "N/A" for nil
86
+ def format_percentage(value)
87
+ return "N/A" if value.nil?
88
+ "#{value}%"
89
+ end
90
+
91
+ # Format a timestamp as relative time (e.g., "3 hours ago")
92
+ def format_time_ago(timestamp)
93
+ return "Never" if timestamp.nil?
94
+ time_ago_in_words(timestamp) + " ago"
95
+ end
96
+
97
+ # Every pg_stat_statements figure is cumulative since the last reset. Each
98
+ # page says so in its header and links to the overview block that resets it.
99
+ def stats_since_link(database, reset_at)
100
+ link_to "stats since #{format_time_ago(reset_at)}",
101
+ database_path(database, anchor: "pg_stat_statements"),
102
+ title: "figures are cumulative since the last pg_stat_statements reset"
103
+ end
104
+
105
+ # Format cache hit ratio for inline display
106
+ def format_inline_cache_stats(heap_ratio, index_ratio)
107
+ heap_display = heap_ratio.nil? ? "-" : "#{heap_ratio}%"
108
+ index_display = index_ratio.nil? ? "-" : "#{index_ratio}%"
109
+ "H: #{heap_display} I: #{index_display}"
110
+ end
111
+
112
+ # Horizontal bar meter for call intensity visualization
113
+ # Returns a 5-segment bar: ██░░░ (low) → █████ (high)
114
+ INTENSITY_SEGMENTS = 5
115
+
116
+ # One character throughout, with colour rather than shading separating the
117
+ # filled part from the rest: mixed block glyphs vary in weight between fonts
118
+ # and read as noise at this size.
119
+ def intensity_bar(value, max_value)
120
+ filled = intensity_segments(value, max_value)
121
+
122
+ # The level class lets the stylesheet brighten the bar with its value.
123
+ tag.span(class: "bar bar-#{filled}") do
124
+ tag.span("█" * filled, class: "bar-on") +
125
+ tag.span("█" * (INTENSITY_SEGMENTS - filled), class: "bar-off")
126
+ end
127
+ end
128
+
129
+ # A non-zero value always keeps one segment lit, so "rare but present" is
130
+ # distinguishable from "absent" at a glance.
131
+ def intensity_segments(value, max_value)
132
+ return 0 if value.nil? || max_value.nil? || max_value.zero?
133
+
134
+ ((value.to_f / max_value) * INTENSITY_SEGMENTS).ceil.clamp(0, INTENSITY_SEGMENTS)
135
+ end
136
+
137
+ # Durations pick their unit by magnitude: 210ms, 1.20s, 2m 05s, 1h 12m.
138
+ # Values stay narrow and read at a glance, and a header sits over data of
139
+ # similar width. The exact figure belongs in a title, not the cell.
140
+ def format_duration_from_ms(total_ms)
141
+ ms = total_ms.to_f
142
+ return "0ms" if ms <= 0
143
+ return format("%.1fms", ms) if ms < 10
144
+ return "#{ms.round}ms" if ms < 1000
145
+
146
+ seconds = ms / 1000.0
147
+ return format("%.2fs", seconds) if seconds < 60
148
+
149
+ minutes, secs = seconds.divmod(60)
150
+ return format("%dm %02ds", minutes, secs) if minutes < 60
151
+
152
+ hours, mins = minutes.divmod(60)
153
+ format("%dh %02dm", hours, mins)
154
+ end
155
+
156
+ # Rails sets intervalstyle = iso_8601 on its connections, so an interval
157
+ # arrives as "PT1.204S". Anything else -- a raw psql connection, or a host
158
+ # that overrides the style -- renders as "HH:MM:SS.fff" with a "N days"
159
+ # prefix once long enough. Both are read.
160
+ def format_duration(interval_text)
161
+ format_duration_from_ms(interval_to_ms(interval_text))
162
+ end
163
+
164
+ def interval_to_ms(interval_text)
165
+ text = interval_text.to_s.strip
166
+ return 0.0 if text.empty?
167
+ return ActiveSupport::Duration.parse(text).to_f * 1000.0 if text.start_with?("P")
168
+
169
+ days = text[/(\d+)\s+days?/, 1].to_i
170
+ hours = minutes = seconds = 0.0
171
+ if (match = text.match(/(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/))
172
+ hours, minutes, seconds = match[1].to_i, match[2].to_i, match[3].to_f
173
+ end
174
+
175
+ (days * 86_400 + hours * 3600 + minutes * 60 + seconds) * 1000.0
176
+ rescue ActiveSupport::Duration::ISO8601Parser::ParsingError
177
+ 0.0
178
+ end
179
+ end
@@ -0,0 +1,58 @@
1
+ # Turns a report cell into markup. The report is passed alongside the column
2
+ # because some formats are relative -- an intensity bar means nothing without
3
+ # the largest value in its column.
4
+ module PgPeek::ReportHelper
5
+ def format_cell(report, column, row)
6
+ value = row.value(column)
7
+ return "" if value.nil? || value.to_s.empty?
8
+
9
+ case column.format
10
+ when :number then format_number(value.to_i)
11
+ when :duration_ms then duration_cell(value.to_f)
12
+ when :duration then duration_cell(interval_to_ms(value))
13
+ when :percent then "#{value}%"
14
+ when :ratio then "~#{value}"
15
+ when :sql then sql_cell(report, value.to_s)
16
+ when :intensity then intensity_bar(value.to_f, report.max_for(column))
17
+ else value.to_s
18
+ end
19
+ end
20
+
21
+ # The compact figure is what you scan; the exact one is a hover away.
22
+ def duration_cell(ms)
23
+ tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(1))} ms")
24
+ end
25
+
26
+ # The SQL text, plus -- when SQLcommenter tagged it -- what issued it (a
27
+ # link into the page that already aggregates this exact query across every
28
+ # call site) and every raw tag behind a disclosure, for the rest.
29
+ def sql_cell(report, sql)
30
+ code = tag.code(strip_sqlcommenter(sql).squish, class: "sql")
31
+ tags = PgPeek::SqlComment.tags(sql)
32
+ return code if tags.empty?
33
+
34
+ safe_join([ code, sql_source(report, tags), sql_tags(tags) ].compact)
35
+ end
36
+
37
+ def sql_source(report, tags)
38
+ label = PgPeek::SqlComment.label(tags)
39
+ return nil unless label
40
+
41
+ href = report.database && sqlcommenter_href(report.database, tags)
42
+ tag.div(safe_join([ "→ ", href ? link_to(label, href) : label ]), class: "dim")
43
+ end
44
+
45
+ def sql_tags(tags)
46
+ tag.details(safe_join([
47
+ tag.summary("tags"),
48
+ tag.pre(tags.map { |key, value| "#{key}: #{value}" }.join("\n"))
49
+ ]), class: "tags")
50
+ end
51
+
52
+ def cell_class(column)
53
+ case column.format
54
+ when :intensity then "intensity"
55
+ else column.numeric? ? "num" : nil
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ module PgPeek
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module PgPeek
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ class PgPeek::ApplicationRecord < ActiveRecord::Base
2
+ self.abstract_class = true
3
+ end
@@ -0,0 +1,131 @@
1
+ class PgPeek::Database
2
+ attr_reader :name, :primary, :replica, :schema_format
3
+
4
+ def self.available_postgres_databases
5
+ ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).select do |db_config|
6
+ db_config.adapter == "postgresql"
7
+ end
8
+ end
9
+
10
+ def self.all
11
+ available_postgres_databases.map do |db_config|
12
+ PgPeek::Database.new(
13
+ name: db_config.name,
14
+ primary: db_config.primary?,
15
+ replica: db_config.replica?,
16
+ schema_format: db_config.schema_format
17
+ )
18
+ end
19
+ end
20
+
21
+ def self.find(name)
22
+ all.find { |db| db.name == name }
23
+ end
24
+
25
+ def initialize(name:, primary: false, replica: false, schema_format:)
26
+ @name = name
27
+ @primary = primary
28
+ @replica = replica
29
+ @schema_format = schema_format
30
+ end
31
+
32
+ def to_param
33
+ @name
34
+ end
35
+
36
+ # The other configured databases on the same server, judged by host and
37
+ # port. Production tends to give each database its own server; review apps
38
+ # put them all on one, where a server-wide action such as a
39
+ # pg_stat_statements reset reaches every one of them.
40
+ def server_peers
41
+ self.class.all.reject { |other| other.name == name }.select { |other| other.server == server }
42
+ end
43
+
44
+ def server
45
+ config = self.class.available_postgres_databases.find { |db_config| db_config.name == name }
46
+ return unless config
47
+
48
+ [ config.host.presence || "localhost", config.configuration_hash[:port].presence || 5432 ].map(&:to_s)
49
+ end
50
+
51
+ def connection
52
+ connection_class = PgPeek.config.connection_class_for(name)
53
+ connection_class&.connection
54
+ end
55
+
56
+ # Cheap enough for the layout to ask on every request: reads the config, does
57
+ # not resolve the class or check out a connection.
58
+ def configured?
59
+ PgPeek.config.connections.key?(name)
60
+ end
61
+
62
+ def connection_configured?
63
+ connection.present?
64
+ end
65
+
66
+ def version_full
67
+ @version_full ||= connection.execute(PgPeek::QueryLoader.mark("SELECT version()")).first["version"]
68
+ end
69
+
70
+ def version
71
+ @version ||= version_full.match(/PostgreSQL ([\d.]+)/)[1]
72
+ end
73
+
74
+ def major_version
75
+ @major_version ||= version.split(".").first.to_i
76
+ end
77
+
78
+ def installed_extensions
79
+ connection.execute(PgPeek::QueryLoader.mark("SELECT name, default_version, installed_version FROM pg_available_extensions WHERE installed_version IS NOT NULL ORDER BY name")).to_a
80
+ end
81
+
82
+ def vitals
83
+ @vitals ||= begin
84
+ query = PgPeek::QueryLoader.load("database/vitals", pg_version: major_version)
85
+ connection.execute(query).first || {}
86
+ end
87
+ end
88
+
89
+ def indexes
90
+ query = PgPeek::QueryLoader.load("indexes/all", pg_version: major_version)
91
+ connection.execute(query).to_a
92
+ end
93
+
94
+ def sessions
95
+ query = PgPeek::QueryLoader.load("activity/sessions", pg_version: major_version)
96
+ connection.execute(query).to_a
97
+ end
98
+
99
+ def waiting_locks
100
+ query = PgPeek::QueryLoader.load("activity/waiting_locks", pg_version: major_version)
101
+ connection.execute(query).to_a
102
+ end
103
+
104
+ def connection_summary
105
+ query = PgPeek::QueryLoader.load("activity/summary", pg_version: major_version)
106
+ connection.execute(query).first || {}
107
+ end
108
+
109
+ def stats_reset_at
110
+ value = vitals["stats_reset"]
111
+ Time.zone.parse(value.to_s) if value.present?
112
+ end
113
+
114
+ def tables_with_dead_tuples(threshold)
115
+ query = PgPeek::QueryLoader.load("tables/dead_tuples", pg_version: major_version, threshold: threshold.to_i)
116
+ connection.execute(query).to_a
117
+ end
118
+
119
+ def tables
120
+ connection.tables.sort - PgPeek.config.excluded_tables
121
+ end
122
+
123
+ def table_exists?(table_name)
124
+ connection.tables.include?(table_name)
125
+ end
126
+
127
+ def find_table(name)
128
+ return nil unless table_exists?(name)
129
+ PgPeek::Table.new(self, name)
130
+ end
131
+ end
@@ -0,0 +1,132 @@
1
+ class PgPeek::PgStatStatements
2
+ LIBRARY_NAME = "pg_stat_statements".freeze
3
+
4
+ attr_reader :database, :default_version, :installed_version
5
+
6
+ def initialize(database:)
7
+ @database = database
8
+
9
+ verify_installation
10
+ end
11
+
12
+ def connection
13
+ database.connection
14
+ end
15
+
16
+ def available?
17
+ default_version.present?
18
+ end
19
+
20
+ def installed?
21
+ installed_version.present?
22
+ end
23
+
24
+ # The extension can be created in the database while the module itself was
25
+ # never loaded at server start. Querying the view then raises
26
+ # PG::ObjectNotInPrerequisiteState, so every read has to check this first.
27
+ def preloaded?
28
+ shared_preload_libraries.split(",").map(&:strip).include?(LIBRARY_NAME)
29
+ end
30
+
31
+ def usable?
32
+ installed? && preloaded?
33
+ end
34
+
35
+ def shared_preload_libraries
36
+ @shared_preload_libraries ||= connection.select_value(PgPeek::QueryLoader.mark("SHOW shared_preload_libraries")).to_s
37
+ end
38
+
39
+ def outdated?
40
+ return false unless installed?
41
+
42
+ @installed_version != @default_version
43
+ end
44
+
45
+ # Server-wide on purpose. pg_stat_statements_info keeps a single stats_reset
46
+ # for the whole server and only moves it when every entry is removed, so a
47
+ # reset scoped to this database would leave "stats since" pointing at the
48
+ # old time.
49
+ def reset!
50
+ return unless usable?
51
+
52
+ connection.execute PgPeek::QueryLoader.mark(<<-SQL)
53
+ SELECT pg_stat_statements_reset();
54
+ SQL
55
+ end
56
+
57
+ def reset_at
58
+ return unless usable?
59
+
60
+ value = connection.select_value PgPeek::QueryLoader.mark(<<-SQL)
61
+ SELECT stats_reset FROM pg_stat_statements_info;
62
+ SQL
63
+
64
+ Time.zone.parse(value.to_s) if value.present?
65
+ end
66
+
67
+ def outliers
68
+ return unless usable?
69
+
70
+ query = PgPeek::QueryLoader.load("pg_stat_statements/outliers",
71
+ pg_version: database.major_version,
72
+ limit: PgPeek.config.outliers_limit)
73
+ result = connection.execute(query)
74
+ result.to_a
75
+ end
76
+
77
+ def statement_count
78
+ return unless usable?
79
+
80
+ connection.select_value(PgPeek::QueryLoader.mark(<<~SQL)).to_i
81
+ SELECT count(*) FROM pg_stat_statements
82
+ WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
83
+ SQL
84
+ end
85
+
86
+ def endpoints
87
+ return unless usable?
88
+
89
+ query = PgPeek::QueryLoader.load("pg_stat_statements/endpoints",
90
+ pg_version: database.major_version,
91
+ limit: PgPeek.config.outliers_limit)
92
+ connection.execute(query).to_a
93
+ end
94
+
95
+ def jobs
96
+ return unless usable?
97
+
98
+ query = PgPeek::QueryLoader.load("pg_stat_statements/jobs",
99
+ pg_version: database.major_version)
100
+ result = connection.execute(query)
101
+ result.to_a
102
+ end
103
+
104
+ def outliers_by_job(job_class)
105
+ return unless usable?
106
+
107
+ # Build pattern to match job='JobClassName' in SQLcommenter
108
+ # Need to escape for SQL LIKE and quote properly
109
+ escaped_job = job_class.gsub("'", "''")
110
+ pattern = "%job='#{escaped_job}'%"
111
+
112
+ query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_job",
113
+ pg_version: database.major_version,
114
+ job_pattern: connection.quote(pattern),
115
+ limit: PgPeek.config.outliers_limit)
116
+ result = connection.execute(query)
117
+ result.to_a
118
+ end
119
+
120
+ private
121
+
122
+ def verify_installation
123
+ result = connection.execute PgPeek::QueryLoader.mark(<<-SQL)
124
+ SELECT default_version, installed_version FROM pg_available_extensions WHERE name = 'pg_stat_statements';
125
+ SQL
126
+
127
+ if result.any?
128
+ @default_version = result.first["default_version"]
129
+ @installed_version = result.first["installed_version"]
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,25 @@
1
+ # Parses the trailing SQLcommenter comment Rails' query_log_tags appends to a
2
+ # query -- e.g. /*action='index',application='Dummy',controller='posts'*/ --
3
+ # and reduces it to the one thing worth showing at a glance: what issued this
4
+ # query. Used by both the Sessions report and the view layer, so there is one
5
+ # parser rather than the two that used to disagree on how tags are shaped.
6
+ class PgPeek::SqlComment
7
+ TAG = /(\w+)='([^']*)'/
8
+
9
+ def self.tags(query)
10
+ comment = query.to_s[%r{/\*([^*]*=[^*]*)\*/}, 1] or return {}
11
+ comment.scan(TAG).to_h { |key, value| [ key, CGI.unescape(value) ] }
12
+ end
13
+
14
+ # A job always names itself. Otherwise, the endpoint that issued this, if
15
+ # SQLcommenter tagged it. Otherwise nil -- callers fall back to whatever
16
+ # else identifies the source (an application_name, or nothing).
17
+ def self.label(tags)
18
+ return tags["job"] if tags["job"].present?
19
+
20
+ controller = tags["namespaced_controller"] || tags["controller"]
21
+ return nil if controller.blank?
22
+
23
+ [ controller, tags["action"] ].compact_blank.join("#")
24
+ end
25
+ end