lib/smart_city/data/timing.ex

defmodule SmartCity.Data.Timing do
  @moduledoc """
  Timing struct for adding timing metrics to `SmartCity.Data` messages
  """
  @type t :: %SmartCity.Data.Timing{
          app: String.t(),
          label: String.t(),
          start_time: DateTime.t(),
          end_time: DateTime.t()
        }

  @enforce_keys [:app, :label]
  @derive Jason.Encoder
  defstruct app: nil, label: nil, start_time: nil, end_time: nil

  @validate_keys [:app, :label, :start_time, :end_time]

  @doc """
  Creates a new `SmartCity.Data.Timing` struct, passing in all fields.

  Returns a `SmartCity.Data.Timing` struct or raises `ArgumentError`.

  ## Parameters

    - app: application for which timing metrics are being measured
    - label: description of timing measurement
    - start_time: time when measurement has begun
    - end_time: time when measurement has finished

  ## Examples

      iex> SmartCity.Data.Timing.new("foo", "bar", "not_validated", "not_validated")
      %SmartCity.Data.Timing{
        app: "foo",
        label: "bar",
        start_time: "not_validated",
        end_time: "not_validated"
      }
  """
  @spec new(term(), term(), term(), term()) :: SmartCity.Data.Timing.t()
  def new(app, label, start_time, end_time) do
    new(app: app, label: label, start_time: start_time, end_time: end_time)
  end

  @doc """
  Creates a new `SmartCity.Data.Timing` from opts.

  Returns a `SmartCity.Data.Timing` struct or raises `ArgumentError`

  ## Parameters

    - opts: Keyword list or map containing struct attributes
          Required keys: #{@enforce_keys |> Enum.map_join(", ", &"`#{Atom.to_string(&1)}`")}
          See `Kernel.struct!/2`.
  """
  @spec new(
          %{
            :app => term(),
            :label => term(),
            optional(:start_time) => term(),
            optional(:end_time) => term()
          }
          | list()
        ) :: SmartCity.Data.Timing.t()
  def new(opts) do
    struct!(__MODULE__, opts)
  end

  @doc """
  Gets the current time. This function should always be used for generating times to be used in timings to ensure consistency across all services.

  Returns current UTC Time in ISO8601 format
  """
  @spec current_time() :: String.t()
  def current_time do
    DateTime.utc_now() |> DateTime.to_iso8601()
  end

  @doc """
  Validate that all required keys are present and valid (not nil).

  Set by `@validate_keys` module attribute.
  Currently checks: #{@enforce_keys |> Enum.map_join(", ", &"`#{Atom.to_string(&1)}`")}

  Returns `{:ok, timing}` on success or `{:error, reason}` on failure

  ## Parameters

    - timing: The `SmartCity.Data.Timing` struct to validate
  """
  @spec validate(SmartCity.Data.Timing.t()) ::
          {:ok, SmartCity.Data.Timing.t()} | {:error, String.t()}
  def validate(%__MODULE__{} = timing) do
    case check_keys(timing, @validate_keys) do
      [] -> {:ok, timing}
      errors -> {:error, join_error_message(errors)}
    end
  end

  @doc """
  Validate that all required keys are present and valid (not nil).

  Returns `timing` on success, or raises `ArgumentError` on failure
  See `validate/1`
  """
  @spec validate!(SmartCity.Data.Timing.t()) :: SmartCity.Data.Timing.t()
  def validate!(%__MODULE__{} = timing) do
    case validate(timing) do
      {:ok, timing} -> timing
      {:error, reason} -> raise ArgumentError, reason
    end
  end

  @doc """
  Wraps the results of a function call with measured timing information

  Returns {:ok, `result`, `timing`} on success, or {:error, `reason`} on failure
  """
  @spec measure(String.t(), String.t(), (() -> {:ok, term()} | {:error, term()})) ::
          {:ok, term(), SmartCity.Data.Timing.t()} | {:error, String.t()}
  def measure(app, label, function) when is_function(function) do
    start_time = DateTime.utc_now()

    case function.() do
      {:ok, result} ->
        {:ok, result, new(%{app: app, label: label, start_time: start_time, end_time: DateTime.utc_now()})}

      {:error, reason} ->
        {:error, reason}

      reason ->
        {:error, reason}
    end
  end

  defp check_keys(timing, keys) do
    keys
    |> Enum.map(&check_key(timing, &1))
    |> List.flatten()
  end

  defp check_key(timing, key) do
    case Map.get(timing, key, :missing_key) do
      :missing_key -> {:missing_key, key}
      nil -> {:invalid, key}
      _ -> []
    end
  end

  defp join_error_message(errors) do
    error_msg =
      errors
      |> Enum.map_join(", ", fn {reason, key} -> "#{Atom.to_string(key)}(#{Atom.to_string(reason)})" end)

    "Errors with: #{error_msg}"
  end
end