pghero 2.2.1 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of pghero might be problematic. Click here for more details.

Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -53
  3. data/README.md +20 -8
  4. data/app/assets/javascripts/pghero/Chart.bundle.js +16260 -15580
  5. data/app/assets/javascripts/pghero/application.js +8 -7
  6. data/app/assets/javascripts/pghero/chartkick.js +1973 -1325
  7. data/app/assets/javascripts/pghero/highlight.pack.js +2 -2
  8. data/app/assets/javascripts/pghero/jquery.js +3605 -4015
  9. data/app/assets/javascripts/pghero/nouislider.js +2479 -0
  10. data/app/assets/stylesheets/pghero/application.css +1 -1
  11. data/app/assets/stylesheets/pghero/nouislider.css +299 -0
  12. data/app/controllers/pg_hero/home_controller.rb +97 -42
  13. data/app/helpers/pg_hero/home_helper.rb +11 -0
  14. data/app/views/pg_hero/home/_live_queries_table.html.erb +14 -3
  15. data/app/views/pg_hero/home/connections.html.erb +9 -0
  16. data/app/views/pg_hero/home/index.html.erb +49 -10
  17. data/app/views/pg_hero/home/live_queries.html.erb +1 -1
  18. data/app/views/pg_hero/home/maintenance.html.erb +16 -2
  19. data/app/views/pg_hero/home/relation_space.html.erb +2 -2
  20. data/app/views/pg_hero/home/show_query.html.erb +4 -5
  21. data/app/views/pg_hero/home/space.html.erb +3 -3
  22. data/app/views/pg_hero/home/system.html.erb +4 -4
  23. data/app/views/pg_hero/home/tune.html.erb +2 -1
  24. data/lib/generators/pghero/config_generator.rb +1 -1
  25. data/lib/generators/pghero/query_stats_generator.rb +3 -20
  26. data/lib/generators/pghero/space_stats_generator.rb +3 -20
  27. data/lib/generators/pghero/templates/config.yml.tt +21 -1
  28. data/lib/pghero.rb +82 -17
  29. data/lib/pghero/database.rb +104 -19
  30. data/lib/pghero/methods/basic.rb +34 -25
  31. data/lib/pghero/methods/connections.rb +35 -0
  32. data/lib/pghero/methods/constraints.rb +30 -0
  33. data/lib/pghero/methods/explain.rb +1 -1
  34. data/lib/pghero/methods/indexes.rb +1 -1
  35. data/lib/pghero/methods/maintenance.rb +3 -1
  36. data/lib/pghero/methods/queries.rb +7 -3
  37. data/lib/pghero/methods/query_stats.rb +93 -25
  38. data/lib/pghero/methods/sequences.rb +1 -1
  39. data/lib/pghero/methods/space.rb +4 -0
  40. data/lib/pghero/methods/suggested_indexes.rb +1 -1
  41. data/lib/pghero/methods/system.rb +219 -23
  42. data/lib/pghero/methods/users.rb +4 -0
  43. data/lib/pghero/query_stats.rb +1 -3
  44. data/lib/pghero/space_stats.rb +5 -0
  45. data/lib/pghero/stats.rb +6 -0
  46. data/lib/pghero/version.rb +1 -1
  47. data/lib/tasks/pghero.rake +10 -4
  48. metadata +15 -12
  49. data/app/assets/javascripts/pghero/jquery.nouislider.min.js +0 -31
  50. data/app/assets/stylesheets/pghero/jquery.nouislider.css +0 -165
data/lib/pghero.rb CHANGED
@@ -5,6 +5,7 @@ require "forwardable"
5
5
  # methods
6
6
  require "pghero/methods/basic"
7
7
  require "pghero/methods/connections"
8
+ require "pghero/methods/constraints"
8
9
  require "pghero/methods/explain"
9
10
  require "pghero/methods/indexes"
10
11
  require "pghero/methods/kill"
@@ -26,32 +27,37 @@ require "pghero/version"
26
27
 
27
28
  module PgHero
28
29
  autoload :Connection, "pghero/connection"
30
+ autoload :Stats, "pghero/stats"
29
31
  autoload :QueryStats, "pghero/query_stats"
32
+ autoload :SpaceStats, "pghero/space_stats"
30
33
 
31
34
  class Error < StandardError; end
32
35
  class NotEnabled < Error; end
33
36
 
37
+ MUTEX = Mutex.new
38
+
34
39
  # settings
