How to Monitor Waffle with Vigilmon
Waffle is the go-to file upload library for Elixir and Phoenix applications. It handles local storage, S3-compatible cloud backends, image transformations, and custom storage adapters — all behind a clean, consistent API. But when an S3 bucket goes offline, a storage permission changes, or a file processing pipeline stalls, Waffle fails quietly. Users see upload errors, profile images stop loading, and attachments silently disappear — while your application server stays healthy and returns HTTP 200.
External monitoring catches upload pipeline failures that application logs miss. This tutorial covers:
- A health endpoint that validates Waffle's storage backend connectivity
- HTTP uptime monitoring with Vigilmon
- Heartbeat monitoring to detect when file processing jobs stall
- Slack alerts and a status page
Step 1: Add Waffle to your project
# mix.exs
defp deps do
[
{:waffle, "~> 1.1"},
{:waffle_ecto, "~> 0.0.11"}, # if using Ecto attachments
{:ex_aws_s3, "~> 2.5"}, # for S3 storage backend
{:hackney, "~> 1.9"},
{:req, "~> 0.4"} # for heartbeat pings
]
end
Configure Waffle with S3:
# config/config.exs
config :waffle,
storage: Waffle.Storage.S3,
bucket: {:system, "WAFFLE_BUCKET"},
asset_host: {:system, "WAFFLE_ASSET_HOST"}
config :ex_aws,
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
region: {:system, "AWS_REGION"}
Or for local development:
# config/dev.exs
config :waffle,
storage: Waffle.Storage.Local,
storage_dir: "priv/waffle_storage"
Step 2: Define a health-check uploader
Create a minimal Waffle uploader used only for health checks:
# lib/my_app/uploaders/health_check_uploader.ex
defmodule MyApp.Uploaders.HealthCheckUploader do
use Waffle.Definition
@versions [:original]
def storage_dir(_version, {_file, _scope}), do: ".health"
def filename(_version, {_file, _scope}), do: "vigilmon-check"
def validate({file, _}), do: file.file_name == "vigilmon-check.txt"
end
Step 3: Build a health endpoint that validates Waffle storage
Add a plug that exercises the configured storage backend:
# 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 all_ok?(checks), 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
storage = storage_check()
%{
status: status_label(storage.status == "ok"),
storage: storage
}
end
defp storage_check do
backend = Application.get_env(:waffle, :storage)
case backend do
Waffle.Storage.S3 -> s3_storage_check()
Waffle.Storage.Local -> local_storage_check()
_ -> %{status: "ok", note: "custom storage adapter — skipping probe"}
end
end
defp s3_storage_check do
bucket = Application.get_env(:waffle, :bucket) |> resolve_env()
case ExAws.S3.head_bucket(bucket) |> ExAws.request() do
{:ok, _} ->
%{status: "ok", backend: "s3", bucket: bucket}
{:error, {:http_error, 403, _}} ->
# 403 = credentials valid, list permission restricted — still healthy
%{status: "ok", backend: "s3", bucket: bucket, note: "list restricted"}
{:error, {:http_error, 404, _}} ->
%{status: "error", backend: "s3", reason: "bucket not found: #{bucket}"}
{:error, reason} ->
%{status: "error", backend: "s3", reason: inspect(reason)}
end
end
defp local_storage_check do
storage_dir = Application.get_env(:waffle, :storage_dir) || "priv/waffle_storage"
if File.dir?(storage_dir) do
# Attempt a write to confirm the directory is writable
test_path = Path.join(storage_dir, ".health_check")
case File.write(test_path, "ok") do
:ok ->
File.rm(test_path)
%{status: "ok", backend: "local", path: storage_dir}
{:error, reason} ->
%{status: "error", backend: "local", reason: inspect(reason)}
end
else
%{status: "error", backend: "local", reason: "storage directory missing: #{storage_dir}"}
end
end
defp resolve_env({:system, var}), do: System.get_env(var)
defp resolve_env(val), do: val
defp all_ok?(checks), do: checks.status == "ok"
defp status_label(true), do: "ok"
defp status_label(false), do: "degraded"
end
Register the plug:
# 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",
# "storage": {"status": "ok", "backend": "s3", "bucket": "my-app-uploads"}
# }
Step 4: Monitor the health endpoint with Vigilmon
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute
- Enable keyword check: body must contain
"status":"ok" - Save
File storage failures are often silent at the HTTP layer — the web server responds fine while uploads route to a broken backend. The keyword match on "status":"ok" ensures the actual storage probe passed.
Step 5: Heartbeat monitoring for file processing pipelines
If you run background jobs that process uploaded files — image resizing, document conversion, virus scanning — a heartbeat confirms the pipeline ran end-to-end:
# lib/my_app/workers/upload_pipeline_heartbeat.ex
defmodule MyApp.Workers.UploadPipelineHeartbeat do
use GenServer
require Logger
@interval :timer.minutes(10)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:run, state) do
run_check()
schedule()
{:noreply, state}
end
defp run_check do
# Create a minimal temp file and store it through Waffle
tmp_path = System.tmp_dir!() |> Path.join("waffle-heartbeat-#{:os.getpid()}.txt")
File.write!(tmp_path, "vigilmon heartbeat #{DateTime.utc_now()}")
waffle_file = %Waffle.File{path: tmp_path, file_name: "vigilmon-check.txt"}
result = MyApp.Uploaders.HealthCheckUploader.store({waffle_file, nil})
File.rm(tmp_path)
case result do
{:ok, _filename} ->
Logger.debug("Waffle heartbeat: upload OK")
ping_vigilmon()
{:error, reason} ->
Logger.error("Waffle heartbeat upload 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
error -> Logger.warning("Vigilmon heartbeat ping failed: #{inspect(error)}")
end
end
end
defp schedule, do: Process.send_after(self(), :run, @interval)
end
Add it to your supervision tree:
children = [
MyApp.Repo,
MyAppWeb.Endpoint,
MyApp.Workers.UploadPipelineHeartbeat
]
In Vigilmon, create a Heartbeat Monitor:
- Click New Monitor → Heartbeat
- Set expected interval: 20 minutes
- Copy the ping URL
- Set it as
VIGILMON_HEARTBEAT_URLin your environment
If S3 credentials expire, the bucket policy changes, or the processing worker crashes, the heartbeat stops — and Vigilmon alerts you within one missed interval.
Step 6: Track upload success rate in your health response
Add upload metrics to the health response so Vigilmon can alert on degraded upload rates:
# lib/my_app/upload_stats.ex
defmodule MyApp.UploadStats do
use Agent
def start_link(_), do: Agent.start_link(fn -> %{success: 0, failure: 0} end, name: __MODULE__)
def record_success, do: Agent.update(__MODULE__, &Map.update!(&1, :success, fn n -> n + 1 end))
def record_failure, do: Agent.update(__MODULE__, &Map.update!(&1, :failure, fn n -> n + 1 end))
def stats do
Agent.get(__MODULE__, & &1)
end
end
Call MyApp.UploadStats.record_success() / record_failure() in your upload callbacks, then expose stats in the health check:
defp upload_stats_check do
%{success: ok, failure: err} = MyApp.UploadStats.stats()
total = ok + err
error_rate = if total > 0, do: Float.round(err / total * 100, 1), else: 0.0
status = if error_rate < 10.0, do: "ok", else: "warning"
%{status: status, success: ok, failure: err, error_rate_pct: error_rate}
end
Set a Vigilmon keyword alert to fire on "status":"warning" when upload error rates climb.
Step 7: Slack alerts and status page
Slack alerts:
- In Vigilmon, go to Notifications → New Channel → Slack
- Paste your Slack incoming webhook URL
- Enable the channel on your Waffle health monitor and heartbeat monitor
When storage goes unreachable:
🔴 DOWN: yourdomain.com/health
Keyword "status":"ok" not found — storage: error
Detected: EU-West
2 minutes ago
When upload error rate climbs:
🟡 KEYWORD ALERT: yourdomain.com/health
Body contains "status":"warning"
error_rate_pct: 14.3
Status page:
- Go to Status Pages → New Status Page
- Add your storage health monitor and heartbeat monitor
- Share the URL with your team
What you've built
| What | How |
|------|-----|
| Health endpoint | Plug with S3/local storage backend probe |
| Keyword check | Vigilmon keyword match on "status":"ok" |
| Uptime monitoring | Vigilmon HTTP monitor → /health |
| Upload pipeline liveness | End-to-end Waffle store + Vigilmon heartbeat |
| Error rate alerting | Upload success/failure stats in health response |
| Slack alerts | Vigilmon Slack notification channel |
| Status page | Vigilmon public status page |
Waffle moves your files into storage. Vigilmon tells you when the path is broken.
Next steps
- Add per-bucket monitors if your application uploads to multiple S3 buckets (e.g., avatars, documents, exports)
- Use Vigilmon's response time history to detect storage latency increases before they cause user-visible upload timeouts
- Monitor thumbnail generation separately if you use image transformations — failures there often don't propagate to the main upload response
Get started free at vigilmon.online.