tutorial

How to Monitor Memoize (Elixir) with Vigilmon

Learn how to add health checks, cache observability, and alerts to Elixir applications using the Memoize library with Vigilmon.

How to Monitor Memoize (Elixir) with Vigilmon

Memoize is an Elixir library that caches the results of function calls using ETS (Erlang Term Storage), providing transparent result caching with configurable expiration and invalidation. With a single macro annotation, expensive computations — database queries, external API calls, heavy data transformations — become cached and reusable across processes.

But caching introduces its own failure modes. An ETS table that grows unbounded can exhaust memory. Cache invalidation failures silently serve stale data. A cold cache after a restart can spike database load beyond capacity. These failures are invisible without monitoring.

This tutorial adds production observability to an Elixir application using Memoize:

  • An application health check with ETS cache metrics
  • HTTP uptime monitoring with Vigilmon
  • Cache hit/miss ratio tracking
  • Heartbeat monitoring for cache warm-up and invalidation jobs
  • Alerts on cache size and memory usage

Step 1: Add Memoize to your project

# mix.exs
defp deps do
  [
    {:memoize, "~> 1.4"},
    {:plug_cowboy, "~> 2.0"},   # or {:bandit, "~> 1.0"}
    {:plug, "~> 1.14"},
    {:jason, "~> 1.4"},
    {:req, "~> 0.4"}
  ]
end

Start the Memoize application in your supervisor:

# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      {Memoize, []},  # ← starts the ETS cache table
      MyApp.Repo,
      MyAppWeb.Endpoint
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end
end

Step 2: Use Memoize in your application code

# lib/my_app/services/pricing_service.ex
defmodule MyApp.PricingService do
  use Memoize

  @doc """
  Fetch and cache pricing rules. Cache expires after 5 minutes.
  """
  defmemo get_pricing_rules(region), expires_in: :timer.minutes(5) do
    # Expensive database query — only runs on cache miss
    MyApp.Repo.all(from r in MyApp.PricingRule, where: r.region == ^region)
  end

  @doc """
  Cache external API response for 1 minute.
  """
  defmemo fetch_exchange_rates(currency), expires_in: :timer.minutes(1) do
    case Req.get("https://api.example.com/rates/#{currency}") do
      {:ok, %{status: 200, body: body}} -> {:ok, body}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Cache without expiry — invalidate manually when data changes.
  """
  defmemo get_config(key) do
    MyApp.Repo.get_by(MyApp.Config, key: key)
  end
end

Invalidate the manually-cached config when it changes:

# After updating config in the database:
Memoize.invalidate(MyApp.PricingService, :get_config, [key])

Step 3: Add a health check with cache metrics

# 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 = %{
      database: check_database(),
      cache: check_cache(),
      memory: check_memory()
    }

    status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(%{
      status: (if status == 200, do: "ok", else: "degraded"),
      checks: checks,
      cache_stats: get_cache_stats()
    }))
    |> halt()
  end

  def call(conn, _opts), do: conn

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

  defp check_cache do
    # Verify Memoize's ETS table is accessible
    table = :memoize_cache  # Memoize uses this table name internally

    case :ets.info(table) do
      :undefined -> :error
      info ->
        size = Keyword.get(info, :size, 0)
        memory_words = Keyword.get(info, :memory, 0)
        memory_bytes = memory_words * :erlang.system_info(:wordsize)
        memory_mb = memory_bytes / (1024 * 1024)

        cond do
          memory_mb > 500 -> :error  # Cache using more than 500 MB
          true -> :ok
        end
    end
  rescue
    _ -> :error
  end

  defp check_memory do
    case :memsup.get_system_memory_data() do
      [] -> :ok
      data ->
        total = Keyword.get(data, :total_memory, 1)
        free = Keyword.get(data, :free_memory, total)
        used_pct = (total - free) / total * 100
        if used_pct < 90, do: :ok, else: :error
    end
  end

  defp get_cache_stats do
    table = :memoize_cache

    case :ets.info(table) do
      :undefined ->
        %{status: "unavailable"}
      info ->
        size = Keyword.get(info, :size, 0)
        memory_words = Keyword.get(info, :memory, 0)
        memory_bytes = memory_words * :erlang.system_info(:wordsize)

        %{
          entries: size,
          memory_bytes: memory_bytes,
          memory_mb: Float.round(memory_bytes / (1024 * 1024), 2)
        }
    end
  rescue
    _ -> %{status: "error"}
  end
end

Mount it before your router:

# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug MyAppWeb.Plugs.HealthCheck
  plug MyAppWeb.Router
end

Test it:

curl http://localhost:4000/health
# {"status":"ok","checks":{"database":"ok","cache":"ok","memory":"ok"},"cache_stats":{"entries":42,"memory_bytes":16384,"memory_mb":0.02}}

Step 4: Track cache hit/miss ratios

Memoize doesn't expose hit/miss ratios natively, but you can wrap your cached functions to track them:

# lib/my_app/cache_metrics.ex
defmodule MyApp.CacheMetrics do
  use Agent

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

  def record_hit, do: Agent.update(__MODULE__, fn s -> Map.update!(s, :hits, &(&1 + 1)) end)
  def record_miss, do: Agent.update(__MODULE__, fn s -> Map.update!(s, :misses, &(&1 + 1)) end)

  def stats do
    Agent.get(__MODULE__, fn s ->
      total = s.hits + s.misses
      hit_rate = if total > 0, do: Float.round(s.hits / total * 100, 1), else: 0.0
      Map.put(s, :hit_rate_pct, hit_rate)
    end)
  end
end

Use it in your service:

defmodule MyApp.PricingService do
  use Memoize

  defmemo get_pricing_rules(region), expires_in: :timer.minutes(5) do
    MyApp.CacheMetrics.record_miss()
    MyApp.Repo.all(from r in MyApp.PricingRule, where: r.region == ^region)
  end

  def get_pricing_rules_tracked(region) do
    # Check if already in cache by attempting a lookup before calling
    result = get_pricing_rules(region)
    MyApp.CacheMetrics.record_hit()
    result
  end
end

Add cache metrics to your health endpoint response:

# In HealthCheck plug
|> send_resp(status, Jason.encode!(%{
  status: (if status == 200, do: "ok", else: "degraded"),
  checks: checks,
  cache_stats: get_cache_stats(),
  cache_metrics: MyApp.CacheMetrics.stats()
}))

Step 5: Set up HTTP monitoring in Vigilmon

Point Vigilmon at your health endpoint:

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute (paid) or 5 minutes (free)
  5. Under Advanced, add a keyword check for "ok" to verify the health status
  6. Save

Why external monitoring matters for cached applications:

Cache failures are insidious — the application appears healthy to internal checks while serving stale or incorrect data. External monitoring detects:

  • Post-restart cold cache causing database overload (slow response times)
  • ETS table memory exhaustion crashing the node
  • Cache invalidation failures causing data inconsistency (symptom: elevated error rates in app logs correlate with specific data changes)

Vigilmon's response time tracking is especially useful here: a sudden latency spike on a cached endpoint often indicates a cold cache or cache miss storm after a restart.


Step 6: Heartbeat monitoring for cache warm-up jobs

After a restart, a cold cache can overwhelm your database. Run a warm-up job that populates the cache on startup and pings Vigilmon when complete:

# lib/my_app/workers/cache_warmup_worker.ex
defmodule MyApp.Workers.CacheWarmupWorker do
  use GenServer

  require Logger

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

  @impl true
  def init(state) do
    # Warm cache on startup (run asynchronously to not block supervisor)
    send(self(), :warmup)
    {:ok, state}
  end

  @impl true
  def handle_info(:warmup, state) do
    Logger.info("Starting cache warmup...")

    regions = MyApp.Repo.all(from r in MyApp.Region, select: r.code)

    Enum.each(regions, fn region ->
      # Pre-populate the Memoize cache for each region
      MyApp.PricingService.get_pricing_rules(region)
    end)

    Logger.info("Cache warmup complete for #{length(regions)} regions")
    ping_heartbeat(:warmup)

    {:noreply, state}
  end

  defp ping_heartbeat(:warmup) do
    url = Application.get_env(:my_app, :vigilmon)[:cache_warmup_heartbeat_url]
    if url do
      case Req.get(url, receive_timeout: 10_000) do
        {:ok, _} -> :ok
        {:error, reason} -> Logger.warning("Warmup heartbeat failed: #{inspect(reason)}")
      end
    end
  end
end

For periodic cache invalidation jobs:

# lib/my_app/workers/cache_invalidation_worker.ex
defmodule MyApp.Workers.CacheInvalidationWorker do
  use GenServer

  require Logger

  @interval :timer.hours(1)

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

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

  @impl true
  def handle_info(:invalidate, state) do
    case invalidate_stale_entries() do
      :ok ->
        ping_heartbeat()
        Logger.info("Cache invalidation cycle complete")
      {:error, reason} ->
        Logger.error("Cache invalidation failed: #{inspect(reason)}")
    end

    schedule_tick()
    {:noreply, state}
  end

  defp schedule_tick, do: Process.send_after(self(), :invalidate, @interval)

  defp invalidate_stale_entries do
    # Example: invalidate config entries when the DB has newer versions
    Memoize.invalidate(MyApp.PricingService, :get_config)
    :ok
  end

  defp ping_heartbeat do
    url = Application.get_env(:my_app, :vigilmon)[:cache_invalidation_heartbeat_url]
    if url, do: Req.get(url, receive_timeout: 5_000)
  end
end

Add both workers to your supervision tree:

children = [
  {Memoize, []},
  MyApp.Repo,
  MyApp.CacheMetrics,
  MyApp.Workers.CacheWarmupWorker,
  MyApp.Workers.CacheInvalidationWorker,
  MyAppWeb.Endpoint
]

In Vigilmon, create heartbeat monitors for each worker:

  1. New Monitor → Heartbeat
  2. For warm-up: set interval to 10 minutes (it runs once on startup — monitor that it completed within 10 minutes of deployment)
  3. For invalidation: set interval to 70 minutes (hourly job with 10-minute grace)
  4. Copy ping URLs → set as environment variables

Step 7: Alerts via Slack

In Vigilmon, go to Notifications → New Channel → Slack and paste your incoming webhook URL.

Enable the channel on all monitors. Cache-related incidents often look like:

  • HTTP health check degraded (ETS memory too high)
  • Response time spike (cold cache after restart)
  • Invalidation heartbeat missed (worker crashed silently)

Configure Vigilmon to send all three alert types to your on-call channel so cache incidents are caught quickly.


Step 8: Status page

  1. Status Pages → New Status Page in Vigilmon
  2. Add your HTTP health monitor and all heartbeat monitors
  3. Share the URL in your README and runbook

README badge:

![Uptime](https://vigilmon.online/badge/your-monitor-slug)

What you've built

| What | How | |------|-----| | Health check plug | HealthCheck plug with ETS table inspection | | Cache size monitoring | :ets.info/1 — entry count and memory usage | | Cache hit/miss tracking | CacheMetrics Agent with rate calculation | | HTTP uptime monitoring | Vigilmon HTTP monitor → /health | | Cold cache detection | Vigilmon response time history | | Slack downtime alerts | Vigilmon Slack notification channel | | Cache warm-up heartbeat | Heartbeat ping after startup warm-up completes | | Invalidation heartbeat | Heartbeat ping on each invalidation cycle | | Status page | Vigilmon public status page |

Memoize keeps your functions fast. Vigilmon keeps the cache — and everything that depends on it — healthy.


Next steps

  • Add ETS memory metrics to your observability stack (Prometheus, Datadog) using [:ets.info(:memoize_cache, :memory)] on a scheduled interval
  • Use Vigilmon's response time history to identify which endpoints slow down most after cache expiry
  • Set a Vigilmon alert threshold on your health endpoint response time to catch cold-cache stampedes before they cause user-visible slowdowns
  • Add per-function heartbeats for your most critical memoized functions to verify each one is being called and cached as expected

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 →