pghero_fork 2.7.3

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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +391 -0
  3. data/CONTRIBUTING.md +42 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +3 -0
  6. data/app/assets/images/pghero/favicon.png +0 -0
  7. data/app/assets/javascripts/pghero/Chart.bundle.js +20755 -0
  8. data/app/assets/javascripts/pghero/application.js +158 -0
  9. data/app/assets/javascripts/pghero/chartkick.js +2436 -0
  10. data/app/assets/javascripts/pghero/highlight.pack.js +2 -0
  11. data/app/assets/javascripts/pghero/jquery.js +10872 -0
  12. data/app/assets/javascripts/pghero/nouislider.js +2672 -0
  13. data/app/assets/stylesheets/pghero/application.css +514 -0
  14. data/app/assets/stylesheets/pghero/arduino-light.css +86 -0
  15. data/app/assets/stylesheets/pghero/nouislider.css +310 -0
  16. data/app/controllers/pg_hero/home_controller.rb +449 -0
  17. data/app/helpers/pg_hero/home_helper.rb +30 -0
  18. data/app/views/layouts/pg_hero/application.html.erb +68 -0
  19. data/app/views/pg_hero/home/_connections_table.html.erb +16 -0
  20. data/app/views/pg_hero/home/_live_queries_table.html.erb +51 -0
  21. data/app/views/pg_hero/home/_queries_table.html.erb +72 -0
  22. data/app/views/pg_hero/home/_query_stats_slider.html.erb +16 -0
  23. data/app/views/pg_hero/home/_suggested_index.html.erb +18 -0
  24. data/app/views/pg_hero/home/connections.html.erb +32 -0
  25. data/app/views/pg_hero/home/explain.html.erb +27 -0
  26. data/app/views/pg_hero/home/index.html.erb +518 -0
  27. data/app/views/pg_hero/home/index_bloat.html.erb +72 -0
  28. data/app/views/pg_hero/home/live_queries.html.erb +11 -0
  29. data/app/views/pg_hero/home/maintenance.html.erb +55 -0
  30. data/app/views/pg_hero/home/queries.html.erb +33 -0
  31. data/app/views/pg_hero/home/relation_space.html.erb +14 -0
  32. data/app/views/pg_hero/home/show_query.html.erb +106 -0
  33. data/app/views/pg_hero/home/space.html.erb +83 -0
  34. data/app/views/pg_hero/home/system.html.erb +34 -0
  35. data/app/views/pg_hero/home/tune.html.erb +53 -0
  36. data/config/routes.rb +32 -0
  37. data/lib/generators/pghero/config_generator.rb +13 -0
  38. data/lib/generators/pghero/query_stats_generator.rb +18 -0
  39. data/lib/generators/pghero/space_stats_generator.rb +18 -0
  40. data/lib/generators/pghero/templates/config.yml.tt +46 -0
  41. data/lib/generators/pghero/templates/query_stats.rb.tt +15 -0
  42. data/lib/generators/pghero/templates/space_stats.rb.tt +13 -0
  43. data/lib/pghero.rb +246 -0
  44. data/lib/pghero/connection.rb +5 -0
  45. data/lib/pghero/database.rb +175 -0
  46. data/lib/pghero/engine.rb +16 -0
  47. data/lib/pghero/methods/basic.rb +160 -0
  48. data/lib/pghero/methods/connections.rb +77 -0
  49. data/lib/pghero/methods/constraints.rb +30 -0
  50. data/lib/pghero/methods/explain.rb +29 -0
  51. data/lib/pghero/methods/indexes.rb +332 -0
  52. data/lib/pghero/methods/kill.rb +28 -0
  53. data/lib/pghero/methods/maintenance.rb +93 -0
  54. data/lib/pghero/methods/queries.rb +75 -0
  55. data/lib/pghero/methods/query_stats.rb +349 -0
  56. data/lib/pghero/methods/replication.rb +74 -0
  57. data/lib/pghero/methods/sequences.rb +124 -0
  58. data/lib/pghero/methods/settings.rb +37 -0
  59. data/lib/pghero/methods/space.rb +141 -0
  60. data/lib/pghero/methods/suggested_indexes.rb +329 -0
  61. data/lib/pghero/methods/system.rb +287 -0
  62. data/lib/pghero/methods/tables.rb +68 -0
  63. data/lib/pghero/methods/users.rb +87 -0
  64. data/lib/pghero/query_stats.rb +5 -0
  65. data/lib/pghero/space_stats.rb +5 -0
  66. data/lib/pghero/stats.rb +6 -0
  67. data/lib/pghero/version.rb +3 -0
  68. data/lib/tasks/pghero.rake +27 -0
  69. data/licenses/LICENSE-chart.js.txt +9 -0
  70. data/licenses/LICENSE-chartkick.js.txt +22 -0
  71. data/licenses/LICENSE-highlight.js.txt +29 -0
  72. data/licenses/LICENSE-jquery.txt +20 -0
  73. data/licenses/LICENSE-moment.txt +22 -0
  74. data/licenses/LICENSE-nouislider.txt +21 -0
  75. metadata +130 -0
@@ -0,0 +1,28 @@
1
+ module PgHero
2
+ module Methods
3
+ module Kill
4
+ def kill(pid)
5
+ select_one("SELECT pg_terminate_backend(#{pid.to_i})")
6
+ end
7
+
8
+ def kill_long_running_queries(min_duration: nil)
9
+ running_queries(min_duration: min_duration || long_running_query_sec).each { |query| kill(query[:pid]) }
10
+ true
11
+ end
12
+
13
+ def kill_all
14
+ select_all <<-SQL
15
+ SELECT
16
+ pg_terminate_backend(pid)
17
+ FROM
18
+ pg_stat_activity
19
+ WHERE
20
+ pid <> pg_backend_pid()
21
+ AND query <> '<insufficient privilege>'
22
+ AND datname = current_database()
23
+ SQL
24
+ true
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,93 @@
1
+ module PgHero
2
+ module Methods
3
+ module Maintenance
4
+ # https://www.postgresql.org/docs/9.1/static/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND
5
+ # "the system will shut down and refuse to start any new transactions
6
+ # once there are fewer than 1 million transactions left until wraparound"
7
+ # warn when 10,000,000 transactions left
8
+ def transaction_id_danger(threshold: 10000000, max_value: 2146483648)
9
+ max_value = max_value.to_i
10
+ threshold = threshold.to_i
11
+
12
+ select_all <<-SQL
13
+ SELECT
14
+ n.nspname AS schema,
15
+ c.relname AS table,
16
+ #{quote(max_value)} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid)) AS transactions_left
17
+ FROM
18
+ pg_class c
19
+ INNER JOIN
20
+ pg_catalog.pg_namespace n ON n.oid = c.relnamespace
21
+ LEFT JOIN
22
+ pg_class t ON c.reltoastrelid = t.oid
23
+ WHERE
24
+ c.relkind = 'r'
25
+ AND (#{quote(max_value)} - GREATEST(AGE(c.relfrozenxid), AGE(t.relfrozenxid))) < #{quote(threshold)}
26
+ ORDER BY
27
+ 3, 1, 2
28
+ SQL
29
+ end
30
+
31
+ def autovacuum_danger
32
+ max_value = select_one("SHOW autovacuum_freeze_max_age").to_i
33
+ transaction_id_danger(threshold: 2000000, max_value: max_value)
34
+ end
35
+
36
+ def vacuum_progress
37
+ if server_version_num >= 90600
38
+ select_all <<-SQL
39
+ SELECT
40
+ pid,
41
+ phase
42
+ FROM
43
+ pg_stat_progress_vacuum
44
+ WHERE
45
+ datname = current_database()
46
+ SQL
47
+ else
48
+ []
49
+ end
50
+ end
51
+
52
+ def maintenance_info
53
+ select_all <<-SQL
54
+ SELECT
55
+ schemaname AS schema,
56
+ relname AS table,
57
+ last_vacuum,
58
+ last_autovacuum,
59
+ last_analyze,
60
+ last_autoanalyze,
61
+ n_dead_tup AS dead_rows,
62
+ n_live_tup AS live_rows
63
+ FROM
64
+ pg_stat_user_tables
65
+ ORDER BY
66
+ 1, 2
67
+ SQL
68
+ end
69
+
70
+ def analyze(table, verbose: false)
71
+ execute "ANALYZE #{verbose ? "VERBOSE " : ""}#{quote_table_name(table)}"
72
+ true
73
+ end
74
+
75
+ def analyze_tables(verbose: false, min_size: nil, tables: nil)
76
+ tables = table_stats(table: tables).reject { |s| %w(information_schema pg_catalog).include?(s[:schema]) }
77
+ tables = tables.select { |s| s[:size_bytes] > min_size } if min_size
78
+ tables.map { |s| s.slice(:schema, :table) }.each do |stats|
79
+ begin
80
+ with_transaction(lock_timeout: 5000, statement_timeout: 120000) do
81
+ analyze "#{stats[:schema]}.#{stats[:table]}", verbose: verbose
82
+ end
83
+ success = true
84
+ rescue ActiveRecord::StatementInvalid => e
85
+ $stderr.puts e.message
86
+ success = false
87
+ end
88
+ stats[:success] = success
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,75 @@
1
+ module PgHero
2
+ module Methods
3
+ module Queries
4
+ def running_queries(min_duration: nil, all: false)
5
+ query = <<-SQL
6
+ SELECT
7
+ pid,
8
+ state,
9
+ application_name AS source,
10
+ age(NOW(), COALESCE(query_start, xact_start)) AS duration,
11
+ #{server_version_num >= 90600 ? "(wait_event IS NOT NULL) AS waiting" : "waiting"},
12
+ query,
13
+ COALESCE(query_start, xact_start) AS started_at,
14
+ EXTRACT(EPOCH FROM NOW() - COALESCE(query_start, xact_start)) * 1000.0 AS duration_ms,
15
+ usename AS user
16
+ FROM
17
+ pg_stat_activity
18
+ WHERE
19
+ state <> 'idle'
20
+ AND pid <> pg_backend_pid()
21
+ AND datname = current_database()
22
+ #{min_duration ? "AND NOW() - COALESCE(query_start, xact_start) > interval '#{min_duration.to_i} seconds'" : nil}
23
+ #{all ? nil : "AND query <> '<insufficient privilege>'"}
24
+ ORDER BY
25
+ COALESCE(query_start, xact_start) DESC
26
+ SQL
27
+
28
+ select_all(query, query_columns: [:query])
29
+ end
30
+
31
+ def long_running_queries
32
+ running_queries(min_duration: long_running_query_sec)
33
+ end
34
+
35
+ # from https://wiki.postgresql.org/wiki/Lock_Monitoring
36
+ # and https://big-elephants.com/2013-09/exploring-query-locks-in-postgres/
37
+ def blocked_queries
38
+ query = <<-SQL
39
+ SELECT
40
+ COALESCE(blockingl.relation::regclass::text,blockingl.locktype) as locked_item,
41
+ blockeda.pid AS blocked_pid,
42
+ blockeda.usename AS blocked_user,
43
+ blockeda.query as blocked_query,
44
+ age(now(), blockeda.query_start) AS blocked_duration,
45
+ blockedl.mode as blocked_mode,
46
+ blockinga.pid AS blocking_pid,
47
+ blockinga.usename AS blocking_user,
48
+ blockinga.state AS state_of_blocking_process,
49
+ blockinga.query AS current_or_recent_query_in_blocking_process,
50
+ age(now(), blockinga.query_start) AS blocking_duration,
51
+ blockingl.mode as blocking_mode
52
+ FROM
53
+ pg_catalog.pg_locks blockedl
54
+ LEFT JOIN
55
+ pg_stat_activity blockeda ON blockedl.pid = blockeda.pid
56
+ LEFT JOIN
57
+ pg_catalog.pg_locks blockingl ON blockedl.pid != blockingl.pid AND (
58
+ blockingl.transactionid = blockedl.transactionid
59
+ OR (blockingl.relation = blockedl.relation AND blockingl.locktype = blockedl.locktype)
60
+ )
61
+ LEFT JOIN
62
+ pg_stat_activity blockinga ON blockingl.pid = blockinga.pid AND blockinga.datid = blockeda.datid
63
+ WHERE
64
+ NOT blockedl.granted
65
+ AND blockeda.query <> '<insufficient privilege>'
66
+ AND blockeda.datname = current_database()
67
+ ORDER BY
68
+ blocked_duration DESC
69
+ SQL
70
+
71
+ select_all(query, query_columns: [:blocked_query, :current_or_recent_query_in_blocking_process])
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,349 @@
1
+ module PgHero
2
+ module Methods
3
+ module QueryStats
4
+ def query_stats(historical: false, start_at: nil, end_at: nil, min_average_time: nil, min_calls: nil, **options)
5
+ current_query_stats = historical && end_at && end_at < Time.now ? [] : current_query_stats(**options)
6
+ historical_query_stats = historical && historical_query_stats_enabled? ? historical_query_stats(start_at: start_at, end_at: end_at, **options) : []
7
+
8
+ query_stats = combine_query_stats((current_query_stats + historical_query_stats).group_by { |q| [q[:query_hash], q[:user]] })
9
+ query_stats = combine_query_stats(query_stats.group_by { |q| [normalize_query(q[:query]), q[:user]] })
10
+
11
+ # add percentages
12
+ all_queries_total_minutes = [current_query_stats, historical_query_stats].sum { |s| (s.first || {})[:all_queries_total_minutes] || 0 }
13
+ query_stats.each do |query|
14
+ query[:average_time] = query[:total_minutes] * 1000 * 60 / query[:calls]
15
+ query[:total_percent] = query[:total_minutes] * 100.0 / all_queries_total_minutes
16
+ end
17
+
18
+ sort = options[:sort] || "total_minutes"
19
+ query_stats = query_stats.sort_by { |q| -q[sort.to_sym] }.first(100)
20
+ if min_average_time
21
+ query_stats.reject! { |q| q[:average_time] < min_average_time }
22
+ end
23
+ if min_calls
24
+ query_stats.reject! { |q| q[:calls] < min_calls }
25
+ end
26
+ query_stats
27
+ end
28
+
29
+ def query_stats_available?
30
+ select_one("SELECT COUNT(*) AS count FROM pg_available_extensions WHERE name = 'pg_stat_statements'") > 0
31
+ end
32
+
33
+ # only cache if true
34
+ def query_stats_enabled?
35
+ @query_stats_enabled ||= query_stats_readable?
36
+ end
37
+
38
+ def query_stats_extension_enabled?
39
+ select_one("SELECT COUNT(*) AS count FROM pg_extension WHERE extname = 'pg_stat_statements'") > 0
40
+ end
41
+
42
+ def query_stats_readable?
43
+ select_all("SELECT * FROM pg_stat_statements LIMIT 1")
44
+ true
45
+ rescue ActiveRecord::StatementInvalid
46
+ false
47
+ end
48
+
49
+ def enable_query_stats
50
+ execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
51
+ true
52
+ end
53
+
54
+ def disable_query_stats
55
+ execute("DROP EXTENSION IF EXISTS pg_stat_statements")
56
+ true
57
+ end
58
+
59
+ # TODO scope by database in PgHero 3.0
60
+ # (add database: database_name to options)
61
+ def reset_query_stats(**options)
62
+ reset_instance_query_stats(**options)
63
+ end
64
+
65
+ # resets query stats for the entire instance
66
+ # it's possible to reset stats for a specific
67
+ # database, user or query hash in Postgres 12+
68
+ def reset_instance_query_stats(database: nil, user: nil, query_hash: nil, raise_errors: false)
69
+ if database || user || query_hash
70
+ raise PgHero::Error, "Requires PostgreSQL 12+" if server_version_num < 120000
71
+
72
+ if database
73
+ database_id = execute("SELECT oid FROM pg_database WHERE datname = #{quote(database)}").first.try(:[], "oid")
74
+ raise PgHero::Error, "Database not found: #{database}" unless database_id
75
+ else
76
+ database_id = 0
77
+ end
78
+
79
+ if user
80
+ user_id = execute("SELECT usesysid FROM pg_user WHERE usename = #{quote(user)}").first.try(:[], "usesysid")
81
+ raise PgHero::Error, "User not found: #{user}" unless user_id
82
+ else
83
+ user_id = 0
84
+ end
85
+
86
+ if query_hash
87
+ query_id = query_hash.to_i
88
+ # may not be needed
89
+ # but not intuitive that all query hashes are reset with 0
90
+ raise PgHero::Error, "Invalid query hash: #{query_hash}" if query_id == 0
91
+ else
92
+ query_id = 0
93
+ end
94
+
95
+ execute("SELECT pg_stat_statements_reset(#{quote(user_id.to_i)}, #{quote(database_id.to_i)}, #{quote(query_id.to_i)})")
96
+ else
97
+ execute("SELECT pg_stat_statements_reset()")
98
+ end
99
+ true
100
+ rescue ActiveRecord::StatementInvalid => e
101
+ raise e if raise_errors
102
+ false
103
+ end
104
+
105
+ # https://stackoverflow.com/questions/20582500/how-to-check-if-a-table-exists-in-a-given-schema
106
+ def historical_query_stats_enabled?
107
+ # TODO use schema from config
108
+ # make sure primary database is PostgreSQL first
109
+ query_stats_table_exists? && capture_query_stats? && !missing_query_stats_columns.any?
110
+ end
111
+
112
+ def query_stats_table_exists?
113
+ table_exists?("pghero_query_stats")
114
+ end
115
+
116
+ def missing_query_stats_columns
117
+ %w(query_hash user) - PgHero::QueryStats.column_names
118
+ end
119
+
120
+ def supports_query_hash?
121
+ server_version_num >= 90400
122
+ end
123
+
124
+ # resetting query stats will reset across the entire Postgres instance
125
+ # this is problematic if multiple PgHero databases use the same Postgres instance
126
+ #
127
+ # to get around this, we capture queries for every Postgres database before we
128
+ # reset query stats for the Postgres instance with the `capture_query_stats` option
129
+ def capture_query_stats(raise_errors: false)
130
+ return if config["capture_query_stats"] && config["capture_query_stats"] != true
131
+
132
+ # get all databases that use same query stats and build mapping
133
+ mapping = {id => database_name}
134
+ PgHero.databases.select { |_, d| d.config["capture_query_stats"] == id }.each do |_, d|
135
+ mapping[d.id] = d.database_name
136
+ end
137
+
138
+ now = Time.now
139
+
140
+ query_stats = {}
141
+ mapping.each do |database_id, database_name|
142
+ query_stats[database_id] = query_stats(limit: 1000000, database: database_name)
143
+ end
144
+
145
+ query_stats = query_stats.select { |_, v| v.any? }
146
+
147
+ # nothing to do
148
+ return if query_stats.empty?
149
+
150
+ # use mapping, not query stats here
151
+ # TODO add option for this, and make default in PgHero 3.0
152
+ if false # mapping.size == 1 && server_version_num >= 120000
153
+ query_stats.each do |db_id, db_query_stats|
154
+ if reset_query_stats(database: mapping[db_id], raise_errors: raise_errors)
155
+ insert_query_stats(db_id, db_query_stats, now)
156
+ end
157
+ end
158
+ else
159
+ if reset_query_stats(raise_errors: raise_errors)
160
+ query_stats.each do |db_id, db_query_stats|
161
+ insert_query_stats(db_id, db_query_stats, now)
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ def clean_query_stats
168
+ PgHero::QueryStats.where(database: id).where("captured_at < ?", 14.days.ago).delete_all
169
+ end
170
+
171
+ def slow_queries(query_stats: nil, **options)
172
+ query_stats ||= self.query_stats(options)
173
+ query_stats.select { |q| q[:calls].to_i >= slow_query_calls.to_i && q[:average_time].to_f >= slow_query_ms.to_f }
174
+ end
175
+
176
+ def query_hash_stats(query_hash, user: nil)
177
+ if historical_query_stats_enabled? && supports_query_hash?
178
+ start_at = 24.hours.ago
179
+ select_all_stats <<-SQL
180
+ SELECT
181
+ captured_at,
182
+ total_time / 1000 / 60 AS total_minutes,
183
+ (total_time / calls) AS average_time,
184
+ calls,
185
+ (SELECT regexp_matches(query, '.*/\\*(.+?)\\*/'))[1] AS origin
186
+ FROM
187
+ pghero_query_stats
188
+ WHERE
189
+ database = #{quote(id)}
190
+ AND captured_at >= #{quote(start_at)}
191
+ AND query_hash = #{quote(query_hash)}
192
+ #{user ? "AND \"user\" = #{quote(user)}" : ""}
193
+ ORDER BY
194
+ 1 ASC
195
+ SQL
196
+ else
197
+ raise NotEnabled, "Query hash stats not enabled"
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ # http://www.craigkerstiens.com/2013/01/10/more-on-postgres-performance/
204
+ def current_query_stats(limit: nil, sort: nil, database: nil, query_hash: nil)
205
+ if query_stats_enabled?
206
+ limit ||= 100
207
+ sort ||= "total_minutes"
208
+ total_time = server_version_num >= 130000 ? "(total_plan_time + total_exec_time)" : "total_time"
209
+ query = <<-SQL
210
+ WITH query_stats AS (
211
+ SELECT
212
+ LEFT(query, 10000) AS query,
213
+ #{supports_query_hash? ? "queryid" : "md5(query)"} AS query_hash,
214
+ rolname AS user,
215
+ (#{total_time} / 1000 / 60) AS total_minutes,
216
+ (#{total_time} / calls) AS average_time,
217
+ calls
218
+ FROM
219
+ pg_stat_statements
220
+ INNER JOIN
221
+ pg_database ON pg_database.oid = pg_stat_statements.dbid
222
+ INNER JOIN
223
+ pg_roles ON pg_roles.oid = pg_stat_statements.userid
224
+ WHERE
225
+ calls > 0 AND
226
+ pg_database.datname = #{database ? quote(database) : "current_database()"}
227
+ #{query_hash ? "AND queryid = #{quote(query_hash)}" : nil}
228
+ )
229
+ SELECT
230
+ query,
231
+ query_hash,
232
+ query_stats.user,
233
+ total_minutes,
234
+ average_time,
235
+ calls,
236
+ total_minutes * 100.0 / (SELECT SUM(total_minutes) FROM query_stats) AS total_percent,
237
+ (SELECT SUM(total_minutes) FROM query_stats) AS all_queries_total_minutes
238
+ FROM
239
+ query_stats
240
+ ORDER BY
241
+ #{quote_table_name(sort)} DESC
242
+ LIMIT #{limit.to_i}
243
+ SQL
244
+
245
+ # we may be able to skip query_columns
246
+ # in more recent versions of Postgres
247
+ # as pg_stat_statements should be already normalized
248
+ select_all(query, query_columns: [:query])
249
+ else
250
+ raise NotEnabled, "Query stats not enabled"
251
+ end
252
+ end
253
+
254
+ def historical_query_stats(sort: nil, start_at: nil, end_at: nil, query_hash: nil)
255
+ if historical_query_stats_enabled?
256
+ sort ||= "total_minutes"
257
+ query = <<-SQL
258
+ WITH query_stats AS (
259
+ SELECT
260
+ #{supports_query_hash? ? "query_hash" : "md5(query)"} AS query_hash,
261
+ pghero_query_stats.user AS user,
262
+ array_agg(LEFT(query, 10000) ORDER BY REPLACE(LEFT(query, 1000), '?', '!') COLLATE "C" ASC) AS query,
263
+ (SUM(total_time) / 1000 / 60) AS total_minutes,
264
+ (SUM(total_time) / SUM(calls)) AS average_time,
265
+ SUM(calls) AS calls
266
+ FROM
267
+ pghero_query_stats
268
+ WHERE
269
+ database = #{quote(id)}
270
+ #{supports_query_hash? ? "AND query_hash IS NOT NULL" : ""}
271
+ #{start_at ? "AND captured_at >= #{quote(start_at)}" : ""}
272
+ #{end_at ? "AND captured_at <= #{quote(end_at)}" : ""}
273
+ #{query_hash ? "AND query_hash = #{quote(query_hash)}" : ""}
274
+ GROUP BY
275
+ 1, 2
276
+ )
277
+ SELECT
278
+ query_hash,
279
+ query_stats.user,
280
+ query[1] AS query,
281
+ query[array_length(query, 1)] AS explainable_query,
282
+ total_minutes,
283
+ average_time,
284
+ calls,
285
+ total_minutes * 100.0 / (SELECT SUM(total_minutes) FROM query_stats) AS total_percent,
286
+ (SELECT SUM(total_minutes) FROM query_stats) AS all_queries_total_minutes
287
+ FROM
288
+ query_stats
289
+ ORDER BY
290
+ #{quote_table_name(sort)} DESC
291
+ LIMIT 100
292
+ SQL
293
+
294
+ # we can skip query_columns if all stored data is normalized
295
+ # for now, assume it's not
296
+ select_all_stats(query, query_columns: [:query, :explainable_query])
297
+ else
298
+ raise NotEnabled, "Historical query stats not enabled"
299
+ end
300
+ end
301
+
302
+ def combine_query_stats(grouped_stats)
303
+ query_stats = []
304
+ grouped_stats.each do |_, stats2|
305
+ value = {
306
+ query: (stats2.find { |s| s[:query] } || {})[:query],
307
+ user: (stats2.find { |s| s[:user] } || {})[:user],
308
+ query_hash: (stats2.find { |s| s[:query_hash] } || {})[:query_hash],
309
+ total_minutes: stats2.sum { |s| s[:total_minutes] },
310
+ calls: stats2.sum { |s| s[:calls] }.to_i,
311
+ all_queries_total_minutes: stats2.sum { |s| s[:all_queries_total_minutes] }
312
+ }
313
+ value[:total_percent] = value[:total_minutes] * 100.0 / value[:all_queries_total_minutes]
314
+ value[:explainable_query] = stats2.map { |s| s[:explainable_query] }.select { |q| q && explainable?(q) }.first
315
+ query_stats << value
316
+ end
317
+ query_stats
318
+ end
319
+
320
+ def explainable?(query)
321
+ query =~ /select/i && !query.include?("?)") && !query.include?("= ?") && !query.include?("$1") && query !~ /limit \?/i
322
+ end
323
+
324
+ # removes comments
325
+ # combines ?, ?, ? => ?
326
+ def normalize_query(query)
327
+ squish(query.to_s.gsub(/\?(, ?\?)+/, "?").gsub(/\/\*.+?\*\//, ""))
328
+ end
329
+
330
+ def insert_query_stats(db_id, db_query_stats, now)
331
+ values =
332
+ db_query_stats.map do |qs|
333
+ [
334
+ db_id,
335
+ qs[:query],
336
+ qs[:total_minutes] * 60 * 1000,
337
+ qs[:calls],
338
+ now,
339
+ supports_query_hash? ? qs[:query_hash] : nil,
340
+ qs[:user]
341
+ ]
342
+ end
343
+
344
+ columns = %w[database query total_time calls captured_at query_hash user]
345
+ insert_stats("pghero_query_stats", columns, values)
346
+ end
347
+ end
348
+ end
349
+ end