tutorial

How to Monitor Thumbnex with Vigilmon

Add uptime monitoring, thumbnail generation health checks, and alerts to your Thumbnex-powered Elixir application — so LibreOffice failures, ImageMagick errors, and disk pressure don't silently break document previews.

How to Monitor Thumbnex with Vigilmon

Thumbnex is an Elixir library for generating image thumbnails from a wide variety of file types — including PDFs, Office documents (DOCX, XLSX, PPTX), and images — by delegating to LibreOffice and ImageMagick under the hood. It's the most practical way to generate document previews in a Phoenix application without reinventing file conversion.

But Thumbnex is only as reliable as its external dependencies: a missing LibreOffice binary, an ImageMagick version mismatch, or a locked temporary directory will silently break every document preview in your app. External monitoring catches what internal error handling can't — that the whole pipeline is reachable and working from the outside.

This tutorial covers:

  • A health endpoint that validates LibreOffice and ImageMagick availability
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring to detect when thumbnail generation pipelines stall
  • Slack alerts and a status page

Step 1: Add Thumbnex to your project

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

Thumbnex requires both LibreOffice (for Office/PDF conversion) and ImageMagick (for resizing and image output). Install both on your server:

# Debian/Ubuntu
apt-get install -y libreoffice imagemagick ghostscript

# Verify
libreoffice --version
# LibreOffice 7.x.x ...

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

A basic thumbnail:

{:ok, path} = Thumbnex.create_thumbnail("document.docx", "thumbnail.png", max_width: 800, max_height: 600)

Step 2: Build a health endpoint that validates the pipeline

Add a plug that runs a real thumbnail generation 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
    thumbnex = thumbnex_check()
    disk = disk_check()

    all_ok = thumbnex.status == "ok" and disk.status == "ok"

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

  defp thumbnex_check do
    start = System.monotonic_time(:millisecond)
    tmp_dir = System.tmp_dir!()
    tmp_in = Path.join(tmp_dir, "hc_in_#{System.unique_integer()}.png")
    tmp_out = Path.join(tmp_dir, "hc_out_#{System.unique_integer()}.png")

    result =
      try do
        # Create a minimal test image to thumbnail
        case System.cmd("convert", ["-size", "10x10", "xc:white", tmp_in], stderr_to_stdout: true) do
          {_, 0} ->
            case Thumbnex.create_thumbnail(tmp_in, tmp_out, max_width: 5, max_height: 5) do
              {:ok, _} ->
                elapsed = System.monotonic_time(:millisecond) - start
                %{status: "ok", latency_ms: elapsed}

              {:error, reason} ->
                %{status: "error", reason: inspect(reason)}
            end

          {output, code} ->
            %{status: "error", reason: "imagemagick exited #{code}: #{String.slice(output, 0, 200)}"}
        end
      rescue
        e -> %{status: "error", reason: Exception.message(e)}
      after
        File.rm(tmp_in)
        File.rm(tmp_out)
      end

    result
  end

  defp disk_check do
    tmp = System.tmp_dir!()

    case System.cmd("df", ["-k", tmp], stderr_to_stdout: true) do
      {output, 0} ->
        case String.split(output, "\n", trim: true) do
          [_header, data | _] ->
            parts = String.split(data, ~r/\s+/)
            available_kb = parts |> Enum.at(3, "0") |> Integer.parse() |> elem(0)
            status = if available_kb > 512_000, do: "ok", else: "warning"
            %{status: status, available_mb: div(available_kb, 1024)}

          _ ->
            %{status: "unknown"}
        end

      _ ->
        %{status: "unknown"}
    end
  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",
#   "thumbnex": {"status": "ok", "latency_ms": 320},
#   "disk": {"status": "ok", "available_mb": 8192}
# }

A 503 response with "status": "degraded" tells you exactly which dependency failed.


Step 3: Track thumbnail generation metrics with telemetry

Wrap Thumbnex calls to emit telemetry for slow or failed conversions:

# lib/my_app/document_thumbnailer.ex
defmodule MyApp.DocumentThumbnailer do
  require Logger

  def generate(source_path, output_path, opts \\ []) do
    start = System.monotonic_time(:millisecond)

    result = Thumbnex.create_thumbnail(source_path, output_path, opts)

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

    :telemetry.execute(
      [:my_app, :thumbnex, :generate],
      %{duration_ms: elapsed},
      %{extension: ext, status: status}
    )

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

    result
  end
end

Attach a telemetry handler to log slow conversions:

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

  @slow_threshold_ms 10_000

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

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

Call MyApp.Telemetry.setup() from application.ex.


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 essential: LibreOffice can disappear after a system update while the endpoint continues returning HTTP 200. Without the check, every document preview silently fails while Vigilmon reports green.


Step 5: Heartbeat monitoring for thumbnail generation queues

If you process thumbnails asynchronously — background jobs generating previews for uploaded files — a heartbeat confirms the pipeline is alive:

# lib/my_app/workers/thumbnailer_heartbeat.ex
defmodule MyApp.Workers.ThumbnailerHeartbeat 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_probe()
    schedule()
    {:noreply, state}
  end

  defp run_probe do
    tmp_dir = System.tmp_dir!()
    tmp_in = Path.join(tmp_dir, "hb_in_#{System.unique_integer()}.png")
    tmp_out = Path.join(tmp_dir, "hb_out_#{System.unique_integer()}.png")

    try do
      {_, 0} = System.cmd("convert", ["-size", "4x4", "xc:white", tmp_in])

      case MyApp.DocumentThumbnailer.generate(tmp_in, tmp_out, max_width: 2, max_height: 2) do
        {:ok, _} ->
          ping_vigilmon()

        {:error, reason} ->
          Logger.error("[ThumbnailerHeartbeat] probe failed: #{inspect(reason)}")
      end
    rescue
      e -> Logger.error("[ThumbnailerHeartbeat] error: #{Exception.message(e)}")
    after
      File.rm(tmp_in)
      File.rm(tmp_out)
    end
  end

  defp ping_vigilmon do
    url = Application.get_env(:my_app, :vigilmon)[:thumbnailer_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.ThumbnailerHeartbeat
]

In Vigilmon, create a Heartbeat Monitor:

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

Step 6: 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 LibreOffice breaks or disappears:

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

When the thumbnail pipeline stalls:

🔴 HEARTBEAT MISSED: Thumbnailer Pipeline
Expected ping within 15 minutes — none received
Last seen: 28 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 a live Thumbnex probe | | Disk check | df-based available-space check | | Keyword check | Vigilmon keyword match on "status":"ok" | | Uptime monitoring | Vigilmon HTTP monitor → /health | | Pipeline liveness | Heartbeat GenServer + Vigilmon heartbeat monitor | | Slow conversion detection | Telemetry handler with threshold logging | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

Thumbnex keeps your document previews sharp. Vigilmon tells you when the pipeline goes dark.


Next steps

  • Track per-format (PDF, DOCX, XLSX) conversion latency in the health response to detect format-specific regressions
  • Add a LibreOffice-specific probe that converts a tiny test PDF to verify Office conversion separately from image-to-image
  • Use Vigilmon's response time history to correlate thumbnail latency with upload volume spikes
  • Add separate heartbeat monitors per Oban queue if you process different document types in separate queues

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 →