pg_reports 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +126 -80
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
  8. data/app/views/pg_reports/dashboard/index.html.erb +310 -124
  9. data/bin/pg_reports +2 -2
  10. data/config/brakeman.ignore +10 -56
  11. data/config/locales/en.yml +39 -8
  12. data/config/locales/ru.yml +38 -8
  13. data/config/locales/uk.yml +38 -8
  14. data/config/routes.rb +1 -0
  15. data/lib/pg_reports/access_policy.rb +39 -0
  16. data/lib/pg_reports/compatibility.rb +10 -2
  17. data/lib/pg_reports/configuration.rb +33 -2
  18. data/lib/pg_reports/connection/error_translator.rb +21 -0
  19. data/lib/pg_reports/connection/target.rb +43 -10
  20. data/lib/pg_reports/csv_cell.rb +20 -0
  21. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  22. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  23. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  24. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  25. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  26. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  27. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  28. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  29. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  30. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  31. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  32. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  33. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  34. data/lib/pg_reports/drop_index_migration.rb +56 -0
  35. data/lib/pg_reports/engine.rb +4 -3
  36. data/lib/pg_reports/executor.rb +46 -9
  37. data/lib/pg_reports/modules/queries.rb +1 -0
  38. data/lib/pg_reports/query_monitor.rb +478 -192
  39. data/lib/pg_reports/rate_limiter.rb +63 -0
  40. data/lib/pg_reports/report.rb +1 -1
  41. data/lib/pg_reports/report_definition.rb +11 -1
  42. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  43. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  44. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  45. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  46. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  47. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  48. data/lib/pg_reports/sql_redactor.rb +32 -0
  49. data/lib/pg_reports/standalone.rb +83 -20
  50. data/lib/pg_reports/version.rb +1 -1
  51. data/lib/pg_reports.rb +7 -2
  52. metadata +17 -9
