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
- 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
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:
- Click New Monitor → Heartbeat
- Set expected interval: 20 minutes
- Copy the ping URL
- Set it as
VIGILMON_HEARTBEAT_URLin 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:
- In Vigilmon, go to Notifications → New Channel → Slack
- Paste your Slack incoming webhook URL
- 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:
- Go to Status Pages → New Status Page
- Add your health monitor and heartbeat monitor
- 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.