tutorial

How to Monitor Ash Framework with Vigilmon

Set up uptime monitoring, health checks, heartbeat monitors for background tasks, and instant alerts for your Ash Framework application with Vigilmon.

Ash Framework is a declarative, resource-based application framework for Elixir that lets you model your domain once and derive APIs, authorization policies, and persistence layers from that single source of truth. Whether you're serving a JSON:API, a GraphQL schema, or a LiveView interface, all of it flows through Ash resources — which means a single resource misconfiguration, a broken Ecto adapter, or an exhausted connection pool can silently cascade across every interface your app exposes. Vigilmon gives you the external observability layer to catch those failures before your users do: HTTP uptime checks, heartbeat monitors for Ash Reactor pipelines, and instant alerts to email or Slack.

What You'll Build

  • A /health endpoint that validates your Ash data layer and API surface
  • Vigilmon HTTP monitors for your Ash JSON:API and GraphQL endpoints
  • A heartbeat monitor for Ash Reactor or scheduled Ash actions
  • Alert channels (email and Slack)

Prerequisites

  • An Ash Framework application (Ash 3.x)
  • A free Vigilmon account

Step 1: Add a Health Check Endpoint

Ash apps typically expose APIs through AshJsonApi or AshGraphql. Your health check should validate the data layer that backs those APIs.

# 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(),
      ash_resources: check_ash_resources()
    }

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

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(%{
      status: if(degraded, do: "degraded", else: "ok"),
      checks: Map.new(checks, fn {k, v} ->
        {k, if(v == :ok, do: "ok", else: "error")}
      end)
    }))
    |> 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
    end
  end

  defp check_ash_resources do
    # Perform a lightweight read against a representative Ash resource
    # to verify the data layer is responsive
    case Ash.read(MyApp.Accounts.User, action: :list, page: [limit: 1]) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  rescue
    _ -> :error
  end
end

Register the plug in 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

  # AshJsonApi and AshGraphql routers
  plug MyAppWeb.Router
end

If you're using AshJsonApi with a standalone Plug router (without Phoenix):

# lib/my_app/router.ex
defmodule MyApp.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/health" do
    checks = %{database: database_ok?()}
    status = if Enum.all?(checks, fn {_, v} -> v end), do: 200, else: 503
    body = Jason.encode!(%{status: if(status == 200, do: "ok", else: "degraded"), checks: checks})

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, body)
  end

  defp database_ok? do
    match?({:ok, _}, Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []))
  end
end

Test it:

curl -s http://localhost:4000/health | jq .
# {
#   "status": "ok",
#   "checks": { "database": "ok", "ash_resources": "ok" }
# }

Step 2: Configure Vigilmon HTTP Monitors

Log in to VigilmonMonitors → New Monitor.

Monitor 1: Health Endpoint

| Field | Value | |---|---| | URL | https://yourapp.com/health | | Method | GET | | Expected status | 200 | | Expected body | "status":"ok" | | Check interval | 1 minute |

Monitor 2: JSON:API Root

If you expose AshJsonApi, monitor the API root — it validates that your Ash resource routing is wired correctly:

| Field | Value | |---|---| | URL | https://yourapp.com/api/json | | Method | GET | | Expected status | 200 | | Check interval | 1 minute |

Monitor 3: GraphQL Introspection (optional)

For AshGraphql deployments, a lightweight introspection query confirms the schema is serving:

| Field | Value | |---|---| | URL | https://yourapp.com/api/graphql | | Method | POST | | Body | {"query":"{__typename}"} | | Expected body | "data" | | Check interval | 5 minutes |


Step 3: Heartbeat for Ash Reactor Pipelines

Ash Reactor lets you define multi-step, transactional workflows. Long-running Reactor pipelines (nightly data syncs, report generation) can fail silently — the BEAM process exits cleanly but the work never completes.

