tutorial

How to Monitor Timex with Vigilmon

--- title: How to Monitor Timex with Vigilmon published: true description: Monitor your Elixir applications using Timex for date and time handling — detect ...


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 datatzdata (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/1 vs DateTime.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 errorsTimex.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!/2 raises, 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:

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →