pg_reports 0.6.2 → 0.8.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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Grafana
5
+ # Renders selected reports in Prometheus exposition format.
6
+ # Severity is derived from REPORT_CONFIG thresholds in Dashboard::ReportsRegistry.
7
+ class Exporter
8
+ SEVERITY_ORDER = {"ok" => 0, "warning" => 1, "critical" => 2}.freeze
9
+ MAX_LABEL_VALUE_LENGTH = 200
10
+ RESERVED_LABEL_NAMES = %w[report severity row error].freeze
11
+
12
+ MODULES = {
13
+ queries: -> { Modules::Queries },
14
+ indexes: -> { Modules::Indexes },
15
+ tables: -> { Modules::Tables },
16
+ connections: -> { Modules::Connections },
17
+ system: -> { Modules::System },
18
+ schema_analysis: -> { Modules::SchemaAnalysis }
19
+ }.freeze
20
+
21
+ def self.render
22
+ new.render
23
+ end
24
+
25
+ def initialize(favorites: PgReports.config.grafana_favorites,
26
+ cache_ttl: PgReports.config.grafana_cache_ttl,
27
+ clock: Time)
28
+ @favorites = normalize(favorites)
29
+ @cache_ttl = cache_ttl
30
+ @clock = clock
31
+ end
32
+
33
+ def render
34
+ results = @favorites.map { |key, opts| collect(key, opts) }
35
+
36
+ lines = []
37
+ emit(lines, "pg_reports_issues", "Number of rows by severity for the report") do |emit|
38
+ results.each do |r|
39
+ next unless r[:ok]
40
+ r[:severities].each { |sev, count| emit.call({report: r[:key], severity: sev}, count) }
41
+ end
42
+ end
43
+
44
+ emit(lines, "pg_reports_rows", "Total rows returned by the report") do |emit|
45
+ results.each { |r| emit.call({report: r[:key]}, r[:rows]) if r[:ok] }
46
+ end
47
+
48
+ emit(lines, "pg_reports_run_seconds", "Time spent collecting the report") do |emit|
49
+ results.each { |r| emit.call({report: r[:key]}, r[:duration].round(4)) if r[:ok] }
50
+ end
51
+
52
+ emit(lines, "pg_reports_last_run_timestamp", "Unix timestamp of last collection") do |emit|
53
+ results.each { |r| emit.call({report: r[:key]}, r[:timestamp]) if r[:ok] }
54
+ end
55
+
56
+ emit(lines, "pg_reports_up", "Whether collection succeeded (1) or failed (0)") do |emit|
57
+ results.each do |r|
58
+ labels = {report: r[:key]}
59
+ labels[:error] = r[:error] unless r[:ok]
60
+ emit.call(labels, r[:ok] ? 1 : 0)
61
+ end
62
+ end
63
+
64
+ emit(lines, "pg_reports_row", "One series per row of the report (drives Grafana table panels). Each row column becomes a label.") do |emit|
65
+ results.each do |r|
66
+ next unless r[:ok] && r[:rows_data]
67
+ r[:rows_data].each_with_index do |row_labels, idx|
68
+ emit.call(row_labels.merge(report: r[:key], row: idx), 1)
69
+ end
70
+ end
71
+ end
72
+
73
+ (lines << "").join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ def normalize(favorites)
79
+ case favorites
80
+ when Hash
81
+ favorites.each_with_object({}) { |(k, v), h| h[k.to_sym] = (v || {}).symbolize_keys }
82
+ when Array
83
+ favorites.each_with_object({}) { |k, h| h[k.to_sym] = {} }
84
+ else
85
+ {}
86
+ end
87
+ end
88
+
89
+ def collect(key, opts)
90
+ cached(key, opts) { run(key, opts) }
91
+ rescue => e
92
+ {key: key, ok: false, error: e.class.name, message: e.message}
93
+ end
94
+
95
+ def cached(key, opts)
96
+ ttl = opts[:ttl] || @cache_ttl
97
+ if ttl && defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
98
+ Rails.cache.fetch("pg_reports/grafana/#{key}", expires_in: ttl) { yield }
99
+ else
100
+ yield
101
+ end
102
+ end
103
+
104
+ def run(key, opts)
105
+ mod = module_for(key) or raise ArgumentError, "Unknown report: #{key}"
106
+
107
+ started = @clock.now
108
+ report = mod.public_send(key, **report_args(opts))
109
+ finished = @clock.now
110
+
111
+ {
112
+ key: key,
113
+ ok: true,
114
+ rows: report.size,
115
+ severities: severity_counts(key, report),
116
+ rows_data: opts.fetch(:expose_rows, true) ? row_label_sets(report) : nil,
117
+ duration: finished - started,
118
+ timestamp: finished.to_i
119
+ }
120
+ end
121
+
122
+ def row_label_sets(report)
123
+ report.map { |row| row_to_labels(row) }
124
+ end
125
+
126
+ def row_to_labels(row)
127
+ labels = {}
128
+ row.each do |column, value|
129
+ next if value.nil?
130
+
131
+ name = sanitize_label_name(column.to_s)
132
+ next if name.empty? || RESERVED_LABEL_NAMES.include?(name)
133
+
134
+ formatted = format_label_value(value)
135
+ next if formatted.length > MAX_LABEL_VALUE_LENGTH
136
+
137
+ labels[name] = formatted
138
+ end
139
+ labels
140
+ end
141
+
142
+ def sanitize_label_name(name)
143
+ cleaned = name.gsub(/[^a-zA-Z0-9_]/, "_")
144
+ cleaned = "_#{cleaned}" if cleaned.match?(/\A[0-9]/)
145
+ cleaned
146
+ end
147
+
148
+ def format_label_value(value)
149
+ case value
150
+ when Float then format("%g", value)
151
+ when Time, DateTime then value.iso8601
152
+ else value.to_s
153
+ end
154
+ end
155
+
156
+ def module_for(key)
157
+ Dashboard::ReportsRegistry::REPORTS.each do |category, info|
158
+ next unless info[:reports].key?(key.to_sym)
159
+ factory = MODULES[category] or return nil
160
+ return factory.call
161
+ end
162
+ nil
163
+ end
164
+
165
+ def report_args(opts)
166
+ # Only forward kwargs that report methods accept; keep the surface tiny.
167
+ opts.slice(:limit).compact
168
+ end
169
+
170
+ def severity_counts(key, report)
171
+ thresholds = Dashboard::ReportsRegistry.thresholds(key)
172
+ counts = Hash.new(0)
173
+
174
+ if thresholds.empty?
175
+ counts["ok"] = report.size
176
+ return counts
177
+ end
178
+
179
+ report.each { |row| counts[row_severity(row, thresholds)] += 1 }
180
+ counts
181
+ end
182
+
183
+ def row_severity(row, thresholds)
184
+ worst = "ok"
185
+ thresholds.each do |field, t|
186
+ value = row[field.to_s] || row[field]
187
+ next if value.nil?
188
+
189
+ worst = max_severity(worst, severity_for(value.to_f, t))
190
+ end
191
+ worst
192
+ end
193
+
194
+ def severity_for(value, thresholds)
195
+ critical = thresholds[:critical]
196
+ warning = thresholds[:warning]
197
+
198
+ if thresholds[:inverted]
199
+ return "critical" if critical && value <= critical
200
+ return "warning" if warning && value <= warning
201
+ else
202
+ return "critical" if critical && value >= critical
203
+ return "warning" if warning && value >= warning
204
+ end
205
+ "ok"
206
+ end
207
+
208
+ def max_severity(a, b)
209
+ (SEVERITY_ORDER[a] >= SEVERITY_ORDER[b]) ? a : b
210
+ end
211
+
212
+ def emit(lines, metric, help)
213
+ buffer = []
214
+ emitter = ->(labels, value) {
215
+ buffer << "#{metric}#{format_labels(labels)} #{value}"
216
+ }
217
+ yield emitter
218
+ return if buffer.empty?
219
+
220
+ lines << "# HELP #{metric} #{help}"
221
+ lines << "# TYPE #{metric} gauge"
222
+ lines.concat(buffer)
223
+ end
224
+
225
+ def format_labels(labels)
226
+ return "" if labels.nil? || labels.empty?
227
+
228
+ pairs = labels.map { |k, v| %(#{k}="#{escape_label(v)}") }
229
+ "{#{pairs.join(",")}}"
230
+ end
231
+
232
+ def escape_label(value)
233
+ value.to_s
234
+ .gsub("\\", "\\\\\\\\")
235
+ .gsub('"', '\\"')
236
+ .gsub("\n", '\\n')
237
+ end
238
+ end
239
+ end
240
+ end
@@ -3,42 +3,43 @@
3
3
  module PgReports
4
4
  # Generates module methods dynamically from YAML report definitions
5
5
  class ModuleGenerator
6
- def self.generate!
7
- ReportLoader.load_all.each do |module_name, reports|
8
- module_class = get_module(module_name)
9
- next unless module_class
10
-
11
- reports.each do |report_name, definition|
12
- define_report_method(module_class, report_name, definition)
6
+ class << self
7
+ def generate!
8
+ ReportLoader.load_all.each do |module_name, reports|
9
+ module_class = get_module(module_name)
10
+ next unless module_class
11
+
12
+ reports.each do |report_name, definition|
13
+ define_report_method(module_class, report_name, definition)
14
+ end
13
15
  end
14
16
  end
15
- end
16
17
 
17
- private
18
+ private
18
19
 
19
- def self.get_module(module_name)
20
- const_name = module_name.to_s.split("_").map(&:capitalize).join
21
- PgReports::Modules.const_get(const_name)
22
- rescue NameError
23
- # Module doesn't exist, skip it
24
- # We don't auto-create modules to avoid conflicts
25
- nil
26
- end
20
+ def get_module(module_name)
21
+ const_name = module_name.to_s.split("_").map(&:capitalize).join
22
+ PgReports::Modules.const_get(const_name)
23
+ rescue NameError
24
+ # Module doesn't exist, skip it
25
+ # We don't auto-create modules to avoid conflicts
26
+ nil
27
+ end
27
28
 
28
- def self.define_report_method(module_class, report_name, definition)
29
- params_config = definition.config["parameters"] || {}
29
+ def define_report_method(module_class, report_name, definition)
30
+ params_config = definition.config["parameters"] || {}
30
31
 
31
- # Extract default parameter values
32
- defaults = params_config.transform_values { |v| v["default"] }
32
+ # Extract default parameter values
33
+ defaults = params_config.transform_values { |v| v["default"] }
33
34
 
34
- # Define the method on the module
35
- # We capture the definition in a local variable to avoid closure issues
36
- captured_definition = definition
37
- captured_defaults = defaults
35
+ # Capture definition + defaults so the singleton method closes over them
36
+ captured_definition = definition
37
+ captured_defaults = defaults
38
38
 
39
- module_class.define_singleton_method(report_name) do |**params|
40
- merged_params = captured_defaults.merge(params)
41
- captured_definition.generate_report(**merged_params)
39
+ module_class.define_singleton_method(report_name) do |**params|
40
+ merged_params = captured_defaults.merge(params)
41
+ captured_definition.generate_report(**merged_params)
42
+ end
42
43
  end
43
44
  end
44
45
  end
@@ -112,7 +112,7 @@ module PgReports
112
112
  counter_belongs_to(model).each do |assoc|
113
113
  counter_col = counter_cache_column_name(model, assoc)
114
114
  parent = parent_class_for(assoc)
115
- next unless parent && parent.table_exists?
115
+ next unless parent&.table_exists?
116
116
 
117
117
  unless parent.column_names.include?(counter_col)
118
118
  results << {
@@ -173,7 +173,10 @@ module PgReports
173
173
  private
174
174
 
175
175
  def pg_version
176
- @pg_version ||= begin
176
+ # Cache per-connection so switching targets/databases re-resolves the version.
177
+ cache = (Thread.current[:pg_reports_pg_version_cache] ||= {})
178
+ key = executor.connection.object_id
179
+ cache[key] ||= begin
177
180
  result = executor.execute("SELECT current_setting('server_version_num')::int AS v")
178
181
  result.first&.fetch("v", 0).to_i
179
182
  end
@@ -290,13 +290,16 @@ module PgReports
290
290
  # when gem is installed from RubyGems
291
291
  next if path.include?("/query_monitor.rb")
292
292
 
293
- # Filter queries from pg_reports internal modules only:
294
- # - Installed gem: /gems/pg_reports-X.Y.Z/lib/
295
- # - Local gem: /pg_reports/lib/pg_reports/modules/
296
- # Note: We intentionally DO NOT filter dashboard_controller.rb
297
- # to allow monitoring of user application queries made during dashboard page loads
298
- path.match?(%r{/gems/pg_reports[-\d.]+/lib/}) ||
299
- path.match?(%r{/pg_reports/lib/pg_reports/modules/})
293
+ # Filter queries from pg_reports internal code:
294
+ # - Installed gem: /gems/pg_reports-X.Y.Z/lib/ or /gems/pg_reports-X.Y.Z/app/
295
+ # - Local gem: /pg_reports/lib/pg_reports/ or /pg_reports/app/(controllers|views)/pg_reports/
296
+ # This includes the dashboard controller's own SQL execution endpoints
297
+ # (execute_query, explain_analyze) which call AR directly without going
298
+ # through Executor — without this match nothing in the caller stack
299
+ # would identify the query as ours.
300
+ path.match?(%r{/gems/pg_reports[-\d.]+/(lib|app)/}) ||
301
+ path.match?(%r{/pg_reports/lib/pg_reports/}) ||
302
+ path.match?(%r{/pg_reports/app/(controllers|views)/pg_reports/})
300
303
  end
301
304
  end
302
305
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.6.2"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/pg_reports.rb CHANGED
@@ -6,6 +6,9 @@ require "active_record"
6
6
 
7
7
  require_relative "pg_reports/version"
8
8
  require_relative "pg_reports/error"
9
+ require_relative "pg_reports/connection/target"
10
+ require_relative "pg_reports/connection/registry"
11
+ require_relative "pg_reports/connection/error_translator"
9
12
  require_relative "pg_reports/compatibility"
10
13
  require_relative "pg_reports/configuration"
11
14
  require_relative "pg_reports/sql_loader"
@@ -33,6 +36,10 @@ require_relative "pg_reports/modules/schema_analysis"
33
36
  # Dashboard
34
37
  require_relative "pg_reports/dashboard/reports_registry"
35
38
 
39
+ # Grafana / Prometheus exporter
40
+ require_relative "pg_reports/grafana/exporter"
41
+ require_relative "pg_reports/grafana/dashboard_builder"
42
+
36
43
  # Rails Engine
37
44
  require_relative "pg_reports/engine" if defined?(Rails::Engine)
38
45
 
@@ -134,6 +141,56 @@ module PgReports
134
141
  ReportLoader.reload!
135
142
  ModuleGenerator.generate!
136
143
  end
144
+
145
+ # Connection registry — multi-target / multi-database support.
146
+ # The :primary target is auto-discovered from ActiveRecord on first access.
147
+ def connection_registry
148
+ @connection_registry ||= Connection::Registry.new
149
+ end
150
+
151
+ # Run a block against a specific target (and optionally a specific database
152
+ # on that target). Honored by Executor and any code routing through
153
+ # PgReports.config.connection.
154
+ #
155
+ # PgReports.with_target(:analytics) { PgReports.slow_queries }
156
+ # PgReports.with_target(:primary, database: "logs") { PgReports.table_sizes }
157
+ def with_target(name, database: nil, &block)
158
+ connection_registry.with_context(target: name, database: database, &block)
159
+ end
160
+
161
+ # Switch only the database on whatever target is currently active
162
+ # (defaults to the registry's default target).
163
+ #
164
+ # PgReports.with_database("reporting") { PgReports.database_sizes }
165
+ def with_database(database, &block)
166
+ target = connection_registry.current_name || connection_registry.default_name
167
+ connection_registry.with_context(target: target, database: database, &block)
168
+ end
169
+
170
+ # Name of the currently effective target (taking with_target into account).
171
+ def current_target_name
172
+ connection_registry.current_name || connection_registry.default_name
173
+ end
174
+
175
+ # Name of the currently effective database (taking with_database into account).
176
+ def current_database_name
177
+ connection_registry.current_database_name
178
+ end
179
+
180
+ # List databases on the currently active target's cluster.
181
+ # Each row: { "name" => String, "size" => String, "current" => Boolean }
182
+ def list_databases
183
+ target = connection_registry.fetch
184
+ target.list_databases(current: current_database_name)
185
+ end
186
+
187
+ # List of registered targets, each as { name:, default_database:, current: }.
188
+ def list_targets
189
+ current = current_target_name
190
+ connection_registry.targets.map do |t|
191
+ {name: t.name, default_database: t.default_database, current: t.name == current}
192
+ end
193
+ end
137
194
  end
138
195
  end
139
196
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ namespace :pg_reports do
6
+ namespace :grafana do
7
+ desc "Write importable Grafana dashboard JSON for the configured grafana_favorites. " \
8
+ "Defaults to pg_reports.json in pwd. Override with OUTPUT=, FAVORITES=, TITLE=, UID=, REFRESH=, TIME_FROM=."
9
+ task dashboard: :environment do
10
+ favorites = if ENV["FAVORITES"]
11
+ ENV["FAVORITES"].split(",").map { |k| k.strip.to_sym }
12
+ else
13
+ PgReports.config.grafana_favorites
14
+ end
15
+
16
+ builder = PgReports::Grafana::DashboardBuilder.new(
17
+ favorites: favorites,
18
+ title: ENV.fetch("TITLE", PgReports::Grafana::DashboardBuilder::DEFAULT_TITLE),
19
+ uid: ENV.fetch("UID", PgReports::Grafana::DashboardBuilder::DEFAULT_UID),
20
+ refresh: ENV.fetch("REFRESH", "1m"),
21
+ time_from: ENV.fetch("TIME_FROM", "now-6h")
22
+ )
23
+
24
+ output_path = ENV.fetch("OUTPUT", "pg_reports.json")
25
+ File.write(output_path, JSON.pretty_generate(builder.build))
26
+ warn "Wrote #{output_path}"
27
+ end
28
+
29
+ desc "Write the current /metrics payload to a file. Defaults to pg_reports.metrics in pwd. Override with OUTPUT=."
30
+ task metrics: :environment do
31
+ output_path = ENV.fetch("OUTPUT", "pg_reports.metrics")
32
+ File.write(output_path, PgReports::Grafana::Exporter.render)
33
+ warn "Wrote #{output_path}"
34
+ end
35
+ end
36
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_reports
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -148,11 +148,14 @@ files:
148
148
  - LICENSE.txt
149
149
  - README.md
150
150
  - app/controllers/pg_reports/dashboard_controller.rb
151
+ - app/controllers/pg_reports/metrics_controller.rb
151
152
  - app/views/layouts/pg_reports/application.html.erb
153
+ - app/views/pg_reports/dashboard/_database_selector.html.erb
152
154
  - app/views/pg_reports/dashboard/_fake_source_data.html.erb
153
155
  - app/views/pg_reports/dashboard/_show_modals.html.erb
154
156
  - app/views/pg_reports/dashboard/_show_scripts.html.erb
155
157
  - app/views/pg_reports/dashboard/_show_styles.html.erb
158
+ - app/views/pg_reports/dashboard/_target_selector.html.erb
156
159
  - app/views/pg_reports/dashboard/index.html.erb
157
160
  - app/views/pg_reports/dashboard/show.html.erb
158
161
  - config/locales/en.yml
@@ -163,6 +166,9 @@ files:
163
166
  - lib/pg_reports/annotation_parser.rb
164
167
  - lib/pg_reports/compatibility.rb
165
168
  - lib/pg_reports/configuration.rb
169
+ - lib/pg_reports/connection/error_translator.rb
170
+ - lib/pg_reports/connection/registry.rb
171
+ - lib/pg_reports/connection/target.rb
166
172
  - lib/pg_reports/dashboard/reports_registry.rb
167
173
  - lib/pg_reports/definitions/connections/active_connections.yml
168
174
  - lib/pg_reports/definitions/connections/blocking_queries.yml
@@ -214,6 +220,8 @@ files:
214
220
  - lib/pg_reports/executor.rb
215
221
  - lib/pg_reports/explain_analyzer.rb
216
222
  - lib/pg_reports/filter.rb
223
+ - lib/pg_reports/grafana/dashboard_builder.rb
224
+ - lib/pg_reports/grafana/exporter.rb
217
225
  - lib/pg_reports/module_generator.rb
218
226
  - lib/pg_reports/modules/connections.rb
219
227
  - lib/pg_reports/modules/indexes.rb
@@ -278,6 +286,7 @@ files:
278
286
  - lib/pg_reports/sql_loader.rb
279
287
  - lib/pg_reports/telegram_sender.rb
280
288
  - lib/pg_reports/version.rb
289
+ - lib/tasks/pg_reports.rake
281
290
  homepage: https://github.com/deadalice/pg_reports
282
291
  licenses:
283
292
  - MIT