@@ -1,51 +1,5 @@
1
1
  {
2
2
  "ignored_warnings": [
3
- {
4
- "warning_type": "SQL Injection",
5
- "warning_code": 0,
6
- "fingerprint": "5209397462e689c349f77e534f897c12a8550b9a013bd535153a929c12b944e2",
7
- "check_name": "SQL",
8
- "message": "Possible SQL injection",
9
- "file": "app/controllers/pg_reports/dashboard_controller.rb",
10
- "line": 357,
11
- "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
12
- "code": "ActiveRecord::Base.connection.execute(\"SELECT COUNT(*) FROM (#{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}) AS count_query\")",
13
- "render_path": null,
14
- "location": {
15
- "type": "method",
16
- "class": "PgReports::DashboardController",
17
- "method": "execute_query"
18
- },
19
- "user_input": "params[:params]",
20
- "confidence": "High",
21
- "cwe_id": [
22
- 89
23
- ],
24
- "note": "Reviewed & accepted: user-query execution feature (execute_query count). Gated by config.allow_raw_query_execution; the query is fetched by hash from an internal registry (retrieve_query_by_hash), parameters are validated/substituted, and SecurityError checks reject dangerous input. Not arbitrary attacker-controlled SQL."
25
- },
26
- {
27
- "warning_type": "SQL Injection",
28
- "warning_code": 0,
29
- "fingerprint": "52294539b2a9666f9a79f7ad52cdd1dc531a3b71e487e5134174029cd5138571",
30
- "check_name": "SQL",
31
- "message": "Possible SQL injection",
32
- "file": "app/controllers/pg_reports/dashboard_controller.rb",
33
- "line": 440,
34
- "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
35
- "code": "ActiveRecord::Base.connection.execute(\"SELECT COUNT(*) FROM (#{params[:query].to_s}) AS count_query\")",
36
- "render_path": null,
37
- "location": {
38
- "type": "method",
39
- "class": "PgReports::DashboardController",
40
- "method": "run_query"
41
- },
42
- "user_input": "params[:query]",
43
- "confidence": "High",
44
- "cwe_id": [
45
- 89
46
- ],
47
- "note": "Reviewed & accepted: SQL Console (run_query count). Gated by config.allow_raw_query_execution; the client-typed query passes enforce_select_only! (SELECT-only, no semicolons, keyword denylist) and runs under a bounded statement_timeout. Intentional DBA tooling; same risk profile as execute_query/explain_analyze."
48
- },
49
3
  {
50
4
  "warning_type": "Cross-Site Request Forgery",
51
5
  "warning_code": 7,
@@ -71,27 +25,27 @@
71
25
  {
72
26
  "warning_type": "SQL Injection",
73
27
  "warning_code": 0,
74
- "fingerprint": "f133378f2542f3c09b527aebcc0939a1d227c46b0ab124be613e1e5aac829c79",
28
+ "fingerprint": "d79e7adc175add96cdc7054b79ce7861dca0ce3dfd0bd8ec025f953b62806ae3",
75
29
  "check_name": "SQL",
76
30
  "message": "Possible SQL injection",
77
- "file": "app/controllers/pg_reports/dashboard_controller.rb",
78
- "line": 278,
31
+ "file": "lib/pg_reports/executor.rb",
32
+ "line": 42,
79
33
  "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
80
- "code": "ActiveRecord::Base.connection.execute(\"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}\")",
34
+ "code": "connection.exec_query(\"#{\"/* pg_reports */\"} #{interpolate_params(sql, params)}\", \"PgReports\")",
81
35
  "render_path": null,
82
36
  "location": {
83
37
  "type": "method",
84
- "class": "PgReports::DashboardController",
85
- "method": "explain_analyze"
38
+ "class": "PgReports::Executor",
39
+ "method": "execute"
86
40
  },
87
- "user_input": "params[:params]",
88
- "confidence": "High",
41
+ "user_input": "interpolate_params(sql, params)",
42
+ "confidence": "Medium",
89
43
  "cwe_id": [
90
44
  89
91
45
  ],
92
- "note": "Reviewed & accepted: EXPLAIN ANALYZE feature. Same safeguards as execute_query - query resolved by hash from internal registry, params validated, SecurityError guards. Intentional DBA tooling."
46
+ "note": "Reviewed & accepted: `sql` comes from SQL files shipped with the gem or string literals in its modules, never from a request. interpolate_params makes one pass and every value goes through quote_value, so a value cannot become SQL. Dashboard raw queries use exec_params in DashboardController instead."
93
47
  }
94
48
  ],
95
- "updated": "2026-07-10 19:15:00 +0300",
49
+ "updated": "2026-11-07 12:00:00 +0300",
96
50
  "brakeman_version": "8.0.5"
97
51
  }
@@ -24,6 +24,18 @@ en:
24
24
  - "Prepared statements can reduce parsing overhead for frequent queries."
25
25
  ai_prompt: "Reduce the call frequency of these heavy PostgreSQL queries. For each query: identify the caller in application code, suggest caching strategies (Redis/Memcached), detect N+1 patterns and suggest eager loading fixes. Show code changes needed."
26
26
 
27
+ frequent_queries:
28
+ title: "Most Frequent Queries"
29
+ what: "Queries executed at the highest rate, in calls per second, sorted from most to least frequent down to the threshold."
30
+ how: "Divides calls from pg_stat_statements by the time the query has been tracked: since the entry was created (stats_since, PostgreSQL 17+) or since the last statistics reset (pg_stat_statements_info.stats_reset, PostgreSQL 14–16). Unlike Heavy Queries, which ranks by the cumulative call count, a query's place here does not depend on how long it has been accumulating calls."
31
+ nuances:
32
+ - "This is an average over the whole tracked period (see tracked_since), not the current load: a recent spike is diluted by a long quiet period, and the report cannot show when calls actually happened."
33
+ - "On PostgreSQL 14–16 every query shares the same window, so a query deployed long after the last reset is understated. Reset the statistics and let them accumulate for a representative period to compare fairly."
34
+ - "An entry evicted from pg_stat_statements (see pg_stat_statements.max) loses its counters, so the rate starts over when it is seen again."
35
+ - "Very frequent, fast queries are candidates for application-level caching; many similar queries point to N+1 and a need for eager loading."
36
+ - "Requires PostgreSQL 14+ and pg_stat_statements 1.9+. After a major upgrade run ALTER EXTENSION pg_stat_statements UPDATE."
37
+ ai_prompt: "Reduce the execution rate of these high-frequency PostgreSQL queries. For each query: identify the caller in application code, determine whether the rate is justified, suggest caching (Redis/Memcached), batching, or eager loading to fix N+1 patterns. Show code changes needed."
38
+
27
39
  expensive_queries:
28
40
  title: "Expensive Queries"
29
41
  what: "Queries consuming the most total time (total_time = mean_time × calls)."
@@ -686,14 +698,27 @@ en:
686
698
  update_interval: "Updates every 5s"
687
699
  toggle_title: "Toggle live monitoring"
688
700
  query_monitor_title: "SQL Query Monitor"
689
- session_label: "Session:"
701
+ auto_stop_label: "Stops in"
690
702
  queries_label: "Queries:"
691
703
  feed_empty: "Click \"Start Monitoring\" to begin capturing SQL queries"
692
704
  feed_no_queries: "No queries captured yet..."
693
705
  unknown_source: "Unknown source"
694
- expand_collapse_title: "Expand/Collapse"
695
- scope_note_short: "scope: host application"
696
- scope_note_long: "Query Monitor subscribes to ActiveSupport::Notifications in the host application's process. It captures every SQL the host app runs, on whichever database the host app is connected to. The dashboard's database selector does not change this scope."
706
+ col_time: "Time"
707
+ col_duration: "ms"
708
+ col_source: "Source"
709
+ col_sql: "SQL"
710
+ session_ended: "Monitoring ended"
711
+ scope_process_warning: "Only this server process is covered: Rails.cache is NullStore or MemoryStore, so other processes cannot see the session. Requests served by another worker will show monitoring as stopped. Use a shared cache (Redis, Memcached) or set config.query_monitor_cache_store."
712
+ scope_note_short_host: "scope: host application"
713
+ scope_note_long_host: "Query Monitor captures the SQL run by every process of the host application that shares its cache: web workers and background jobs alike, on whichever database each is connected to. The dashboard's database selector does not change this scope. A session stops by itself after config.query_monitor_max_duration."
714
+ scope_note_short_sampled: "sampled from pg_stat_activity"
715
+ scope_note_long_sampled: "Samples pg_stat_activity on the selected database every 0.5s. It sees every client of that database, but a backend only keeps its latest statement: fast queries replaced between two samples are missed, so this is a sample rather than a complete log. A session stops by itself after config.query_monitor_max_duration."
716
+ source_switch_label: "Capture method"
717
+ source_notifications: "App"
718
+ source_activity: "Database"
719
+ source_notifications_title: "Every query of this application, with its source. Queries from other clients are not seen."
720
+ source_activity_title: "Samples what all clients of the selected database run. Fast queries between samples are missed."
721
+ hidden_sessions_warning: "%{count} sessions are hidden: this role cannot read other roles' queries. Grant access with: GRANT pg_read_all_stats TO your_role;"
697
722
  metrics:
698
723
  connections_label: "Connections"
699
724
  tps_label: "TPS"
@@ -785,20 +810,23 @@ en:
785
810
  create_migration_failed: "Failed to create migration"
786
811
  explain_disabled_toast: "EXPLAIN ANALYZE is disabled. Enable in configuration: config.allow_raw_query_execution = true"
787
812
  execute_disabled_toast: "Query execution is disabled. Enable in configuration: config.allow_raw_query_execution = true"
788
- migration_disabled_toast: "Migration creation is disabled. Enable in configuration: config.allow_raw_query_execution = true"
813
+ migration_disabled_toast: "Migration creation is disabled. Enable in configuration: config.allow_migration_creation = true"
789
814
  query_monitoring_error: "Query monitoring error"
790
815
  query_hash_required: "Query hash is required"
791
816
  query_execution_disabled: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
792
817
  query_not_found_expired: "Query not found or expired. Please refresh the page."
793
- query_monitor_unavailable_standalone: "SQL Query Monitor isn't available in standalone mode — there is no host application process to observe."
818
+ query_monitor_disabled: "SQL Query Monitor is turned off by config.query_monitor_enabled."
794
819
  query_timed_out: "Query timed out after %{ms}ms (statement_timeout). Try narrowing the query or increase config.raw_query_statement_timeout_ms."
820
+ report_timed_out: "The report was stopped after %{ms}ms (statement_timeout). Increase config.report_statement_timeout_ms if this database needs longer."
821
+ report_lock_timed_out: "The report needs a lock another session holds, so it was stopped instead of waiting. See Blocking Queries and Locks."
795
822
  rate_limit_exceeded: "Too many requests. Please wait a bit before trying again."
796
823
  security_violation_prefix: "Security violation:"
797
824
  trigger_variables_not_allowed: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
798
825
  missing_parameter_values: "Please provide values for all parameter placeholders ($1, $2, etc.)"
799
826
  migration_dev_only: "Migration creation is only allowed in development environment"
800
- filename_code_required: "File name and code are required"
801
- invalid_filename_format: "Invalid migration file name format"
827
+ index_not_found: "Index not found, or its name exists in several schemas"
828
+ migration_exists: "A migration for this index already exists in db/migrate"
829
+ migration_primary_only: "Migrations can only be generated from the application's primary database"
802
830
  migrations_dir_not_found: "Migrations directory not found"
803
831
  success:
804
832
  statistics_reset: "Statistics have been reset successfully"
@@ -831,6 +859,9 @@ en:
831
859
  heavy_queries:
832
860
  name: "Heavy Queries"
833
861
  description: "Most frequently called queries"
862
+ frequent_queries:
863
+ name: "Most Frequent Queries"
864
+ description: "Queries executed at the highest rate (calls per second)"
834
865
  expensive_queries:
835
866
  name: "Expensive Queries"
836
867
  description: "Queries consuming most total time"
@@ -22,6 +22,17 @@ ru:
22
22
  - "Рассмотрите batch-операции вместо множества одиночных запросов."
23
23
  - "Prepared statements могут снизить накладные расходы на парсинг для частых запросов."
24
24
 
25
+ frequent_queries:
26
+ title: "Most Frequent Queries"
27
+ what: "Запросы, выполняющиеся с наибольшей частотой (вызовов в секунду), отсортированные по убыванию вплоть до порога."
28
+ how: "Поле calls из pg_stat_statements делится на время, в течение которого запрос отслеживается: с момента создания записи (stats_since, PostgreSQL 17+) или с последнего сброса статистики (pg_stat_statements_info.stats_reset, PostgreSQL 14–16). В отличие от Heavy Queries, где сортировка идёт по накопленному числу вызовов, позиция запроса здесь не зависит от того, как долго он накапливал вызовы."
29
+ nuances:
30
+ - "Это среднее значение за весь отслеживаемый период (см. tracked_since), а не текущая нагрузка: недавний всплеск размывается долгим затишьем, а когда именно были вызовы, отчёт показать не может."
31
+ - "На PostgreSQL 14–16 окно у всех запросов общее, поэтому для запроса, появившегося намного позже последнего сброса, частота занижена. Для честного сравнения сбросьте статистику и дайте ей накопиться за показательный период."
32
+ - "Запись, вытесненная из pg_stat_statements (см. pg_stat_statements.max), теряет счётчики, и при следующем появлении частота считается заново."
33
+ - "Очень частые быстрые запросы — кандидаты на кэширование на уровне приложения, а много похожих запросов указывает на N+1 и необходимость eager loading."
34
+ - "Нужны PostgreSQL 14+ и pg_stat_statements 1.9+. После мажорного обновления выполните ALTER EXTENSION pg_stat_statements UPDATE."
35
+
25
36
  expensive_queries:
26
37
  title: "Expensive Queries"
27
38
  what: "Запросы, потребляющие наибольшее суммарное время (total_time = mean_time × calls)."
@@ -651,14 +662,27 @@ ru:
651
662
  update_interval: "Обновление каждые 5с"
652
663
  toggle_title: "Переключить live-мониторинг"
653
664
  query_monitor_title: "Монитор SQL-запросов"
654
- session_label: "Сессия:"
665
+ auto_stop_label: "Остановится через"
655
666
  queries_label: "Запросов:"
656
667
  feed_empty: "Нажмите «Запустить мониторинг», чтобы начать захват SQL-запросов"
657
668
  feed_no_queries: "Запросы пока не захвачены..."
658
669
  unknown_source: "Источник неизвестен"
659
- expand_collapse_title: "Развернуть / свернуть"
660
- scope_note_short: "область: хост-приложение"
661
- scope_note_long: "Монитор SQL-запросов подписан на ActiveSupport::Notifications в процессе хост-приложения и захватывает каждый SQL, который выполняет приложение, в той базе, к которой приложение подключено. Селектор базы данных в дашборде на эту область не влияет."
670
+ col_time: "Время"
671
+ col_duration: "мс"
672
+ col_source: "Источник"
673
+ col_sql: "SQL"
674
+ session_ended: "Мониторинг завершён"
675
+ scope_process_warning: "Отслеживается только этот процесс сервера: Rails.cache — это NullStore или MemoryStore, поэтому другие процессы не видят сессию. На запросах, попавших в другой воркер, мониторинг будет выглядеть остановленным. Подключите общий кэш (Redis, Memcached) или задайте config.query_monitor_cache_store."
676
+ scope_note_short_host: "область: хост-приложение"
677
+ scope_note_long_host: "Монитор записывает SQL всех процессов приложения, использующих общий кэш: и веб-воркеров, и фоновых задач — в той базе, к которой подключён каждый из них. Переключатель базы в дашборде на это не влияет. Сессия останавливается сама через config.query_monitor_max_duration."
678
+ scope_note_short_sampled: "выборка из pg_stat_activity"
679
+ scope_note_long_sampled: "Каждые 0,5 с читается pg_stat_activity выбранной базы. Видны все её клиенты, но backend хранит только последний запрос: быстрые запросы, сменившиеся между двумя чтениями, не попадут в ленту. Это выборка, а не полный журнал. Сессия останавливается сама через config.query_monitor_max_duration."
680
+ source_switch_label: "Способ записи"
681
+ source_notifications: "App"
682
+ source_activity: "Database"
683
+ source_notifications_title: "Все запросы этого приложения с источником. Запросы других клиентов не видны."
684
+ source_activity_title: "Выборка запросов всех клиентов выбранной базы. Быстрые запросы между чтениями теряются."
685
+ hidden_sessions_warning: "Скрыто сессий: %{count} — этой роли недоступны запросы других ролей. Выдайте доступ: GRANT pg_read_all_stats TO your_role;"
662
686
  metrics:
663
687
  connections_label: "Соединения"
664
688
  tps_label: "TPS"
@@ -750,20 +774,23 @@ ru:
750
774
  create_migration_failed: "Не удалось создать миграцию"
751
775
  explain_disabled_toast: "EXPLAIN ANALYZE отключен. Включите в конфигурации: config.allow_raw_query_execution = true"
752
776
  execute_disabled_toast: "Выполнение запросов отключено. Включите в конфигурации: config.allow_raw_query_execution = true"
753
- migration_disabled_toast: "Создание миграций отключено. Включите в конфигурации: config.allow_raw_query_execution = true"
777
+ migration_disabled_toast: "Создание миграций отключено. Включите в конфигурации: config.allow_migration_creation = true"
754
778
  query_monitoring_error: "Ошибка мониторинга запросов"
755
779
  query_hash_required: "Требуется хэш запроса"
756
780
  query_execution_disabled: "Выполнение запросов из дашборда отключено. Включите в конфигурации: 'config.allow_raw_query_execution = true'"
757
781
  query_not_found_expired: "Запрос не найден или истёк срок действия. Обновите страницу."
758
- query_monitor_unavailable_standalone: "SQL Query Monitor недоступен в автономном режиме — нет процесса основного приложения для наблюдения."
782
+ query_monitor_disabled: "SQL-монитор отключён настройкой config.query_monitor_enabled."
759
783
  query_timed_out: "Превышено время ожидания запроса (%{ms} мс, statement_timeout). Сузьте запрос или увеличьте config.raw_query_statement_timeout_ms."
784
+ report_timed_out: "Отчёт остановлен через %{ms} мс (statement_timeout). Если этой базе нужно больше времени, увеличьте config.report_statement_timeout_ms."
785
+ report_lock_timed_out: "Отчёту нужна блокировка, которую держит другая сессия, поэтому он остановлен, а не ждёт. Смотрите Blocking Queries и Locks."
760
786
  rate_limit_exceeded: "Слишком много запросов. Подождите немного и попробуйте снова."
761
787
  security_violation_prefix: "Нарушение безопасности:"
762
788
  trigger_variables_not_allowed: "Нельзя выполнить EXPLAIN ANALYZE для запросов с триггерными переменными (NEW, OLD). Они доступны только в контексте триггерных функций."
763
789
  missing_parameter_values: "Укажите значения для всех плейсхолдеров параметров ($1, $2 и т.д.)"
764
790
  migration_dev_only: "Создание миграций разрешено только в development-окружении"
765
- filename_code_required: "Имя файла и код обязательны"
766
- invalid_filename_format: "Неверный формат имени файла миграции"
791
+ index_not_found: "Индекс не найден, либо такое имя есть в нескольких схемах"
792
+ migration_exists: "Миграция для этого индекса уже есть в db/migrate"
793
+ migration_primary_only: "Миграции можно создавать только из основной базы приложения"
767
794
  migrations_dir_not_found: "Каталог миграций не найден"
768
795
  success:
769
796
  statistics_reset: "Статистика успешно сброшена"
@@ -796,6 +823,9 @@ ru:
796
823
  heavy_queries:
797
824
  name: "Частые запросы"
798
825
  description: "Самые часто вызываемые запросы"
826
+ frequent_queries:
827
+ name: "Самые частые запросы"
828
+ description: "Запросы с наибольшей частотой выполнения (вызовов в секунду)"
799
829
  expensive_queries:
800
830
  name: "Дорогие запросы"
801
831
  description: "Запросы с наибольшим суммарным временем"
@@ -22,6 +22,17 @@ uk:
22
22
  - "Розгляньте batch-операції замість множини одиночних запитів."
23
23
  - "Prepared statements можуть знизити накладні витрати на парсинг для частих запитів."
24
24
 
25
+ frequent_queries:
26
+ title: "Most Frequent Queries"
27
+ what: "Запити, що виконуються з найбільшою частотою (викликів за секунду), відсортовані за спаданням аж до порога."
28
+ how: "Поле calls з pg_stat_statements ділиться на час, протягом якого запит відстежується: з моменту створення запису (stats_since, PostgreSQL 17+) або з останнього скидання статистики (pg_stat_statements_info.stats_reset, PostgreSQL 14–16). На відміну від Heavy Queries, де сортування йде за накопиченою кількістю викликів, позиція запиту тут не залежить від того, як довго він накопичував виклики."
29
+ nuances:
30
+ - "Це середнє значення за весь відстежуваний період (див. tracked_since), а не поточне навантаження: нещодавній сплеск розмивається довгим затишшям, а коли саме були виклики, звіт показати не може."
31
+ - "На PostgreSQL 14–16 вікно в усіх запитів спільне, тому для запиту, що з'явився набагато пізніше за останнє скидання, частота занижена. Для чесного порівняння скиньте статистику й дайте їй накопичитися за показовий період."
32
+ - "Запис, витіснений з pg_stat_statements (див. pg_stat_statements.max), втрачає лічильники, і при наступній появі частота рахується заново."
33
+ - "Дуже часті швидкі запити — кандидати на кешування на рівні застосунку, а багато схожих запитів вказує на N+1 і потребу в eager loading."
34
+ - "Потрібні PostgreSQL 14+ і pg_stat_statements 1.9+. Після мажорного оновлення виконайте ALTER EXTENSION pg_stat_statements UPDATE."
35
+
25
36
  expensive_queries:
26
37
  title: "Expensive Queries"
27
38
  what: "Запити, що споживають найбільше сумарного часу (total_time = mean_time × calls)."
@@ -651,14 +662,27 @@ uk:
651
662
  update_interval: "Оновлення кожні 5с"
652
663
  toggle_title: "Перемкнути live-моніторинг"
653
664
  query_monitor_title: "Монітор SQL-запитів"
654
- session_label: "Сесія:"
665
+ auto_stop_label: "Зупиниться через"
655
666
  queries_label: "Запитів:"
656
667
  feed_empty: "Натисніть «Запустити моніторинг», щоб почати захоплення SQL-запитів"
657
668
  feed_no_queries: "Запитів поки не захоплено..."
658
669
  unknown_source: "Джерело невідоме"
659
- expand_collapse_title: "Розгорнути / згорнути"
660
- scope_note_short: "область: хост-застосунок"
661
- scope_note_long: "Монітор SQL-запитів підписаний на ActiveSupport::Notifications у процесі хост-застосунку та захоплює кожен SQL, який виконує застосунок, у тій базі, до якої він підключений. Селектор бази даних у дашборді на цю область не впливає."
670
+ col_time: "Час"
671
+ col_duration: "мс"
672
+ col_source: "Джерело"
673
+ col_sql: "SQL"
674
+ session_ended: "Моніторинг завершено"
675
+ scope_process_warning: "Відстежується лише цей процес сервера: Rails.cache — це NullStore або MemoryStore, тому інші процеси не бачать сесію. На запитах, що потрапили до іншого воркера, моніторинг виглядатиме зупиненим. Підключіть спільний кеш (Redis, Memcached) або задайте config.query_monitor_cache_store."
676
+ scope_note_short_host: "область: хост-застосунок"
677
+ scope_note_long_host: "Монітор записує SQL усіх процесів застосунку, що використовують спільний кеш: і веб-воркерів, і фонових задач — у тій базі, до якої підключено кожен із них. Перемикач бази в дашборді на це не впливає. Сесія зупиняється сама через config.query_monitor_max_duration."
678
+ scope_note_short_sampled: "вибірка з pg_stat_activity"
679
+ scope_note_long_sampled: "Кожні 0,5 с читається pg_stat_activity вибраної бази. Видно всіх її клієнтів, але backend зберігає лише останній запит: швидкі запити, що змінилися між двома читаннями, не потраплять до стрічки. Це вибірка, а не повний журнал. Сесія зупиняється сама через config.query_monitor_max_duration."
680
+ source_switch_label: "Спосіб запису"
681
+ source_notifications: "App"
682
+ source_activity: "Database"
683
+ source_notifications_title: "Усі запити цього застосунку з джерелом. Запити інших клієнтів не видно."
684
+ source_activity_title: "Вибірка запитів усіх клієнтів вибраної бази. Швидкі запити між читаннями губляться."
685
+ hidden_sessions_warning: "Приховано сесій: %{count} — цій ролі недоступні запити інших ролей. Надайте доступ: GRANT pg_read_all_stats TO your_role;"
662
686
  metrics:
663
687
  connections_label: "З'єднання"
664
688
  tps_label: "TPS"
@@ -750,20 +774,23 @@ uk:
750
774
  create_migration_failed: "Не вдалося створити міграцію"
751
775
  explain_disabled_toast: "EXPLAIN ANALYZE вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
752
776
  execute_disabled_toast: "Виконання запитів вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
753
- migration_disabled_toast: "Створення міграцій вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
777
+ migration_disabled_toast: "Створення міграцій вимкнено. Увімкніть у конфігурації: config.allow_migration_creation = true"
754
778
  query_monitoring_error: "Помилка моніторингу запитів"
755
779
  query_hash_required: "Потрібен хеш запиту"
756
780
  query_execution_disabled: "Виконання запитів із дашборду вимкнено. Увімкніть у конфігурації: 'config.allow_raw_query_execution = true'"
757
781
  query_not_found_expired: "Запит не знайдено або термін дії минув. Оновіть сторінку."
758
- query_monitor_unavailable_standalone: "SQL Query Monitor недоступний в автономному режимі — немає процесу основного застосунку для спостереження."
782
+ query_monitor_disabled: "SQL-монітор вимкнено налаштуванням config.query_monitor_enabled."
759
783
  query_timed_out: "Перевищено час очікування запиту (%{ms} мс, statement_timeout). Звузьте запит або збільшіть config.raw_query_statement_timeout_ms."
784
+ report_timed_out: "Звіт зупинено через %{ms} мс (statement_timeout). Якщо цій базі потрібно більше часу, збільште config.report_statement_timeout_ms."
785
+ report_lock_timed_out: "Звіту потрібне блокування, яке тримає інша сесія, тому його зупинено, а не очікує. Дивіться Blocking Queries і Locks."
760
786
  rate_limit_exceeded: "Забагато запитів. Зачекайте трохи і спробуйте знову."
761
787
  security_violation_prefix: "Порушення безпеки:"
762
788
  trigger_variables_not_allowed: "Не можна виконати EXPLAIN ANALYZE для запитів із тригерними змінними (NEW, OLD). Вони доступні лише в контексті тригерних функцій."
763
789
  missing_parameter_values: "Вкажіть значення для всіх плейсхолдерів параметрів ($1, $2 тощо)"
764
790
  migration_dev_only: "Створення міграцій дозволено лише в development-середовищі"
765
- filename_code_required: "Ім'я файлу та код обов'язкові"
766
- invalid_filename_format: "Невірний формат імені файлу міграції"
791
+ index_not_found: "Індекс не знайдено, або таке ім'я є в кількох схемах"
792
+ migration_exists: "Міграція для цього індексу вже є в db/migrate"
793
+ migration_primary_only: "Міграції можна створювати лише з основної бази застосунку"
767
794
  migrations_dir_not_found: "Каталог міграцій не знайдено"
768
795
  success:
769
796
  statistics_reset: "Статистику успішно скинуто"
@@ -796,6 +823,9 @@ uk:
796
823
  heavy_queries:
797
824
  name: "Часті запити"
798
825
  description: "Найчастіше викликані запити"
826
+ frequent_queries:
827
+ name: "Найчастіші запити"
828
+ description: "Запити з найбільшою частотою виконання (викликів за секунду)"
799
829
  expensive_queries:
800
830
  name: "Дорогі запити"
801
831
  description: "Запити з найбільшим сумарним часом"
data/config/routes.rb CHANGED
@@ -16,6 +16,7 @@ PgReports::Engine.routes.draw do
16
16
  post "explain_analyze", to: "dashboard#explain_analyze", as: :explain_analyze
17
17
  post "execute_query", to: "dashboard#execute_query", as: :execute_query
18
18
  post "run_query", to: "dashboard#run_query", as: :run_query
19
+ post "migration_preview", to: "dashboard#migration_preview", as: :migration_preview
19
20
  post "create_migration", to: "dashboard#create_migration", as: :create_migration
20
21
 
21
22
  # Query monitoring
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # What happens when an endpoint has no credentials configured.
5
+ #
6
+ # Nothing is reachable by default except from the machine itself: a dashboard
7
+ # started for a quick look on a laptop stays open to that laptop, not to the
8
+ # network it happens to be bound to (`rails s -b 0.0.0.0`, a Docker port).
9
+ module AccessPolicy
10
+ # For dashboard_auth or grafana_metrics_token: the host application protects
11
+ # this route itself, so the check here must not turn anyone away.
12
+ HOST_MANAGED = :host_managed
13
+
14
+ DASHBOARD_DENIED = <<~MESSAGE
15
+ PgReports: access denied.
16
+
17
+ This dashboard has no authentication configured, so it only answers requests
18
+ from the machine it runs on, and never in production.
19
+
20
+ Set one of these in your PgReports.configure block:
21
+
22
+ config.dashboard_auth = -> { authenticate_or_request_with_http_basic { |u, p| ... } }
23
+ config.dashboard_auth = :host_managed # the route is already protected by your app
24
+ MESSAGE
25
+
26
+ # request.local? requires both the socket peer and the resolved client to be
27
+ # loopback, so an X-Forwarded-For header cannot talk its way past this.
28
+ #
29
+ # Production is refused outright because a local peer proves nothing there: a
30
+ # reverse proxy on the same host that sets no forwarding header looks local for
31
+ # every client. Standalone has no such proxy unless someone builds one, and
32
+ # RAILS_ENV says nothing about how it is deployed.
33
+ def self.open_access_allowed?(request)
34
+ return false if Rails.env.production? && !PgReports.config.standalone
35
+
36
+ request.local?
37
+ end
38
+ end
39
+ end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module PgReports
4
4
  # Checks runtime environment and warns about outdated or unsupported versions.
5
- # Called once at boot (Ruby/Rails) and lazily on first DB access (PostgreSQL).
5
+ # Ruby/Rails are checked at boot. PostgreSQL waits for the dashboard, so that
6
+ # booting the host application costs no query and no connection.
6
7
  module Compatibility
7
8
  # Keep in sync with gemspec constraints
8
9
  MINIMUM_RUBY_VERSION = "2.7"
9
- MINIMUM_RAILS_VERSION = "5.0"
10
+ MINIMUM_RAILS_VERSION = "6.1"
10
11
  MINIMUM_PG_VERSION = 12_00_00 # server_version_num format
11
12
  MINIMUM_PG_VERSION_LABEL = "12"
12
13
 
@@ -37,6 +38,13 @@ module PgReports
37
38
  "Some reports may return errors or incomplete data."
38
39
  end
39
40
 
41
+ def check_postgresql_once!
42
+ return if @postgresql_checked
43
+
44
+ @postgresql_checked = true
45
+ check_postgresql!
46
+ end
47
+
40
48
  def check_all!
41
49
  check_ruby!
42
50
  check_rails!
@@ -9,6 +9,7 @@ module PgReports
9
9
  # Query analysis thresholds
10
10
  attr_accessor :slow_query_threshold_ms # Queries slower than this are considered slow
11
11
  attr_accessor :heavy_query_threshold_calls # Queries with more calls than this are heavy
12
+ attr_accessor :frequent_query_threshold_per_second # Queries at or above this rate are frequent
12
13
  attr_accessor :expensive_query_threshold_ms # Total time threshold for expensive queries
13
14
 
14
15
  # Index analysis thresholds
@@ -26,7 +27,7 @@ module PgReports
26
27
  attr_accessor :max_query_length # Truncate query text to this length
27
28
 
28
29
  # Dashboard settings
29
- attr_accessor :dashboard_auth # Proc for dashboard authentication
30
+ attr_accessor :dashboard_auth # Proc run as a before_action, or :host_managed. Unset: loopback only, never in production
30
31
  attr_accessor :standalone # True when running via PgReports::Standalone (no host app)
31
32
 
32
33
  # Assets / privacy settings
@@ -36,7 +37,13 @@ module PgReports
36
37
  attr_accessor :fake_source_data # Inject fake source data for IDE link testing
37
38
 
38
39
  # Query monitoring settings
40
+ attr_accessor :query_monitor_enabled # Off in production by default; false removes the monitor entirely
41
+ attr_accessor :query_monitor_sources # Capture methods on offer: :notifications, :activity
42
+ attr_accessor :query_monitor_cache_store # Cache shared by all processes (default: Rails.cache)
43
+ attr_accessor :query_monitor_max_duration # Seconds after which a session stops by itself
39
44
  attr_accessor :query_monitor_log_file # Path to log file for query monitoring
45
+ attr_accessor :query_monitor_log_max_bytes # Size at which the log is rotated to <file>.1. nil or 0 disables rotation.
46
+ attr_accessor :query_monitor_redact_literals # Replace string literals in captured SQL with '?'. On outside development.
40
47
  attr_accessor :query_monitor_max_queries # Maximum number of queries to keep in buffer
41
48
  attr_accessor :query_monitor_backtrace_filter # Proc to filter backtrace lines
42
49
 
@@ -44,12 +51,13 @@ module PgReports
44
51
  attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
45
52
  attr_accessor :allow_migration_creation # Allow dashboard's "Generate Migration" button to write files into db/migrate/
46
53
  attr_accessor :raw_query_statement_timeout_ms # Postgres statement_timeout (ms) applied to Execute Query / EXPLAIN ANALYZE / SQL Console. 0 disables it.
54
+ attr_accessor :report_statement_timeout_ms # Postgres statement_timeout (ms) for the SQL behind reports and live metrics. 0 disables it.
47
55
  attr_accessor :raw_query_rate_limit # Max privileged requests (raw query execution / Generate Migration) per client IP per window. nil disables rate limiting.
48
56
  attr_accessor :raw_query_rate_limit_window_seconds # Window size (seconds) for raw_query_rate_limit
49
57
 
50
58
  # Grafana / Prometheus exporter settings
51
59
  attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
52
- attr_accessor :grafana_metrics_token # Bearer token required to access /metrics (nil = no auth)
60
+ attr_accessor :grafana_metrics_token # Bearer token required to access /metrics, or :host_managed. Unset: as dashboard_auth
53
61
  attr_accessor :grafana_cache_ttl # Default cache TTL for collected reports, seconds
54
62
 
55
63
  def initialize
@@ -60,6 +68,7 @@ module PgReports
60
68
  # Query thresholds
61
69
  @slow_query_threshold_ms = 100
62
70
  @heavy_query_threshold_calls = 1000
71
+ @frequent_query_threshold_per_second = 1
63
72
  @expensive_query_threshold_ms = 10_000
64
73
 
65
74
  # Index thresholds
@@ -87,7 +96,19 @@ module PgReports
87
96
  @fake_source_data = ENV.fetch("PG_REPORTS_FAKE_SOURCE_DATA", "false") == "true"
88
97
 
89
98
  # Query monitoring
99
+ rails_env = Rails.env if defined?(Rails) && Rails.respond_to?(:env)
100
+ # Off in production, so that a deploy adds nothing to the application's
101
+ # queries until someone decides they want the monitor there
102
+ @query_monitor_enabled = !rails_env&.production?
103
+ # Two sources are a choice for a developer; production keeps to one
104
+ @query_monitor_sources = rails_env&.development? ? %i[notifications activity] : %i[notifications]
105
+ @query_monitor_cache_store = nil
106
+ @query_monitor_max_duration = 900
90
107
  @query_monitor_log_file = defined?(Rails) ? Rails.root.join("log", "pg_reports.log") : nil
108
+ @query_monitor_log_max_bytes = 10 * 1024 * 1024
109
+ # A developer needs the values and they are their own; anywhere else they are
110
+ # somebody's passwords and personal data
111
+ @query_monitor_redact_literals = !rails_env&.development?
91
112
  @query_monitor_max_queries = 100
92
113
  @query_monitor_backtrace_filter = ->(location) {
93
114
  # Exclude gem paths, framework paths
@@ -107,6 +128,7 @@ module PgReports
107
128
  ActiveModel::Type::Boolean.new.cast(env_override)
108
129
  end
109
130
  @raw_query_statement_timeout_ms = ENV.fetch("PG_REPORTS_RAW_QUERY_STATEMENT_TIMEOUT_MS", 5_000).to_i
131
+ @report_statement_timeout_ms = ENV.fetch("PG_REPORTS_REPORT_STATEMENT_TIMEOUT_MS", 30_000).to_i
110
132
  @raw_query_rate_limit = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT", 30).to_i
111
133
  @raw_query_rate_limit_window_seconds = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT_WINDOW_SECONDS", 60).to_i
112
134
 
@@ -141,6 +163,15 @@ module PgReports
141
163
  PgReports.connection_registry.default_name
142
164
  end
143
165
 
166
+ def query_monitor_available?
167
+ query_monitor_enabled && query_monitor_available_sources.any?
168
+ end
169
+
170
+ # Standalone has no application to subscribe to, so only sampling applies
171
+ def query_monitor_available_sources
172
+ standalone ? [:activity] : Array(query_monitor_sources).map(&:to_sym)
173
+ end
174
+
144
175
  def telegram_configured?
145
176
  telegram_bot_token.present? && telegram_chat_id.present?
146
177
  end
@@ -35,6 +35,8 @@ module PgReports
35
35
  when "53300" then too_many_connections(message)
36
36
  when "42P01" then undefined_table(message)
37
37
  when "42883" then undefined_function(message)
38
+ when "57014" then query_canceled(error)
39
+ when "55P03" then lock_wait_timed_out(message)
38
40
  else generic(error)
39
41
  end
40
42
 
@@ -52,6 +54,25 @@ module PgReports
52
54
  nil
53
55
  end
54
56
 
57
+ # 57014 also covers pg_cancel_backend, which says so in its own words
58
+ def query_canceled(error)
59
+ return generic(error) unless error.message.include?("statement timeout")
60
+
61
+ {
62
+ title: "Report timed out",
63
+ detail: I18n.t("pg_reports.ui.errors.report_timed_out", ms: PgReports.config.report_statement_timeout_ms.to_i),
64
+ hint: nil
65
+ }
66
+ end
67
+
68
+ def lock_wait_timed_out(_message)
69
+ {
70
+ title: "Report blocked by a lock",
71
+ detail: I18n.t("pg_reports.ui.errors.report_lock_timed_out"),
72
+ hint: nil
73
+ }
74
+ end
75
+
55
76
  def permission_denied(message)
56
77
  target = extract_object(message, /permission denied for (?:database|schema|table|relation|view) "?([\w.]+)"?/)
57
78
  kind = extract_object(message, /permission denied for (database|schema|table|relation|view)/)