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 runmix gettext.extract && mix gettext.mergefor all locales; non-English users see English - Stale .pot files — the
.pottemplate file diverges from what is in the code;mix gettext.extracthas not been run after a refactor removed old strings and added new ones - Missing plural forms —
ngettext/3for a new locale without the correct plural rules defined in the.poheader; 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 runtimeKeyErroronly 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
- Sign in at vigilmon.online
- Click New Monitor → Heartbeat
- Name it
Gettext Translation Coverage — main branch - Set the expected interval to 25 hours
- Copy the URL and store it as
VIGILMON_GETTEXT_HEARTBEAT_URLin 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
.pofiles 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.