aprs.me/lib/aprsme/telemetry/database_metrics.ex
Graham McIntire adaf9599e2
chore: update dependencies and fix pre-existing issues
Dep upgrades (mix deps.update --all):
- phoenix_live_view 1.1.27 → 1.1.28
- swoosh 1.23.1 → 1.25.0
- bandit 1.10.3 → 1.10.4
- credo 1.7.17 → 1.7.18
- hammer 7.2.0 → 7.3.0
- igniter 0.7.6 → 0.7.9
- and minor: fine, lazy_html, meck, mimerl
- removed stale lock entries: geocalc, gettext_pseudolocalize, gridsquare

Bug fixes surfaced during update:
- ETS cache tables were :protected (owned by Application master), preventing
  the Cache GenServer from writing; change to :public + write_concurrency
  so device/symbol/query caches actually work
- historical_dot_html returned Phoenix.HTML.safe tuple instead of plain string;
  symbol_html is JSON-encoded for JS so it must be binary
- String.slice always returns binary so the || "Unknown error" fallback was
  unreachable dead code (dialyzer guard_fail)
- Supervisor.which_children always returns a list so the _ -> branch in
  database_metrics was unreachable dead code (dialyzer pattern_match_cov)
- Add @type t :: %__MODULE__{} to User schema to resolve unknown_type in user_auth spec
- Extract nested if in leader_election to fix Credo nesting depth violation
- Update .dialyzer_ignore.exs: remove 2 stale entries, add false positives for
  Ecto.Multi/Gettext opaque types and Mix.Task PLT limitations
2026-04-15 14:07:29 -05:00

259 lines
7.4 KiB
Elixir

