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/1withoutsend_resp/3hangs the connection until the client times out, invisible in application logs - Memory leak from accumulating assigns — plugs that accumulate large values in
conn.assignswithout 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
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Set interval to 60 seconds
- Alert condition: status must be
200and response must contain"status":"ok"
Add a second monitor to catch slow pipeline responses:
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Under Advanced → Response Time Threshold, set alert if response exceeds 2000ms
- 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.