pg_reports 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77275298b8ef6a1dba986e9b47849d1685e2d09cacdd64e3b086b20ab403574c
4
- data.tar.gz: 893da425321112adf5d4f9944fb72028898c77119ec95ad92d27c21681a80a0e
3
+ metadata.gz: 67cc51703472362dd188536abd8196660d5e4bb928d9f9ee53d7e2939290772e
4
+ data.tar.gz: 99fe7733e13344ac1e6b0a9a981728525005437a36e6c17846da455de5365a9b
5
5
  SHA512:
6
- metadata.gz: c99a83908899a5896734b7025b4b593dad0c0d4045b9578e1912ccb332aa9f29dcb0c1e79fe69a403f4a248e99a540fc1b0627af44d7ddc1c1c889c738c1ffa7
7
- data.tar.gz: 4ff3fe8ae5a907dee4a6eb9186adb1e9819d49e62c84c7447c49999dd2c4f09d01cd962711461437f18f1853ba1c3ea9b226af5a822a373034f61f93521ef1f5
6
+ metadata.gz: f408472b95f858f7770511b89885afd98b14074ee5fa3ed67c75ae36b29cb34a96adf1ac17ec0f6cf69bb9cbb51843def46483ff60ee121e2317d930da23deaf
7
+ data.tar.gz: fb4760d0e84fbe5a29b157cb37ee765454e7a26863a01b64aee1ba4262d83b28272e16664b7e797981f72594deb192e7f1a22a0c93ef6fb1399d584932446d91
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-04-26
11
+
12
+ ### Added
13
+
14
+ - **Experimental Grafana / Prometheus support.** Selected reports can now be exposed at `<mount_point>/metrics` in Prometheus exposition format. Severity (`ok` / `warning` / `critical`) is derived automatically from the existing `REPORT_CONFIG` thresholds, including inverted ones (`cache_hit_ratio` etc.). Per-row data is emitted as `pg_reports_row` with each row column as a label, suitable for Grafana table panels via the "Labels to fields" transformation.
15
+ - New config: `grafana_favorites`, `grafana_metrics_token`, `grafana_cache_ttl`. Reports are cached via `Rails.cache` with a per-report TTL override to keep frequent scrapes from hammering the database.
16
+ - New endpoint: `MetricsController` with timing-safe bearer-token auth.
17
+ - New rake tasks: `pg_reports:grafana:dashboard` (writes a ready-to-import Grafana dashboard JSON) and `pg_reports:grafana:metrics` (prints the current metrics payload).
18
+ - New module `PgReports::Grafana::Exporter` and `PgReports::Grafana::DashboardBuilder`.
19
+ - Documentation: [docs/grafana.md](docs/grafana.md) (integration guide) and [docs/grafana-local-setup.md](docs/grafana-local-setup.md) (local Prometheus + Grafana setup without Docker).
20
+ - **Note:** the metric format may change in future minor versions until 1.0.
21
+
22
+ ## [0.6.2] - 2026-04-25
23
+
24
+ ### Added
25
+
26
+ - **Native Rails QueryLogs source_location support** — `AnnotationParser` now recognizes the `source_location` tag emitted by `ActiveRecord::QueryLogs` and splits it into separate `:file` / `:line` fields for the dashboard's source column. Values are URL-decoded first, since Rails CGI-escapes tag values (so `%2F.../%3A19` becomes `/.../foo.rb:19`).
27
+
28
+ ### Fixed
29
+
30
+ - **`counter_cache_issues` crashed/misreported on Rails 7.1+ Hash form.** `belongs_to :user, counter_cache: :col` is internally normalized to `{active: true, column: :col}` on Rails 7.1+, but the helper only handled `true` / Symbol / String — so the column came out as `Hash#inspect` (`{active: true, column: "usage_count"}`) and the suggested migration was malformed. `counter_cache_column_name` now handles all four forms (Boolean, Symbol, String, Hash with `:column` key, Hash without `:column`).
31
+ - **`polymorphic_without_index` crashed with `NoMethodError: undefined method '&' for an instance of String`** when a table had an expression index (e.g. `CREATE INDEX ON x (LOWER(email))`). PostgreSQL returns `IndexDefinition#columns` as a String for expression indexes, not an Array. Both the polymorphic check and `coverage_label` now filter out non-array indexes.
32
+
33
+ ### Changed
34
+
35
+ - **README simplified** — full reports listing moved to [docs/reports.md](docs/reports.md), long sections (EXPLAIN ANALYZE, SQL Query Monitor, Connection pool analytics, IDE integration, Telegram delivery, raw query execution, source tracking) collapsed into `<details>` blocks. From 582 lines to ~340.
36
+ - **Query source tracking** documentation now leads with native `ActiveRecord::QueryLogs` (Rails 7.0+) including a `source_location` lambda example. Marginalia mentioned only as the option for Rails < 7.0.
37
+
10
38
  ## [0.6.1] - 2026-04-24
