tutorial

How to Monitor EctoEnum Applications with Vigilmon

Add uptime monitoring, health checks, and alerts to Elixir applications using EctoEnum for custom enum field types — including database schema validation, enum value coverage, and Ecto migration health.

How to Monitor EctoEnum Applications with Vigilmon

EctoEnum is an Elixir library for declaring custom enum field types in Ecto schemas. It automatically casts string and atom values to and from database enum columns or integer representations, eliminating boilerplate for status, role, and state fields.

EctoEnum itself is a library, not a server — it doesn't run a process you can ping. What you monitor is the Elixir/Phoenix application that uses EctoEnum, with specific attention to the health of the Ecto repository, the correctness of enum schema constraints, and the state machine transitions driven by those enums.

This tutorial adds production observability to a Phoenix application using EctoEnum:

  • A health check endpoint that validates the Ecto repository and enum schema
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for state machine processors and background jobs
  • Slack alerts and a status page

Why Monitor an EctoEnum Application?

Applications using EctoEnum manage business state transitions — orders, subscriptions, workflows. These fail in observable ways:

  • Migration divergence: a new enum value was added in code but the database migration wasn't run, causing cast failures on insert
  • Enum constraint mismatch: the database column allows values the application doesn't define (or vice versa), causing unexpected errors
  • State machine stall: a background job that transitions records between enum states crashes, leaving records stuck in intermediate states
  • Database connection pool exhaustion: Ecto queries time out, enum-driven state transitions queue up, user operations start failing

External monitoring catches connection pool exhaustion and state machine stalls before they cause visible user errors.


Key Metrics to Monitor

| Metric | Why It Matters | |--------|---------------| | Ecto repository connectivity | Can the app reach the database? | | Enum constraint health | Do DB constraints match application enum definitions? | | State transition job liveness | Are background state processors running? | | Stuck record detection | Are records stalled in intermediate states? | | Connection pool utilization | Is the pool under pressure? |


Step 1: Add a health check endpoint

Phoenix already has a structure for this. Add a health check plug that validates the repository and — optionally — a critical enum constraint.

# 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 = %{
      database: check_database(),
      enum_schema: check_enum_schema(),
      state_processor: check_state_processor()
    }

    status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503

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

  def call(conn, _opts), do: conn

  defp check_database do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  defp check_enum_schema do
    # Verify the orders table has the expected status column
    # This catches migration/code divergence early
    query = """
      SELECT column_name
      FROM information_schema.columns
      WHERE table_name = 'orders' AND column_name = 'status'
    """
    case Ecto.Adapters.SQL.query(MyApp.Repo, query, []) do
      {:ok, %{rows: [_]}} -> :ok
      {:ok, %{rows: []}} -> :error
      {:error, _} -> :error
    end
  end

  defp check_state_processor do
    case Process.whereis(MyApp.OrderStateProcessor) do
      pid when is_pid(pid) ->
        if Process.alive?(pid), do: :ok, else: :error
      nil -> :error
    end
  end

  defp status_label(200), do: "ok"
  defp status_label(_), do: "degraded"
end

Add to your endpoint before the router:

# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug MyAppWeb.Plugs.HealthCheck

  # ... rest of your plugs
  plug MyAppWeb.Router
end

Test it:

mix phx.server
curl http://localhost:4000/health
# {"status":"ok","checks":{"database":"ok","enum_schema":"ok","state_processor":"ok"}}

Step 2: Set up HTTP monitoring in Vigilmon

Point Vigilmon at your health endpoint:

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute (paid) or 5 minutes (free)
  5. Add a keyword check: "database":"ok" must be present
  6. Save

Vigilmon pings from multiple geographic regions. For a database-backed application, this catches connection pool exhaustion (which is often regional — affecting users in one data center first) before it becomes global.


Step 3: Heartbeat monitoring for state machine processors

The most dangerous failure mode for EctoEnum applications is silent state machine stalls. An order stuck in "processing" status, a subscription stuck in "canceling", a user account stuck in "pending_verification" — these are business-critical bugs that often have no error log.

Background state processor heartbeat

# lib/my_app/order_state_processor.ex
defmodule MyApp.OrderStateProcessor do
  use GenServer

  require Logger

  @interval :timer.minutes(1)

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

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

  def handle_info(:tick, state) do
    case process_pending_orders() do
      {:ok, count} ->
        Logger.info("Processed #{count} orders")
        ping_heartbeat()

      {:error, reason} ->
        Logger.error("Order processing failed: #{inspect(reason)}")
        # No heartbeat — Vigilmon alerts after the window
    end

    schedule_tick()
    {:noreply, state}
  end

  defp process_pending_orders do
    # Transition orders from :pending to :processing
    orders = MyApp.Repo.all(
      from o in MyApp.Order,
        where: o.status == ^:pending,
        limit: 100
    )

    results = Enum.map(orders, &transition_order/1)
    errors = Enum.filter(results, &match?({:error, _}, &1))

    if Enum.empty?(errors) do
      {:ok, length(orders)}
    else
      {:error, errors}
    end
  end

  defp transition_order(order) do
    order
    |> Ecto.Changeset.change(status: :processing)
    |> MyApp.Repo.update()
  end

  defp schedule_tick, do: Process.send_after(self(), :tick, @interval)

  defp ping_heartbeat do
    url = Application.get_env(:my_app, :vigilmon)[:order_processor_heartbeat_url]
    if url, do: Req.get(url, receive_timeout: 5_000)
  end
