tutorial

How to Monitor NimbleParsec with Vigilmon

Build robust parsing pipelines with NimbleParsec in Elixir and use Vigilmon to alert your team when parsers drift from spec, degrade in performance, or fail silently.

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 repeat combinators 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

  1. Sign in at vigilmon.online
  2. Click New Monitor → Heartbeat
  3. Name it NimbleParsec Parser Tests — main branch
  4. Set the expected interval to 25 hours — tests run on every push to main
  5. Copy the URL and store it as VIGILMON_PARSER_HEARTBEAT_URL in 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 StreamData to 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 /health to correlate parser changes with production latency

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 →