pg_reports 0.9.0 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +222 -93
  7. data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
  8. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
  9. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
  10. data/app/views/pg_reports/dashboard/index.html.erb +346 -238
  11. data/app/views/pg_reports/dashboard/show.html.erb +1 -1
  12. data/bin/pg_reports +2 -2
  13. data/config/brakeman.ignore +10 -56
  14. data/config/locales/en.yml +45 -11
  15. data/config/locales/ru.yml +44 -11
  16. data/config/locales/uk.yml +44 -11
  17. data/config/routes.rb +2 -0
  18. data/lib/pg_reports/access_policy.rb +39 -0
  19. data/lib/pg_reports/compatibility.rb +10 -2
  20. data/lib/pg_reports/configuration.rb +33 -2
  21. data/lib/pg_reports/connection/error_translator.rb +21 -0
  22. data/lib/pg_reports/connection/target.rb +43 -10
  23. data/lib/pg_reports/csv_cell.rb +20 -0
  24. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  25. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  26. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  27. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  28. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  29. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  30. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  31. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  32. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  34. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  35. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  36. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  37. data/lib/pg_reports/drop_index_migration.rb +56 -0
  38. data/lib/pg_reports/engine.rb +4 -3
  39. data/lib/pg_reports/executor.rb +46 -9
  40. data/lib/pg_reports/modules/queries.rb +1 -0
  41. data/lib/pg_reports/query_monitor.rb +478 -192
  42. data/lib/pg_reports/rate_limiter.rb +63 -0
  43. data/lib/pg_reports/report.rb +1 -1
  44. data/lib/pg_reports/report_definition.rb +11 -1
  45. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  46. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  47. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  48. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  49. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  50. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  51. data/lib/pg_reports/sql_redactor.rb +32 -0
  52. data/lib/pg_reports/standalone.rb +83 -20
  53. data/lib/pg_reports/version.rb +1 -1
  54. data/lib/pg_reports.rb +7 -2
  55. metadata +18 -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
- CACHE_KEY_ENABLED = "pg_reports:query_monitor:enabled"
12
- CACHE_KEY_SESSION_ID = "pg_reports:query_monitor:session_id"
13
- CACHE_TTL = 24.hours
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
- def initialize
16
- @subscriber = nil
17
- @mutex = Mutex.new
18
- @queries = []
19
- @handling_event = false
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
- # Local state — used by the event handler to avoid cache reads
22
- # (which generate SQL events and cause infinite recursion with DB-backed caches)
23
- @enabled = false
24
- @session_id = nil
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
- sync_from_cache
27
- ensure_subscription_if_enabled
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
- attr_reader :enabled
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
- attr_reader :session_id
82
+ def enabled
83
+ !read_state.nil?
84
+ end
33
85
 
34
- def start
35
- @mutex.synchronize do
36
- if enabled
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
- new_session_id = SecureRandom.uuid
42
- @queries = []
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
- # Update local state first (used by event handler — no cache round-trip)
45
- @enabled = true
46
- @session_id = new_session_id
94
+ subscribe if source == "notifications"
47
95
 
48
- # Store state in cache so other processes can see it
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
- Rails.logger.info("PgReports: Monitoring started, session_id=#{new_session_id}") if defined?(Rails)
98
+ new_session_id = SecureRandom.uuid
99
+ claim_session(new_session_id, source)
53
100
 
54
- # Subscribe to sql.active_record events in THIS process
55
- ensure_subscription
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
- # Write session start marker to file
58
- write_session_marker("session_start")
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
- {success: true, message: "Query monitoring started", session_id: new_session_id}
111
+ if source == "activity"
112
+ prepare_sampling(new_session_id)
113
+ else
114
+ @active_session = new_session_id
61
115
  end
62
- rescue => e
63
- @enabled = false
64
- @session_id = nil
65
- cache_write(CACHE_KEY_ENABLED, false)
66
- {success: false, error: e.message}
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
- @mutex.synchronize do
71
- unless enabled
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
- # Clear local state immediately — stops event handler from processing
78
- @enabled = false
79
- @session_id = nil
80
-
81
- # Unsubscribe from notifications in THIS process
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
- # Write session end marker to file
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: enabled,
108
- session_id: session_id,
109
- query_count: @queries.size,
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
- def queries(limit: nil, session_id: nil)
122
- result = @queries.dup
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
- # Filter by session_id if provided
125
- if session_id
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
- # Limit results if requested
130
- if limit
131
- result = result.last(limit)
132
- end
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
- result
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
- Rails.logger.warn("PgReports: Failed to load queries from log: #{e.message}") if defined?(Rails)
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
- # Cache helpers - work with or without Rails.cache
172
- def cache_read(key)
173
- return nil unless cache_available?
174
- Rails.cache.read(key)
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
- Rails.logger.warn("PgReports: Cache read failed: #{e.message}") if defined?(Rails.logger)
268
+ log_warning("Failed to read monitoring state: #{e.message}")
177
269
  nil
178
270
  end
179
271
 
180
- def cache_write(key, value)
181
- return false unless cache_available?
182
- Rails.cache.write(key, value, expires_in: CACHE_TTL)
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
- Rails.logger.warn("PgReports: Cache write failed: #{e.message}") if defined?(Rails.logger)
185
- false
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
- def cache_delete(key)
189
- return false unless cache_available?
190
- Rails.cache.delete(key)
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
- Rails.logger.warn("PgReports: Cache delete failed: #{e.message}") if defined?(Rails.logger)
193
- false
400
+ log_warning("Query monitor flusher stopped: #{e.message}")
194
401
  end
195
402
 
196
- def cache_available?
197
- defined?(Rails) && defined?(Rails.cache)
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
- # Sync local state from shared cache (called on initialize for multi-process support)
201
- def sync_from_cache
202
- @enabled = enabled
203
- @session_id = session_id
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
- # Ensure this process is subscribed to notifications if monitoring is enabled
207
- def ensure_subscription_if_enabled
208
- return unless @enabled
209
- ensure_subscription
461
+ def report_capture_error(message)
462
+ @capture_error = message
463
+ log_warning(message)
210
464
  end
211
465
 
212
- def ensure_subscription
213
- return if @subscriber # Already subscribed
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
- @subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
216
- handle_sql_event(name, started, finished, unique_id, payload)
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
- Rails.logger.debug("PgReports: Subscribed to sql.active_record in process #{Process.pid}") if defined?(Rails.logger)
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 handle_sql_event(name, started, finished, unique_id, payload)
223
- # Use local @enabled instead of enabled (which hits cache and may generate SQL,
224
- # causing infinite recursion with database-backed cache stores like SolidCache)
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
- @handling_event = true
229
- begin
230
- # Skip if should be filtered
231
- return if should_skip?(payload)
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 should_skip?(payload)
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
- def query_from_pg_reports?
288
- # Check if query originates from pg_reports gem internal code
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
- # Skip framework and gem paths
327
- next if path.match?(%r{/(gems|ruby|railties)/})
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 = @session_id)
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
- File.open(log_file_path, "a") do |f|
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
- Rails.logger.warn("PgReports: Failed to write session marker: #{e.message}")
642
+ log_warning("Failed to write session marker: #{e.message}")
378
643
  end
379
644
 
380
- def flush_to_file
645
+ def append_to_log(entries)
381
646
  return unless log_file_enabled?
382
- return if @queries.empty?
383
647
 
384
- File.open(log_file_path, "a") do |f|
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
- Rails.logger.warn("PgReports: Failed to flush queries to file: #{e.message}")
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