tutorial

How to Monitor Bandit with Vigilmon

Learn how to add health checks, uptime monitoring, and alerts to your Bandit HTTP server with Vigilmon.

How to Monitor Bandit with Vigilmon

Bandit is a pure Elixir HTTP server implementing HTTP/1.1, HTTP/2, and WebSocket protocols from scratch. Designed as a modern, Plug-compatible alternative to Cowboy, Bandit offers superior observability through telemetry events, improved HTTP/2 performance, and a codebase written entirely in Elixir for easier debugging and introspection.

Phoenix 1.7.11+ defaults to Bandit as its HTTP adapter. If you've upgraded Phoenix recently, your app is likely already running on Bandit — and its rich telemetry integration makes it an excellent candidate for deep monitoring.

This tutorial adds production observability to a Bandit-backed application:

  • A health check plug with Bandit-specific metrics
  • HTTP uptime monitoring with Vigilmon
  • Telemetry-driven connection and request tracking
  • Alerting via Slack
  • Heartbeat monitoring for background jobs

Step 1: Add Bandit to your project

If you're not already using Bandit, add it to your mix.exs:

# mix.exs
defp deps do
  [
    {:bandit, "~> 1.0"},
    {:plug, "~> 1.14"},
    {:jason, "~> 1.4"},
    {:req, "~> 0.4"}
  ]
end

For a Phoenix app, configure Bandit as the adapter in config/config.exs:

# config/config.exs
config :my_app, MyAppWeb.Endpoint,
  adapter: Bandit.PhoenixAdapter

Step 2: Add a health check plug

# 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(),
      memory: check_memory(),
      bandit: check_bandit()
    }

    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: (if status == 200, do: "ok", else: "degraded"),
      checks: checks,
      server: "bandit"
    }))
    |> 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
  rescue
    _ -> :error
  end

  defp check_bandit do
    # Bandit registers itself under ThousandIsland — verify it's alive
    case Process.whereis(MyAppWeb.Endpoint.HTTP) do
      nil -> :error
      pid when is_pid(pid) ->
        if Process.alive?(pid), do: :ok, else: :error
    end
  rescue
    _ -> :ok  # process name not registered in all configurations
  end

  defp check_memory do
    case :memsup.get_system_memory_data() do
      [] -> :ok
      data ->
        total = Keyword.get(data, :total_memory, 1)
        free = Keyword.get(data, :free_memory, total)
        used_pct = (total - free) / total * 100
        if used_pct < 90, do: :ok, else: :error
    end
  end
end

Mount it in your Phoenix 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  # ← before other plugs
  plug MyAppWeb.Router
end

Test it:

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

Step 3: Instrument with Bandit's telemetry

Bandit emits rich :telemetry events for every connection and request lifecycle. Attach handlers to track request throughput, error rates, and connection counts:

# lib/my_app/bandit_telemetry.ex
defmodule MyApp.BanditTelemetry do
  require Logger

  def attach do
    :telemetry.attach_many(
      "bandit-metrics",
      [
        [:bandit, :request, :stop],
        [:bandit, :request, :exception],
        [:bandit, :websocket, :stop]
      ],
      &handle_event/4,
      nil
    )
  end

  def handle_event([:bandit, :request, :stop], measurements, metadata, _config) do
    duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)

    Logger.info("Bandit request completed",
      method: metadata.conn.method,
      path: metadata.conn.request_path,
      status: metadata.conn.status,
      duration_ms: duration_ms,
      http_version: metadata.conn.http_protocol
    )

    # Emit to your metrics backend (StatsD, Prometheus, etc.)
    # :telemetry.execute([:my_app, :http, :request], %{duration: duration_ms}, metadata)
  end

  def handle_event([:bandit, :request, :exception], _measurements, metadata, _config) do
    Logger.error("Bandit request exception",
      path: metadata.conn.request_path,
      error: inspect(metadata.error)
    )
  end

  def handle_event([:bandit, :websocket, :stop], measurements, metadata, _config) do
    duration_s = System.convert_time_unit(measurements.duration, :native, :second)
    Logger.info("WebSocket connection closed",
      path: metadata.conn.request_path,
      duration_s: duration_s
    )
  end
