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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Connection
5
+ # Represents a single named PostgreSQL target (host+credentials).
6
+ # Holds a memoized AR-subclass-backed connection per database it has been
7
+ # asked for, so switching between databases on the same target reuses pools.
8
+ #
9
+ # The :primary target wraps ActiveRecord::Base directly when accessed at its
10
+ # default database — we don't open a parallel pool to the host app's DB.
11
+ # For non-default databases we create an isolated AR subclass that has its
12
+ # own pool, so the host application's pool is never affected.
13
+ class Target
14
+ class ConnectionFailed < PgReports::Error; end
15
+
16
+ attr_reader :name
17
+
18
+ def initialize(name, spec)
19
+ @name = name.to_sym
20
+ @spec = normalize_spec(spec)
21
+ @pools = {} # database (string) => AR class
22
+ end
23
+
24
+ # Configuration hash used as a template for derived databases.
25
+ def spec
26
+ @spec.dup
27
+ end
28
+
29
+ def default_database
30
+ @spec[:database]&.to_s
31
+ end
32
+
33
+ # Resolve the AR class backing the connection for `database` (nil = default).
34
+ # Returns a class responding to `.connection` (ActiveRecord::Base or subclass).
35
+ def ar_class_for(database = nil)
36
+ db = (database || default_database).to_s
37
+ raise ArgumentError, "Cannot resolve connection: target #{name.inspect} has no default database and none was given" if db.empty?
38
+
39
+ @pools[db] ||= build_pool_class(db)
40
+ end
41
+
42
+ # Returns the active PG connection for `database`, opening it if needed.
43
+ def connection_for(database = nil)
44
+ ar_class_for(database).connection
45
+ rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad => e
46
+ raise ConnectionFailed, "Cannot connect to #{name}/#{database || default_database}: #{e.message}"
47
+ end
48
+
49
+ # List all databases visible on this target's cluster (using pg_database).
50
+ # Result rows: { "name" => String, "size" => String, "current" => Boolean }
51
+ def list_databases(current: nil)
52
+ rows = connection_for.exec_query(<<~SQL).to_a
53
+ SELECT datname AS name,
54
+ pg_size_pretty(pg_database_size(datname)) AS size
55
+ FROM pg_database
56
+ WHERE datistemplate = false AND datallowconn = true
57
+ ORDER BY datname
58
+ SQL
59
+ current_db = (current || default_database).to_s
60
+ rows.each { |r| r["current"] = (r["name"].to_s == current_db) }
61
+ rows
62
+ end
63
+
64
+ # Close all derived pools we own. The :primary AR::Base pool is never touched.
65
+ def disconnect!
66
+ @pools.each_value do |klass|
67
+ next if klass.equal?(ActiveRecord::Base)
68
+ klass.connection_pool.disconnect! if klass.connection_pool.connected?
69
+ rescue
70
+ # Best-effort cleanup
71
+ end
72
+ @pools.clear
73
+ end
74
+
75
+ private
76
+
77
+ def normalize_spec(spec)
78
+ hash = case spec
79
+ when Hash
80
+ spec.transform_keys(&:to_sym)
81
+ when ActiveRecord::DatabaseConfigurations::HashConfig
82
+ spec.configuration_hash.transform_keys(&:to_sym)
83
+ else
84
+ raise ArgumentError, "Unsupported spec type: #{spec.class}"
85
+ end
86
+
87
+ # Ensure adapter defaults to postgresql; we only support PG.
88
+ hash[:adapter] ||= "postgresql"
89
+ hash
90
+ end
91
+
92
+ def build_pool_class(database)
93
+ # When asked for the primary target's default database, reuse AR::Base
94
+ # so we don't open a parallel pool to the same DB the host app uses.
95
+ if name == :primary && database == default_database
96
+ return ActiveRecord::Base
97
+ end
98
+
99
+ klass = Class.new(ActiveRecord::Base) { self.abstract_class = true }
100
+ const_name = "Pool_#{name}_#{database}".gsub(/\W/, "_")
101
+ if PgReports::Connection.const_defined?(const_name, false)
102
+ PgReports::Connection.send(:remove_const, const_name)
103
+ end
104
+ PgReports::Connection.const_set(const_name, klass)
105
+
106
+ klass.establish_connection(@spec.merge(database: database))
107
+ klass
108
+ end
109
+ end
110
+ end
111
+ end
@@ -277,8 +277,8 @@ module PgReports
277
277
  seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
278
278
  tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys"},
