How to Monitor NimbleParsec with Vigilmon
NimbleParsec is a high-performance binary parser combinator library for Elixir. It lets you define parsers declaratively using composable combinators — ascii_string, integer, repeat, choice, tag — and compiles them at build time into highly optimized pattern-matching functions. The result is a parser that runs at native BEAM speed without the overhead of a runtime interpreter.
NimbleParsec powers parsing in production Elixir systems: custom DSLs, configuration file formats, protocol decoders, structured log parsers, and more. When a NimbleParsec-based parser silently fails — accepting malformed input, refusing valid input, or regressing on performance — it corrupts data pipelines quietly. The parser compiled correctly; it just doesn't match the spec anymore.
Vigilmon heartbeat monitors paired with structured parser health checks catch these regressions before they reach production.
Why Monitor NimbleParsec?
Parser failures are uniquely dangerous because they often degrade rather than crash:
- Silent accepts — a parser that becomes too permissive after a combinator change starts accepting malformed inputs, causing downstream processing to fail with confusing errors
- Silent rejects — a parser that becomes too strict starts rejecting valid inputs that previously worked, causing data pipeline stalls or dropped messages
- Performance regression — adding backtracking or unbounded
repeatcombinators causes parsing time to grow exponentially with input size - UTF-8 boundary errors — parsers processing multi-byte strings fail on non-ASCII input when byte-level combinators are used where character-level combinators are needed
- Spec drift — parser combinators updated to handle a new edge case break existing behaviors in ways test suites don't catch
Key Metrics to Track
| Metric | What it tells you | |--------|-------------------| | Parser test pass rate | Whether combinators match expected inputs | | Parse success rate (production) | Percentage of real inputs parsed successfully | | Parse duration P99 | Latency regression indicator | | Invalid input rejection rate | Whether the parser still rejects known-bad inputs | | Parse error category distribution | Whether error types shift after combinator changes |
Step 1: Add NimbleParsec to Your Project
# mix.exs
defp deps do
[
{:nimble_parsec, "~> 1.4"},
# rest of your deps
]
end
mix deps.get
Define a parser module:
# lib/my_app/parsers/config_parser.ex
defmodule MyApp.Parsers.ConfigParser do
import NimbleParsec
# Comments: # to end of line
comment =
string("#")
|> repeat(utf8_char([{:not, ?\n}]))
|> ignore()
# Key-value pair: key = value
key =
ascii_string([?a..?z, ?A..?Z, ?_, ?0..?9], min: 1)
|> tag(:key)
value =
ascii_string([{:not, ?\n}, {:not, ?#}], min: 1)
|> map({String, :trim, []})
|> tag(:value)
assignment =
key
|> ignore(optional(string(" ")))
|> ignore(string("="))
|> ignore(optional(string(" ")))
|> concat(value)
line =
choice([comment, assignment])
|> ignore(optional(string("\n")))
defparsec :parse_config, repeat(line) |> eos()
@doc """
Parse a configuration string. Returns {:ok, pairs} or {:error, reason}.
"""
def parse(input) when is_binary(input) do
case parse_config(input) do
{:ok, tokens, "", _, _, _} -> {:ok, to_pairs(tokens)}
{:ok, _, rest, _, _, _} -> {:error, "unexpected input at: #{inspect(String.slice(rest, 0, 50))}"}
{:error, reason, _, _, _, _} -> {:error, reason}
end
end
defp to_pairs(tokens) do
tokens
|> Enum.chunk_every(2)
|> Enum.map(fn [[key: [k]], [value: [v]]] -> {k, v} end)
|> Map.new()
end
end
Step 2: Write Property-Based Tests
Unit tests verify specific inputs. Property-based tests verify invariants across the entire input space:
# test/my_app/parsers/config_parser_test.exs
defmodule MyApp.Parsers.ConfigParserTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias MyApp.Parsers.ConfigParser
describe "parse/1 round-trip" do
property "any generated config round-trips through parse" do
check all(
pairs <- list_of(
tuple({
string(:alphanumeric, min_length: 1),
string(:alphanumeric, min_length: 1)
}),
min_length: 1
)
) do
input = Enum.map_join(pairs, "\n", fn {k, v} -> "#{k} = #{v}" end)
assert {:ok, result} = ConfigParser.parse(input)
Enum.each(pairs, fn {k, v} ->
assert result[k] == v
end)
end
end
property "comments do not produce key-value pairs" do
check all(comment_text <- string(:alphanumeric)) do
input = "# #{comment_text}\n"
assert {:ok, result} = ConfigParser.parse(input)
assert result == %{}
end
end
end
describe "rejection" do
test "rejects input with missing value" do
assert {:error, _} = ConfigParser.parse("key =\n")
end
test "rejects input with missing equals" do
assert {:error, _} = ConfigParser.parse("key value\n")
end
end
end
Run with:
mix test test/my_app/parsers/
Step 3: Add Parser Benchmarks
Track parse performance so regressions are caught before production:
# bench/config_parser_bench.exs
alias MyApp.Parsers.ConfigParser
small_config = Enum.map_join(1..10, "\n", fn i -> "key_#{i} = value_#{i}" end)
large_config = Enum.map_join(1..1000, "\n", fn i -> "key_#{i} = value_#{i}" end)
Benchee.run(
%{
"parse small (10 pairs)" => fn -> ConfigParser.parse(small_config) end,
"parse large (1000 pairs)" => fn -> ConfigParser.parse(large_config) end
},
time: 5,
warmup: 2,
formatters: [Benchee.Formatters.Console]
)
Set performance budgets:
# test/my_app/parsers/config_parser_perf_test.exs
defmodule MyApp.Parsers.ConfigParserPerfTest do
use ExUnit.Case, async: false
@tag :performance
test "parses 1000 config pairs in under 50ms" do
input = Enum.map_join(1..1000, "\n", fn i -> "key_#{i} = value_#{i}" end)
{time_us, {:ok, _result}} = :timer.tc(fn -> MyApp.Parsers.ConfigParser.parse(input) end)
assert time_us < 50_000, "Parse took #{time_us}μs — exceeds 50ms budget"
end
end
Step 4: Run Parser Tests in CI
# .github/workflows/parser.yml
name: Parser Tests
on:
push:
branches: [main]
pull_request:
jobs:
parser-tests:
name: NimbleParsec Parser Tests
runs-on: ubuntu-latest
env:
VIGILMON_PARSER_HEARTBEAT_URL: ${{ secrets.VIGILMON_PARSER_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: Run parser unit tests
run: mix test test/my_app/parsers/ --color
- name: Run property-based tests
run: mix test test/my_app/parsers/ --color --seed 0
env:
EXUNIT_SEED: "0"
- name: Run performance tests
run: mix test --only performance
- name: Ping Vigilmon heartbeat
if: success()
run: curl -fsS "$VIGILMON_PARSER_HEARTBEAT_URL"
Step 5: Instrument Parsers in Production
Track parse success rates and latency in production:
# lib/my_app/parsers/instrumented_parser.ex
defmodule MyApp.Parsers.InstrumentedParser do
require Logger
@doc """
Parses input and emits telemetry for success rate and duration tracking.
"""
def parse(parser_module, input, opts \\ []) do
start = System.monotonic_time(:microsecond)
result = parser_module.parse(input)
duration_us = System.monotonic_time(:microsecond) - start
parser_name = opts[:name] || inspect(parser_module)
case result do
{:ok, _} ->
:telemetry.execute(
[:my_app, :parser, :success],
%{duration_us: duration_us},
%{parser: parser_name}
)
{:error, reason} ->
Logger.warning("Parse failure",
parser: parser_name,
input_size: byte_size(input),
reason: reason,
input_preview: String.slice(input, 0, 100)
)
:telemetry.execute(
[:my_app, :parser, :failure],
%{duration_us: duration_us},
%{parser: parser_name, reason: reason}
)
end
result
end
end
Expose via a health endpoint:
defp check_parser_health do
# Test parse on a known-good fixture
fixture = "test_key = test_value\n"
case MyApp.Parsers.ConfigParser.parse(fixture) do
{:ok, %{"test_key" => "test_value"}} -> :ok
_ -> :error
end
end
Step 6: Create a Heartbeat Monitor in Vigilmon
- Sign in at vigilmon.online
- Click New Monitor → Heartbeat
- Name it
NimbleParsec Parser 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_PARSER_HEARTBEAT_URLin your CI secrets
If parser tests fail — including property-based or performance regressions — no heartbeat is sent and Vigilmon alerts you.
Step 7: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack:
🔴 MISSED: NimbleParsec Parser Tests — main branch
Last passing parser test: 30 hours ago (expected every 25 hours)
Action: Check CI — possible combinator change broke parser correctness or performance budget
PagerDuty for production parse failure rate spikes — use your telemetry pipeline to fire an alert when [:my_app, :parser, :failure] telemetry events exceed a threshold rate.
Common NimbleParsec Pitfalls
Unbounded repeat causing exponential backtracking:
# Dangerous — repeat with no minimum can backtrack deeply
bad_parser = repeat(choice([string("a"), string("ab")]))
# Safe — commit after each token, no ambiguous choices
good_parser = repeat(
choice([
string("ab") |> tag(:two), # longest match first
string("a") |> tag(:one)
])
)
Always put longer alternatives before shorter ones in choice/1.
Byte vs. character parsing:
# WRONG for multi-byte UTF-8 — utf8_char handles codepoints
bad = ascii_char([?a..?z])
# CORRECT for Unicode text
good = utf8_char([{:not, ?\n}])
Not consuming all input:
# If you don't add eos(), partial parses succeed silently
defparsec :parse_thing, my_combinators() |> eos()
# ^^^^ always add this
What You've Built
| What | How |
|------|-----|
| NimbleParsec CI integration | Parser tests on every push |
| Property-based correctness testing | Round-trip and rejection property tests |
| Performance budget enforcement | Time-bounded test for large input parsing |
| Production instrumentation | Telemetry events for success rate and latency |
| Parser health check | Known-good fixture test in /health endpoint |
| CI monitoring | Vigilmon heartbeat — alerts when tests miss |
| Failure alerting | Slack notification channel |
NimbleParsec builds fast, correct parsers at compile time. Vigilmon ensures the correctness checks that validate those parsers never silently stop running.
Next Steps
- Add fuzzing with
StreamDatato discover edge cases in combinator coverage - Track parse error category distribution over time to detect input format drift
- Set up a separate heartbeat for protocol parser tests when multiple parsers share a CI step
- Use Vigilmon response time history on
/healthto correlate parser changes with production latency
Get started free at vigilmon.online.