tutorial

How to Monitor Plug with Vigilmon

Instrument your Elixir Plug pipeline with Telemetry, expose a structured health endpoint, and use Vigilmon to alert when middleware failures, slow plugs, or request error rates spike.

How to Monitor Plug with Vigilmon

Plug is the composable middleware specification that powers all Elixir web applications. Every Phoenix request passes through a pipeline of Plugs — authentication, rate limiting, body parsing, session handling, CORS headers — before reaching your controller. If you use Phoenix, you use Plug. If you use Bandit or Cowboy directly, you use Plug. It is the universal abstraction layer for HTTP in Elixir.

The pipeline nature of Plug creates a subtle failure mode: a misbehaving plug in the middle of the pipeline silently degrades every request that passes through it. A plug that adds 200ms of latency for token verification, a plug that leaks memory on malformed headers, or a plug that sporadically halts the connection without an error response — these issues are hard to attribute because they affect all routes, not just the ones that broke.

Vigilmon monitors give you visibility into your Plug pipeline so you can isolate which middleware is causing trouble before users start complaining.


Why Monitor Plug?

Plug pipeline failures are systemic:

  • Middleware latency regression — a plug that calls an external service (Redis, JWT validator) adds latency to every request; a slowdown in that service degrades your entire application
  • Error rate spike — a plug that raises on unexpected input patterns causes 500s for a class of requests without breaking others
  • Halt without response — a plug that calls halt/1 without send_resp/3 hangs the connection until the client times out, invisible in application logs
  • Memory leak from accumulating assigns — plugs that accumulate large values in conn.assigns without cleanup can grow request memory per-conn
  • Auth plug misconfiguration — a plug that skips authentication on routes it should protect, or vice versa, is a security issue that monitoring can detect via anomalous request patterns
  • Pipeline order bugs — a body parser plug placed after a plug that reads the body causes silent failures on POST requests

Key Metrics to Track

| Metric | What it tells you | |--------|-------------------| | Request rate by route | Baseline traffic for anomaly detection | | Request error rate (4xx/5xx) | Whether plugs are producing unexpected responses | | Plug execution latency | Time spent in each plug in the pipeline | | Halt rate | How often pipelines are short-circuited before the controller | | Body parse failures | Malformed request bodies reaching your plug pipeline | | Auth plug rejection rate | Rate of 401/403 responses from authentication plugs |


Step 1: Enable Plug.Telemetry in Your Pipeline

Plug ships with built-in Telemetry instrumentation. Enable it in your Phoenix endpoint:

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

  plug Plug.Telemetry,
    event_prefix: [:my_app, :plug],
    log: {Plug.Telemetry, :default_log, []}

  plug Plug.RequestId
  plug Plug.Head
  plug Plug.Session, store: :cookie, key: "_my_app_key", signing_salt: "abc123"

  plug MyAppWeb.Router
end

Plug.Telemetry emits two events per request:

  • [:my_app, :plug, :start] — when the request enters the pipeline
  • [:my_app, :plug, :stop] — when the response is sent

For per-plug latency, instrument individual plugs:

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

  def init(opts), do: opts

  def call(conn, opts) do
    start = System.monotonic_time()

    result_conn = do_auth(conn, opts)

    duration = System.monotonic_time() - start
    :telemetry.execute(
      [:my_app, :plug, :auth],
      %{duration: duration},
      %{
        halted: result_conn.halted,
        status: result_conn.status || :not_set,
        path: conn.request_path
      }
    )

    result_conn
  end

  defp do_auth(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, claims} <- MyApp.Auth.verify_token(token) do
      assign(conn, :current_user_id, claims["sub"])
    else
      _ ->
        conn
        |> put_resp_content_type("application/json")
        |> send_resp(401, Jason.encode!(%{error: "unauthorized"}))
        |> halt()
    end
  end
end

Step 2: Attach Telemetry Handlers

# lib/my_app/plug_telemetry.ex
defmodule MyApp.PlugTelemetry do
  require Logger

  def attach do
    :telemetry.attach_many(
      "my-app-plug-handler",
      [
        [:my_app, :plug, :stop],
        [:my_app, :plug, :auth],
        [:phoenix, :router_dispatch, :stop],
        [:phoenix, :router_dispatch, :exception]
      ],
      &handle_event/4,
      nil
    )
  end

  defp handle_event([:my_app, :plug, :stop], %{duration: d}, meta, _) do
    ms = System.convert_time_unit(d, :native, :millisecond)

    if ms > 5_000 do
      Logger.error("Plug pipeline slow: #{ms}ms for #{meta[:conn].request_path}")
    end

    if meta[:conn] && meta[:conn].status >= 500 do
      Logger.error("Plug pipeline 5xx: status=#{meta[:conn].status} path=#{meta[:conn].request_path}")
    end
  end

  defp handle_event([:my_app, :plug, :auth], %{duration: d}, %{halted: true} = meta, _) do
    ms = System.convert_time_unit(d, :native, :millisecond)
    Logger.info("Auth plug rejected request: path=#{meta.path} latency=#{ms}ms")
  end

  defp handle_event([:phoenix, :router_dispatch, :exception], _, meta, _) do
    Logger.error("Router exception: #{inspect(meta[:reason])} on #{meta[:conn].request_path}")
  end

  defp handle_event(_, _, _, _), do: :ok
end

Call MyApp.PlugTelemetry.attach() in Application.start/2.


Step 3: Define Telemetry.Metrics for Your Plug Pipeline

# lib/my_app/telemetry.ex
import Telemetry.Metrics

