How to Monitor NimbleOptions with Vigilmon
NimbleOptions is a schema-based validation library for Elixir keyword lists and option maps. It's the de facto standard across the Elixir ecosystem for validating configuration and function arguments — used inside Broadway, Finch, Req, Ecto, and dozens of other production libraries. You define a schema once, call NimbleOptions.validate!/2, and get validated options with correct types, defaults applied, and a clear error message when callers pass invalid arguments.
The validation happens at runtime, not compile time. When a NimbleOptions schema changes — adding a required option, narrowing a type, removing an option that callers still pass — those callers break at runtime with a NimbleOptions.ValidationError. In a library with many callers, a schema change without a test coverage sweep causes production failures in modules the change author never considered.
Vigilmon heartbeat monitors paired with schema validation CI checks catch these regressions before they reach production.
Why Monitor NimbleOptions?
NimbleOptions failures surface at the call site, not at the schema definition:
- Breaking schema changes — removing an option or making an optional key required breaks every caller that relied on the old schema
- Type coercion silently failing — a
:pos_integertype check that was:non_neg_integernow rejects zero where it was previously allowed - Default value drift — changing a default changes behavior for every caller that relies on that default, without a compile-time warning
- Missing required options in tests — test fixtures that pass
[]to a function with required options produce confusing validation errors rather than clear test failures - Schema deprecations not enforced — a
deprecated: "use X instead"option that still accepts values means callers never migrate off the deprecated key
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| Validation test pass rate | Whether all callers pass schemas with current option sets |
| ValidationError rate in production | How often callers pass invalid options at runtime |
| Schema coverage | Percentage of public options-accepting functions with schemas |
| Deprecated option usage rate | Whether callers are migrating off deprecated keys |
| CI pass rate for option contract tests | Whether schema/caller contract tests run regularly |
Step 1: Add NimbleOptions to Your Project
# mix.exs
defp deps do
[
{:nimble_options, "~> 1.1"},
# rest of your deps
]
end
mix deps.get
Define and validate a schema:
# lib/my_app/http_client.ex
defmodule MyApp.HttpClient do
@options_schema NimbleOptions.new!([
base_url: [
type: :string,
required: true,
doc: "Base URL for all requests"
],
timeout: [
type: :pos_integer,
default: 5_000,
doc: "Request timeout in milliseconds"
],
retries: [
type: :non_neg_integer,
default: 3,
doc: "Number of retry attempts on failure"
],
pool_size: [
type: :pos_integer,
default: 10,
doc: "Connection pool size"
],
headers: [
type: {:list, :any},
default: [],
doc: "Default headers to include with every request"
],
telemetry_prefix: [
type: {:list, :atom},
default: [:my_app, :http],
doc: "Telemetry event prefix"
]
])
@moduledoc """
HTTP client with connection pooling and retry logic.
## Options
#{NimbleOptions.docs(@options_schema)}
"""
def start_link(opts) do
validated = NimbleOptions.validate!(opts, @options_schema)
# Start the client with validated options
GenServer.start_link(__MODULE__, validated, name: __MODULE__)
end
def init(opts) do
{:ok, %{
base_url: opts[:base_url],
timeout: opts[:timeout],
retries: opts[:retries],
pool_size: opts[:pool_size],
headers: opts[:headers],
telemetry_prefix: opts[:telemetry_prefix]
}}
end
end
Step 2: Write Schema Contract Tests
For each NimbleOptions schema, write tests that cover the contract explicitly:
# test/my_app/http_client_test.exs
defmodule MyApp.HttpClientTest do
use ExUnit.Case, async: true
@valid_opts [base_url: "https://api.example.com"]
describe "option schema validation" do
test "accepts valid minimal options" do
assert {:ok, validated} = NimbleOptions.validate(@valid_opts, MyApp.HttpClient.__schema__())
assert validated[:base_url] == "https://api.example.com"
assert validated[:timeout] == 5_000 # default
assert validated[:retries] == 3 # default
end
test "rejects missing required base_url" do
assert {:error, %NimbleOptions.ValidationError{} = err} =
NimbleOptions.validate([], MyApp.HttpClient.__schema__())
assert Exception.message(err) =~ "base_url"
assert Exception.message(err) =~ "required"
end
test "rejects non-positive timeout" do
assert {:error, err} =
NimbleOptions.validate([base_url: "https://example.com", timeout: 0], MyApp.HttpClient.__schema__())
assert Exception.message(err) =~ "timeout"
end
test "rejects negative retries" do
assert {:error, err} =
NimbleOptions.validate([base_url: "https://example.com", retries: -1], MyApp.HttpClient.__schema__())
assert Exception.message(err) =~ "retries"
end
test "applies defaults" do
assert {:ok, validated} = NimbleOptions.validate(@valid_opts, MyApp.HttpClient.__schema__())
assert validated[:pool_size] == 10
assert validated[:headers] == []
assert validated[:telemetry_prefix] == [:my_app, :http]
end
test "rejects unknown options" do
assert {:error, err} =
NimbleOptions.validate([base_url: "https://example.com", unknown_key: true], MyApp.HttpClient.__schema__())
assert Exception.message(err) =~ "unknown_key"
end
end
end
Expose the schema for testing:
# In MyApp.HttpClient
def __schema__, do: @options_schema
Step 3: Add Schema Coverage Check
Track which option-accepting functions have NimbleOptions schemas:
# lib/mix/tasks/check_option_schemas.ex
defmodule Mix.Tasks.CheckOptionSchemas do
use Mix.Task
@shortdoc "Verify key public functions have NimbleOptions schemas"
# Functions that accept options keyword lists and should have schemas
@option_functions [
{MyApp.HttpClient, :start_link, 1},
{MyApp.Worker, :start_link, 1},
{MyApp.Mailer, :deliver, 2}
]
def run(_args) do
missing =
@option_functions
|> Enum.reject(fn {mod, fun, _arity} ->
# Check if the module exports __schema__/0
Code.ensure_loaded!(mod)
function_exported?(mod, :__schema__, 0)
end)
|> Enum.map(fn {mod, fun, arity} -> "#{inspect(mod)}.#{fun}/#{arity}" end)
if missing == [] do
Mix.shell().info("All option-accepting functions have NimbleOptions schemas.")
else
Mix.shell().error("Missing NimbleOptions schema for:\n" <> Enum.join(missing, "\n"))
exit({:shutdown, 1})
end
end
end
Step 4: Run Schema Tests in CI
# .github/workflows/options.yml
name: Options Contract Tests
on:
push:
branches: [main]
pull_request:
jobs:
option-contracts:
name: NimbleOptions Schema Tests
runs-on: ubuntu-latest
env:
VIGILMON_OPTIONS_HEARTBEAT_URL: ${{ secrets.VIGILMON_OPTIONS_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') }}
- run: mix deps.get
- name: Check option schema coverage
run: mix check_option_schemas
- name: Run schema contract tests
run: mix test --color --seed 0
- name: Ping Vigilmon heartbeat
if: success()
run: curl -fsS "$VIGILMON_OPTIONS_HEARTBEAT_URL"
Step 5: Instrument Validation Errors in Production
Track ValidationError occurrences so you know when callers are passing invalid options:
# lib/my_app/validated_options.ex
defmodule MyApp.ValidatedOptions do
require Logger
@doc """
Validate options against a schema, logging and emitting telemetry on failure.
Raises on invalid options — same as NimbleOptions.validate!/2 but with observability.
"""
def validate!(opts, schema, context \\ []) do
case NimbleOptions.validate(opts, schema) do
{:ok, validated} ->
validated
{:error, %NimbleOptions.ValidationError{} = err} ->
message = Exception.message(err)
caller = Keyword.get(context, :caller, "unknown")
Logger.error("NimbleOptions validation failed",
caller: caller,
error: message,
opts_keys: Keyword.keys(opts)
)
:telemetry.execute(
[:my_app, :options, :validation_error],
%{count: 1},
%{caller: caller}
)
raise err
end
end
end
Expose via health check:
defp check_options_validation do
# Validate a known-good fixture against the schema
fixture = [base_url: "https://health.internal"]
case NimbleOptions.validate(fixture, MyApp.HttpClient.__schema__()) do
{:ok, _} -> :ok
{:error, err} ->
require Logger
Logger.error("Schema health check failed: #{Exception.message(err)}")
:error
end
end
Step 6: Create a Heartbeat Monitor in Vigilmon
- Sign in at vigilmon.online
- Click New Monitor → Heartbeat
- Name it
NimbleOptions Contract Tests — main branch - Set the expected interval to 25 hours — tests run on every push to main
- Copy the URL and store it as
VIGILMON_OPTIONS_HEARTBEAT_URLin your CI secrets
If schema contract tests fail — due to a breaking schema change or missing test coverage — no heartbeat is sent and Vigilmon alerts you.
Step 7: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack:
🔴 MISSED: NimbleOptions Contract Tests — main branch
Last passing options tests: 30 hours ago (expected every 25 hours)
Action: Check CI — possible schema change broke caller contract tests
Email for production ValidationError spikes — configure your observability stack to alert when [:my_app, :options, :validation_error] telemetry exceeds a threshold, and route those alerts to the team email via Vigilmon or your alerting provider.
Common NimbleOptions Pitfalls
Schema changes that break callers:
# Before: timeout was optional with default
timeout: [type: :pos_integer, default: 5_000]
# After: required — breaks any caller that relied on the default
timeout: [type: :pos_integer, required: true]
Prefer deprecating old behavior before removing it:
timeout: [
type: :pos_integer,
default: 5_000,
deprecated: "Set timeout explicitly; the default will be removed in v2.0"
]
Type mismatch between schema and struct:
# Schema says :string but struct field is :atom
@options_schema NimbleOptions.new!([
level: [type: :atom, default: :info]
])
# Don't forget to convert in the consumer
def init(opts) do
%{level: opts[:level]} # already an atom — no conversion needed
end
Not validating in the right place:
Validate at the boundary — in start_link/1, new/1, or the first public function that accepts options — not deep in a private function. Early validation produces clear error messages at the call site.
# Good: validate at the public boundary
def start_link(opts) do
validated = NimbleOptions.validate!(opts, @schema)
GenServer.start_link(__MODULE__, validated)
end
# Bad: validate deep in a private function — error message is confusing
defp setup(opts) do
NimbleOptions.validate!(opts, @schema) # ← hard to trace back to the caller
end
What You've Built
| What | How |
|------|-----|
| NimbleOptions CI integration | Schema contract tests on every push |
| Schema coverage gate | Custom Mix task checking exported schemas |
| Validation error observability | Telemetry + Logger on production failures |
| Schema health check | Known-good fixture validation in /health endpoint |
| CI monitoring | Vigilmon heartbeat — alerts when tests miss |
| Failure alerting | Slack notification channel |
NimbleOptions makes function argument validation declarative and self-documenting. Vigilmon ensures the tests that protect those schemas never silently stop running.
Next Steps
- Generate documentation directly from schemas using
NimbleOptions.docs/1in your@moduledoc - Add property-based tests with
StreamDatato generate valid and invalid option combinations automatically - Track deprecated option usage in telemetry to measure migration progress
- Set up a separate heartbeat for integration tests that exercise the full options validation stack end-to-end
Get started free at vigilmon.online.