tutorial

How to Monitor Exile with Vigilmon

Add uptime monitoring, external process health checks, and alerts to your Exile-powered Elixir application — so FFmpeg failures, streaming stalls, and process leaks don't silently break your media pipelines.

How to Monitor Exile with Vigilmon

Exile is an Elixir library for running external OS processes with demand-driven streaming I/O. Unlike System.cmd/3, Exile streams stdin and stdout without buffering the entire output in memory — making it the right tool for wrapping long-running CLI tools like FFmpeg, ImageMagick, and other media processing utilities.

But Exile is only as reliable as the external processes it wraps. A missing FFmpeg binary, an OS process that exits without producing expected output, or a leaked process that accumulates over time can silently degrade your media pipeline. External monitoring surfaces what Exile's internal error handling can't: that the entire execution path works end-to-end.

This tutorial covers:

  • A health endpoint that validates external process execution via Exile
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring to detect when media processing pipelines stall
  • Slack alerts and a status page

Step 1: Add Exile to your project

# mix.exs
defp deps do
  [
    {:exile, "~> 0.10"},
    {:req, "~> 0.4"}   # for heartbeat pings
  ]
end

Install any external tools your pipeline depends on:

# Debian/Ubuntu
apt-get install -y ffmpeg imagemagick

# Verify
ffmpeg -version
# ffmpeg version 6.x ...

convert --version
# Version: ImageMagick 7.x ...

A basic Exile usage — piping data through FFmpeg:

{:ok, data} =
  Exile.stream!(~w[ffmpeg -i /path/to/input.mp4 -f mp4 pipe:1],
    input: File.stream!("/path/to/input.mp4", [], 65536)
  )
  |> Enum.into([])
  |> then(fn chunks -> {:ok, IO.iodata_to_binary(chunks)} end)

Step 2: Build a health endpoint that validates external process execution

Add a plug that runs a short Exile probe and reports the result:

# 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 = run_checks()
    status = if checks.status == "ok", 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
    exile = exile_check()
    ffmpeg = ffmpeg_check()

    all_ok = exile.status == "ok" and ffmpeg.status == "ok"

    %{
      status: if(all_ok, do: "ok", else: "degraded"),
      exile: exile,
      ffmpeg: ffmpeg
    }
  end

  defp exile_check do
    start = System.monotonic_time(:millisecond)

    result =
      try do
        # Run a trivial command to verify Exile itself works
        output =
          Exile.stream!(~w[echo health-ok])
          |> Enum.into([])
          |> IO.iodata_to_binary()
          |> String.trim()

        elapsed = System.monotonic_time(:millisecond) - start

        if output == "health-ok" do
          %{status: "ok", latency_ms: elapsed}
        else
          %{status: "error", reason: "unexpected output: #{output}"}
        end
      rescue
        e -> %{status: "error", reason: Exception.message(e)}
      end

    result
  end

  defp ffmpeg_check do
    start = System.monotonic_time(:millisecond)

    result =
      try do
        # Check FFmpeg is available and reports a valid version string
        output =
          Exile.stream!(~w[ffmpeg -version])
          |> Enum.into([])
          |> IO.iodata_to_binary()

        elapsed = System.monotonic_time(:millisecond) - start

        if String.contains?(output, "ffmpeg version") do
          %{status: "ok", latency_ms: elapsed}
        else
          %{status: "error", reason: "unexpected ffmpeg output"}
        end
      rescue
        e -> %{status: "error", reason: Exception.message(e)}
      end

    result
  end
end

Register the plug before your router:

# 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",
#   "exile": {"status": "ok", "latency_ms": 12},
#   "ffmpeg": {"status": "ok", "latency_ms": 85}
# }

Step 3: Track media processing metrics with telemetry

Wrap Exile calls to emit telemetry on each process execution:

# lib/my_app/media_processor.ex
defmodule MyApp.MediaProcessor do
  require Logger

  def transcode(input_path, output_path, format) do
    start = System.monotonic_time(:millisecond)

    result =
      try do
        Exile.stream!(
          ~w[ffmpeg -i #{input_path} -f #{format} #{output_path}],
          stderr_to_console: false
        )
        |> Stream.run()

        {:ok, output_path}
      rescue
        e -> {:error, Exception.message(e)}
      end

    elapsed = System.monotonic_time(:millisecond) - start
    status = if match?({:ok, _}, result), do: "ok", else: "error"

    :telemetry.execute(
      [:my_app, :exile, :transcode],
      %{duration_ms: elapsed},
      %{format: format, status: status}
    )

    if status == "error" do
      Logger.error("[Exile] transcode failed in #{elapsed}ms for #{format}: #{inspect(result)}")
    end

    result
  end
end

Attach a telemetry handler for slow transcodes:

# lib/my_app/telemetry.ex
defmodule MyApp.Telemetry do
  require Logger

  @slow_threshold_ms 30_000

  def setup do
    :telemetry.attach(
      "exile-transcode-logger",
      [:my_app, :exile, :transcode],
      &handle_event/4,
      nil
    )
  end

  def handle_event(_event, %{duration_ms: ms}, %{format: fmt, status: status}, _cfg) do
    if ms >= @slow_threshold_ms do
      Logger.warning("[Exile slow transcode] #{fmt} took #{ms}ms — status=#{status}")
    end
  end
end

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

The keyword check is critical: FFmpeg disappears silently after OS package upgrades. Without the keyword check, Vigilmon would report HTTP 200 while every transcoding job fails.


Step 5: Heartbeat monitoring for media processing pipelines

If you transcode or process media asynchronously, a heartbeat confirms the pipeline is running:

# lib/my_app/workers/media_pipeline_heartbeat.ex
defmodule MyApp.Workers.MediaPipelineHeartbeat do
  use GenServer
  require Logger

  @interval :timer.minutes(5)

  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_probe()
    schedule()
    {:noreply, state}
  end

  defp run_probe do
    try do
      # Verify Exile can execute a process and return output
      output =
        Exile.stream!(~w[echo heartbeat-ok])
        |> Enum.into([])
        |> IO.iodata_to_binary()
        |> String.trim()

      if output == "heartbeat-ok" do
        ping_vigilmon()
      else
        Logger.error("[MediaPipelineHeartbeat] unexpected probe output: #{output}")
      end
    rescue
      e -> Logger.error("[MediaPipelineHeartbeat] probe failed: #{Exception.message(e)}")
    end
  end

  defp ping_vigilmon do
    url = Application.get_env(:my_app, :vigilmon)[:media_heartbeat_url]

    if url do
      case Req.get(url, receive_timeout: 5_000) do
        {:ok, %{status: 200}} -> :ok
        error -> Logger.warning("Vigilmon heartbeat failed: #{inspect(error)}")
      end
    end
  end

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

Add to your supervision tree:

children = [
  MyApp.Repo,
  MyAppWeb.Endpoint,
  MyApp.Workers.MediaPipelineHeartbeat
]

In Vigilmon, create a Heartbeat Monitor:

  1. Click New Monitor → Heartbeat
  2. Set expected interval: 10 minutes
  3. Copy the ping URL
  4. Set it as VIGILMON_MEDIA_HEARTBEAT_URL in your environment

Step 6: Monitor for OS process leaks

Exile uses demand-driven I/O to avoid buffering issues, but a downstream consumer that stops reading can cause a process to accumulate. Add a system-level check:

# lib/my_app_web/plugs/health_check.ex  (add to run_checks/0)
defp os_process_check do
  case System.cmd("pgrep", ["-c", "ffmpeg"], stderr_to_stdout: true) do
    {count_str, 0} ->
      count = count_str |> String.trim() |> Integer.parse() |> elem(0)
      # Alert if more than 10 concurrent ffmpeg processes — likely a leak
      status = if count < 10, do: "ok", else: "warning"
      %{status: status, ffmpeg_processes: count}

    {_, _} ->
      # pgrep exits 1 when no processes found — that's fine
      %{status: "ok", ffmpeg_processes: 0}
  end
end

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 both your HTTP monitor and heartbeat monitor

When FFmpeg goes missing:

🔴 DOWN: yourdomain.com/health
Keyword "status":"ok" not found
Detected: EU-West
4 minutes ago

When the media pipeline stalls:

🔴 HEARTBEAT MISSED: Media Processing Pipeline
Expected ping within 10 minutes — none received
Last seen: 22 minutes ago

Status page:

  1. Go to Status Pages → New Status Page
  2. Add your health monitor and heartbeat monitor
  3. Share the URL with your team

What you've built

| What | How | |------|-----| | Health endpoint | Plug running live Exile + FFmpeg probes | | Process check | pgrep-based ffmpeg count in health response | | Keyword check | Vigilmon keyword match on "status":"ok" | | Uptime monitoring | Vigilmon HTTP monitor → /health | | Pipeline liveness | Heartbeat GenServer + Vigilmon heartbeat monitor | | Slow processing detection | Telemetry handler with threshold logging | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

Exile keeps your media pipelines streaming. Vigilmon tells you when they go silent.


Next steps

  • Add per-format heartbeat monitors if you have separate Oban queues for video, audio, and image processing
  • Track OS process counts over time to detect gradual ffmpeg leaks before they exhaust system resources
  • Use Vigilmon's response time history to correlate media processing latency with queue depth spikes
  • Add a Vigilmon keyword check on "ffmpeg":{"status":"ok"} separately from the top-level status to pinpoint FFmpeg-specific failures

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 →