pg_reports 0.9.1 → 1.0.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 +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +126 -80
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
- data/app/views/pg_reports/dashboard/index.html.erb +310 -124
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +39 -8
- data/config/locales/ru.yml +38 -8
- data/config/locales/uk.yml +38 -8
- data/config/routes.rb +1 -0
- data/lib/pg_reports/access_policy.rb +39 -0
- data/lib/pg_reports/compatibility.rb +10 -2
- data/lib/pg_reports/configuration.rb +33 -2
- data/lib/pg_reports/connection/error_translator.rb +21 -0
- data/lib/pg_reports/connection/target.rb +43 -10
- data/lib/pg_reports/csv_cell.rb +20 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
- data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
- data/lib/pg_reports/definitions/connections/locks.yml +1 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
- data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
- data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
- data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
- data/lib/pg_reports/drop_index_migration.rb +56 -0
- data/lib/pg_reports/engine.rb +4 -3
- data/lib/pg_reports/executor.rb +46 -9
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +478 -192
- data/lib/pg_reports/rate_limiter.rb +63 -0
- data/lib/pg_reports/report.rb +1 -1
- data/lib/pg_reports/report_definition.rb +11 -1
- data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
- data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
- data/lib/pg_reports/sql/connections/locks.sql +1 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
- data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
- data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
- data/lib/pg_reports/sql_redactor.rb +32 -0
- data/lib/pg_reports/standalone.rb +83 -20
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +7 -2
- metadata +17 -9
|
@@ -3,111 +3,155 @@
|
|
|
3
3
|
require "singleton"
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_support/cache"
|
|
6
8
|
|
|
7
9
|
module PgReports
|
|
10
|
+
# Captures the SQL the host application runs, across all of its processes.
|
|
11
|
+
#
|
|
12
|
+
# Web workers, job processes and the dashboard's own request can each be a
|
|
13
|
+
# different process, so nothing the dashboard depends on may live in this
|
|
14
|
+
# object's memory: the session and the captured queries live in a shared cache
|
|
15
|
+
# (see #store), and every process keeps a subscriber that only does real work
|
|
16
|
+
# while that cache says a session is running.
|
|
17
|
+
#
|
|
18
|
+
# The captured queries are a ring: an entry's key is its sequence number modulo
|
|
19
|
+
# query_monitor_max_queries, so a session's footprint in the cache is bounded
|
|
20
|
+
# no matter how much SQL the application runs.
|
|
21
|
+
#
|
|
22
|
+
# Request threads only append to an in-memory batch; a short-lived background
|
|
23
|
+
# thread moves batches to the cache, so capturing adds no cache round trip to
|
|
24
|
+
# the host application's queries.
|
|
25
|
+
#
|
|
26
|
+
# A session has one of two sources. :notifications is the above. :activity has
|
|
27
|
+
# the background thread of the process that started the session sample
|
|
28
|
+
# pg_stat_activity on the selected database, feeding the same ring; it is the
|
|
29
|
+
# only source standalone mode has, because there is no application to subscribe
|
|
30
|
+
# to. A backend only remembers its latest statement, so a sample sees every
|
|
31
|
+
# client of the database but misses statements replaced between two samples.
|
|
8
32
|
class QueryMonitor
|
|
9
33
|
include Singleton
|
|
10
34
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
35
|
+
KEY_PREFIX = "pg_reports:query_monitor"
|
|
36
|
+
CACHE_KEY_STATE = "#{KEY_PREFIX}:state"
|
|
37
|
+
CACHE_KEY_LAST_SESSION = "#{KEY_PREFIX}:last_session"
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
39
|
+
# How long captured queries stay readable (and downloadable) after a session ends
|
|
40
|
+
BUFFER_RETENTION = 3600
|
|
41
|
+
FLUSH_INTERVAL = 0.5 # seconds between moves of a batch to the cache
|
|
42
|
+
STATE_SYNC_INTERVAL = 1.0 # seconds between checks for a session started elsewhere
|
|
43
|
+
MAX_SQL_LENGTH = 10_000 # bounds an entry's size: a bulk INSERT can be megabytes
|
|
44
|
+
LOG_PERMISSIONS = 0o600 # captured SQL is for whoever runs the application
|
|
45
|
+
|
|
46
|
+
# Standalone sets this on its connections, which is how samples leave them out
|
|
47
|
+
APPLICATION_NAME = "pg_reports"
|
|
48
|
+
INSUFFICIENT_PRIVILEGE = "<insufficient privilege>"
|
|
49
|
+
# What clients run around their queries; ";" is how the driver checks a connection
|
|
50
|
+
CONNECTION_NOISE = /\A\s*(;|(BEGIN|COMMIT|ROLLBACK|START TRANSACTION)\b)/i
|
|
20
51
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
52
|
+
# Set while this thread is doing the monitor's own cache work. With a
|
|
53
|
+
# database-backed cache (SolidCache) that work is itself SQL, which must not
|
|
54
|
+
# be captured, or each capture would trigger the next one.
|
|
55
|
+
INTERNAL_FLAG = :pg_reports_query_monitor_internal
|
|
25
56
|
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
def initialize
|
|
58
|
+
@subscriber = nil
|
|
59
|
+
@lock = Mutex.new
|
|
60
|
+
@pending = []
|
|
61
|
+
@dropped = 0
|
|
62
|
+
@active_session = nil
|
|
63
|
+
@next_sync_at = 0.0
|
|
64
|
+
@flusher = nil
|
|
65
|
+
@sample_context = nil
|
|
66
|
+
@sampling_session = nil
|
|
67
|
+
@sampled_queries = nil
|
|
68
|
+
@hidden_sessions = 0
|
|
69
|
+
@capture_error = nil
|
|
28
70
|
end
|
|
29
71
|
|
|
30
|
-
|
|
72
|
+
# Idempotent. Called once per process at boot, so that a process which never
|
|
73
|
+
# serves a dashboard request still captures once a session starts.
|
|
74
|
+
def subscribe
|
|
75
|
+
@lock.synchronize do
|
|
76
|
+
@subscriber ||= ActiveSupport::Notifications.subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
|
|
77
|
+
handle_sql_event(name, started, finished, unique_id, payload)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
31
81
|
|
|
32
|
-
|
|
82
|
+
def enabled
|
|
83
|
+
!read_state.nil?
|
|
84
|
+
end
|
|
33
85
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Rails.logger.info("PgReports: Monitoring already active, session_id=#{session_id}") if defined?(Rails)
|
|
38
|
-
return {success: false, message: "Monitoring already active"}
|
|
39
|
-
end
|
|
86
|
+
def session_id
|
|
87
|
+
read_state&.fetch(:session_id, nil)
|
|
88
|
+
end
|
|
40
89
|
|
|
41
|
-
|
|
42
|
-
|
|
90
|
+
def start(source: nil)
|
|
91
|
+
source = (source.presence || available_sources.first).to_s
|
|
92
|
+
return {success: false, error: "Unknown monitoring source: #{source}"} unless available_sources.map(&:to_s).include?(source)
|
|
43
93
|
|
|
44
|
-
|
|
45
|
-
@enabled = true
|
|
46
|
-
@session_id = new_session_id
|
|
94
|
+
subscribe if source == "notifications"
|
|
47
95
|
|
|
48
|
-
|
|
49
|
-
cache_write(CACHE_KEY_ENABLED, true)
|
|
50
|
-
cache_write(CACHE_KEY_SESSION_ID, new_session_id)
|
|
96
|
+
return {success: false, message: "Monitoring already active"} if read_state
|
|
51
97
|
|
|
52
|
-
|
|
98
|
+
new_session_id = SecureRandom.uuid
|
|
99
|
+
claim_session(new_session_id, source)
|
|
53
100
|
|
|
54
|
-
|
|
55
|
-
|
|
101
|
+
# unless_exist lets concurrent starts race safely; the read decides who won
|
|
102
|
+
# and also tells us whether the cache accepted the write at all.
|
|
103
|
+
winner = read_state
|
|
104
|
+
return {success: false, error: "Query monitor could not write to the cache store"} unless winner
|
|
105
|
+
return {success: false, message: "Monitoring already active"} unless winner[:session_id] == new_session_id
|
|
56
106
|
|
|
57
|
-
|
|
58
|
-
|
|
107
|
+
# Better to refuse now than to run a session nothing can be read back from
|
|
108
|
+
sequence(new_session_id)
|
|
109
|
+
verify_activity_tracking if source == "activity"
|
|
59
110
|
|
|
60
|
-
|
|
111
|
+
if source == "activity"
|
|
112
|
+
prepare_sampling(new_session_id)
|
|
113
|
+
else
|
|
114
|
+
@active_session = new_session_id
|
|
61
115
|
end
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
ensure_flusher
|
|
117
|
+
write_session_marker("session_start", new_session_id)
|
|
118
|
+
|
|
119
|
+
{success: true, message: "Query monitoring started", session_id: new_session_id}
|
|
120
|
+
rescue StandardError, NotImplementedError => e
|
|
121
|
+
internal { store.delete(CACHE_KEY_STATE) } if new_session_id
|
|
122
|
+
{success: false, error: "Query monitor cannot use this cache store: #{e.message}"}
|
|
67
123
|
end
|
|
68
124
|
|
|
69
125
|
def stop
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return {success: false, message: "Monitoring not active"}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
current_session_id = @session_id
|
|
126
|
+
state = read_state
|
|
127
|
+
return {success: false, message: "Monitoring not active"} unless state
|
|
76
128
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if @subscriber
|
|
83
|
-
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
84
|
-
@subscriber = nil
|
|
85
|
-
end
|
|
129
|
+
internal { store.delete(CACHE_KEY_STATE) }
|
|
130
|
+
@active_session = nil
|
|
131
|
+
@sampling_session = nil
|
|
132
|
+
flush_pending
|
|
133
|
+
write_session_marker("session_end", state[:session_id])
|
|
86
134
|
|
|
87
|
-
|
|
88
|
-
write_session_marker("session_end", current_session_id)
|
|
89
|
-
|
|
90
|
-
# Flush queries to file
|
|
91
|
-
flush_to_file
|
|
92
|
-
|
|
93
|
-
# Clear state from cache so other processes see it
|
|
94
|
-
cache_delete(CACHE_KEY_ENABLED)
|
|
95
|
-
cache_delete(CACHE_KEY_SESSION_ID)
|
|
96
|
-
|
|
97
|
-
@queries = []
|
|
98
|
-
|
|
99
|
-
{success: true, message: "Query monitoring stopped", session_id: current_session_id}
|
|
100
|
-
end
|
|
135
|
+
{success: true, message: "Query monitoring stopped", session_id: state[:session_id]}
|
|
101
136
|
rescue => e
|
|
102
137
|
{success: false, error: e.message}
|
|
103
138
|
end
|
|
104
139
|
|
|
105
140
|
def status
|
|
141
|
+
state = read_state
|
|
142
|
+
|
|
106
143
|
{
|
|
107
|
-
enabled:
|
|
108
|
-
session_id: session_id,
|
|
109
|
-
query_count:
|
|
110
|
-
history_available: history_available
|
|
144
|
+
enabled: !state.nil?,
|
|
145
|
+
session_id: state && state[:session_id],
|
|
146
|
+
query_count: total_queries,
|
|
147
|
+
history_available: history_available?,
|
|
148
|
+
expires_in: state && [state[:expires_at] - Time.now.to_i, 0].max,
|
|
149
|
+
scope: capture_scope,
|
|
150
|
+
source: state ? state[:source] : available_sources.first.to_s,
|
|
151
|
+
available_sources: available_sources,
|
|
152
|
+
hidden_sessions: @hidden_sessions,
|
|
153
|
+
capture_error: @capture_error,
|
|
154
|
+
max_queries: ring_size
|
|
111
155
|
}
|
|
112
156
|
end
|
|
113
157
|
|
|
@@ -118,20 +162,27 @@ module PgReports
|
|
|
118
162
|
log_file_enabled? && File.exist?(log_file_path)
|
|
119
163
|
end
|
|
120
164
|
|
|
121
|
-
|
|
122
|
-
|
|
165
|
+
# Captured queries, oldest first, for the running session or else the most
|
|
166
|
+
# recent one. `since` is a sequence number: only later entries are returned.
|
|
167
|
+
def queries(limit: nil, session_id: nil, since: 0)
|
|
168
|
+
flush_pending
|
|
123
169
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
result = result.select { |q| q[:session_id] == session_id }
|
|
127
|
-
end
|
|
170
|
+
session = session_id.presence || current_or_last_session
|
|
171
|
+
return [] unless session
|
|
128
172
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
newest = sequence(session)
|
|
174
|
+
oldest = [newest - ring_size + 1, since.to_i + 1, 1].max
|
|
175
|
+
oldest = [oldest, newest - limit + 1].max if limit
|
|
176
|
+
return [] if newest < oldest
|
|
177
|
+
|
|
178
|
+
keys = (oldest..newest).to_h { |seq| [seq, entry_key(session, seq)] }
|
|
179
|
+
found = internal { store.read_multi(*keys.values) }
|
|
133
180
|
|
|
134
|
-
|
|
181
|
+
# A slot can already hold a newer lap of the ring, or have been evicted
|
|
182
|
+
keys.filter_map do |seq, key|
|
|
183
|
+
entry = found[key]
|
|
184
|
+
entry if entry && entry[:seq] == seq
|
|
185
|
+
end
|
|
135
186
|
end
|
|
136
187
|
|
|
137
188
|
def load_from_log(session_id: nil, limit: nil)
|
|
@@ -150,122 +201,352 @@ module PgReports
|
|
|
150
201
|
next if session_id && entry[:session_id] != session_id
|
|
151
202
|
|
|
152
203
|
queries << entry
|
|
204
|
+
queries.shift if limit && queries.size > limit
|
|
153
205
|
rescue JSON::ParserError
|
|
154
206
|
# Skip malformed lines
|
|
155
207
|
next
|
|
156
208
|
end
|
|
157
209
|
end
|
|
158
210
|
|
|
159
|
-
# Limit results if requested
|
|
160
|
-
queries = queries.last(limit) if limit
|
|
161
|
-
|
|
162
211
|
queries
|
|
163
212
|
rescue => e
|
|
164
|
-
|
|
213
|
+
log_warning("Failed to load queries from log: #{e.message}")
|
|
165
214
|
[]
|
|
166
215
|
end
|
|
167
216
|
end
|
|
168
217
|
|
|
169
218
|
private
|
|
170
219
|
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
220
|
+
# The cache every process must share for the monitor to see all of them.
|
|
221
|
+
# Without one (Rails' NullStore default, or the per-process MemoryStore) this
|
|
222
|
+
# falls back to a private store, so one process still works and #status can
|
|
223
|
+
# report that only one process is covered.
|
|
224
|
+
def store
|
|
225
|
+
# A session belongs to the standalone process whose thread is sampling for
|
|
226
|
+
# it, so it must not outlive that process in a cache on disk. This cannot
|
|
227
|
+
# depend on the session's source: the store is how the source is learned.
|
|
228
|
+
return local_store if PgReports.config.standalone
|
|
229
|
+
|
|
230
|
+
PgReports.config.query_monitor_cache_store ||
|
|
231
|
+
(shared_store? ? Rails.cache : local_store)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def shared_store?
|
|
235
|
+
return true if PgReports.config.query_monitor_cache_store
|
|
236
|
+
|
|
237
|
+
cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
|
|
238
|
+
return false if cache.nil?
|
|
239
|
+
|
|
240
|
+
!cache.is_a?(ActiveSupport::Cache::NullStore) && !cache.is_a?(ActiveSupport::Cache::MemoryStore)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def local_store
|
|
244
|
+
@local_store ||= ActiveSupport::Cache::MemoryStore.new
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def available_sources
|
|
248
|
+
PgReports.config.query_monitor_available_sources
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def capture_scope
|
|
252
|
+
return "database" if PgReports.config.standalone
|
|
253
|
+
|
|
254
|
+
shared_store? ? "shared" : "process"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def internal
|
|
258
|
+
previous = Thread.current[INTERNAL_FLAG]
|
|
259
|
+
Thread.current[INTERNAL_FLAG] = true
|
|
260
|
+
yield
|
|
261
|
+
ensure
|
|
262
|
+
Thread.current[INTERNAL_FLAG] = previous
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def read_state
|
|
266
|
+
internal { store.read(CACHE_KEY_STATE) }
|
|
175
267
|
rescue => e
|
|
176
|
-
|
|
268
|
+
log_warning("Failed to read monitoring state: #{e.message}")
|
|
177
269
|
nil
|
|
178
270
|
end
|
|
179
271
|
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
|
|
272
|
+
def claim_session(new_session_id, source)
|
|
273
|
+
duration = PgReports.config.query_monitor_max_duration.to_i
|
|
274
|
+
state = {session_id: new_session_id, source: source, expires_at: Time.now.to_i + duration}
|
|
275
|
+
|
|
276
|
+
internal do
|
|
277
|
+
# Only the raw form of a counter can be incremented on Redis and Memcached
|
|
278
|
+
store.write(sequence_key(new_session_id), 0, raw: true, expires_in: retention)
|
|
279
|
+
store.write(CACHE_KEY_STATE, state, unless_exist: true, expires_in: duration)
|
|
280
|
+
store.write(CACHE_KEY_LAST_SESSION, new_session_id, expires_in: retention) if read_state&.fetch(:session_id) == new_session_id
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def current_or_last_session
|
|
285
|
+
session_id || internal { store.read(CACHE_KEY_LAST_SESSION) }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def total_queries
|
|
289
|
+
session = current_or_last_session
|
|
290
|
+
session ? sequence(session) : 0
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Asked of the cache through increment, not read: the counter is stored raw,
|
|
294
|
+
# and a cache that wraps another (RailsBrotliCache) deserializes what it reads.
|
|
295
|
+
# The expiry is repeated because some stores rewrite the entry on increment.
|
|
296
|
+
def sequence(session)
|
|
297
|
+
internal { store.increment(sequence_key(session), 0, raw: true, expires_in: retention) }.to_i
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def retention
|
|
301
|
+
PgReports.config.query_monitor_max_duration.to_i + BUFFER_RETENTION
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def sequence_key(session)
|
|
305
|
+
"#{KEY_PREFIX}:#{session}:seq"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def entry_key(session, seq)
|
|
309
|
+
"#{KEY_PREFIX}:#{session}:entry:#{seq % ring_size}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def ring_size
|
|
313
|
+
[PgReports.config.query_monitor_max_queries.to_i, 1].max
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def handle_sql_event(name, started, finished, unique_id, payload)
|
|
317
|
+
return if Thread.current[INTERNAL_FLAG]
|
|
318
|
+
|
|
319
|
+
session = capturing_session
|
|
320
|
+
return unless session
|
|
321
|
+
return if skip_by_payload?(payload)
|
|
322
|
+
|
|
323
|
+
locations = caller_locations(0, 60)
|
|
324
|
+
return if query_from_pg_reports?(locations)
|
|
325
|
+
|
|
326
|
+
add_to_batch(
|
|
327
|
+
type: "query",
|
|
328
|
+
session_id: session,
|
|
329
|
+
duration_ms: ((finished - started) * 1000).round(2),
|
|
330
|
+
name: payload[:name],
|
|
331
|
+
timestamp: Time.current.iso8601(3),
|
|
332
|
+
**statement_fields(payload[:sql].to_s) { extract_source_location(locations) }
|
|
333
|
+
)
|
|
183
334
|
rescue => e
|
|
184
|
-
|
|
185
|
-
|
|
335
|
+
# Runs inside the host application's query; it must never break it
|
|
336
|
+
log_warning("Failed to capture a query: #{e.message}")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# This runs on every query of every process, including when nothing is being
|
|
340
|
+
# monitored, so the cache is consulted at most once per STATE_SYNC_INTERVAL.
|
|
341
|
+
def capturing_session
|
|
342
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
343
|
+
if now >= @next_sync_at
|
|
344
|
+
@next_sync_at = now + STATE_SYNC_INTERVAL
|
|
345
|
+
sync_session
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
@active_session
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def sync_session
|
|
352
|
+
@active_session = notifications_session(read_state)
|
|
353
|
+
ensure_flusher if @active_session
|
|
186
354
|
end
|
|
187
355
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
356
|
+
# An :activity session is left to the process sampling for it, or events
|
|
357
|
+
# would record the same application SQL a second time.
|
|
358
|
+
def notifications_session(state)
|
|
359
|
+
state[:session_id] if state && state[:source] == "notifications"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def add_to_batch(entry)
|
|
363
|
+
@lock.synchronize do
|
|
364
|
+
@pending << entry
|
|
365
|
+
next unless @pending.size > ring_size
|
|
366
|
+
|
|
367
|
+
@pending.shift
|
|
368
|
+
@dropped += 1
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Runs only while a session is active. Forked workers do not inherit the
|
|
373
|
+
# thread, so alive? is also what restarts it after a fork.
|
|
374
|
+
def ensure_flusher
|
|
375
|
+
@lock.synchronize do
|
|
376
|
+
return if @flusher&.alive?
|
|
377
|
+
|
|
378
|
+
@flusher = Thread.new { run_flusher }
|
|
379
|
+
@flusher.name = "pg_reports-query-monitor"
|
|
380
|
+
@flusher.report_on_exception = false
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def run_flusher
|
|
385
|
+
Thread.current[INTERNAL_FLAG] = true
|
|
386
|
+
|
|
387
|
+
loop do
|
|
388
|
+
sleep FLUSH_INTERVAL
|
|
389
|
+
sample_activity if @sampling_session
|
|
390
|
+
flush_pending
|
|
391
|
+
|
|
392
|
+
state = read_state
|
|
393
|
+
@active_session = notifications_session(state)
|
|
394
|
+
@sampling_session = nil unless state && state[:session_id] == @sampling_session
|
|
395
|
+
break unless @active_session || @sampling_session
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
flush_pending
|
|
191
399
|
rescue => e
|
|
192
|
-
|
|
193
|
-
false
|
|
400
|
+
log_warning("Query monitor flusher stopped: #{e.message}")
|
|
194
401
|
end
|
|
195
402
|
|
|
196
|
-
def
|
|
197
|
-
|
|
403
|
+
def flush_pending
|
|
404
|
+
batch, dropped = @lock.synchronize do
|
|
405
|
+
taken = [@pending, @dropped]
|
|
406
|
+
@pending = []
|
|
407
|
+
@dropped = 0
|
|
408
|
+
taken
|
|
409
|
+
end
|
|
410
|
+
return if batch.empty?
|
|
411
|
+
|
|
412
|
+
batch.group_by { |entry| entry[:session_id] }.each do |session, entries|
|
|
413
|
+
write_entries(session, entries, dropped)
|
|
414
|
+
dropped = 0
|
|
415
|
+
end
|
|
416
|
+
append_to_log(batch)
|
|
417
|
+
@capture_error = nil
|
|
418
|
+
rescue StandardError, NotImplementedError => e
|
|
419
|
+
report_capture_error("Failed to store captured queries: #{e.message}")
|
|
198
420
|
end
|
|
199
421
|
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
422
|
+
# Reserving the numbers with one atomic increment is what lets any number of
|
|
423
|
+
# processes write into the same ring.
|
|
424
|
+
# Queries dropped in a burst still take a number, so the total stays honest.
|
|
425
|
+
def write_entries(session, entries, dropped = 0)
|
|
426
|
+
internal do
|
|
427
|
+
newest = store.increment(sequence_key(session), entries.size + dropped, raw: true, expires_in: retention)
|
|
428
|
+
next unless newest
|
|
429
|
+
|
|
430
|
+
first = newest - entries.size + 1
|
|
431
|
+
numbered = entries.each_with_index.to_h do |entry, index|
|
|
432
|
+
seq = first + index
|
|
433
|
+
[entry_key(session, seq), entry.merge(seq: seq)]
|
|
434
|
+
end
|
|
435
|
+
store.write_multi(numbered, expires_in: retention)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# A thread starts outside the request's target and database, so the viewer's
|
|
440
|
+
# selection has to be carried over explicitly.
|
|
441
|
+
def prepare_sampling(session)
|
|
442
|
+
@sample_context = current_context
|
|
443
|
+
@sampling_session = session
|
|
444
|
+
@sampled_queries = nil
|
|
445
|
+
@hidden_sessions = 0
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def sample_activity
|
|
449
|
+
rows = read_activity
|
|
450
|
+
visible, hidden = rows.partition { |row| row["query"] != INSUFFICIENT_PRIVILEGE }
|
|
451
|
+
@hidden_sessions = hidden.size
|
|
452
|
+
|
|
453
|
+
# The first sample only shows what ran before this session began
|
|
454
|
+
capture_finished_queries(visible) if @sampled_queries
|
|
455
|
+
|
|
456
|
+
@sampled_queries = visible.to_h { |row| [row["pid"], row["query_start"]] }
|
|
457
|
+
rescue => e
|
|
458
|
+
report_capture_error("Failed to sample pg_stat_activity: #{e.message}")
|
|
204
459
|
end
|
|
205
460
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ensure_subscription
|
|
461
|
+
def report_capture_error(message)
|
|
462
|
+
@capture_error = message
|
|
463
|
+
log_warning(message)
|
|
210
464
|
end
|
|
211
465
|
|
|
212
|
-
|
|
213
|
-
|
|
466
|
+
# pg_stat_activity is part of PostgreSQL and needs no extension. This setting
|
|
467
|
+
# is the one thing that can leave it empty, which would otherwise look like a
|
|
468
|
+
# database with nothing running.
|
|
469
|
+
def verify_activity_tracking
|
|
470
|
+
rows = activity_executor(current_context) { |executor| executor.execute("SHOW track_activities") }
|
|
471
|
+
return unless rows.first&.fetch("track_activities", nil) == "off"
|
|
214
472
|
|
|
215
|
-
|
|
216
|
-
|
|
473
|
+
raise Error, "track_activities is off, so pg_stat_activity shows no queries"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Goes to the pool directly: PgReports.config.connection would lease a
|
|
477
|
+
# connection to this thread, which would then keep a pool slot for the whole
|
|
478
|
+
# session. with_connection hands it back after every sample.
|
|
479
|
+
def read_activity
|
|
480
|
+
activity_executor(@sample_context) { |executor| executor.execute_from_file(:queries, :activity_sample) }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def activity_executor(context)
|
|
484
|
+
target = PgReports.connection_registry.fetch(context[:target])
|
|
485
|
+
|
|
486
|
+
target.ar_class_for(context[:database]).connection_pool.with_connection do |connection|
|
|
487
|
+
yield Executor.new(connection: connection)
|
|
217
488
|
end
|
|
489
|
+
end
|
|
218
490
|
|
|
219
|
-
|
|
491
|
+
def current_context
|
|
492
|
+
registry = PgReports.connection_registry
|
|
493
|
+
{target: registry.current_name || registry.default_name, database: registry.current_database}
|
|
220
494
|
end
|
|
221
495
|
|
|
222
|
-
def
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return unless @enabled
|
|
226
|
-
return if @handling_event
|
|
496
|
+
def capture_finished_queries(rows)
|
|
497
|
+
rows.each do |row|
|
|
498
|
+
next if row["query_start"].nil? || @sampled_queries[row["pid"]] == row["query_start"]
|
|
227
499
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
duration_ms = ((finished - started) * 1000).round(2)
|
|
234
|
-
sql = payload[:sql]
|
|
235
|
-
query_name = payload[:name]
|
|
236
|
-
|
|
237
|
-
# Extract source location
|
|
238
|
-
source_location = extract_source_location
|
|
239
|
-
|
|
240
|
-
# Build query entry
|
|
241
|
-
query_entry = {
|
|
242
|
-
type: "query",
|
|
243
|
-
session_id: @session_id,
|
|
244
|
-
sql: sql,
|
|
245
|
-
duration_ms: duration_ms,
|
|
246
|
-
name: query_name,
|
|
247
|
-
source_location: source_location,
|
|
248
|
-
timestamp: Time.current.iso8601
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
add_to_buffer(query_entry)
|
|
252
|
-
ensure
|
|
253
|
-
@handling_event = false
|
|
500
|
+
sql = row["query"].to_s
|
|
501
|
+
next if sql.match?(CONNECTION_NOISE) || skip_by_payload?(sql: sql, name: row["application_name"])
|
|
502
|
+
|
|
503
|
+
add_to_batch(sampled_entry(row, sql))
|
|
254
504
|
end
|
|
255
505
|
end
|
|
256
506
|
|
|
257
|
-
def
|
|
507
|
+
def sampled_entry(row, sql)
|
|
508
|
+
{
|
|
509
|
+
type: "query",
|
|
510
|
+
session_id: @sampling_session,
|
|
511
|
+
duration_ms: row["duration_ms"].to_f,
|
|
512
|
+
name: row["application_name"].presence,
|
|
513
|
+
timestamp: sampled_time(row["query_start"]).iso8601(3),
|
|
514
|
+
**statement_fields(sql)
|
|
515
|
+
}
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# An annotation (Marginalia, Rails QueryLogs) was written by the application
|
|
519
|
+
# at the call site, so it beats anything worked out from a backtrace. It is
|
|
520
|
+
# then only noise in the statement itself.
|
|
521
|
+
def statement_fields(sql)
|
|
522
|
+
annotation = AnnotationParser.parse(sql)
|
|
523
|
+
location = {file: annotation[:file], line: annotation[:line].to_i} if annotation[:file]
|
|
524
|
+
|
|
525
|
+
{
|
|
526
|
+
sql: stored_sql(AnnotationParser.strip_annotations(sql)[0, MAX_SQL_LENGTH]),
|
|
527
|
+
source: AnnotationParser.format_for_display(annotation),
|
|
528
|
+
source_location: location || (yield if block_given?)
|
|
529
|
+
}
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# After the length limit, which keeps the work bounded on this path through the
|
|
533
|
+
# host application's queries. The redactor treats the cut as the end of a string.
|
|
534
|
+
def stored_sql(sql)
|
|
535
|
+
PgReports.config.query_monitor_redact_literals ? SqlRedactor.redact(sql) : sql
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# A Time or a String, depending on the Rails version
|
|
539
|
+
def sampled_time(value)
|
|
540
|
+
value.respond_to?(:iso8601) ? value : Time.parse(value)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def skip_by_payload?(payload)
|
|
258
544
|
sql = payload[:sql]
|
|
259
545
|
name = payload[:name]
|
|
260
546
|
|
|
261
547
|
# Skip if query is from pg_reports itself (check by name)
|
|
262
548
|
return true if name&.start_with?("PgReports")
|
|
263
549
|
|
|
264
|
-
# Skip if query is from pg_reports gem (check backtrace)
|
|
265
|
-
if query_from_pg_reports?
|
|
266
|
-
return true
|
|
267
|
-
end
|
|
268
|
-
|
|
269
550
|
# Skip SCHEMA queries
|
|
270
551
|
return true if name&.start_with?("SCHEMA")
|
|
271
552
|
|
|
@@ -284,9 +565,8 @@ module PgReports
|
|
|
284
565
|
false
|
|
285
566
|
end
|
|
286
567
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
locations = caller_locations(0, 30)
|
|
568
|
+
# Check if query originates from pg_reports gem internal code
|
|
569
|
+
def query_from_pg_reports?(locations)
|
|
290
570
|
return false unless locations
|
|
291
571
|
|
|
292
572
|
locations.any? do |location|
|
|
@@ -311,24 +591,21 @@ module PgReports
|
|
|
311
591
|
end
|
|
312
592
|
end
|
|
313
593
|
|
|
314
|
-
def extract_source_location
|
|
315
|
-
# Get caller locations, skip first few frames (this file, active_support)
|
|
316
|
-
# Increase limit to 50 to capture more of the stack
|
|
317
|
-
locations = caller_locations(5, 50)
|
|
318
|
-
|
|
594
|
+
def extract_source_location(locations)
|
|
319
595
|
return nil unless locations
|
|
320
596
|
|
|
597
|
+
keep_frame = PgReports.config.query_monitor_backtrace_filter
|
|
598
|
+
|
|
321
599
|
# Find first application code location
|
|
322
|
-
# Look for paths that are NOT from gems/ruby/railties
|
|
323
600
|
app_location = locations.find do |location|
|
|
324
601
|
path = location.path
|
|
325
602
|
|
|
326
|
-
|
|
327
|
-
next
|
|
603
|
+
next false if path.include?("/query_monitor.rb") || path.start_with?("<internal:")
|
|
604
|
+
next false unless keep_frame.call(location)
|
|
328
605
|
|
|
329
606
|
# Skip pg_reports internal paths
|
|
330
|
-
next if path.match?(%r{/pg_reports/lib/pg_reports/})
|
|
331
|
-
next if path.match?(%r{/pg_reports/app/controllers/pg_reports/})
|
|
607
|
+
next false if path.match?(%r{/pg_reports/lib/pg_reports/})
|
|
608
|
+
next false if path.match?(%r{/pg_reports/app/controllers/pg_reports/})
|
|
332
609
|
|
|
333
610
|
# This is likely application code
|
|
334
611
|
true
|
|
@@ -346,21 +623,11 @@ module PgReports
|
|
|
346
623
|
nil
|
|
347
624
|
end
|
|
348
625
|
|
|
349
|
-
def add_to_buffer(query_entry)
|
|
350
|
-
@queries << query_entry
|
|
351
|
-
|
|
352
|
-
# Trim to max_queries to prevent memory bloat
|
|
353
|
-
max_queries = PgReports.config.query_monitor_max_queries
|
|
354
|
-
if @queries.size > max_queries
|
|
355
|
-
@queries = @queries.last(max_queries)
|
|
356
|
-
end
|
|
357
|
-
end
|
|
358
|
-
|
|
359
626
|
# JSON.generate, not #to_json: ActiveSupport overrides #to_json and (up to
|
|
360
627
|
# Rails 7.0) passes json's removed `quirks_mode` option, so on json >= 3 it
|
|
361
628
|
# raises ArgumentError. The rescue below would swallow that and leave the
|
|
362
629
|
# log silently empty. json's own generator takes no such option.
|
|
363
|
-
def write_session_marker(marker_type, sid
|
|
630
|
+
def write_session_marker(marker_type, sid)
|
|
364
631
|
return unless log_file_enabled?
|
|
365
632
|
|
|
366
633
|
marker = {
|
|
@@ -369,25 +636,40 @@ module PgReports
|
|
|
369
636
|
timestamp: Time.current.iso8601
|
|
370
637
|
}
|
|
371
638
|
|
|
372
|
-
|
|
373
|
-
f.puts JSON.generate(marker)
|
|
374
|
-
end
|
|
639
|
+
write_log("#{JSON.generate(marker)}\n")
|
|
375
640
|
rescue => e
|
|
376
641
|
# Silently fail - don't break monitoring if file write fails
|
|
377
|
-
|
|
642
|
+
log_warning("Failed to write session marker: #{e.message}")
|
|
378
643
|
end
|
|
379
644
|
|
|
380
|
-
def
|
|
645
|
+
def append_to_log(entries)
|
|
381
646
|
return unless log_file_enabled?
|
|
382
|
-
return if @queries.empty?
|
|
383
647
|
|
|
384
|
-
|
|
385
|
-
@queries.each do |query|
|
|
386
|
-
f.puts JSON.generate(query)
|
|
387
|
-
end
|
|
388
|
-
end
|
|
648
|
+
write_log(entries.map { |entry| "#{JSON.generate(entry)}\n" }.join)
|
|
389
649
|
rescue => e
|
|
390
|
-
|
|
650
|
+
log_warning("Failed to append queries to file: #{e.message}")
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# chmod as well as the mode given to open, which only applies to a new file:
|
|
654
|
+
# a log written by an earlier version stays world-readable otherwise.
|
|
655
|
+
def write_log(text)
|
|
656
|
+
rotate_log
|
|
657
|
+
|
|
658
|
+
File.open(log_file_path, "a", LOG_PERMISSIONS) do |f|
|
|
659
|
+
f.write(text)
|
|
660
|
+
f.chmod(LOG_PERMISSIONS)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# One generation, so the log's footprint is at most twice the limit. Processes
|
|
665
|
+
# can race here; the loser renames a fresh file over the backup, which costs a
|
|
666
|
+
# moment of history and nothing else.
|
|
667
|
+
def rotate_log
|
|
668
|
+
limit = PgReports.config.query_monitor_log_max_bytes.to_i
|
|
669
|
+
return unless limit.positive?
|
|
670
|
+
return unless File.exist?(log_file_path) && File.size(log_file_path) >= limit
|
|
671
|
+
|
|
672
|
+
File.rename(log_file_path, "#{log_file_path}.1")
|
|
391
673
|
end
|
|
392
674
|
|
|
393
675
|
def log_file_enabled?
|
|
@@ -397,5 +679,9 @@ module PgReports
|
|
|
397
679
|
def log_file_path
|
|
398
680
|
PgReports.config.query_monitor_log_file
|
|
399
681
|
end
|
|
682
|
+
|
|
683
|
+
def log_warning(message)
|
|
684
|
+
Rails.logger&.warn("PgReports: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
|
|
685
|
+
end
|
|
400
686
|
end
|
|
401
687
|
end
|