tutorial

How to Monitor Matcha with Vigilmon

Monitor Elixir apps that use Matcha ETS/Mnesia match specifications — track ETS table health, query latency, cache hit rates, and background table maintenance workers with Vigilmon uptime and heartbeat monitoring.

How to Monitor Matcha with Vigilmon

Matcha is a compile-time match specification DSL for Elixir. It compiles readable filter patterns into valid Erlang MatchSpec structures at build time, giving you type-safe, composable queries for ETS tables, Mnesia databases, and tracing calls — without constructing match specs by hand or discovering malformed specs at runtime.

When Matcha powers your ETS-backed cache, session store, or rate-limiter, those tables become load-bearing infrastructure. A table that fills, corrupts, or loses its maintenance worker silently degrades your app — Matcha's compile-time safety does not protect you from runtime capacity or availability issues. You need external monitoring to catch those.

This tutorial covers:

  • A health endpoint that reports ETS table sizes and memory usage
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for background ETS maintenance workers (TTL expiry, compaction)
  • Alerts calibrated to table-size and memory thresholds

Why Monitor Matcha?

| Failure mode | Symptom without monitoring | |---|---| | ETS table grows unbounded | VM memory climbs until OOM kill | | TTL expiry worker crashes | Stale entries accumulate; cache never evicts | | Table not found (application crash at startup) | All Matcha queries fail; app returns 500 | | Match specification generates full table scan | Latency spike on every lookup | | Mnesia cluster split-brain | Queries return stale or inconsistent data |


Step 1: Health endpoint with ETS table stats

Add Matcha to your dependencies:

# mix.exs
{:matcha, "~> 0.1"}

Create a module that tracks your critical ETS tables and exposes health data:

# lib/my_app/ets_monitor.ex
defmodule MyApp.EtsMonitor do
  @monitored_tables [:sessions, :rate_limits, :feature_flags]
  @max_table_size   500_000
  @max_table_mb     256

  def health do
    checks =
      Enum.map(@monitored_tables, fn table ->
        {table, table_health(table)}
      end)
      |> Map.new()

    stats =
      Enum.map(@monitored_tables, fn table ->
        {table, table_stats(table)}
      end)
      |> Map.new()

    all_ok = Enum.all?(checks, fn {_, v} -> v == :ok end)
    {all_ok, checks, stats}
  end

  defp table_health(table) do
    case table_stats(table) do
      %{exists: false} -> :error
      %{size: size} when size > @max_table_size -> :error
      %{memory_mb: mb} when mb > @max_table_mb -> :error
      _ -> :ok
    end
  end

  defp table_stats(table) do
    case :ets.info(table) do
      :undefined ->
        %{exists: false}
      info ->
        words = Keyword.get(info, :memory, 0)
        %{
          exists:    true,
          size:      Keyword.get(info, :size, 0),
          memory_mb: Float.round(words * :erlang.system_info(:wordsize) / 1_048_576, 2)
        }
    end
  end
end

Wire it into your health plug:

# 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
    {ets_ok, ets_checks, ets_stats} = MyApp.EtsMonitor.health()
    db_ok = check_database()

    all_ok = db_ok and ets_ok
    status = if all_ok, do: 200, else: 503

    body = Jason.encode!(%{
      status: if(all_ok, do: "ok", else: "degraded"),
      checks: Map.merge(%{database: if(db_ok, do: "ok", else: "error")}, ets_checks),
      ets:    ets_stats
    })

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, body)
    |> halt()
  end

  def call(conn, _opts), do: conn

  defp check_database do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> true
      _ -> false
    end
  end
end

Test it:

curl http://localhost:4000/health | jq .
# {
#   "status": "ok",
#   "checks": {"database": "ok", "sessions": "ok", "rate_limits": "ok"},
#   "ets": {
#     "sessions":    {"exists": true, "size": 1234, "memory_mb": 0.8},
#     "rate_limits": {"exists": true, "size": 892,  "memory_mb": 0.3}
#   }
# }

Step 2: A Matcha-powered example — session lookup

To illustrate what you are monitoring, here is a typical Matcha query against the :sessions table:

# lib/my_app/session_cache.ex
defmodule MyApp.SessionCache do
  require Matcha

  def lookup_active(user_id) do
    :ets.select(:sessions, Matcha.spec do
      {^user_id, session_id, expires_at, data}
      when expires_at > ^System.system_time(:second) ->
        {session_id, data}
    end)
  end

  def put(user_id, session_id, data, ttl_seconds) do
    expires_at = System.system_time(:second) + ttl_seconds
    :ets.insert(:sessions, {user_id, session_id, expires_at, data})
  end
end

The compile-time match spec means a malformed spec raises at compile time, not in production. But it does not protect against the table being full, missing, or corrupted — which is exactly what the health endpoint above covers.


Step 3: Set up HTTP monitoring in Vigilmon

  1. Sign up at vigilmon.online.
  2. Click New Monitor → HTTP.
  3. Enter https://your-app.example.com/health.
  4. Set check interval to 60 seconds.
  5. Add a keyword check for "ok" to catch degraded responses with HTTP 200.
  6. Set alert threshold to 2 consecutive failures.

Step 4: Heartbeat monitoring for ETS maintenance workers

ETS tables backing session caches and rate-limiters need periodic TTL sweeps. Matcha makes those sweeps readable; Vigilmon makes sure they keep running.

# lib/my_app/session_sweeper.ex
defmodule MyApp.SessionSweeper do
  use GenServer
  require Logger
  require Matcha

  @interval :timer.minutes(5)

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  @impl true
  def init(state) do
    schedule_sweep()
    {:ok, state}
  end

  @impl true
  def handle_info(:sweep, state) do
    case run_sweep() do
      {:ok, deleted} ->
        Logger.info("SessionSweeper removed #{deleted} expired entries")
        ping_heartbeat()
      {:error, reason} ->
        Logger.error("SessionSweeper failed: #{inspect(reason)}")
    end

    schedule_sweep()
    {:noreply, state}
  end

  defp run_sweep do
    now = System.system_time(:second)

    expired =
      :ets.select(:sessions, Matcha.spec do
        {user_id, session_id, expires_at, _data}
        when expires_at < ^now ->
          {user_id, session_id}
      end)

    Enum.each(expired, fn {user_id, session_id} ->
      :ets.match_delete(:sessions, {user_id, session_id, :_, :_})
    end)

    {:ok, length(expired)}
  rescue
    e -> {:error, e}
  end

  defp ping_heartbeat do
    url = Application.get_env(:my_app, :vigilmon)[:sweeper_heartbeat_url]
    if url do
      Task.start(fn ->
        :httpc.request(:get, {String.to_charlist(url), []}, [], [])
      end)
    end
  end

  defp schedule_sweep, do: Process.send_after(self(), :sweep, @interval)
end

Add to your supervision tree:

children = [
  MyApp.Repo,
  {MyApp.SessionCache, []},   # creates the :sessions ETS table
  MyApp.SessionSweeper,
  MyAppWeb.Endpoint,
]

Configure the heartbeat URL:

# config/runtime.exs
config :my_app, :vigilmon,
  sweeper_heartbeat_url: System.get_env("VIGILMON_SWEEPER_HEARTBEAT_URL")

In Vigilmon, create a Heartbeat Monitor:

  1. Click New Monitor → Heartbeat.
  2. Grace period: 10 minutes (5-min sweep interval + buffer).
  3. Copy the ping URL into VIGILMON_SWEEPER_HEARTBEAT_URL.

Step 5: Key metrics to alert on

| Metric | Alert threshold | Why | |---|---|---| | /health HTTP status | Non-200 for 2 checks | App or database unreachable | | ETS table size in response | > 500 000 entries | Table growing unbounded | | ETS table memory_mb in response | > 256 MB per table | Memory pressure from large cache | | ETS table exists | false in response | Table not created; all Matcha queries fail | | Sweeper heartbeat silence | Grace period exceeded | TTL eviction stopped; cache never clears |

Use Vigilmon's keyword check with a body match on "error" to catch individual table failures embedded in the JSON response.


Step 6: Status page

  1. Go to Status Pages → New in Vigilmon.
  2. Add monitors: "Application health" (HTTP) and "Session sweeper" (heartbeat).
  3. Share the URL in your team's incident-response runbook.

When a Matcha-backed ETS table starts filling up, the status page gives on-call engineers instant signal: the health check degrades before the OOM kill, and the heartbeat monitor shows whether the sweeper is still running.


Conclusion

Matcha's compile-time match specifications eliminate an entire class of runtime MatchSpec errors — but they cannot prevent your ETS tables from running out of memory or your maintenance workers from crashing. With Vigilmon you add the runtime safety layer:

  • HTTP uptime checks that detect application and database failures
  • ETS health data surfaced in your health endpoint via EtsMonitor
  • Heartbeat monitors that confirm TTL sweepers keep running
  • Keyword alerts that fire when any individual table exceeds capacity thresholds

Start with the health endpoint and one sweeper heartbeat, then add monitors for each critical ETS table as your Matcha usage grows.

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 →