tutorial

How to Monitor Gettext (Elixir) with Vigilmon

--- title: How to Monitor Gettext (Elixir) with Vigilmon published: true description: Monitor your Elixir/Phoenix Gettext i18n setup — detect missing transl...


title: How to Monitor Gettext (Elixir) with Vigilmon published: true description: Monitor your Elixir/Phoenix Gettext i18n setup — detect missing translations, stale .po files, and coverage regressions across locales before they reach production users. tags: elixir, phoenix, gettext, i18n, monitoring

Gettext is the standard internationalization library for Elixir and Phoenix applications. It provides GNU Gettext-compatible .po and .pot file support, compile-time extraction of translation strings, and macros (gettext/1, ngettext/3, dgettext/3) that developers use throughout templates and business logic. When a string is translated, users see the right language. When it is missing, they see the original English — or worse, an empty string, a crash, or a garbled interpolation.

Translation regressions are quiet. Your CI runs green, your app compiles, but a locale that had 95% coverage six weeks ago now has 60% because the last sprint added forty new strings and nobody updated the German .po files. Vigilmon heartbeats paired with Gettext coverage checks catch these regressions automatically.


Why Monitor Gettext?

Gettext failures are silent by design — a missing translation falls back to the source string rather than crashing. That is great for resilience and terrible for observability:

  • New strings added without translation — developers add gettext("New feature description") but do not run mix gettext.extract && mix gettext.merge for all locales; non-English users see English
  • Stale .pot files — the .pot template file diverges from what is in the code; mix gettext.extract has not been run after a refactor removed old strings and added new ones
  • Missing plural formsngettext/3 for a new locale without the correct plural rules defined in the .po header; some counts get the wrong string
  • Fuzzy translations — after a source string changes, Gettext marks the old translation as #, fuzzy; fuzzy entries are returned at runtime without warning
  • Interpolation mismatch — source string has %{name} but translation has %{username}; the runtime KeyError only surfaces when that specific locale string is rendered

Key Metrics to Track

| Metric | What it tells you | |--------|-------------------| | Translation coverage (%) per locale | Percentage of strings translated for each supported language | | Fuzzy entry count | Translations marked fuzzy after a source string changed | | Missing plural form count | Plural strings not fully translated in all required forms | | Stale .pot file | Whether extracted strings match what is in the code | | CI heartbeat | Whether coverage checks ran recently | | Runtime fallback rate | How often requests return English for non-English locales |


Step 1: Configure Gettext in Your Phoenix App

# mix.exs
defp deps do
  [
    {:gettext, "~> 0.26"},
    # rest of your deps
  ]
end

Your Phoenix app already has a Gettext module generated by mix phx.new:

# lib/my_app_web/gettext.ex
defmodule MyAppWeb.Gettext do
  use Gettext.Backend,
    otp_app: :my_app,
    default_locale: "en",
    priv: "priv/gettext"
end

The .po files live under priv/gettext/<locale>/LC_MESSAGES/:

priv/gettext/
  default.pot
  de/LC_MESSAGES/default.po
  fr/LC_MESSAGES/default.po
  es/LC_MESSAGES/default.po

Extract all strings and merge into .po files:

mix gettext.extract
mix gettext.merge priv/gettext

Step 2: Write a Coverage Check Mix Task

# lib/mix/tasks/check_translations.ex
defmodule Mix.Tasks.CheckTranslations do
  use Mix.Task

  @shortdoc "Check translation coverage and report missing/fuzzy entries"

  @locales ["de", "fr", "es", "pt"]
  @coverage_threshold 90

  def run(_args) do
    results =
      Enum.map(@locales, fn locale ->
        po_path = "priv/gettext/#{locale}/LC_MESSAGES/default.po"

        if File.exists?(po_path) do
          {total, translated, fuzzy} = parse_po_stats(po_path)
          coverage = if total > 0, do: round(translated / total * 100), else: 0
          {locale, total, translated, fuzzy, coverage}
        else
          {locale, 0, 0, 0, 0}
        end
      end)

    Enum.each(results, fn {locale, total, translated, fuzzy, coverage} ->
      status = if coverage >= @coverage_threshold, do: "OK", else: "FAIL"
      Mix.shell().info(
        "#{status} #{locale}: #{translated}/#{total} translated (#{coverage}%), #{fuzzy} fuzzy"
      )
    end)

    failing =
      Enum.filter(results, fn {_locale, _total, _translated, _fuzzy, coverage} ->
        coverage < @coverage_threshold
      end)

    if failing != [] do
      failing_locales = Enum.map_join(failing, ", ", fn {locale, _, _, _, cov} ->
        "#{locale} (#{cov}%)"
      end)
      Mix.shell().error("Translation coverage below #{@coverage_threshold}%: #{failing_locales}")
      exit({:shutdown, 1})
    else
      Mix.shell().info("All locales meet #{@coverage_threshold}% coverage threshold.")
    end
  end

  defp parse_po_stats(path) do
    content = File.read!(path)
    lines = String.split(content, "\n")

    empty_msgid = ~s(msgid "")

    total =
      Enum.count(lines, fn line ->
        String.starts_with?(line, "msgid ") and line != empty_msgid
      end)

    fuzzy = Enum.count(lines, fn line -> String.contains?(line, "#, fuzzy") end)
    empty_msgstr = Enum.count(lines, fn line -> line == ~s(msgstr "") end)
    translated = total - empty_msgstr - fuzzy
    {total, max(translated, 0), fuzzy}
  end
end

Run it locally:

mix check_translations
# OK de: 142/150 translated (95%), 2 fuzzy
# OK fr: 138/150 translated (92%), 0 fuzzy
# FAIL es: 90/150 translated (60%), 5 fuzzy
# Translation coverage below 90%: es (60%)

Step 3: Add a Health Endpoint Check

Include a Gettext spot-check in your health plug:

defp check_gettext do
  locales = ["de", "fr", "es"]

  all_ok =
    Enum.all?(locales, fn locale ->
      Gettext.with_locale(MyAppWeb.Gettext, locale, fn ->
        result = MyAppWeb.Gettext.gettext("Welcome")
        result != "" and result != nil
      end)
    end)

  if all_ok, do: :ok, else: :error
end

Step 4: Run Coverage Checks in CI with a Heartbeat

# .github/workflows/i18n.yml
name: Translation Coverage

on:
  push:
    branches: [main]
  pull_request:

jobs:
  translation-coverage:
    name: Gettext Coverage Check
    runs-on: ubuntu-latest
    env:
      VIGILMON_GETTEXT_HEARTBEAT_URL: ${{ secrets.VIGILMON_GETTEXT_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 .pot file is up to date
        run: |
          mix gettext.extract
          if ! git diff --quiet priv/gettext/default.pot; then
            echo "ERROR: default.pot is stale"
            exit 1
          fi

      - name: Check translation coverage
        run: mix check_translations

      - name: Run i18n tests
        run: mix test test/my_app_web/gettext_test.exs --color

      - name: Ping Vigilmon heartbeat
        if: success()
        run: curl -fsS "$VIGILMON_GETTEXT_HEARTBEAT_URL"

Step 5: Write Gettext Tests

# test/my_app_web/gettext_test.exs
defmodule MyAppWeb.GettextTest do
  use ExUnit.Case, async: true

  import MyAppWeb.Gettext

  @locales ["de", "fr", "es"]

  describe "critical UI strings" do
    for locale <- @locales do
      @locale locale

      test "welcome string is translated in #{locale}" do
        result =
          Gettext.with_locale(MyAppWeb.Gettext, @locale, fn ->
            gettext("Welcome")
          end)

        refute result == "Welcome", "#{@locale}: Welcome not translated"
        refute result == "", "#{@locale}: Welcome translated to empty string"
      end
    end
  end

  describe "plural forms" do
    test "English item count" do
      one = ngettext("1 item", "%{count} items", 1)
      many = ngettext("1 item", "%{count} items", 5)

      assert one == "1 item"
      assert many == "5 items"
    end

    test "German item count uses correct plural" do
      one =
        Gettext.with_locale(MyAppWeb.Gettext, "de", fn ->
          ngettext("1 item", "%{count} items", 1)
        end)

      refute one == "", "German plural not translated"
    end
  end

  describe "interpolation" do
    test "name interpolation works" do
      result = gettext("Hello, %{name}!", name: "World")
      assert result == "Hello, World!"
    end

    test "interpolation in all locales" do
      for locale <- @locales do
        result =
          Gettext.with_locale(MyAppWeb.Gettext, locale, fn ->
            gettext("Hello, %{name}!", name: "World")
          end)

        refute String.contains?(result, "%{name}"), "#{locale}: interpolation not applied"
      end
    end
  end
end

Step 6: Create a Heartbeat Monitor in Vigilmon

  1. Sign in at vigilmon.online
  2. Click New Monitor → Heartbeat
  3. Name it Gettext Translation Coverage — main branch
  4. Set the expected interval to 25 hours
  5. Copy the URL and store it as VIGILMON_GETTEXT_HEARTBEAT_URL in CI secrets

If coverage drops below 90% — because a sprint added new strings without updating .po files — the CI step exits non-zero and no heartbeat fires. Vigilmon alerts you within the missed-interval window.


Step 7: Track Runtime Fallbacks

Wrap Gettext calls to measure how often non-English locales fall back to English:

# lib/my_app_web/safe_gettext.ex
defmodule MyAppWeb.SafeGettext do
  require Logger
  import MyAppWeb.Gettext

  def translate(msgid, opts \\ []) do
    locale = Gettext.get_locale(MyAppWeb.Gettext)
    result = gettext(msgid, opts)

    if locale != "en" and result == msgid do
      :telemetry.execute(
        [:my_app, :gettext, :fallback],
        %{count: 1},
        %{locale: locale, msgid: msgid}
      )
    end

    result
  end
end

Step 8: Alerting

In Vigilmon, go to Notifications → New Channel → Slack:

Heartbeat missed:

🔴 MISSED: Gettext Translation Coverage — main branch
Last passing coverage check: 30 hours ago
Action: Check CI — coverage regression or stale .pot file

What You Built

| What | How | |------|-----| | .pot staleness check | CI step: extract + git diff | | Translation coverage gate | Custom Mix task, 90% threshold per locale | | Plural form tests | ExUnit ngettext/3 tests per locale | | Interpolation tests | ExUnit verifying %{key} substitution | | Gettext health check | Spot-check in /health plug | | CI heartbeat | Vigilmon heartbeat — alerts when coverage checks miss | | Runtime fallback tracking | Telemetry when non-English locale returns English | | Slack alerting | Vigilmon Slack notification channel |

Gettext fallback behavior keeps your app running when translations are missing. Vigilmon ensures missing translations are caught before they ship.


Next Steps

  • Integrate with a translation management platform (Lokalise, Crowdin) and sync .po files before the coverage check
  • Add per-domain coverage checks if your app uses multiple Gettext domains (dgettext/3)
  • Track [:my_app, :gettext, :fallback] in your APM tool to find the most frequently-untranslated strings
  • Set up a Vigilmon status page for your translation team showing coverage metrics per locale over time

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 →