title: How to Monitor Timex with Vigilmon published: true description: Monitor your Elixir applications using Timex for date and time handling — detect timezone data staleness, scheduled job drift, and time arithmetic regressions with Vigilmon heartbeats. tags: elixir, timex, datetime, monitoring
Timex is the comprehensive date and time library for Elixir, providing timezone handling, parsing, formatting, arithmetic, and comparison operations that go well beyond Elixir built-in DateTime and NaiveDateTime. It ships with the IANA timezone database, supports Timex Duration type, and gives you a fluent interface for date math that would otherwise require manual UTC offset calculations.
Timex is used everywhere time matters: billing cycles, scheduled jobs, calendar features, expiry checks, and audit logs. When timezone handling drifts — because IANA timezone data is stale, because a DST transition edge case was never tested, or because a scheduled job silently skips an interval — the impact is silent and often financially significant. Vigilmon heartbeats catch these failures automatically.
Why Monitor Timex?
Date and time bugs are among the hardest to notice in production:
- Stale IANA timezone data —
tzdata(Timex timezone database dependency) updates independently of your app; a DST rule change for a timezone your users live in produces wrong offsets until you update the package - Scheduled job clock drift — a cron-style job that should run at midnight in a specific timezone runs at the wrong time because of a UTC/local offset confusion in
Timex.now/1vsDateTime.utc_now/0 - DST transition edge cases — jobs scheduled for "1:30 AM" on DST spring-forward night run twice or not at all, depending on how the scheduler handles ambiguous times
- Date arithmetic errors —
Timex.shift(date, months: 1)on January 31 produces February 28, not March 3; business logic that does not account for month-end clipping produces incorrect billing dates - Parsing format drift — a third-party API changes its timestamp format from ISO 8601 to RFC 2822;
Timex.parse!/2raises, breaking the ingestion pipeline
Key Metrics to Track
| Metric | What it tells you | |--------|-------------------| | tzdata version | Whether timezone database is current | | Scheduled job execution time drift | Whether jobs run when expected vs actual wall-clock time | | Timex parse error rate | How often incoming timestamp strings fail to parse | | DST transition test coverage | Whether edge cases around DST springs and falls are tested | | CI heartbeat | Whether date/time regression tests ran recently | | Health endpoint response time | Whether Timex operations complete in normal latency |
Step 1: Add Timex to Your Project
# mix.exs
defp deps do
[
{:timex, "~> 3.7"},
{:tzdata, "~> 1.1"},
# rest of your deps
]
end
Configure tzdata as the timezone database:
# config/config.exs
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
Verify basic operations:
iex -S mix
iex> Timex.now("America/New_York")
# DateTime with EDT offset
iex> Timex.shift(~D[2026-01-31], months: 1)
~D[2026-02-28]
iex> Timex.format!(Timex.now(), "{ISO:Extended}")
"2026-07-03T14:30:00.000000+00:00"
Step 2: Write Timezone and DST Tests
# test/my_app/timex_test.exs
defmodule MyApp.TimexTest do
use ExUnit.Case, async: true
describe "timezone conversions" do
test "converts UTC to New York correctly (summer = EDT = UTC-4)" do
utc = Timex.parse!("2026-07-03T18:00:00Z", "{ISO:Extended}")
ny = Timex.Timezone.convert(utc, "America/New_York")
assert ny.hour == 14
end
test "London is UTC in January and UTC+1 in July" do
jan = Timex.parse!("2026-01-15T12:00:00Z", "{ISO:Extended}")
london_jan = Timex.Timezone.convert(jan, "Europe/London")
assert london_jan.hour == 12
jul = Timex.parse!("2026-07-15T12:00:00Z", "{ISO:Extended}")
london_jul = Timex.Timezone.convert(jul, "Europe/London")
assert london_jul.hour == 13
end
test "handles DST spring-forward gap gracefully" do
result = Timex.to_datetime({{2026, 3, 8}, {2, 30, 0}}, "America/New_York")
# 2:30 AM does not exist on spring-forward night
# Timex returns ambiguous or error — either is acceptable
assert match?({:ambiguous, _} | {:error, _} | %DateTime{}, result)
end
end
describe "date arithmetic" do
test "month shift clips to valid date" do
jan31 = ~D[2026-01-31]
result = Timex.shift(jan31, months: 1)
assert result == ~D[2026-02-28]
end
test "adding days preserves timezone" do
dt = Timex.now("America/New_York")
tomorrow = Timex.shift(dt, days: 1)
assert tomorrow.time_zone == dt.time_zone
end
test "duration between dates" do
start = Timex.parse!("2026-01-01T00:00:00Z", "{ISO:Extended}")
finish = Timex.parse!("2026-12-31T23:59:59Z", "{ISO:Extended}")
days = Timex.diff(finish, start, :days)
assert days == 364
end
end
describe "parsing" do
test "parses ISO 8601 extended format" do
assert {:ok, _} = Timex.parse("2026-07-03T14:30:00.000Z", "{ISO:Extended}")
end
test "parses RFC 2822 format" do
assert {:ok, _} = Timex.parse("Thu, 03 Jul 2026 14:30:00 +0000", "{RFC2822}")
end
test "rejects invalid timestamps" do
assert {:error, _} = Timex.parse("not-a-date", "{ISO:Extended}")
assert {:error, _} = Timex.parse("2026-13-01T00:00:00Z", "{ISO:Extended}")
end
end
describe "formatting" do
test "formats as ISO 8601" do
dt = Timex.parse!("2026-07-03T14:30:00Z", "{ISO:Extended}")
result = Timex.format!(dt, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}Z")
assert result == "2026-07-03T14:30:00Z"
end
test "relative time includes ago" do
past = Timex.shift(Timex.now(), hours: -2)
result = Timex.format!(past, "{relative}", :relative)
assert String.contains?(result, "ago")
end
end
end
Step 3: Add a Health Endpoint with Timex Checks
# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
checks = %{
database: check_database(),
timex: check_timex(),
tzdata: check_tzdata()
}
status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{status: status_label(status), checks: checks}))
|> halt()
end
def call(conn, _opts), do: conn
defp check_timex do
case Timex.Timezone.convert(Timex.now(), "America/New_York") do
%DateTime{} -> :ok
{:error, _} -> :error
end
end
defp check_tzdata do
if Tzdata.zone_exists?("America/New_York"), do: :ok, else: :error
end
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
{:error, _} -> :error
end
end
defp status_label(200), do: "ok"
defp status_label(_), do: "degraded"
end
Step 4: Monitor Scheduled Jobs with Heartbeats
For time-based jobs, add heartbeat pings so you know when a job missed its window:
# lib/my_app/workers/billing_worker.ex
defmodule MyApp.Workers.BillingWorker do
use Oban.Worker, queue: :billing, max_attempts: 3
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"billing_date" => date_string}}) do
billing_date = Timex.parse!(date_string, "{YYYY}-{0M}-{0D}")
with :ok <- generate_invoices(billing_date),
:ok <- send_billing_emails(billing_date) do
ping_heartbeat(:billing)
:ok
else
{:error, reason} ->
Logger.error("Billing job failed",
billing_date: date_string,
reason: inspect(reason)
)
{:error, reason}
end
end
defp ping_heartbeat(job_name) do
url = Application.get_env(:my_app, :vigilmon)[:"#{job_name}_heartbeat_url"]
if url do
Task.start(fn ->
case Req.get(url, receive_timeout: 5_000) do
{:ok, _} -> :ok
{:error, reason} ->
Logger.warning("Heartbeat ping failed",
job: job_name,
reason: inspect(reason)
)
end
end)
end
end
defp generate_invoices(_date), do: :ok
defp send_billing_emails(_date), do: :ok
end
Configure heartbeat URLs:
# config/runtime.exs
config :my_app, :vigilmon,
billing_heartbeat_url: System.get_env("VIGILMON_BILLING_HEARTBEAT_URL"),
daily_report_heartbeat_url: System.get_env("VIGILMON_DAILY_REPORT_HEARTBEAT_URL")
Step 5: Run Timex Tests in CI with a Heartbeat
# .github/workflows/datetime.yml
name: Date and Time Tests
on:
push:
branches: [main]
pull_request:
schedule:
# Weekly run to catch stale tzdata before a DST transition
- cron: '0 6 * * 1'
jobs:
datetime-tests:
name: Timex and Timezone Tests
runs-on: ubuntu-latest
env:
VIGILMON_TIMEX_HEARTBEAT_URL: ${{ secrets.VIGILMON_TIMEX_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 Timex tests
run: mix test test/my_app/timex_test.exs --color
- name: Ping Vigilmon heartbeat
if: success()
run: curl -fsS "$VIGILMON_TIMEX_HEARTBEAT_URL"
Step 6: Track Parse Errors in Production
Instrument Timex parse failures to catch when third-party timestamp formats change:
# lib/my_app/safe_timex.ex
defmodule MyApp.SafeTimex do
require Logger
@formats ["{ISO:Extended}", "{RFC2822}", "{RFC3339}"]
@doc "Parse a timestamp string trying multiple formats."
def parse(string, source \\ :unknown) do
result = Enum.find_value(@formats, fn format ->
case Timex.parse(string, format) do
{:ok, dt} -> {:ok, dt}
{:error, _} -> nil
end
end)
case result do
nil ->
Logger.warning("Timex could not parse timestamp",
string: string,
source: source
)
:telemetry.execute(
[:my_app, :timex, :parse_error],
%{count: 1},
%{source: source}
)
{:error, :unparseable}
{:ok, dt} ->
{:ok, dt}
end
end
end
Step 7: Set Up Monitoring in Vigilmon
HTTP Monitor:
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute
Heartbeat Monitors — one per scheduled job:
| Job | Expected interval | Secret |
|-----|------------------|--------|
| Billing Worker | 25 hours | VIGILMON_BILLING_HEARTBEAT_URL |
| Daily Report | 25 hours | VIGILMON_DAILY_REPORT_HEARTBEAT_URL |
| Timex CI Tests | 25 hours | VIGILMON_TIMEX_HEARTBEAT_URL |
Step 8: Alerting
In Vigilmon, configure Notifications → New Channel → Slack:
Health endpoint down:
🔴 DOWN: yourdomain.com/health
Status: 503 — tzdata timezone check failed
Action: Check tzdata package version and IANA database update
Billing job heartbeat missed:
🔴 MISSED: Billing Worker
Last successful billing run: 26 hours ago (expected every 25 hours)
Action: Check Oban queue — possible timezone error or job crash
What You Built
| What | How |
|------|-----|
| Timezone conversion tests | ExUnit tests with known UTC to local expectations |
| DST edge case tests | Tests for spring-forward ambiguous times |
| Date arithmetic tests | Month-clipping, duration, timezone preservation |
| Parse format tests | Multiple format strings and rejection of invalid input |
| Timex health check | Timezone conversion spot-check in /health plug |
| Scheduled job heartbeats | Oban worker pings Vigilmon on success |
| CI heartbeat | Vigilmon heartbeat — alerts when tests miss |
| Parse error tracking | Telemetry and Logger on unrecognized formats |
| Slack alerting | Vigilmon Slack notification channel |
Timex handles the complexity of calendars, timezones, and DST. Vigilmon handles the complexity of knowing when Timex-dependent jobs silently stop running.
Next Steps
- Add scheduled weekly CI runs specifically for timezone tests to catch stale tzdata before a DST transition
- Track
[:my_app, :timex, :parse_error]in your APM tool to detect format drift in third-party timestamp sources - Create separate heartbeat monitors for each time-sensitive job with intervals matched to their expected schedule
- Expose
Tzdata.zone_list()count in your health endpoint to detect a corrupted or empty tzdata installation
Get started free at vigilmon.online.