tutorial

How to Monitor Earmark with Vigilmon

Add uptime monitoring, Markdown rendering health checks, and alerts to your Earmark-powered Elixir application — so parsing failures, slow rendering, and content pipeline stalls don't go undetected.

How to Monitor Earmark with Vigilmon

Earmark is the pure-Elixir Markdown parser that powers ExDoc and is widely used in Phoenix applications for rendering user-submitted content, documentation, and blog posts. It converts Markdown to HTML without any native dependencies — fast, dependency-free, and easy to embed. But when Earmark encounters malformed input, when rendering is called in a tight loop, or when a content pipeline that feeds it breaks down, failures manifest as blank pages, missing content, or unexpectedly slow responses.

External monitoring catches content rendering failures and pipeline stalls before users notice missing or broken content. This tutorial covers:

  • A health endpoint that validates Earmark rendering
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring to detect when content processing pipelines stall
  • Slack alerts and a status page

Step 1: Add Earmark to your project

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

Basic usage:

# Convert Markdown to HTML
{:ok, html, []} = Earmark.as_html("# Hello\n\nThis is **bold**.")
# "<h1>\nHello</h1>\n<p>\nThis is <strong>bold</strong>.</p>\n"

# With options
{:ok, html, messages} =
  Earmark.as_html("# Hello", %Earmark.Options{
    smartypants: true,
    code_class_prefix: "lang-"
  })

Step 2: Build a health endpoint that validates Earmark rendering

Add a plug that exercises Earmark with a known test payload:

# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
  import Plug.Conn
  require Logger

  # Sentinel Markdown that exercises common Earmark features
  @test_markdown """
  # Health Check

  This is a **bold** _italic_ test.

  - Item one
  - Item two

  ```elixir
  IO.puts("hello")

Link """

Expected fragment in the rendered output

@expected_fragment ""

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 renderer = earmark_check()

%{
  status: status_label(renderer.status == "ok"),
  earmark: renderer
}

end

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

result =
  try do
    case Earmark.as_html(@test_markdown, %Earmark.Options{}) do
      {:ok, html, []} ->
        elapsed = System.monotonic_time(:millisecond) - start

        if String.contains?(html, @expected_fragment) do
          %{status: "ok", latency_ms: elapsed}
        else
          %{
            status: "error",
            reason: "rendered HTML missing expected fragment: #{@expected_fragment}"
          }
        end

      {:ok, html, messages} ->
        elapsed = System.monotonic_time(:millisecond) - start
        warning_count = length(messages)

        if String.contains?(html, @expected_fragment) do
          %{status: "ok", latency_ms: elapsed, warnings: warning_count}
        else
          %{status: "error", reason: "rendered HTML missing expected content", warnings: warning_count}
        end

      {:error, _html, messages} ->
        %{status: "error", reason: "Earmark parse errors", message_count: length(messages)}
    end
  rescue
    e ->
      Logger.error("Earmark health check raised: #{Exception.message(e)}")
      %{status: "error", reason: Exception.message(e)}
  end

result

end

defp all_ok?(checks), do: checks.status == "ok"

defp status_label(true), do: "ok" defp status_label(false), do: "degraded" end


Register the plug in your endpoint:

```elixir
# 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",
#   "earmark": {"status": "ok", "latency_ms": 2}
# }

The render latency in the response lets Vigilmon's response time history surface slowdowns before they affect user-facing pages.


Step 3: 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

Earmark failures are silent at the transport layer — the web server returns HTTP 200 while the rendered content is empty or broken. The keyword match on "status":"ok" ensures the actual rendering probe passed.


Step 4: Heartbeat monitoring for content processing pipelines

If your application runs background jobs that fetch, process, or re-render Markdown content — importing from external sources, regenerating static pages, pre-rendering content caches — a heartbeat confirms the pipeline ran end-to-end:

# lib/my_app/workers/content_pipeline_heartbeat.ex
defmodule MyApp.Workers.ContentPipelineHeartbeat do
  use GenServer
  require Logger

  @interval :timer.minutes(10)

  # Simulate a minimal content pipeline operation
  @sample_content """
  # Pipeline Check

  Processed at: {{timestamp}}

  - Fetch ✓
  - Parse ✓
  - Render ✓
  """

  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
    timestamp = DateTime.utc_now() |> DateTime.to_string()
    markdown = String.replace(@sample_content, "{{timestamp}}", timestamp)

    case Earmark.as_html(markdown) do
      {:ok, html, _} when byte_size(html) > 0 ->
        Logger.debug("Content pipeline heartbeat: render OK (#{byte_size(html)} bytes)")
        ping_vigilmon()

      {:error, _html, messages} ->
        Logger.error("Content pipeline heartbeat: Earmark errors #{inspect(messages)}")

      _ ->
        Logger.error("Content pipeline heartbeat: unexpected result")
    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.ContentPipelineHeartbeat
]

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 the content pipeline stalls — because a database query fails, a source feed goes down, or the worker process crashes — the heartbeat stops, and Vigilmon alerts you within one missed interval.


Step 5: Track rendering errors in production

Earmark returns structured warnings and errors for malformed Markdown. Capture these in telemetry so you can alert when error rates climb:

# lib/my_app/markdown.ex
defmodule MyApp.Markdown do
  require Logger

  @slow_render_threshold_ms 100

  def to_html(markdown, opts \\ %Earmark.Options{}) when is_binary(markdown) do
    start = System.monotonic_time(:millisecond)

    result =
      case Earmark.as_html(markdown, opts) do
        {:ok, html, []} ->
          {:ok, html}

        {:ok, html, messages} ->
          Logger.warning(
            "[Earmark] #{length(messages)} warnings rendering #{byte_size(markdown)} byte document"
          )
          {:ok, html}

        {:error, html, messages} ->
          Logger.error(
            "[Earmark] parse errors: #{inspect(Enum.take(messages, 3))}"
          )
          {:error, html}
      end

    elapsed = System.monotonic_time(:millisecond) - start

    if elapsed >= @slow_render_threshold_ms do
      Logger.warning("[Earmark] slow render: #{elapsed}ms for #{byte_size(markdown)} bytes")
    end

    result
  end
end

Replace direct Earmark.as_html/2 calls with MyApp.Markdown.to_html/2 throughout your app. Slow renders and parse errors now flow to your log aggregator, and you can set up log-based alerts or expose aggregate counts in your health endpoint.


Step 6: Expose render stats in the health response

Add an in-memory counter to track rendering errors in your running process:

# lib/my_app/markdown_stats.ex
defmodule MyApp.MarkdownStats do
  use Agent

  def start_link(_), do: Agent.start_link(fn -> %{ok: 0, warn: 0, error: 0} end, name: __MODULE__)

  def record_ok, do: Agent.update(__MODULE__, &Map.update!(&1, :ok, fn n -> n + 1 end))
  def record_warn, do: Agent.update(__MODULE__, &Map.update!(&1, :warn, fn n -> n + 1 end))
  def record_error, do: Agent.update(__MODULE__, &Map.update!(&1, :error, fn n -> n + 1 end))
  def stats, do: Agent.get(__MODULE__, & &1)
end

Expose it in the health check:

defp render_stats_check do
  %{ok: ok, warn: warn, error: err} = MyApp.MarkdownStats.stats()
  total = ok + warn + err
  error_rate = if total > 0, do: Float.round(err / total * 100, 1), else: 0.0
  status = if error_rate < 5.0, do: "ok", else: "warning"

  %{status: status, rendered: ok, warnings: warn, errors: err, error_rate_pct: error_rate}
end

Set a Vigilmon keyword alert to fire on "status":"warning" when error rates climb above your threshold.


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 Earmark health monitor and heartbeat monitor

When rendering breaks:

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

When the content pipeline stalls:

🔴 HEARTBEAT MISSED: Content Pipeline Heartbeat
Expected ping within 20 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 with Earmark render probe + latency | | Keyword check | Vigilmon keyword match on "status":"ok" | | Uptime monitoring | Vigilmon HTTP monitor → /health | | Pipeline liveness | Content pipeline GenServer + Vigilmon heartbeat | | Error rate alerting | Render error/warning stats in health response | | Slow render detection | Threshold logging in MyApp.Markdown wrapper | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

Earmark converts your Markdown to HTML reliably. Vigilmon tells you when the conversion pipeline breaks.


Next steps

  • Add a dedicated monitor for your documentation site if it's generated by ExDoc (which uses Earmark internally)
  • Use Vigilmon's response time history to detect render latency increases caused by abnormally large Markdown documents
  • Alert on accumulated parse warnings if your application accepts user-submitted Markdown — a sudden spike often signals a change in content source format

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 →