279
279
  recently_modified: {name: "Recently Modified", description: "Tables with recent activity"},
280
- update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly", new: true},
281
- unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset", new: true}
280
+ update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly"},
281
+ unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset"}
282
282
  }
283
283
  },
284
284
  connections: {
@@ -316,14 +316,19 @@ module PgReports
316
316
  name: "Schema Analysis",
317
317
  icon: "🔍",
318
318
  color: "#06b6d4",
319
+ # These reports introspect the host application's ActiveRecord models,
320
+ # which are bound to the default database. Running them against a
321
+ # different database in the cluster returns rows that may not map to
322
+ # any model. The dashboard greys the category out in that case.
323
+ target_constraint: :primary_default_database_only,
319
324
  reports: {
320
325
  missing_validations: {name: "Missing Validations", description: "Unique indexes without model validations"},
321
- unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value", new: true},
322
- always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL", new: true},
323
- polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index", new: true},
324
- counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing", new: true},
325
- soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them", new: true},
326
- orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model", new: true}
326
+ unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value"},
327
+ always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL"},
328
+ polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index"},
329
+ counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing"},
330
+ soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them"},
331
+ orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model"}
327
332
  }
328
333
  }
329
334
  }.freeze
@@ -398,6 +403,15 @@ module PgReports
398
403
  def self.problem_fields(report)
399
404
  REPORT_CONFIG.dig(report.to_sym, :problem_fields) || []
400
405
  end
406
+
407
+ # Returns the target constraint declared on a category, or nil.
408
+ # Currently the only constraint is :primary_default_database_only, which
409
+ # means "only meaningful when the dashboard is pointing at the host app's
410
+ # primary target on its default database" — used by Schema Analysis,
411
+ # which depends on ActiveRecord::Base.descendants of the host app.
412
+ def self.target_constraint(category)
413
+ REPORTS.dig(category.to_sym, :target_constraint)
414
+ end
401
415
  end
402
416
  end
403
417
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- # Executes SQL queries and returns results
4
+ # Executes SQL queries and returns results.
5
+ #
6
+ # The connection is resolved lazily on every #execute call so that thread-local
7
+ # context set by PgReports.with_target / with_database is honored even when an
8
+ # Executor instance has been memoized at the module level.
5
9
  class Executor
6
10
  def initialize(connection: nil)
7
- @connection = connection || PgReports.config.connection
11
+ @connection_override = connection
8
12
  end
9
13
 
10
14
  # Execute SQL from a file and return results as array of hashes
@@ -16,10 +20,15 @@ module PgReports
16
20
  # Execute raw SQL and return results as array of hashes
17
21
  def execute(sql, **params)
18
22
  processed_sql = interpolate_params(sql, params)
19
- result = @connection.exec_query(processed_sql)
23
+ result = connection.exec_query(processed_sql)
20
24
  result.to_a
21
25
  end
22
26
 
27
+ # Resolved on every call: explicit override > thread-local > registry default.
28
+ def connection
29
+ @connection_override || PgReports.config.connection
30
+ end
31
+
23
32
  private
24
33
 
25
34
  # Simple parameter interpolation for SQL
@@ -40,11 +49,11 @@ module PgReports
40
49
  when Integer, Float
41
50
  value.to_s
42
51
  when String
43
- @connection.quote(value)
52
+ connection.quote(value)
44
53
  when Array
45
54
  "(#{value.map { |v| quote_value(v) }.join(", ")})"
46
55
  else
47
- @connection.quote(value.to_s)
56
+ connection.quote(value.to_s)
48
57
  end
49
58
  end
50
59
  end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PgReports
