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