tutorial

How to Monitor Ecto with Vigilmon

Add uptime monitoring, query performance tracking, and alerts to your Ecto-powered Elixir application — so slow queries, connection pool exhaustion, and database failures don't go undetected.

How to Monitor Ecto with Vigilmon

Ecto is the de facto data layer for Elixir applications. It provides changesets, schemas, a composable query DSL, and connection pooling through DBConnection — all the plumbing you need to talk to PostgreSQL, MySQL, or SQLite. But when the database goes slow or the pool runs dry, Ecto doesn't shout. Queries time out, changesets fail silently, and users see errors that don't surface in your application logs until it's too late.

External monitoring catches what Ecto can't self-report. This tutorial covers:

  • A health endpoint that validates Ecto connectivity and pool state
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring to detect when background jobs using Ecto stall
  • Slack alerts and a status page

Step 1: Add Ecto to your project

If Ecto isn't already in your project:

# mix.exs
defp deps do
  [
    {:ecto_sql, "~> 3.11"},
    {:postgrex, ">= 0.0.0"},   # or {:myxql, ">= 0.0.0"} for MySQL
    {:req, "~> 0.4"}            # for heartbeat pings
  ]
end

Configure your repo:

# config/config.exs
config :my_app, MyApp.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "my_app_dev",
  pool_size: 10,
  queue_target: 50,
  queue_interval: 1000

The queue_target and queue_interval settings control how long DBConnection waits before raising a checkout error — tuning these gives you early warning of pool pressure before users notice.


Step 2: Build a health endpoint that includes Ecto state

Add a plug that checks database connectivity and pool availability:

# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
  import Plug.Conn

  def init(opts), do: opts

  def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
    checks = run_checks()
    status = if all_ok?(checks), do: 200, else: 503

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(checks))
    |> halt()
  end

  def call(conn, _opts), do: conn

  defp run_checks do
    db = database_check()

    %{
      status: status_label(db.status == "ok"),
      database: db
    }
  end

  defp database_check do
    start = System.monotonic_time(:millisecond)

    result =
      case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", [], timeout: 3_000) do
        {:ok, _} ->
          elapsed = System.monotonic_time(:millisecond) - start
          pool_info = pool_stats()

          %{
            status: "ok",
            latency_ms: elapsed,
            pool: pool_info
          }

        {:error, %DBConnection.ConnectionError{message: msg}} ->
          %{status: "error", reason: msg}

        {:error, reason} ->
          %{status: "error", reason: inspect(reason)}
      end

    result
  end

  defp pool_stats do
    # DBConnection.ConnectionPool exposes telemetry but not a direct stats API.
    # Read pool_size from config as a reference point.
    pool_size = Application.get_env(:my_app, MyApp.Repo)[:pool_size] || 10
    %{configured_size: pool_size}
  end

  defp all_ok?(checks), do: checks.status == "ok"

  defp status_label(true), do: "ok"
  defp status_label(false), do: "degraded"
end

Register the plug in your endpoint before the router:

# lib/my_app_web/endpoint.ex
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router

Test it:

mix phx.server
curl http://localhost:4000/health | jq .
# {
#   "status": "ok",
#   "database": {
#     "status": "ok",
#     "latency_ms": 3,
#     "pool": {"configured_size": 10}
#   }
# }

A 503 with "status": "degraded" tells you the database is unreachable or too slow — before Vigilmon even alerts you.


Step 3: Capture slow queries with Ecto telemetry

Ecto emits telemetry events for every query. Attach a handler to log slow queries and expose a count in your health response:

# lib/my_app/telemetry.ex
defmodule MyApp.Telemetry do
  require Logger

  @slow_query_threshold_ms 200

  def setup do
    :telemetry.attach(
      "ecto-slow-query-logger",
      [:my_app, :repo, :query],
      &handle_query/4,
      nil
    )
  end

  def handle_query(_event, %{total_time: total_time}, %{query: query, source: source}, _config) do
    duration_ms = System.convert_time_unit(total_time, :native, :millisecond)

    if duration_ms >= @slow_query_threshold_ms do
      Logger.warning(
        "[Ecto slow query] #{duration_ms}ms — source=#{source} query=#{String.slice(query, 0, 200)}"
      )
    end
  end
end

Call MyApp.Telemetry.setup() in your application.ex:

# lib/my_app/application.ex
def start(_type, _args) do
  MyApp.Telemetry.setup()
  # ... rest of supervision tree
end

Slow query logs flow to your log aggregator, and you can set up a Vigilmon keyword monitor on a log-shipping health endpoint or use Vigilmon's HTTP monitor with a latency threshold to catch degraded response times.


