How to Monitor Dialyxir with Vigilmon
Dialyxir is the Elixir wrapper around Dialyzer, the BEAM's static analysis tool for detecting type errors, dead code, and broken contracts. Where Credo checks style and Sobelow checks security, Dialyxir checks correctness: it catches functions receiving the wrong types, specs that don't match implementations, functions that can never succeed, and clauses that are unreachable given the actual input types.
Dialyxir takes time to run — the first PLT (Persistent Lookup Table) build can take several minutes — but it catches entire classes of runtime bugs that tests miss. The catch: those catches only happen when Dialyxir actually runs. A PLT that never rebuilds after a dependency update, a CI step skipped to save time, or a persistent type warning suppressed without investigation means you've traded correctness guarantees for CI speed.
Vigilmon heartbeat monitors ensure your type-checking gate is always active.
Why Monitor Dialyxir?
Dialyxir's success typing catches bugs that are invisible to unit tests:
- Invalid specs —
@specdeclarations that don't match the function's actual return type, creating false documentation that misleads callers - Pattern match failures — clauses that can never match given the actual input types (dead branches in
caseandcond) - Broken contracts — calling a function with arguments outside its spec, which will crash at runtime with a confusing error
- Dead code — functions that can never be called because their type signature is impossible to satisfy
- No return — functions that always raise or loop infinitely, which Dialyzer detects from the type graph
These bugs can live undetected in a codebase for months. Dialyxir surfaces them statically, before deployment.
Key Metrics to Track
| Metric | What it tells you | |--------|-------------------| | Dialyxir CI pass rate | Whether type checks are running and clean | | Warning count by category | Distribution of type issues (invalid_contract, no_return, etc.) | | PLT build freshness | Whether the type database is current with dependencies | | New warnings per PR | Type regression rate | | Time since last clean run | Whether type checking has been silently disabled |
Step 1: Add Dialyxir to Your Project
# mix.exs
defp deps do
[
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
# rest of your deps
]
end
mix deps.get
Build the PLT (Persistent Lookup Table) — this takes a few minutes the first time:
mix dialyzer --plt
Cache the PLT in CI to avoid rebuilding it on every run.
Step 2: Configure Dialyxir
Add configuration to mix.exs for CI:
# mix.exs
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.16",
dialyzer: [
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_add_apps: [:mix, :ex_unit],
flags: [
:error_handling,
:missing_return,
:underspecs,
:unknown
],
ignore_warnings: ".dialyzer_ignore.exs",
list_unused_filters: true
],
# rest of project config
]
end
Create an ignore file for known acceptable warnings:
# .dialyzer_ignore.exs
# Format: [{warning_type, file_pattern, line_range}]
# Add suppressions here with a comment explaining why each is acceptable
[
# Third-party library with incorrect specs — tracked in upstream issue #123
# {:no_return, ~r/deps\/some_lib/, :_},
]
Keep the ignore file minimal and comment every entry. An unexplained suppression is a debt that compounds.
Step 3: Cache the PLT in CI
The PLT is the expensive part of Dialyxir. Caching it makes CI practical:
# .github/workflows/typecheck.yml
name: Type Check
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 3 * * 1' # Weekly PLT refresh on Mondays
jobs:
dialyxir:
name: Dialyxir Type Analysis
runs-on: ubuntu-latest
env:
VIGILMON_DIALYXIR_HEARTBEAT_URL: ${{ secrets.VIGILMON_DIALYXIR_HEARTBEAT_URL }}
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: '1.16'
otp-version: '26'
- name: Cache deps
uses: actions/cache@v3
with:
path: deps
key: ${{ runner.os }}-deps-${{ hashFiles('mix.lock') }}
- name: Cache PLT
uses: actions/cache@v3
with:
path: priv/plts
key: ${{ runner.os }}-plt-${{ hashFiles('mix.lock') }}-${{ hashFiles('lib/**/*.ex') }}
restore-keys: |
${{ runner.os }}-plt-${{ hashFiles('mix.lock') }}-
${{ runner.os }}-plt-
- run: mix deps.get
- name: Build PLT
run: mix dialyzer --plt
- name: Run Dialyxir
run: mix dialyzer --format dialyxir
- name: Ping Vigilmon heartbeat
if: success()
run: curl -fsS "$VIGILMON_DIALYXIR_HEARTBEAT_URL"
The PLT cache key includes both mix.lock and source files. When dependencies change, the PLT rebuilds automatically on the next run.
Step 4: Create a Heartbeat Monitor in Vigilmon
- Sign in at vigilmon.online
- Click New Monitor → Heartbeat
- Name it
Dialyxir Type Check — main branch - Set the expected interval to 48 hours — type checks on every PR, weekly full PLT refresh; 48 hours catches a gap of more than one full day
- Copy the URL and store it as
VIGILMON_DIALYXIR_HEARTBEAT_URLin your CI secrets
If Dialyxir finds type violations or CI is broken, no heartbeat is sent and Vigilmon alerts you.
Step 5: Add Type Specs to Critical Modules
Dialyxir is most valuable when your public interfaces are fully specced. A function without a @spec gives Dialyzer less to work with. Enforce specs in CI with a custom check:
# lib/mix/tasks/check_specs.ex
defmodule Mix.Tasks.CheckSpecs do
use Mix.Task
@shortdoc "Verify all public functions in critical modules have @spec"
@critical_modules [
MyApp.Accounts,
MyApp.Orders,
MyApp.Payments
]
def run(_args) do
missing =
@critical_modules
|> Enum.flat_map(fn mod ->
fns = mod.__info__(:functions)
specs = mod.module_info(:attributes) |> Keyword.get_values(:spec) |> Enum.map(&elem(&1, 0))
fns
|> Enum.reject(fn {name, _arity} -> Enum.any?(specs, fn {n, _} -> n == name end) end)
|> Enum.map(fn {name, arity} -> "#{inspect(mod)}.#{name}/#{arity}" end)
end)
if missing == [] do
Mix.shell().info("All critical functions have @spec.")
else
Mix.shell().error("Missing @spec on: #{Enum.join(missing, ", ")}")
exit({:shutdown, 1})
end
end
end
Run this before Dialyxir in CI to ensure the type database has complete input.
Step 6: Expose Type Check Status in Your Health Endpoint
# 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_db(),
type_analysis: check_type_analysis_freshness()
}
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
}))
|> halt()
end
def call(conn, _opts), do: conn
defp check_db do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
_ -> :error
end
end
defp check_type_analysis_freshness do
# Set by CI deploy pipeline when Dialyxir passed before deploy
last_clean_at = Application.get_env(:my_app, :dialyxir_last_clean_at)
if last_clean_at && DateTime.diff(DateTime.utc_now(), last_clean_at, :hour) < 50 do
:ok
else
:error
end
end
end
Set :dialyxir_last_clean_at in your runtime config during deploy, sourcing from an environment variable stamped by CI:
# config/runtime.exs
if clean_at = System.get_env("DIALYXIR_LAST_CLEAN_AT") do
config :my_app, dialyxir_last_clean_at: DateTime.from_unix!(String.to_integer(clean_at))
end
Step 7: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack:
🔴 MISSED: Dialyxir Type Check — main branch
Last clean type check: 52 hours ago (expected every 48 hours)
Action: Check CI — possible type violation blocking deploy or broken PLT build
Email for longer gaps (e.g. after a missed weekly PLT refresh):
Configure a second alert that fires when the heartbeat misses by more than 72 hours — indicating the type check was explicitly disabled rather than just failing.
Common Dialyxir Warnings and What They Mean
invalid_contract — your @spec doesn't match the function's actual return type:
# Warning: spec says String.t() but function can return nil
@spec get_name(integer()) :: String.t()
def get_name(id) do
case Repo.get(User, id) do
nil -> nil # ← Dialyzer catches this
user -> user.name
end
end
# Fix: honest spec
@spec get_name(integer()) :: String.t() | nil
no_return — a function always raises:
# Dialyzer knows this function never returns normally
def process!(item) do
case validate(item) do
{:error, reason} -> raise "Invalid: #{reason}"
# Missing :ok clause — Dialyzer warns
end
end
pattern_match — a clause can never match:
@spec status(atom()) :: String.t()
def status(:active), do: "Active"
def status(:inactive), do: "Inactive"
def status(:pending), do: "Pending"
def status(:deleted), do: "Deleted" # Dialyzer: this clause is unreachable given the spec
Fixing these makes your code more correct, your specs more honest, and your Dialyxir run cleaner.
What You've Built
| What | How |
|------|-----|
| Dialyxir CI integration | mix dialyzer with cached PLT |
| Nightly/weekly PLT refresh | Scheduled GitHub Actions workflow |
| Type check monitoring | Vigilmon heartbeat — alerts when check misses |
| Spec coverage gate | Custom Mix task enforcing @spec on critical modules |
| Failure alerting | Slack notification channel |
| Production health signal | HTTP monitor with type analysis freshness check |
| Deploy-time type stamp | Runtime config from CI env var |
Dialyxir catches type errors before runtime. Vigilmon catches type checking gaps before they become production incidents.
Next Steps
- Add
--format dialyxiroutput to CI logs for human-readable warnings alongside machine-parseable JSON - Track warning counts per category over time to measure correctness improvements
- Set up separate heartbeat intervals for on-push checks (2 hours) and weekly PLT full rebuilds (8 days)
- Use Vigilmon's response time history on
/healthto correlate deploys with performance changes
Get started free at vigilmon.online.