end

Configure the heartbeat URL:

# config/runtime.exs
config :my_app, :vigilmon,
  order_processor_heartbeat_url: System.get_env("VIGILMON_ORDER_PROCESSOR_HEARTBEAT_URL")

In Vigilmon, create a Heartbeat Monitor:

  1. Click New Monitor → Heartbeat
  2. Name: "Order State Processor"
  3. Expected interval: 2 minutes (slightly longer than @interval to allow for processing time)
  4. Copy the ping URL
  5. Set as VIGILMON_ORDER_PROCESSOR_HEARTBEAT_URL in your environment

Oban job heartbeat for enum-driven workflows

If you use Oban for enum state transitions:

# lib/my_app/workers/subscription_renewal_worker.ex
defmodule MyApp.Workers.SubscriptionRenewalWorker do
  use Oban.Worker, queue: :renewals

  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    with {:ok, count} <- renew_expiring_subscriptions() do
      ping_heartbeat()
      Logger.info("Renewed #{count} subscriptions")
      :ok
    else
      {:error, reason} ->
        Logger.error("Subscription renewal failed: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp renew_expiring_subscriptions do
    # Find subscriptions in :active status expiring within 24h
    subscriptions = MyApp.Repo.all(
      from s in MyApp.Subscription,
        where: s.status == ^:active and s.expires_at <= ^DateTime.add(DateTime.utc_now(), 86400, :second)
    )

    results = Enum.map(subscriptions, &renew_subscription/1)
    errors = Enum.filter(results, &match?({:error, _}, &1))

    if Enum.empty?(errors), do: {:ok, length(subscriptions)}, else: {:error, errors}
  end

  defp renew_subscription(sub) do
    sub
    |> Ecto.Changeset.change(
      status: :active,
      expires_at: DateTime.add(sub.expires_at, 2_592_000, :second)  # +30 days
    )
    |> MyApp.Repo.update()
  end

  defp ping_heartbeat do
    url = Application.get_env(:my_app, :vigilmon)[:renewal_heartbeat_url]
    if url, do: Req.get(url, receive_timeout: 5_000)
  end
end

Step 4: Alerts via Slack

In Vigilmon, go to Notifications → New Channel → Slack and paste your Slack incoming webhook URL.

To create a webhook:

  1. api.slack.com/appsCreate New App → From scratch
  2. Enable Incoming WebhooksAdd New Webhook
  3. Pick your engineering alerts channel and copy the URL

Enable the Slack channel on all monitors. When the state processor stalls:

🔴 HEARTBEAT MISSED: Order State Processor
Expected ping every 2 minutes
Last ping: 15 minutes ago

This is critical for e-commerce or subscription applications — a stalled order processor means customers see their orders stuck in "processing" with no updates.


Step 5: Monitor for stuck enum values

Add a database-level monitor for records stuck in transitional states. Add a route that returns counts of records in intermediate states:

# lib/my_app_web/router.ex
scope "/health" do
  get "/stuck-records", MyAppWeb.HealthController, :stuck_records
end
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  def stuck_records(conn, _params) do
    # Records in intermediate states older than 30 minutes are suspicious
    threshold = DateTime.add(DateTime.utc_now(), -1800, :second)

    stuck = %{
      orders_processing: count_stuck(MyApp.Order, :processing, threshold),
      subscriptions_canceling: count_stuck(MyApp.Subscription, :canceling, threshold)
    }

    status = if Enum.all?(stuck, fn {_, v} -> v == 0 end), do: 200, else: 503

    json(conn |> put_status(status), %{stuck_records: stuck})
  end

  defp count_stuck(schema, status, threshold) do
    MyApp.Repo.aggregate(
      from(r in schema, where: r.status == ^status and r.updated_at < ^threshold),
      :count
    )
  end
end

Add a second Vigilmon monitor for this endpoint with a keyword check for "orders_processing":0. A non-zero count with old timestamps means your state processor has stalled.


Step 6: Status page and badge

Status page:

  1. Go to Status Pages → New Status Page in Vigilmon
  2. Add your main health monitor, state processor heartbeat, and stuck-records monitor
  3. Copy the public URL

Share with your customer support team — when users report orders not updating, they can check the status page first before escalating.

README badge:

![Application Uptime](https://vigilmon.online/badge/your-monitor-slug)

What you've built

| What | How | |------|-----| | Health check endpoint | Plug validating DB, enum schema, and processor liveness | | Database connectivity | Ecto.Adapters.SQL.query/3 | | Enum schema validation | Query information_schema.columns for expected columns | | HTTP uptime monitoring | Vigilmon HTTP monitor → /health | | State processor heartbeat | Ping on each successful processing cycle | | Oban job heartbeat | Ping on perform/1 success | | Stuck record detection | Separate endpoint + Vigilmon keyword check | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

EctoEnum keeps your enum values clean. Vigilmon keeps your state machines running.


Next steps

  • Add migration version to the health response to detect code/DB divergence after deploys
  • Use Vigilmon's response time history to catch slow Ecto queries against enum-indexed columns
  • Add per-enum-value counters to the health response for visibility into state distribution
  • Set up separate heartbeat monitors for each enum-driven workflow (orders, subscriptions, onboarding)

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 →