tutorial

How to Monitor Waffle with Vigilmon

Add uptime monitoring, file upload health checks, and alerts to your Waffle-powered Elixir/Phoenix application — so broken storage backends, upload failures, and processing errors don't go undetected.

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

  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

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:

  1. Click New Monitor → Heartbeat
  2. Set expected interval: 20 minutes
  3. Copy the ping URL
  4. Set it as VIGILMON_HEARTBEAT_URL in 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:

  1. In Vigilmon, go to Notifications → New Channel → Slack
  2. Paste your Slack incoming webhook URL
  3. 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:

  1. Go to Status Pages → New Status Page
  2. Add your storage health monitor and heartbeat monitor
  3. 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →