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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +126 -80
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
- data/app/views/pg_reports/dashboard/index.html.erb +310 -124
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +39 -8
- data/config/locales/ru.yml +38 -8
- data/config/locales/uk.yml +38 -8
- data/config/routes.rb +1 -0
- data/lib/pg_reports/access_policy.rb +39 -0
- data/lib/pg_reports/compatibility.rb +10 -2
- data/lib/pg_reports/configuration.rb +33 -2
- data/lib/pg_reports/connection/error_translator.rb +21 -0
- data/lib/pg_reports/connection/target.rb +43 -10
- data/lib/pg_reports/csv_cell.rb +20 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
- data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
- data/lib/pg_reports/definitions/connections/locks.yml +1 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
- data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
- data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
- data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
- data/lib/pg_reports/drop_index_migration.rb +56 -0
- data/lib/pg_reports/engine.rb +4 -3
- data/lib/pg_reports/executor.rb +46 -9
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +478 -192
- data/lib/pg_reports/rate_limiter.rb +63 -0
- data/lib/pg_reports/report.rb +1 -1
- data/lib/pg_reports/report_definition.rb +11 -1
- data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
- data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
- data/lib/pg_reports/sql/connections/locks.sql +1 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
- data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
- data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
- data/lib/pg_reports/sql_redactor.rb +32 -0
- data/lib/pg_reports/standalone.rb +83 -20
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +7 -2
- metadata +17 -9
data/config/brakeman.ignore
CHANGED
|
@@ -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": "
|
|
28
|
+
"fingerprint": "d79e7adc175add96cdc7054b79ce7861dca0ce3dfd0bd8ec025f953b62806ae3",
|
|
75
29
|
"check_name": "SQL",
|
|
76
30
|
"message": "Possible SQL injection",
|
|
77
|
-
"file": "
|
|
78
|
-
"line":
|
|
31
|
+
"file": "lib/pg_reports/executor.rb",
|
|
32
|
+
"line": 42,
|
|
79
33
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
80
|
-
"code": "
|
|
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::
|
|
85
|
-
"method": "
|
|
38
|
+
"class": "PgReports::Executor",
|
|
39
|
+
"method": "execute"
|
|
86
40
|
},
|
|
87
|
-
"user_input": "params
|
|
88
|
-
"confidence": "
|
|
41
|
+
"user_input": "interpolate_params(sql, params)",
|
|
42
|
+
"confidence": "Medium",
|
|
89
43
|
"cwe_id": [
|
|
90
44
|
89
|
|
91
45
|
],
|
|
92
|
-
"note": "Reviewed & accepted:
|
|
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
|
|
49
|
+
"updated": "2026-11-07 12:00:00 +0300",
|
|
96
50
|
"brakeman_version": "8.0.5"
|
|
97
51
|
}
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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"
|
data/config/locales/ru.yml
CHANGED
|
@@ -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
|
-
|
|
665
|
+
auto_stop_label: "Остановится через"
|
|
655
666
|
queries_label: "Запросов:"
|
|
656
667
|
feed_empty: "Нажмите «Запустить мониторинг», чтобы начать захват SQL-запросов"
|
|
657
668
|
feed_no_queries: "Запросы пока не захвачены..."
|
|
658
669
|
unknown_source: "Источник неизвестен"
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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: "Запросы с наибольшим суммарным временем"
|
data/config/locales/uk.yml
CHANGED
|
@@ -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
|
-
|
|
665
|
+
auto_stop_label: "Зупиниться через"
|
|
655
666
|
queries_label: "Запитів:"
|
|
656
667
|
feed_empty: "Натисніть «Запустити моніторинг», щоб почати захоплення SQL-запитів"
|
|
657
668
|
feed_no_queries: "Запитів поки не захоплено..."
|
|
658
669
|
unknown_source: "Джерело невідоме"
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
#
|
|
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 = "
|
|
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
|
|
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
|
|
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)/)
|