6
+ module Grafana
7
+ # Builds an importable Grafana dashboard JSON from configured favorites.
8
+ # Each report becomes a row with two panels:
9
+ # - "rows" stat (total)
10
+ # - "issues by severity" timeseries (ok / warning / critical)
11
+ # Severity colours are wired so that any warning lights yellow, any critical lights red.
12
+ class DashboardBuilder
13
+ DATASOURCE_INPUT = "DS_PROMETHEUS"
14
+
15
+ DEFAULT_TITLE = "PgReports — PostgreSQL Health"
16
+ DEFAULT_UID = "pg-reports"
17
+
18
+ SEVERITY_COLORS = {
19
+ "ok" => "green",
20
+ "warning" => "yellow",
21
+ "critical" => "red"
22
+ }.freeze
23
+
24
+ GRID_WIDTH = 24
25
+ ROW_HEIGHT = 1
26
+ TIMESERIES_HEIGHT = 8
27
+ TABLE_HEIGHT = 10
28
+ REPORT_BLOCK_HEIGHT = TIMESERIES_HEIGHT + TABLE_HEIGHT
29
+
30
+ def self.build(**opts)
31
+ new(**opts).build
32
+ end
33
+
34
+ def initialize(favorites: PgReports.config.grafana_favorites,
35
+ title: DEFAULT_TITLE,
36
+ uid: DEFAULT_UID,
37
+ refresh: "1m",
38
+ time_from: "now-6h")
39
+ @favorites = normalize(favorites)
40
+ @title = title
41
+ @uid = uid
42
+ @refresh = refresh
43
+ @time_from = time_from
44
+ @panel_id = 0
45
+ end
46
+
47
+ def build
48
+ if @favorites.empty?
49
+ raise ArgumentError,
50
+ "No favorites configured. Set PgReports.config.grafana_favorites or pass favorites:."
51
+ end
52
+
53
+ {
54
+ "__inputs" => [datasource_input],
55
+ "__requires" => [grafana_require, prometheus_require],
56
+ "annotations" => {"list" => []},
57
+ "editable" => true,
58
+ "graphTooltip" => 1,
59
+ "panels" => build_panels,
60
+ "refresh" => @refresh,
61
+ "schemaVersion" => 38,
62
+ "tags" => ["pg_reports", "postgresql"],
63
+ "templating" => {"list" => []},
64
+ "time" => {"from" => @time_from, "to" => "now"},
65
+ "timezone" => "browser",
66
+ "title" => @title,
67
+ "uid" => @uid,
68
+ "version" => 1,
69
+ "weekStart" => ""
70
+ }
71
+ end
72
+
73
+ def to_json(*)
74
+ JSON.pretty_generate(build)
75
+ end
76
+
77
+ private
78
+
79
+ def normalize(favorites)
80
+ case favorites
81
+ when Hash then favorites.keys.map(&:to_sym)
82
+ when Array then favorites.map(&:to_sym)
83
+ else []
84
+ end
85
+ end
86
+
87
+ def build_panels
88
+ panels = []
89
+ y = 0
90
+
91
+ grouped_favorites.each do |category, keys|
92
+ panels << row_panel(category_label(category), y)
93
+ y += ROW_HEIGHT
94
+
95
+ keys.each do |key|
96
+ info = report_info(category, key)
97
+ panels << timeseries_panel(key, info, y)
98
+ panels << table_panel(key, info, y + TIMESERIES_HEIGHT)
99
+ y += REPORT_BLOCK_HEIGHT
100
+ end
101
+ end
102
+
103
+ panels
104
+ end
105
+
106
+ def grouped_favorites
107
+ groups = {}
108
+ @favorites.each do |key|
109
+ category = category_for(key)
110
+ next unless category # silently skip unknown keys; exporter logs them
111
+ groups[category] ||= []
112
+ groups[category] << key
113
+ end
114
+ groups
115
+ end
116
+
117
+ def category_for(key)
118
+ Dashboard::ReportsRegistry::REPORTS.each do |category, info|
119
+ return category if info[:reports].key?(key)
120
+ end
121
+ nil
122
+ end
123
+
124
+ def category_label(category)
125
+ Dashboard::ReportsRegistry::REPORTS.dig(category, :name) || category.to_s.humanize
126
+ end
127
+
128
+ def report_info(category, key)
129
+ Dashboard::ReportsRegistry::REPORTS.dig(category, :reports, key) || {name: key.to_s.humanize, description: ""}
130
+ end
131
+
132
+ def row_panel(title, y)
133
+ {
134
+ "id" => next_id,
135
+ "type" => "row",
136
+ "title" => title,
137
+ "collapsed" => false,
138
+ "gridPos" => {"h" => ROW_HEIGHT, "w" => GRID_WIDTH, "x" => 0, "y" => y},
139
+ "panels" => []
140
+ }
141
+ end
142
+
143
+ def timeseries_panel(key, info, y)
144
+ {
145
+ "id" => next_id,
146
+ "type" => "timeseries",
147
+ "title" => "#{info[:name]} — issues by severity",
148
+ "description" => info[:description].to_s,
149
+ "datasource" => datasource_ref,
150
+ "gridPos" => {"h" => TIMESERIES_HEIGHT, "w" => GRID_WIDTH, "x" => 0, "y" => y},
151
+ "targets" => severity_targets(key),
152
+ "options" => {
153
+ "legend" => {"displayMode" => "table", "placement" => "right", "calcs" => ["lastNotNull"]},
154
+ "tooltip" => {"mode" => "multi", "sort" => "desc"}
155
+ },
156
+ "fieldConfig" => {
157
+ "defaults" => {
158
+ "custom" => {
159
+ "drawStyle" => "bars",
160
+ "stacking" => {"mode" => "normal", "group" => "A"},
161
+ "fillOpacity" => 60,
162
+ "lineWidth" => 1
163
+ },
164
+ "color" => {"mode" => "fixed"},
165
+ "mappings" => []
166
+ },
167
+ "overrides" => severity_color_overrides
168
+ }
169
+ }
170
+ end
171
+
172
+ def table_panel(key, info, y)
173
+ {
174
+ "id" => next_id,
175
+ "type" => "table",
176
+ "title" => "#{info[:name]} — current rows",
177
+ "description" => "#{info[:description]}\n\nLatest snapshot of report rows. Each row's columns are unpacked from Prometheus labels via the Labels-to-fields transformation.".strip,
178
+ "datasource" => datasource_ref,
179
+ "gridPos" => {"h" => TABLE_HEIGHT, "w" => GRID_WIDTH, "x" => 0, "y" => y},
180
+ "targets" => [
181
+ {
182
+ "refId" => "A",
183
+ "expr" => %(pg_reports_row{report="#{key}"}),
184
+ "datasource" => datasource_ref,
185
+ "format" => "table",
186
+ "instant" => true,
187
+ "legendFormat" => "__auto"
188
+ }
189
+ ],
190
+ "transformations" => [
191
+ {
192
+ "id" => "labelsToFields",
193
+ "options" => {"mode" => "columns"}
194
+ },
195
+ {
196
+ "id" => "organize",
197
+ "options" => {
198
+ "excludeByName" => {
199
+ "Time" => true,
200
+ "Value" => true,
201
+ "__name__" => true,
202
+ "instance" => true,
203
+ "job" => true,
204
+ "report" => true,
205
+ "row" => true
206
+ },
207
+ "indexByName" => {},
208
+ "renameByName" => {}
209
+ }
210
+ }
211
+ ],
212
+ "options" => {
213
+ "showHeader" => true,
214
+ "cellHeight" => "sm",
215
+ "footer" => {"show" => false}
216
+ },
217
+ "fieldConfig" => {
218
+ "defaults" => {
219
+ "custom" => {"align" => "auto", "displayMode" => "auto"},
220
+ "mappings" => []
221
+ },
222
+ "overrides" => []
223
+ }
224
+ }
225
+ end
226
+
227
+ def severity_targets(key)
228
+ SEVERITY_COLORS.keys.each_with_index.map do |severity, i|
229
+ {
230
+ "refId" => ("A".ord + i).chr,
231
+ "expr" => %(pg_reports_issues{report="#{key}",severity="#{severity}"}),
232
+ "datasource" => datasource_ref,
233
+ "legendFormat" => severity
234
+ }
235
+ end
236
+ end
237
+
238
+ def severity_color_overrides
239
+ SEVERITY_COLORS.map do |severity, color|
240
+ {
241
+ "matcher" => {"id" => "byName", "options" => severity},
242
+ "properties" => [
243
+ {"id" => "color", "value" => {"mode" => "fixed", "fixedColor" => color}}
244
+ ]
245
+ }
246
+ end
247
+ end
248
+
249
+ def datasource_input
250
+ {
251
+ "name" => DATASOURCE_INPUT,
252
+ "label" => "Prometheus",
253
+ "description" => "Datasource that scrapes /pg_reports/metrics",
254
+ "type" => "datasource",
255
+ "pluginId" => "prometheus",
256
+ "pluginName" => "Prometheus"
257
+ }
258
+ end
259
+
260
+ def datasource_ref
261
+ {"type" => "prometheus", "uid" => "${#{DATASOURCE_INPUT}}"}
262
+ end
263
+
264
+ def grafana_require
265
+ {"type" => "grafana", "id" => "grafana", "name" => "Grafana", "version" => "9.0.0"}
266
+ end
267
+
268
+ def prometheus_require
269
+ {"type" => "datasource", "id" => "prometheus", "name" => "Prometheus", "version" => "1.0.0"}
270
+ end
271
+
272
+ def next_id
273
+ @panel_id += 1
274
+ end
275
+ end
276
+ end
277
+ end