end

Start the telemetry handler in your application:

# lib/my_app/application.ex
def start(_type, _args) do
  MyApp.BanditTelemetry.attach()

  children = [
    MyApp.Repo,
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

Step 4: 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. Under Advanced, add a keyword check for "bandit" to verify the correct server is responding
  6. Save

Why external monitoring matters with Bandit:

Bandit's HTTP/2 implementation multiplexes requests over a single connection. Internal health checks run over existing connections and may miss scenarios where:

  • New HTTP/2 connections fail to establish (TLS ALPN negotiation issues)
  • HTTP/1.1 fallback is broken for clients that don't support HTTP/2
  • WebSocket upgrades fail after a code change

Vigilmon's external probe establishes a fresh connection on every check, catching connection-establishment failures your internal checks miss.


Step 5: Monitor HTTP/2 upgrade path

If your deployment supports HTTP/2 (via a reverse proxy or Bandit's native TLS), add a separate monitor targeting the HTTPS endpoint:

In Vigilmon:

  1. Click New Monitor → HTTP
  2. Enter https://yourdomain.com/health
  3. Enable SSL certificate monitoring → Vigilmon alerts 30 days before expiry
  4. Set response time threshold to 1000 ms (HTTP/2 should be fast)

Step 6: Alerts via Slack

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

Enable the channel on your monitor. Down alerts look like:

🔴 DOWN: yourdomain.com/health
Status: 503 Service Unavailable
Detected from: EU-West, US-East
2 minutes ago

Recovery:

✅ RECOVERED: yourdomain.com/health
Downtime: 4 minutes

Set a 2-minute failure grace period in Vigilmon before alerting. This prevents noise from brief deploy windows when Bandit restarts the listener.


Step 7: Heartbeat monitoring for Oban jobs

If your Phoenix/Bandit app runs Oban for background processing:

# lib/my_app/workers/report_worker.ex
defmodule MyApp.Workers.ReportWorker do
  use Oban.Worker, queue: :reports, max_attempts: 3

  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{args: args}) do
    with :ok <- generate_report(args),
         :ok <- deliver_report(args) do
      ping_heartbeat()
      :ok
    else
      {:error, reason} = error ->
        Logger.error("ReportWorker failed: #{inspect(reason)}")
        error
    end
  end

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

  defp generate_report(_args), do: :ok
  defp deliver_report(_args), do: :ok
end

Add config:

# config/runtime.exs
config :my_app, :vigilmon,
  report_heartbeat_url: System.get_env("VIGILMON_REPORT_HEARTBEAT_URL")

In Vigilmon, create a Heartbeat Monitor:

  1. New Monitor → Heartbeat
  2. Set expected interval to match your Oban schedule (e.g. 25 hours for a daily job)
  3. Copy the ping URL → set as VIGILMON_REPORT_HEARTBEAT_URL

Step 8: Status page

  1. Status Pages → New Status Page in Vigilmon
  2. Add all monitors (HTTP, heartbeats)
  3. Copy the public URL

README badge:

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

What you've built

| What | How | |------|-----| | Health check plug | HealthCheck plug with Bandit process check | | Telemetry instrumentation | [:bandit, :request, :stop] event handler | | HTTP uptime monitoring | Vigilmon HTTP monitor → /health | | HTTP/2 connection monitoring | External Vigilmon probe on fresh connections | | SSL certificate alerts | Vigilmon HTTPS monitor with expiry warnings | | Slack downtime alerts | Vigilmon Slack notification channel | | Oban job monitoring | Heartbeat ping on perform/1 success | | Status page | Vigilmon public status page |

Bandit handles the connections. Vigilmon verifies they keep working from the outside.


Next steps

  • Attach to [:bandit, :request, :stop] events and emit p95/p99 latency metrics to your observability stack
  • Use Vigilmon's response time history to track latency regressions after HTTP/2 changes
  • Add separate heartbeat monitors for each Oban queue that processes business-critical work
  • Test your HTTP/2 health check with curl --http2 https://yourdomain.com/health to verify the upgrade path works

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 →