11
39
 
12
40
  ### Added
data/README.md CHANGED
@@ -18,6 +18,7 @@ A comprehensive PostgreSQL monitoring and analysis library for Rails application
18
18
  - 🖥️ **System Overview** - Database sizes, PostgreSQL settings, installed extensions
19
19
  - 🌐 **Web Dashboard** - Beautiful dark-themed UI with sortable tables and expandable rows
20
20
  - 📨 **Telegram Integration** - Send reports directly to Telegram
21
+ - 📈 **Grafana / Prometheus Exporter** - Expose selected reports at `/metrics` with severity derived from configured thresholds
21
22
  - 📥 **Export** - Download reports in TXT, CSV, or JSON format
22
23
  - 🔗 **IDE Integration** - Open source locations in VS Code, Cursor, RubyMine, or IntelliJ (with WSL support)
23
24
  - 📌 **Comparison Mode** - Save records to compare before/after optimization
@@ -131,25 +132,40 @@ end
131
132
  </details>
132
133
 
133
134
  <details>
134
- <summary><strong>Query source tracking (Marginalia / Rails query logs)</strong></summary>
135
+ <summary><strong>Query source tracking (Rails query logs)</strong></summary>
135
136
 
136
- PgReports parses query annotations to show **where queries originated**.
137
+ PgReports parses query annotations to show **where queries originated**. On Rails 7.0+ use the built-in `ActiveRecord::QueryLogs` (no extra gem needed). On older Rails, install [Marginalia](https://github.com/basecamp/marginalia) — PgReports auto-detects both formats.
137
138
 
138
- [Marginalia](https://github.com/basecamp/marginalia):
139
+ Minimal setup — adds controller/action:
139
140
 
140
141
  ```ruby
141
- gem "marginalia"
142
+ # config/application.rb
143
+ config.active_record.query_log_tags_enabled = true
144
+ config.active_record.query_log_tags = [:controller, :action]
142
145
  ```
143
146
 
144
- Rails 7+ query logs:
147
+ To also surface **file path and line number** (so source links jump to the actual call site, not just the controller), add a custom `source_location` lambda that walks `caller_locations` and skips gem/framework frames:
145
148
 
146
149
  ```ruby
147
150
  # config/application.rb
148
151
  config.active_record.query_log_tags_enabled = true
149
- config.active_record.query_log_tags = [:controller, :action]
152
+ config.active_record.query_log_tags = [
153
+ :controller,
154
+ :action,
155
+ :job,
156
+ {
157
+ source_location: -> {
158
+ ignore = %r{/(gems|active_record|active_support|active_model|railties|
159
+ action_controller|action_view|action_pack|action_dispatch|
160
+ rack|core_ext|relation|associations|scoping|connection_adapters)/}x
161
+ loc = caller_locations.find { |l| !l.path.match?(ignore) }
162
+ "#{loc.path}:#{loc.lineno}" if loc
163
+ }
164
+ }
165
+ ]
150
166
  ```
151
167
 
152
- Either form is auto-detected; controller/action and file:line appear in the **source** column on report rows.
168
+ PgReports recognizes the `source_location` tag and splits it into file and line for the **source** column.
153
169
 
154
170
  </details>
155
171
 
@@ -284,7 +300,57 @@ The Export dropdown includes **Copy Prompt** (visible on actionable reports). It
284
300
 
285
301
  </details>
286
302
 
287
- ## Telegram
303
+ <details>
304
+ <summary><strong>Grafana / Prometheus exporter</strong></summary>
305
+
306
+ Expose selected reports at `<mount_point>/metrics` in Prometheus exposition format. The default mount is `/pg_reports`, so the endpoint is typically `/pg_reports/metrics` — but it follows whatever path you used in `mount PgReports::Engine, at: "..."`. Severity (`ok` / `warning` / `critical`) is derived automatically from the thresholds defined in [`Dashboard::ReportsRegistry::REPORT_CONFIG`](lib/pg_reports/dashboard/reports_registry.rb).
307
+
308
+ ```ruby
309
+ PgReports.configure do |config|
310
+ config.grafana_favorites = [
311
+ :slow_queries,
312
+ :unused_indexes,
313
+ :bloated_tables,
314
+ :missing_validations,
315
+ :polymorphic_without_index
316
+ ]
317
+ config.grafana_metrics_token = ENV["PG_REPORTS_METRICS_TOKEN"] # optional bearer token
318
+ config.grafana_cache_ttl = 60 # seconds
319
+ end
320
+ ```
321
+
322
+ Scrape with Prometheus:
323
+
324
+ ```yaml
325
+ scrape_configs:
326
+ - job_name: pg_reports
327
+ metrics_path: /pg_reports/metrics # adjust to your Engine mount point
328
+ scrape_interval: 60s
329
+ authorization: { credentials: "${PG_REPORTS_METRICS_TOKEN}" }
330
+ static_configs:
331
+ - targets: ["app.internal:3000"]
332
+ ```
333
+
334
+ > [!WARNING]
335
+ > Reports are cached via `Rails.cache` for `grafana_cache_ttl` so frequent scrapes don't hammer the database. Without it, Prometheus' default 15s scrape interval against heavy reports like `missing_validations` will DDoS your own DB. Always set a TTL ≥ scrape interval, and consider a longer per-report TTL for expensive reports.
336
+
337
+ The exporter also emits a `pg_reports_row` series per report row (each column becomes a Prometheus label), so the auto-generated dashboard can show a **table panel** with the actual rows that need fixing — not just an aggregate count.
338
+
339
+ Generate a matching Grafana dashboard from the same favorites:
340
+
341
+ ```bash
342
+ bundle exec rake pg_reports:grafana:dashboard
343
+ # writes pg_reports.json in pwd; then Dashboards → Import in Grafana
344
+ ```
345
+
346
+ **[Full Grafana integration guide →](docs/grafana.md)** &nbsp;·&nbsp; **[Local Prometheus + Grafana without Docker →](docs/grafana-local-setup.md)**
347
+
348
+ </details>
349
+
350
+ <details>
351
+ <summary><strong>Telegram delivery</strong></summary>
352
+
353
+ Get a bot token from [@BotFather](https://t.me/BotFather) and your chat ID from [@userinfobot](https://t.me/userinfobot), then:
288
354
 
289
355
  ```ruby
290
356
  PgReports.configure do |config|
@@ -296,7 +362,9 @@ PgReports.slow_queries.send_to_telegram
296
362
  PgReports.health_report.send_to_telegram_as_file
297
363
  ```
298
364
 
299
- Get a bot token from [@BotFather](https://t.me/BotFather) and your chat ID from [@userinfobot](https://t.me/userinfobot).
365
+ Reports under ~50 rows go as a message; larger ones are sent as a file attachment.
366
+
367
+ </details>
300
368
 
301
369
  ## Development
302
370
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+
5
+ module PgReports
6
+ class MetricsController < ActionController::Base
7
+ CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
8
+
9
+ before_action :authenticate_metrics!, if: -> { PgReports.config.grafana_metrics_token.present? }
10
+
11
+ def show
12
+ render plain: PgReports::Grafana::Exporter.render, content_type: CONTENT_TYPE
13
+ end
14
+
15
+ private
16
+
17
+ def authenticate_metrics!
18
+ expected = PgReports.config.grafana_metrics_token.to_s
19
+ provided = request.headers["Authorization"].to_s.sub(/\Abearer\s+/i, "")
20
+
21
+ a = ::Digest::SHA256.digest(expected)
22
+ b = ::Digest::SHA256.digest(provided)
23
+
24
+ head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(a, b) && expected.present?
25
+ end
26
+ end
27
+ end
data/config/routes.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  PgReports::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
5
 
6
+ get "metrics", to: "metrics#show", as: :metrics
7
+
6
8
  get "live_metrics", to: "dashboard#live_metrics", as: :live_metrics
7
9
 
8
10
  post "enable_pg_stat_statements", to: "dashboard#enable_pg_stat_statements", as: :enable_pg_stat_statements
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module PgReports
4
6
  # Parses SQL query comments to extract source location and metadata
5
7
  # Supports:
6
8
  # - Marginalia format: /*application:myapp,controller:users,action:index*/
7
- # - Rails QueryLogs: /*action='index',controller='users'*/
9
+ # - Rails QueryLogs: /*action='index',controller='users',source_location='app/foo.rb:42'*/
8
10
  #
9
11
  module AnnotationParser
10
12
  class << self
@@ -32,6 +34,16 @@ module PgReports
32
34
  end
33
35
  end
34
36
 
37
+ # Rails QueryLogs custom tag — Rails URL-encodes tag values (CGI.escape) to keep
38
+ # SQL comments safe, so "/" → "%2F" and ":" → "%3A". Decode then split into :file/:line.
39
+ if result[:source_location] && !result[:file]
40
+ decoded = CGI.unescape(result[:source_location].to_s)
41
+ if (match = decoded.match(%r{^(.+):(\d+)$}))
42
+ result[:file] = match[1]
43
+ result[:line] = match[2]
44
+ end
45
+ end
46
+
35
47
  result
36
48
  end
37
49
 
@@ -42,6 +42,11 @@ module PgReports
42
42
  # Security settings
43
43
  attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
44
44
 
45
+ # Grafana / Prometheus exporter settings
46
+ attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
47
+ attr_accessor :grafana_metrics_token # Bearer token required to access /metrics (nil = no auth)
48
+ attr_accessor :grafana_cache_ttl # Default cache TTL for collected reports, seconds
49
+
45
50
  def initialize
46
51
  # Telegram
47
52
  @telegram_bot_token = ENV.fetch("PG_REPORTS_TELEGRAM_TOKEN", nil)
@@ -87,6 +92,11 @@ module PgReports
87
92
  @allow_raw_query_execution = ActiveModel::Type::Boolean.new.cast(
88
93
  ENV.fetch("PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION", false)
89
94
  )
95
+
96
+ # Grafana / Prometheus exporter
97
+ @grafana_favorites = []
98
+ @grafana_metrics_token = ENV.fetch("PG_REPORTS_METRICS_TOKEN", nil)
99
+ @grafana_cache_ttl = 60
90
100
  end
91
101
 
92
102
  def connection
@@ -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
@@ -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 << {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/pg_reports.rb CHANGED
@@ -33,6 +33,10 @@ require_relative "pg_reports/modules/schema_analysis"
33
33
  # Dashboard
34
34
  require_relative "pg_reports/dashboard/reports_registry"
35
35
 
36
+ # Grafana / Prometheus exporter
37
+ require_relative "pg_reports/grafana/exporter"
38
+ require_relative "pg_reports/grafana/dashboard_builder"
39
+
36
40
  # Rails Engine
37
41
  require_relative "pg_reports/engine" if defined?(Rails::Engine)
38
42
 
@@ -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.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -148,6 +148,7 @@ 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
152
153
  - app/views/pg_reports/dashboard/_fake_source_data.html.erb
153
154
  - app/views/pg_reports/dashboard/_show_modals.html.erb
@@ -214,6 +215,8 @@ files:
214
215
  - lib/pg_reports/executor.rb
215
216
  - lib/pg_reports/explain_analyzer.rb
216
217
  - lib/pg_reports/filter.rb
218
+ - lib/pg_reports/grafana/dashboard_builder.rb
219
+ - lib/pg_reports/grafana/exporter.rb
217
220
  - lib/pg_reports/module_generator.rb
218
221
  - lib/pg_reports/modules/connections.rb
219
222
  - lib/pg_reports/modules/indexes.rb
@@ -278,6 +281,7 @@ files:
278
281
  - lib/pg_reports/sql_loader.rb
279
282
  - lib/pg_reports/telegram_sender.rb
280
283
  - lib/pg_reports/version.rb
284
+ - lib/tasks/pg_reports.rake
281
285
  homepage: https://github.com/deadalice/pg_reports
282
286
  licenses:
283
287
  - MIT
@@ -285,6 +289,16 @@ metadata:
285
289
  homepage_uri: https://github.com/deadalice/pg_reports
286
290
  source_code_uri: https://github.com/deadalice/pg_reports
287
291
  changelog_uri: https://github.com/deadalice/pg_reports/blob/main/CHANGELOG.md
292
+ post_install_message: |
293
+ Thanks for installing pg_reports v0.7.0!
294
+
295
+ New in 0.7.0 — experimental Grafana / Prometheus support
296
+ ────────────────────────────────────────────────────────
297
+ Expose selected reports at <mount_point>/metrics in Prometheus exposition
298
+ format and import a ready-to-use Grafana dashboard:
299
+
300
+ Setup guide:
301
+ https://github.com/deadalice/pg_reports/blob/main/docs/grafana.md
288
302
  rdoc_options: []
289
303
  require_paths:
290
304
  - lib