35
40
  class << self
36
- attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :explain_timeout_sec, :total_connections_threshold, :cache_hit_rate_threshold, :env, :show_migrations, :config_path
41
+ attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :explain_timeout_sec, :total_connections_threshold, :cache_hit_rate_threshold, :env, :show_migrations, :config_path, :filter_data
37
42
  end
38
43
  self.long_running_query_sec = (ENV["PGHERO_LONG_RUNNING_QUERY_SEC"] || 60).to_i
39
44
  self.slow_query_ms = (ENV["PGHERO_SLOW_QUERY_MS"] || 20).to_i
40
45
  self.slow_query_calls = (ENV["PGHERO_SLOW_QUERY_CALLS"] || 100).to_i
41
- self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_i
46
+ self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_f
42
47
  self.total_connections_threshold = (ENV["PGHERO_TOTAL_CONNECTIONS_THRESHOLD"] || 500).to_i
43
48
  self.cache_hit_rate_threshold = 99
44
49
  self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
45
50
  self.show_migrations = true
46
51
  self.config_path = ENV["PGHERO_CONFIG_PATH"] || "config/pghero.yml"
52
+ self.filter_data = ENV["PGHERO_FILTER_DATA"].to_s.size > 0
47
53
 
48
54
  class << self
49
55
  extend Forwardable
50
56
  def_delegators :primary_database, :access_key_id, :analyze, :analyze_tables, :autoindex, :autovacuum_danger,
51
- :best_index, :blocked_queries, :connection_sources, :connection_states, :connection_stats,
57
+ :best_index, :blocked_queries, :connections, :connection_sources, :connection_states, :connection_stats,
52
58
  :cpu_usage, :create_user, :database_size, :db_instance_identifier, :disable_query_stats, :drop_user,
53
59
  :duplicate_indexes, :enable_query_stats, :explain, :historical_query_stats_enabled?, :index_caching,
54
- :index_hit_rate, :index_usage, :indexes, :invalid_indexes, :kill, :kill_all, :kill_long_running_queries,
60
+ :index_hit_rate, :index_usage, :indexes, :invalid_constraints, :invalid_indexes, :kill, :kill_all, :kill_long_running_queries,
55
61
  :last_stats_reset_time, :long_running_queries, :maintenance_info, :missing_indexes, :query_stats,
56
62
  :query_stats_available?, :query_stats_enabled?, :query_stats_extension_enabled?, :query_stats_readable?,
57
63
  :rds_stats, :read_iops_stats, :region, :relation_sizes, :replica?, :replication_lag, :replication_lag_stats,
@@ -68,6 +74,22 @@ module PgHero
68
74
  @time_zone || Time.zone
69
75
  end
70
76
 
77
+ # use method instead of attr_accessor to ensure
78
+ # this works if variable set after PgHero is loaded
79
+ def username
80
+ @username ||= config["username"] || ENV["PGHERO_USERNAME"]
81
+ end
82
+
83
+ # use method instead of attr_accessor to ensure
84
+ # this works if variable set after PgHero is loaded
85
+ def password
86
+ @password ||= config["password"] || ENV["PGHERO_PASSWORD"]
87
+ end
88
+
89
+ def stats_database_url
90
+ @stats_database_url ||= config["stats_database_url"] || ENV["PGHERO_STATS_DATABASE_URL"]
91
+ end
92
+
71
93
  def config
72
94
  @config ||= begin
73
95
  require "erb"
@@ -87,26 +109,49 @@ module PgHero
87
109
  elsif config_file_exists
88
110
  raise "Invalid config file"
89
111
  else