def metrics do
  [
    # Overall request pipeline
    counter("my_app.plug.stop.count"),
    distribution("my_app.plug.stop.duration",
      unit: {:native, :millisecond},
      reporter_options: [buckets: [10, 50, 100, 250, 500, 1000, 5000]]
    ),

    # Phoenix route dispatch
    counter("phoenix.router_dispatch.stop.count",
      tags: [:route, :method]
    ),
    distribution("phoenix.router_dispatch.stop.duration",
      unit: {:native, :millisecond},
      tags: [:route]
    ),
    counter("phoenix.router_dispatch.exception.count",
      tags: [:kind]
    ),

    # Auth plug
    counter("my_app.plug.auth.count",
      tags: [:halted, :status]
    ),
    distribution("my_app.plug.auth.duration",
      unit: {:native, :millisecond}
    )
  ]
end

Step 4: Add a Pipeline 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 = %{
      db: check_db(),
      endpoint_alive: check_endpoint()
    }

    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,
      vsn: Application.spec(:my_app, :vsn) |> to_string()
    }))
    |> halt()
  end

  def call(conn, _opts), do: conn

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

  defp check_endpoint do
    if Process.whereis(MyAppWeb.Endpoint) |> Process.alive?(), do: :ok, else: :down
  end
end

Mount the health plug early in the pipeline before any authentication or rate limiting:

# lib/my_app_web/endpoint.ex
plug MyAppWeb.Plugs.HealthCheck  # before Plug.Telemetry to avoid noise

plug Plug.Telemetry, event_prefix: [:my_app, :plug]
# ... rest of plugs

Step 5: Create Monitors in Vigilmon

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Set URL to https://your-app.example.com/health
  4. Set interval to 60 seconds
  5. Alert condition: status must be 200 and response must contain "status":"ok"

Add a second monitor to catch slow pipeline responses:

  1. Click New Monitor → HTTP
  2. Set URL to https://your-app.example.com/health
  3. Under Advanced → Response Time Threshold, set alert if response exceeds 2000ms
  4. This catches middleware latency regressions that don't cause errors but slow every request

Step 6: Track Error Rate with a Heartbeat

Use a Vigilmon heartbeat to assert that your error rate stays within bounds. Report success only when the 5xx rate is below your SLO:

# lib/my_app/error_rate_watchdog.ex
defmodule MyApp.ErrorRateWatchdog do
  use GenServer

  @heartbeat_url System.get_env("VIGILMON_PLUG_HEARTBEAT_URL")
  @interval :timer.minutes(1)
  @error_threshold 0.01  # 1% 5xx rate

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

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

  def handle_info(:check, state) do
    if error_rate_ok?() do
      ping()
    end

    schedule()
    {:noreply, state}
  end

  defp error_rate_ok? do
    # Read from your metrics store or ETS counter
    total = MyApp.Metrics.get_counter(:total_requests, window: :timer.minutes(5))
    errors = MyApp.Metrics.get_counter(:error_requests, window: :timer.minutes(5))

    total == 0 || errors / total < @error_threshold
  rescue
    _ -> true  # don't alert if metrics unavailable
  end

  defp ping do
    if @heartbeat_url do
      :httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [{:timeout, 3000}], [])
    end
  end

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

Create a Heartbeat monitor in Vigilmon with a 3-minute expected interval.


Step 7: Alerting

In Vigilmon, configure Notifications → New Channel:

Slack for health endpoint failure:

🔴 DOWN: MyApp Plug Pipeline
Monitor: https://your-app.example.com/health
Status: 503 (degraded)
Action: Check Elixir application logs — database or endpoint process may be down

Slack for response time regression:

⚠️ SLOW: MyApp Plug Pipeline
Monitor: https://your-app.example.com/health response time > 2000ms
Action: Check for slow middleware — auth plugin, rate limiter, or body parser may be the culprit

Common Plug Issues and Fixes

Plug that halts without sending a response:

# Bad — halts without a response, the client hangs
def call(conn, _opts) do
  if unauthorized?(conn) do
    halt(conn)  # missing send_resp!
  else
    conn
  end
end

# Good
def call(conn, _opts) do
  if unauthorized?(conn) do
    conn
    |> send_resp(401, "Unauthorized")
    |> halt()
  else
    conn
  end
end

Body parser order issue:

# Bad — reads body before Plug.Parsers processes it
plug MyApp.RawBodyCapture
plug Plug.Parsers, parsers: [:json]

# Good — let the parser run first, then access parsed params
plug Plug.Parsers, parsers: [:json]
plug MyApp.ParamLogger

Plug pipeline bypasses health check auth:

# Ensure /health does not require authentication
pipeline :api do
  plug :accepts, ["json"]
  plug MyAppWeb.Plugs.RequireAuth  # applied to all :api routes
end

# Health routes use a separate pipeline without auth
scope "/", MyAppWeb do
  get "/health", HealthController, :index  # no pipeline
end

What You've Built

| What | How | |------|-----| | Pipeline Telemetry | Plug.Telemetry events on every request | | Per-plug instrumentation | Manual Telemetry events with latency and halt tracking | | Structured health check | /health plug returning JSON status with db check | | Telemetry.Metrics integration | Request counters, latency distributions, auth rejection rate | | Infrastructure monitoring | Vigilmon HTTP monitor on /health with response time threshold | | Error rate heartbeat | GenServer that only pings Vigilmon when 5xx rate is within SLO |

Plug makes your Elixir web stack modular and composable. Vigilmon makes sure every layer of that stack is performing as expected.


Next Steps

  • Add per-route latency panels in Grafana to identify which endpoints are slow
  • Set up a Plug that captures request IDs and correlate them with Vigilmon alert timestamps
  • Instrument your rate limiter plug to track rejection rates per client
  • Use Vigilmon's downtime history to document SLA compliance for your API

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 →