Step 4: Monitor the health endpoint with Vigilmon

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute
  5. Enable keyword check: body must contain "status":"ok"
  6. Save

The keyword check matters here: a misconfigured read replica might return HTTP 200 while Ecto is writing to the wrong host. The keyword match on "status":"ok" ensures the actual database ping succeeded, not just the web process.


Step 5: Heartbeat monitoring for background Ecto tasks

If you run periodic jobs that use Ecto — data cleanups, report generation, import pipelines — a heartbeat tells you the job ran and its database queries succeeded:

# lib/my_app/workers/db_heartbeat_worker.ex
defmodule MyApp.Workers.DbHeartbeatWorker do
  use GenServer
  require Logger

  @interval :timer.minutes(5)

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def init(state) do
    schedule()
    {:ok, state}
  end

  def handle_info(:run, state) do
    run_check()
    schedule()
    {:noreply, state}
  end

  defp run_check do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} ->
        ping_vigilmon()
      {:error, reason} ->
        Logger.error("Ecto heartbeat DB check failed: #{inspect(reason)}")
    end
  end

  defp ping_vigilmon do
    url = Application.get_env(:my_app, :vigilmon)[:heartbeat_url]

    if url do
      case Req.get(url, receive_timeout: 5_000) do
        {:ok, %{status: 200}} -> :ok
        error -> Logger.warning("Vigilmon heartbeat ping failed: #{inspect(error)}")
      end
    end
  end

  defp schedule, do: Process.send_after(self(), :run, @interval)
end

Add it to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.Repo,
  MyAppWeb.Endpoint,
  MyApp.Workers.DbHeartbeatWorker
]

In Vigilmon, create a Heartbeat Monitor:

  1. Click New Monitor → Heartbeat
  2. Set expected interval: 10 minutes
  3. Copy the ping URL
  4. Set it as VIGILMON_HEARTBEAT_URL in your environment

If the GenServer crashes, the Repo is unavailable, or migrations block pool checkout, the heartbeat stops — and Vigilmon alerts you within one missed interval.


Step 6: Expose migration state in the health check

Pending migrations are a common cause of startup failures and confusing query errors. Add a migration check:

# Add to your health check plug
defp migration_check do
  case Ecto.Migrator.migrations(MyApp.Repo) do
    migrations when is_list(migrations) ->
      pending = Enum.count(migrations, fn {status, _, _} -> status == :down end)
      status = if pending == 0, do: "ok", else: "warning"
      %{status: status, pending_migrations: pending}

    _ ->
      %{status: "unknown"}
  end
end

Add it to run_checks/0:

defp run_checks do
  db = database_check()
  migrations = migration_check()

  overall_ok = db.status == "ok" and migrations.status in ["ok", "unknown"]

  %{
    status: status_label(overall_ok),
    database: db,
    migrations: migrations
  }
end

Set a Vigilmon keyword alert to fire when the body contains "status":"warning" so you're notified of pending migrations before they cause runtime errors.


Step 7: Slack alerts and status page

Slack alerts:

  1. In Vigilmon, go to Notifications → New Channel → Slack
  2. Paste your Slack incoming webhook URL
  3. Enable the channel on your Ecto health monitor and heartbeat monitor

When the database goes down:

🔴 DOWN: yourdomain.com/health
Keyword "status":"ok" not found
Detected: EU-West
2 minutes ago

When the heartbeat times out:

🔴 HEARTBEAT MISSED: Ecto DB Heartbeat
Expected ping within 10 minutes — none received
Last seen: 14 minutes ago

Status page:

  1. Go to Status Pages → New Status Page
  2. Add your HTTP monitor and heartbeat monitor
  3. Share the URL with your team

What you've built

| What | How | |------|-----| | Health endpoint | Plug with Ecto ping + latency measurement | | Keyword check | Vigilmon keyword match on "status":"ok" | | Uptime monitoring | Vigilmon HTTP monitor → /health | | DB liveness | Heartbeat GenServer + Vigilmon heartbeat monitor | | Slow query detection | Ecto telemetry handler with threshold logging | | Migration alerting | Pending migration count in health response | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

Ecto keeps your data layer solid. Vigilmon tells you when the foundation cracks.


Next steps

  • Add per-Repo monitors if your app uses multiple Ecto repos (primary + read replica)
  • Use Vigilmon's response time history to detect gradual query degradation before it becomes an outage
  • Alert on latency_ms thresholds by combining the health endpoint with Vigilmon's body-content checks

Get started free at vigilmon.online.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →