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:
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute (paid) or 5 minutes (free)
- Under Advanced, add a keyword check for
"ok"to verify the health status - 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:
- New Monitor → Heartbeat
- For warm-up: set interval to 10 minutes (it runs once on startup — monitor that it completed within 10 minutes of deployment)
- For invalidation: set interval to 70 minutes (hourly job with 10-minute grace)
- 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
- Status Pages → New Status Page in Vigilmon
- Add your HTTP health monitor and all heartbeat monitors
- Share the URL in your
READMEand runbook
README badge:

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.