90
- {
91
- "databases" => {
92
- "primary" => {
93
- "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config,
94
- "db_instance_identifier" => ENV["PGHERO_DB_INSTANCE_IDENTIFIER"]
95
- }
112
+ databases = {}
113
+
114
+ if !ENV["PGHERO_DATABASE_URL"] && spec_supported?
115
+ ActiveRecord::Base.configurations.configs_for(env_name: env, include_replicas: true).each do |db|
116
+ databases[db.spec_name] = {"spec" => db.spec_name}
117
+ end
118
+ end
119
+
120
+ if databases.empty?
121
+ databases["primary"] = {
122
+ "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config
96
123
  }
124
+ end
125
+
126
+ if databases.size == 1
127
+ databases.values.first.merge!(
128
+ "db_instance_identifier" => ENV["PGHERO_DB_INSTANCE_IDENTIFIER"],
129
+ "gcp_database_id" => ENV["PGHERO_GCP_DATABASE_ID"],
130
+ "azure_resource_id" => ENV["PGHERO_AZURE_RESOURCE_ID"]
131
+ )
132
+ end
133
+
134
+ {
135
+ "databases" => databases
97
136
  }
98
137
  end
99
138
  end
100
139
  end
101
140
 
141
+ # ensure we only have one copy of databases
142
+ # so there's only one connection pool per database
102
143
  def databases
103
- @databases ||= begin
104
- Hash[
105
- config["databases"].map do |id, c|
106
- [id.to_sym, PgHero::Database.new(id, c)]
107
- end
108
- ]
144
+ unless defined?(@databases)
145
+ # only use mutex on initialization
146
+ MUTEX.synchronize do
147
+ # return if another process initialized while we were waiting
148
+ return @databases if defined?(@databases)
149
+
150
+ @databases = config["databases"].map { |id, c| [id.to_sym, Database.new(id, c)] }.to_h
151
+ end
109
152
  end
153
+
154
+ @databases
110
155
  end
111
156
 
112
157
  def primary_database
@@ -137,7 +182,7 @@ module PgHero
137
182
 
138
183
  def autoindex_all(create: false, verbose: true)
139
184
  each_database do |database|
140
- puts "Autoindexing #{database}..." if verbose
185
+ puts "Autoindexing #{database.id}..." if verbose
141
186
  database.autoindex(create: create)
142
187
  end
143
188
  end
@@ -146,6 +191,26 @@ module PgHero
146
191
  ActiveSupport::NumberHelper.number_to_human_size(value, precision: 3)
147
192
  end
148
193
 
194
+ # delete previous stats
195
+ # go database by database to use an index
196
+ # stats for old databases are not cleaned up since we can't use an index
197
+ def clean_query_stats
198
+ each_database do |database|
199
+ database.clean_query_stats
200
+ end
201
+ end
202
+
203
+ def clean_space_stats
204
+ each_database do |database|
205
+ database.clean_space_stats
206
+ end
207
+ end
208
+
209
+ # private
210
+ def spec_supported?
211
+ ActiveRecord::VERSION::MAJOR >= 6
212
+ end
213
+
149
214
  private
150
215
 
151
216
  def each_database
@@ -2,6 +2,7 @@ module PgHero
2
2
  class Database
3
3
  include Methods::Basic
4
4
  include Methods::Connections
5
+ include Methods::Constraints
5
6
  include Methods::Explain
6
7
  include Methods::Indexes
7
8
  include Methods::Kill
@@ -22,16 +23,17 @@ module PgHero
22
23
  def initialize(id, config)
23
24
  @id = id
24
25
  @config = config || {}
26
+
27
+ # preload model to ensure only one connection pool
28
+ # this doesn't actually start any connections
29
+ @adapter_checked = false
30
+ @connection_model = build_connection_model
25
31
  end
26
32
 
27
33
  def name
28
34
  @name ||= @config["name"] || id.titleize
29
35
  end
30
36
 
31
- def db_instance_identifier
32
- @db_instance_identifier ||= @config["db_instance_identifier"]
33
- end
34
-
35
37
  def capture_query_stats?
36
38
  config["capture_query_stats"] != false
37
39
  end
@@ -53,35 +55,118 @@ module PgHero
53
55
  end
54
56
 
55
57
  def explain_timeout_sec
56
- (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_i
58
+ (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_f
57
59
  end
58
60
 
59
61
  def long_running_query_sec
60
62
  (config["long_running_query_sec"] || PgHero.config["long_running_query_sec"] || PgHero.long_running_query_sec).to_i
61
63
  end
62
64
 
65
+ # defaults to 100 megabytes
63
66
  def index_bloat_bytes
64
- (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 100.megabytes).to_i
67
+ (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 104857600).to_i
65
68
  end
66
69
 
67
- private
70
+ def aws_access_key_id
71
+ config["aws_access_key_id"] || PgHero.config["aws_access_key_id"] || ENV["PGHERO_ACCESS_KEY_ID"] || ENV["AWS_ACCESS_KEY_ID"]
72
+ end
68
73
 
69
- def connection_model
70
- @connection_model ||= begin
71
- url = config["url"]
72
- Class.new(PgHero::Connection) do
73
- def self.name
74
- "PgHero::Connection::Database#{object_id}"
74
+ def aws_secret_access_key
75
+ config["aws_secret_access_key"] || PgHero.config["aws_secret_access_key"] || ENV["PGHERO_SECRET_ACCESS_KEY"] || ENV["AWS_SECRET_ACCESS_KEY"]
76
+ end
77
+
78
+ def aws_region
79
+ config["aws_region"] || PgHero.config["aws_region"] || ENV["PGHERO_REGION"] || ENV["AWS_REGION"] || (defined?(Aws) && Aws.config[:region]) || "us-east-1"
80
+ end
81
+
82
+ # environment variable is only used if no config file
83
+ def aws_db_instance_identifier
84
+ @aws_db_instance_identifier ||= config["aws_db_instance_identifier"] || config["db_instance_identifier"]
85
+ end
86
+
87
+ # environment variable is only used if no config file
88
+ def gcp_database_id
89
+ @gcp_database_id ||= config["gcp_database_id"]
90
+ end
91
+
92
+ # environment variable is only used if no config file
93
+ def azure_resource_id
94
+ @azure_resource_id ||= config["azure_resource_id"]
95
+ end
96
+
97
+ # must check keys for booleans
98
+ def filter_data
99
+ unless defined?(@filter_data)
100
+ @filter_data =
101
+ if config.key?("filter_data")
102
+ config["filter_data"]
103
+ elsif PgHero.config.key?("filter_data")
104
+ PgHero.config.key?("filter_data")
105
+ else
106
+ PgHero.filter_data
75
107
  end
76
- case url
77
- when String
78
- url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
79
- when Hash
80
- url[:connect_timeout] ||= 5
108
+
109
+ if @filter_data
110
+ begin
111
+ require "pg_query"
112
+ rescue LoadError
113
+ raise Error, "pg_query required for filter_data"
81
114
  end
82
- establish_connection url if url
83
115
  end
84
116
  end
117
+
118
+ @filter_data
119
+ end
120
+
121
+ # TODO remove in next major version
122
+ alias_method :access_key_id, :aws_access_key_id
123
+ alias_method :secret_access_key, :aws_secret_access_key
124
+ alias_method :region, :aws_region
125
+ alias_method :db_instance_identifier, :aws_db_instance_identifier
126
+
127
+ private
128
+
129
+ # check adapter lazily
130
+ def connection_model
131
+ unless @adapter_checked
132
+ # rough check for Postgres adapter
133
+ # keep this message generic so it's useful
134
+ # when empty url set in Docker image pghero.yml
135
+ unless @connection_model.connection.adapter_name =~ /postg/i
136
+ raise Error, "Invalid connection URL"
137
+ end
138
+ @adapter_checked = true
139
+ end
140
+
141
+ @connection_model
142
+ end
143
+
144
+ # just return the model
145
+ # do not start a connection
146
+ def build_connection_model
147
+ url = config["url"]
148
+
149
+ # resolve spec
150
+ if !url && config["spec"]
151
+ raise Error, "Spec requires Rails 6+" unless PgHero.spec_supported?
152
+ resolved = ActiveRecord::Base.configurations.configs_for(env_name: PgHero.env, spec_name: config["spec"], include_replicas: true)
153
+ raise Error, "Spec not found: #{config["spec"]}" unless resolved
154
+ url = resolved.config
155
+ end
156
+
157
+ Class.new(PgHero::Connection) do
158
+ def self.name
159
+ "PgHero::Connection::Database#{object_id}"
160
+ end
161
+
162
+ case url
163
+ when String
164
+ url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
165
+ when Hash
166
+ url[:connect_timeout] ||= 5
167
+ end
168
+ establish_connection url if url
169
+ end
85
170
  end
86
171
  end
87
172
  end
@@ -18,6 +18,10 @@ module PgHero
18
18
  select_one("SELECT current_database()")
19
19
  end
20
20
 
21
+ def current_user
22
+ select_one("SELECT current_user")
23
+ end
24
+
21
25
  def server_version
22
26
  @server_version ||= select_one("SHOW server_version")
23
27
  end
@@ -32,14 +36,34 @@ module PgHero
32
36
 
33
37
  private
34
38
 
35
- def select_all(sql, conn = nil)
39
+ def select_all(sql, conn: nil, query_columns: [])
36
40
  conn ||= connection
37
41
  # squish for logs
38
42
  retries = 0
39
43
  begin
40
44
  result = conn.select_all(add_source(squish(sql)))
41
- cast_method = ActiveRecord::VERSION::MAJOR < 5 ? :type_cast : :cast_value
42
- result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(cast_method, val)] }] }
45
+ result = result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(:cast_value, val)] }] }
46
+ if filter_data
47
+ query_columns.each do |column|
48
+ result.each do |row|
49
+ begin
50
+ row[column] = PgQuery.normalize(row[column])
51
+ rescue PgQuery::ParseError
52
+ # try replacing "interval $1" with "$1::interval"
53
+ # see https://github.com/lfittl/pg_query/issues/169 for more info
54
+ # this is not ideal since it changes the query slightly
55
+ # we could skip normalization
56
+ # but this has a very small chance of data leakage
57
+ begin
58
+ row[column] = PgQuery.normalize(row[column].gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
59
+ rescue PgQuery::ParseError
60
+ row[column] = "<unable to filter data>"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ result
43
67
  rescue ActiveRecord::StatementInvalid => e
44
68
  # fix for random internal errors
45
69
  if e.message.include?("PG::InternalError") && retries < 2
@@ -52,8 +76,8 @@ module PgHero
52
76
  end
53
77
  end
54
78
 
55
- def select_all_stats(sql)
56
- select_all(sql, stats_connection)
79
+ def select_all_stats(sql, **options)
80
+ select_all(sql, **options, conn: stats_connection)
57
81
  end
58
82
 
59
83
  def select_all_size(sql)
@@ -64,12 +88,12 @@ module PgHero
64
88
  result
65
89
  end
66
90
 
67
- def select_one(sql, conn = nil)
68
- select_all(sql, conn).first.values.first
91
+ def select_one(sql, conn: nil)
92
+ select_all(sql, conn: conn).first.values.first
69
93
  end
70
94
 
71
95
  def select_one_stats(sql)
72
- select_one(sql, stats_connection)
96
+ select_one(sql, conn: stats_connection)
73
97
  end
74
98
 
75
99
  def execute(sql)
@@ -81,7 +105,7 @@ module PgHero
81
105
  end
82
106
 
83
107
  def stats_connection
84
- ::PgHero::QueryStats.connection
108
+ ::PgHero::Stats.connection
85
109
  end
86
110
 
87
111
  def insert_stats(table, columns, values)
@@ -125,22 +149,7 @@ module PgHero
125
149
  end
126
150
 
127
151
  def table_exists?(table)
128
- ["PostgreSQL", "PostGIS"].include?(stats_connection.adapter_name) &&
129
- select_one_stats(<<-SQL
130
- SELECT EXISTS (
131
- SELECT
132
- 1
133
- FROM
134
- pg_catalog.pg_class c
135
- INNER JOIN
136
- pg_catalog.pg_namespace n ON n.oid = c.relnamespace
137
- WHERE
138
- n.nspname = 'public'
139
- AND c.relname = #{quote(table)}
140
- AND c.relkind = 'r'
141
- )
142
- SQL
143
- )
152
+ stats_connection.table_exists?(table)
144
153
  end
145
154
  end
146
155
  end
@@ -1,6 +1,41 @@
1
1
  module PgHero
2
2
  module Methods
3
3
  module Connections
4
+ def connections
5
+ if server_version_num >= 90500
6
+ select_all <<-SQL
7
+ SELECT
8
+ pg_stat_activity.pid,
9
+ datname AS database,
10
+ usename AS user,
11
+ application_name AS source,
12
+ client_addr AS ip,
13
+ state,
14
+ ssl
15
+ FROM
16
+ pg_stat_activity
17
+ LEFT JOIN
18
+ pg_stat_ssl ON pg_stat_activity.pid = pg_stat_ssl.pid
19
+ ORDER BY
20
+ pg_stat_activity.pid
21
+ SQL
22
+ else
23
+ select_all <<-SQL
24
+ SELECT
25
+ pid,
26
+ datname AS database,
27
+ usename AS user,
28
+ application_name AS source,
29
+ client_addr AS ip,
30
+ state
31
+ FROM
32
+ pg_stat_activity
33
+ ORDER BY
34
+ pid
35
+ SQL
36
+ end
37
+ end
38
+
4
39
  def total_connections
5
40
  select_one("SELECT COUNT(*) FROM pg_stat_activity")
6
41
  end