defmodule Aprsme.Telemetry.DatabaseMetrics do
@moduledoc """
Collects database metrics from PostgreSQL and PgBouncer
"""
require Logger
def collect_db_pool_metrics do
pool_size = Application.get_env(:aprsme, Aprsme.Repo)[:pool_size] || 10
case Process.whereis(Aprsme.Repo) do
nil -> report_pool_metrics_unavailable(pool_size)
repo_pid when is_pid(repo_pid) -> collect_from_repo(repo_pid, pool_size)
end
rescue
e ->
Logger.debug("Error collecting pool metrics: #{inspect(e)}")
report_pool_metrics_error()
end
defp collect_from_repo(repo_pid, pool_size) do
children = Supervisor.which_children(repo_pid)
pool_info = find_pool_child(children)
collect_from_pool(pool_info, pool_size)
end
defp find_pool_child(children) do
Enum.find(children, fn
{DBConnection.ConnectionPool, _, _, _} -> true
{DBConnection.Ownership, _, _, _} -> true
_ -> false
end)
end
defp collect_from_pool({_, pool_pid, _, _}, pool_size) when is_pid(pool_pid) do
# Try to get pool telemetry
case try_get_pool_telemetry(pool_size) do
{:ok, _metrics} -> report_pool_metrics_estimated(pool_size)
_ -> report_pool_metrics_idle(pool_size)
end
end
defp collect_from_pool(_, pool_size), do: report_pool_metrics_idle(pool_size)
defp try_get_pool_telemetry(pool_size) do
{:ok, %{pool_size: pool_size, idle_time: 0, queue_time: 0}}
catch
_, _ -> {:error, :not_available}
end
defp report_pool_metrics_unavailable(pool_size) do
emit_pool_metrics(%{
size: pool_size,
idle: 0,
busy: 0,
available: 0,
queue_length: 0,
total: 0
})
end
defp report_pool_metrics_idle(pool_size) do
emit_pool_metrics(%{
size: pool_size,
idle: pool_size,
busy: 0,
available: pool_size,
queue_length: 0,
total: pool_size
})
end
defp report_pool_metrics_estimated(pool_size) do
# Cannot reliably introspect DBConnection pool state, so report
# pool_size as total without guessing busy/idle breakdown.
report_pool_metrics_idle(pool_size)
end
defp report_pool_metrics_error do
emit_pool_metrics(%{
size: 0,
idle: 0,
busy: 0,
available: 0,
queue_length: 0,
total: 0
})
end
defp emit_pool_metrics(metrics) do
:telemetry.execute([:aprsme, :repo, :pool], metrics, %{})
end
def collect_postgres_metrics do
# Skip database metrics collection in test environment
if Application.get_env(:aprsme, :env) == :test do
:ok
else
do_collect_postgres_metrics()
end
end
defp do_collect_postgres_metrics do
# Check if Repo is started before collecting metrics
case Process.whereis(Aprsme.Repo) do
nil ->
# Repo not started yet, skip metrics collection silently
:ok
_pid ->
collect_database_metrics()
end
end
defp collect_database_metrics do
collect_database_size()
collect_connection_stats()
collect_packets_table_stats()
collect_query_performance_stats()
collect_replication_lag()
rescue
e ->
Logger.debug("Error collecting PostgreSQL metrics: #{inspect(e)}")
end
defp collect_database_size do
case Aprsme.Repo.query("SELECT pg_database_size(current_database()) as size") do
{:ok, %{rows: [[size]]}} ->
:telemetry.execute([:aprsme, :postgres, :database], %{size_bytes: to_number(size)}, %{})
_ ->
:ok
end
end
defp collect_connection_stats do
case Aprsme.Repo.query("""
SELECT
count(*) as total,
count(*) FILTER (WHERE state = 'active') as active,
count(*) FILTER (WHERE state = 'idle') as idle,
count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction,
count(*) FILTER (WHERE wait_event_type IS NOT NULL) as waiting
FROM pg_stat_activity
WHERE datname = current_database()
""") do
{:ok, %{rows: [[total, active, idle, idle_in_tx, waiting]]}} ->
:telemetry.execute(
[:aprsme, :postgres, :connections],
%{
total: to_number(total),
active: to_number(active),
idle: to_number(idle),
idle_in_transaction: to_number(idle_in_tx),
waiting: to_number(waiting)
},
%{}
)
_ ->
:ok
end
end
defp collect_packets_table_stats do
case Aprsme.Repo.query("""
SELECT
n_live_tup as live_tuples,
n_dead_tup as dead_tuples,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
pg_table_size(c.oid) as table_size,
pg_indexes_size(c.oid) as indexes_size
FROM pg_stat_user_tables s
JOIN pg_class c ON c.relname = s.relname
WHERE s.relname = 'packets'
""") do
{:ok, %{rows: [[live, dead, ins, upd, del, table_size, idx_size]]}} ->
emit_packets_table_telemetry(live, dead, ins, upd, del, table_size, idx_size)
_ ->
:ok
end
end
defp collect_query_performance_stats do
case Aprsme.Repo.query("""
SELECT
sum(calls) as total_calls,
sum(total_exec_time) as total_time,
avg(mean_exec_time) as avg_time,
max(max_exec_time) as max_time
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat%'
""") do
{:ok, %{rows: [[calls, total_time, avg_time, max_time]]}} ->
:telemetry.execute(
[:aprsme, :postgres, :query_stats],
%{
total_calls: to_number(calls, :integer),
total_time_ms: to_number(total_time),
avg_time_ms: to_number(avg_time),
max_time_ms: to_number(max_time)
},
%{}
)
_ ->
:ok
end
end
defp collect_replication_lag do
case Aprsme.Repo.query("""
SELECT
extract(epoch from (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds
WHERE pg_is_in_recovery()
""") do
{:ok, %{rows: [[lag]]}} when not is_nil(lag) ->
:telemetry.execute([:aprsme, :postgres, :replication], %{lag_seconds: lag}, %{})
_ ->
:ok
end
end
def collect_pgbouncer_metrics do
# PgBouncer metrics would require a separate connection to PgBouncer's admin interface
# For now, we'll skip these as they require additional setup
:ok
end
defp emit_packets_table_telemetry(live, dead, ins, upd, del, table_size, idx_size) do
:telemetry.execute(
[:aprsme, :postgres, :packets_table],
%{
live_tuples: to_number(live),
dead_tuples: to_number(dead),
total_inserts: to_number(ins),
total_updates: to_number(upd),
total_deletes: to_number(del),
table_size_bytes: to_number(table_size),
indexes_size_bytes: to_number(idx_size)
},
%{}
)
end
# Coerce Decimal/nil values to native Erlang numbers for :telemetry.execute
defp to_number(nil), do: 0
defp to_number(%Decimal{} = d), do: Decimal.to_float(d)
defp to_number(n) when is_number(n), do: n
defp to_number(_), do: 0
defp to_number(nil, :integer), do: 0
defp to_number(%Decimal{} = d, :integer), do: d |> Decimal.to_float() |> trunc()
defp to_number(n, :integer) when is_number(n), do: trunc(n)
defp to_number(_, :integer), do: 0
end