# lib/my_app/reactors/nightly_sync_reactor.ex
defmodule MyApp.Reactors.NightlySyncReactor do
  use Reactor

  input :since

  step :fetch_records do
    argument :since, input(:since)
    run fn %{since: since}, _ ->
      MyApp.Accounts.User
      |> Ash.Query.filter(updated_at > ^since)
      |> Ash.read()
    end
  end

  step :sync_to_external do
    argument :records, result(:fetch_records)
    run fn %{records: records}, _ ->
      MyApp.ExternalSync.push(records)
    end
  end

  step :ping_heartbeat do
    argument :result, result(:sync_to_external)
    run fn _, _ ->
      url = Application.get_env(:my_app, :vigilmon)[:sync_heartbeat_url]
      if url do
        case Req.get(url, receive_timeout: 5_000) do
          {:ok, _} -> {:ok, :pinged}
          {:error, reason} ->
            require Logger
            Logger.warning("Vigilmon heartbeat ping failed: #{inspect(reason)}")
            {:ok, :ping_failed}
        end
      else
        {:ok, :no_url_configured}
      end
    end
  end
end

Trigger the Reactor from an Oban job or cron:

# lib/my_app/workers/nightly_sync_worker.ex
defmodule MyApp.Workers.NightlySyncWorker do
  use Oban.Worker, queue: :default, max_attempts: 3

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    since = DateTime.add(DateTime.utc_now(), -86_400, :second)

    case Reactor.run(MyApp.Reactors.NightlySyncReactor, %{since: since}) do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

In Vigilmon:

  1. Monitors → New Heartbeat Monitor
  2. Expected interval: 25 hours (daily sync with a 1-hour grace)
  3. Copy the ping URL and set in your environment:
VIGILMON_SYNC_HEARTBEAT_URL=https://vigilmon.online/api/heartbeat/xxxxxxxx
# config/runtime.exs
config :my_app, :vigilmon,
  sync_heartbeat_url: System.get_env("VIGILMON_SYNC_HEARTBEAT_URL")

Step 4: Monitor Ash Policy Authorization Health

Ash's policy authorizer runs on every read and write. A misconfigured policy can cause your API to return blanket 403s — which looks like downtime from the outside. Add a monitor for an endpoint that requires authorization to detect policy failures:

# In your health check, optionally probe Ash policy evaluation
defp check_policy do
  actor = %{role: :health_checker}
  case Ash.read(MyApp.Accounts.User, actor: actor, action: :list, authorize?: true, page: [limit: 1]) do
    {:ok, _} -> :ok
    {:error, %Ash.Error.Forbidden{}} -> :ok   # expected if health_checker role has no access
    {:error, _} -> :error
  end
rescue
  _ -> :error
end

This check returns :ok for both "allowed" and "correctly forbidden" results, and :error only for unexpected failures — so the health endpoint won't false-alert on intentional authorization boundaries.


Step 5: Alert Channels

In Vigilmon → Alert Channels:

Email

  1. Alert Channels → Email → add your on-call address
  2. Attach to all monitors

Slack

  1. Create a Slack Incoming Webhook for your #alerts channel
  2. Alert Channels → Webhook → paste the URL
  3. Payload template:
{
  "text": "🔴 *{{ monitor.name }}* is DOWN\n{{ monitor.url }}\nStatus: {{ event.status }}\n<https://vigilmon.online|Open Vigilmon>"
}

Step 6: Verify Your Setup

# 1. Health endpoint returns 200 with all checks green
curl -s https://yourapp.com/health | jq .

# 2. Simulate a database failure (stop Postgres or revoke connection)
# Expected: health returns 503 with "ash_resources": "error"
# Expected: Vigilmon alerts within 2 minutes

# 3. Trigger the Reactor manually and confirm the heartbeat is pinged
Reactor.run(MyApp.Reactors.NightlySyncReactor, %{since: ~U[2000-01-01 00:00:00Z]})

# 4. In Vigilmon, use "Test Alert" to confirm email and Slack delivery

Summary

| Monitor | What It Catches | |---|---| | HTTP health endpoint | Database failures, broken Ash data layers | | JSON:API root | Ash resource routing failures, bad deploys | | GraphQL introspection | Schema compilation errors, AshGraphql misconfiguration | | Reactor heartbeat | Silent pipeline failures, stalled Oban jobs |


Next Steps

  • Add per-domain monitors if your Ash app serves multiple Phoenix scopes
  • Use Vigilmon's response time graphs to catch slow Ash policy evaluations before they time out
  • Add a heartbeat for each Ash Reactor that drives time-sensitive workflows
  • Configure separate alert channels for data-layer failures vs. authorization failures

Ready to get started? Sign up for Vigilmon free — have your first monitor running in under 5 minutes.

Monitor your app with Vigilmon

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

Start free →