tutorial

How to Monitor BodyGuard with Vigilmon

Keep your Elixir/Phoenix authorization layer healthy — expose policy evaluation health, detect authorization failures, and alert on degraded access control with BodyGuard and Vigilmon.

How to Monitor BodyGuard with Vigilmon

BodyGuard is the clean authorization library for Elixir and Phoenix. It delegates authorization decisions to policy modules, keeping business logic separate from access control. When a policy returns {:error, :unauthorized} for the wrong reason — a missing context, a misconfigured role, or a dependent service going down — users get locked out silently while your logs fill with noise.

External monitoring catches what your policy layer can't self-report. This tutorial covers:

  • A health endpoint that validates BodyGuard policy evaluation
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for background authorization jobs
  • Slack alerts and a status page

Step 1: Add BodyGuard to your project

# mix.exs
defp deps do
  [
    {:bodyguard, "~> 2.4"},
    {:req, "~> 0.4"}   # for heartbeat pings
  ]
end

Define a simple policy module:

# lib/my_app/policies/post_policy.ex
defmodule MyApp.Policies.PostPolicy do
  @behaviour BodyGuard.Policy

  def authorize(:create, user, _params) do
    if user.role in [:editor, :admin], do: :ok, else: {:error, :unauthorized}
  end

  def authorize(:update, user, post) do
    if post.author_id == user.id or user.role == :admin, do: :ok, else: {:error, :unauthorized}
  end

  def authorize(:delete, %{role: :admin}, _post), do: :ok
  def authorize(:delete, _user, _post), do: {:error, :unauthorized}

  def authorize(_action, _user, _params), do: {:error, :unauthorized}
end

Use it in your controllers:

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller

  def create(conn, params) do
    with :ok <- BodyGuard.permit(MyApp.Policies.PostPolicy, :create, conn.assigns.current_user, params) do
      # proceed with creation
    end
  end
end

Step 2: Build a health endpoint for BodyGuard

Since BodyGuard is pure Elixir with no external I/O, the health check validates that policy modules load correctly and that any external dependencies they consult (database, cache) are reachable:

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

  def init(opts), do: opts

  def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
    checks = run_checks()
    status = if checks.status == "ok", 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
    authz = authorization_check()

    %{
      status: if(authz.status == "ok", do: "ok", else: "degraded"),
      authorization: authz
    }
  end

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

    # Probe each registered policy with a known test principal
    probe_user = %{id: 0, role: :admin}
    probe_resource = %{author_id: 0}

    policies = Application.get_env(:my_app, :health_check_policies, [])

    results =
      Enum.map(policies, fn {policy_module, action} ->
        case BodyGuard.permit(policy_module, action, probe_user, probe_resource) do
          :ok ->
            %{policy: inspect(policy_module), action: action, status: "ok"}

          {:error, :unauthorized} ->
            # Expected for non-admin actions with admin user is unusual — log a warning
            Logger.warning("[BodyGuard] unexpected unauthorized for probe: #{inspect(policy_module)}.#{action}")
            %{policy: inspect(policy_module), action: action, status: "unexpected_deny"}

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

    elapsed = System.monotonic_time(:millisecond) - start
    has_error = Enum.any?(results, &(&1.status == "error"))

    %{
      status: if(has_error, do: "error", else: "ok"),
      evaluation_ms: elapsed,
      policies: results
    }
  end
end

Configure which policies to probe:

# config/config.exs
config :my_app, :health_check_policies, [
  {MyApp.Policies.PostPolicy, :create},
  {MyApp.Policies.UserPolicy, :read}
]

Register the plug 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",
#   "authorization": {
#     "status": "ok",
#     "evaluation_ms": 1,
#     "policies": [
#       {"policy": "MyApp.Policies.PostPolicy", "action": "create", "status": "ok"}
#     ]
#   }
# }

Step 3: Track authorization failure rates

Aggregate authorization denials in your telemetry pipeline so you can alert on spikes:

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

  def setup do
    :telemetry.attach(
      "bodyguard-deny-logger",
      [:bodyguard, :permit, :stop],
      &handle_permit/4,
      nil
    )
  end

  def handle_permit(_event, _measurements, %{result: {:error, :unauthorized}, policy: policy, action: action}, _config) do
    Logger.warning("[BodyGuard] denied #{inspect(policy)}.#{action}")
    :counters.add(deny_counter(), 1, 1)
  end

  def handle_permit(_event, _measurements, _meta, _config), do: :ok

  def deny_count, do: :counters.get(deny_counter(), 1)

  defp deny_counter do
    case :persistent_term.get({__MODULE__, :deny_counter}, nil) do
      nil ->
        ref = :counters.new(1, [:write_concurrency])
        :persistent_term.put({__MODULE__, :deny_counter}, ref)
        ref

      ref ->
        ref
    end
  end
end

Expose the denial count in your health endpoint:

defp run_checks do
  authz = authorization_check()
  deny_count = MyApp.Telemetry.deny_count()

  %{
    status: if(authz.status == "ok", do: "ok", else: "degraded"),
    authorization: authz,
    recent_denials: deny_count
  }
end

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

If a policy module fails to load after a hot code upgrade, or a database-backed role lookup times out inside a policy, the health endpoint returns "status":"degraded" while still serving HTTP 200. The keyword check catches that.


Step 5: Heartbeat monitoring for permission sync jobs

If you sync roles or permissions from an external system (LDAP, a permissions service, a config store), use a heartbeat to confirm the sync ran successfully:

# lib/my_app/workers/permission_sync_worker.ex
defmodule MyApp.Workers.PermissionSyncWorker do
  use GenServer
  require Logger

  @interval :timer.minutes(15)

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

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

  def handle_info(:sync, state) do
    sync_permissions()
    schedule()
    {:noreply, state}
  end

  defp sync_permissions do
    # Sync roles/permissions from your external source
    case MyApp.Permissions.sync() do
      {:ok, count} ->
        Logger.info("[PermissionSyncWorker] synced #{count} permission records")
        ping_vigilmon()

      {:error, reason} ->
        Logger.error("[PermissionSyncWorker] sync 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
        err -> Logger.warning("[PermissionSyncWorker] heartbeat ping failed: #{inspect(err)}")
      end
    end
  end

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

In Vigilmon, create a Heartbeat Monitor:

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

If the sync fails or the worker crashes, Vigilmon alerts you before stale permissions cause security incidents.


Step 6: 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 BodyGuard health monitor and heartbeat monitor

When policy evaluation breaks:

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

When the permission sync misses:

🔴 HEARTBEAT MISSED: Permission Sync
Expected ping within 30 minutes — none received
Last seen: 47 minutes ago

Status page:

  1. Go to Status Pages → New Status Page
  2. Add your monitors
  3. Share the URL with your team

What you've built

| What | How | |------|-----| | Health endpoint | Plug with BodyGuard policy probe | | Keyword check | Vigilmon keyword match on "status":"ok" | | Uptime monitoring | Vigilmon HTTP monitor → /health | | Policy liveness | Probe admin user against registered policies | | Denial tracking | Telemetry handler counting authorization denials | | Permission sync monitoring | Heartbeat GenServer + Vigilmon heartbeat monitor | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

BodyGuard keeps access control clean and policy-driven. Vigilmon tells you when the authorization layer can't be trusted.


Next steps

  • Alert when denial counts spike — a sudden burst of {:error, :unauthorized} responses can indicate a broken role assignment or a misconfigured policy after a deploy
  • Add per-policy response time tracking to detect policies with slow database lookups
  • Monitor role sync latency to ensure authorization decisions use fresh permission data

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 →