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:
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute (paid) or 5 minutes (free)
- Add a keyword check:
"database":"ok"must be present - 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:
- Click New Monitor → Heartbeat
- Name: "Order State Processor"
- Expected interval: 2 minutes (slightly longer than
@intervalto allow for processing time) - Copy the ping URL
- Set as
VIGILMON_ORDER_PROCESSOR_HEARTBEAT_URLin 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:
- api.slack.com/apps → Create New App → From scratch
- Enable Incoming Webhooks → Add New Webhook
- 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:
- Go to Status Pages → New Status Page in Vigilmon
- Add your main health monitor, state processor heartbeat, and stuck-records monitor
- 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:

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.