tutorial

How to Monitor Dialyxir with Vigilmon

Integrate Dialyxir type-checking into your Elixir CI pipeline and use Vigilmon heartbeat monitors to alert your team when type analysis stops running or finds new violations.

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@spec declarations 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 case and cond)
  • 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

  1. Sign in at vigilmon.online
  2. Click New Monitor → Heartbeat
  3. Name it Dialyxir Type Check — main branch
  4. 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
  5. Copy the URL and store it as VIGILMON_DIALYXIR_HEARTBEAT_URL in 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 dialyxir output 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 /health to correlate deploys with performance changes

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 →