lib/wobserver/util/metrics/formatter.ex

defmodule Wobserver.Util.Metrics.Formatter do
  @moduledoc ~S"""
  Formatter.
  """

  alias Wobserver.Util.Node.Discovery

  @doc ~S"""
  Format a set of `data` with a `label`.

  The `data` must be given as a `list` of tuples with the following format: `{value, labels}`, where `labels` is a keyword list with labels and their values.

  The following options can also be given:
    - `type`, the type of the metric. The following values are currently supported: `:gauge`, `:counter`.
    - `help`, a single line text description of the metric.
  """
  @callback format_data(
    name :: String.t,
    data :: [{integer | float, keyword}],
    type :: :atom,
    help :: String.t
  ) :: String.t

  @doc ~S"""
  Combines formatted metrics together.

  Arguments:
    - `metrics`, a list of formatted metrics for one node.
  """
  @callback combine_metrics(
    metrics :: list[String.t]
  ) :: String.t

  @doc ~S"""
  Merges formatted sets of metrics from different nodes together.

  The merge should prevent double declarations of help and type.

  Arguments:
    - `metrics`, a list of formatted sets metrics for multiple node.
  """
  @callback merge_metrics(
    metrics :: list[String.t]
  ) :: String.t

  @doc ~S"""
  Format a set of `data` with a `label` for a metric parser/aggregater.

  The following options can also be given:
    - `type`, the type of the metric. The following values are currently supported: `:gauge`, `:counter`.
    - `help`, a single line text description of the metric.
    - `formatter`, a module implementing the `Formatter` behaviour to format metrics.
  """
  @spec format(
    data :: any,
    label :: String.t,
    type :: :gauge | :counter | nil,
    help :: String.t | nil,
    formatter :: atom | nil
  ) :: String.t | :error
  def format(data, label, type \\ nil, help \\ nil, formatter \\ nil)

  def format(data, label, type, help, nil) do
    format(data, label, type, help, Application.get_env(
      :wobserver,
      :metric_format,
      Wobserver.Util.Metrics.Prometheus
    ))
  end

  def format(data, label, type, help, formatter) when is_binary(formatter) do
    {new_formatter, []} = Code.eval_string formatter

    format(data, label, type, help, new_formatter)
  end

  def format(data, label, type, help, formatter)
  when is_integer(data) or is_float(data) do
    format([{data, []}], label, type, help, formatter)
  end

  def format(data, label, type, help, formatter) when is_map(data) do
    data
    |> map_to_data()
    |> format(label, type, help, formatter)
  end

  def format(data, label, type, help, formatter) when is_binary(data) do
    {new_data, []} = Code.eval_string data

    format(new_data, label, type, help, formatter)
  catch
    :error, _ -> :error
  end

  def format(data, label, type, help, formatter) when is_function(data) do
    data.()
    |> format(label, type, help, formatter)
  end

  def format(data, label, type, help, formatter) do
    cond do
      Keyword.keyword?(data) ->
        data
        |> list_to_data()
        |> format(label, type, help, formatter)
      is_list(data) ->
        data =
          data
          |> Enum.map(fn {value, labels} ->
              {value, Keyword.merge([node: Discovery.local.name], labels)}
            end)

        formatter.format_data(label, data, type, help)
      true ->
        :error
    end
  end

  @doc ~S"""
  Formats a keyword list of metrics using a given `formatter`.

  **Metrics**

  The key is the name of the metric and the value can be given in the following formats:
    - `data`
    - `{data, type}`
    - `{data, type, help}`

  The different fields are:
    - `data`, the actual metrics information.
    - `type`, the type of the metric.
              Possible values: `:gauge`, `:counter`.
    - `help`, a one line text description of the metric.

  The `data` can be given in the following formats:
    - `integer` | `float`, just a single value.
    - `map`, where every key will be turned into a type value.
    - `keyword` list, where every key will be turned into a type value
    - `list` of tuples with the following format: `{value, labels}`, where `labels` is a keyword list with labels and their values.
    - `function` | `string`, a function or String that can be evaluated to a function, which, when called, returns one of the above data-types.

  Example:
  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: 5]
  "simple{node=\"10.74.181.35\"} 5\n"
  ```

  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: {5, :gauge}]
  "# TYPE simple gauge\nsimple{node=\"10.74.181.35\"} 5\n"
  ```

  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: {5, :gauge, "Example desc."}]
  "# HELP simple Example desc.\n
  # TYPE simple gauge\n
  simple{node=\"10.74.181.35\"} 5\n"
  ```

  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: %{floor: 5, wall: 8}]
  "simple{node=\"10.74.181.35\",type=\"floor\"} 5\n
  simple{node=\"10.74.181.35\",type=\"wall\"} 8\n"
  ```

  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: [floor: 5, wall: 8]]
  "simple{node=\"10.74.181.35\",type=\"floor\"} 5\n
  simple{node=\"10.74.181.35\",type=\"wall\"} 8\n"
  ```

  ```elixir
  iex> Wobserver.Util.Metrics.Formatter.format_all [simple: [{5, [location: :floor]}, {8, [location: :wall]}]]
  "simple{node=\"10.74.181.35\",location=\"floor\"} 5\n
  simple{node=\"10.74.181.35\",location=\"wall\"} 8\n"
  ```
  """
  @spec format_all(data :: list, formatter :: atom) :: String.t | :error
  def format_all(data, formatter \\ nil)

  def format_all(data, nil) do
    format_all(data, Application.get_env(
      :wobserver,
      :metric_format,
      Wobserver.Util.Metrics.Prometheus
    ))
  end

  def format_all(data, formatter) do
    formatted =
      data
      |> Enum.map(&helper(&1, formatter))

    case Enum.member?(formatted, :error) do
      true -> :error
      _ -> formatted |> formatter.combine_metrics
    end
  end

  @doc ~S"""
  Merges formatted sets of metrics from different nodes together using a given `formatter`.

  The merge should prevent double declarations of help and type.

  Arguments:
    - `metrics`, a list of formatted sets metrics for multiple node.
  """
  @spec merge_metrics(metrics :: list(String.t), formatter :: atom)
   :: String.t | :error
  def merge_metrics(metrics, formatter \\ nil)

  def merge_metrics(metrics, nil) do
    merge_metrics(metrics,
      Application.get_env(
        :wobserver,
        :metric_format,
        Wobserver.Util.Metrics.Prometheus
      )
    )
  end

  def merge_metrics(metrics, formatter) do
    # Old way: One node error, means no metrics
    #
    # case Enum.member?(metrics, :error) do
    #   true -> :error
    #   false -> formatter.merge_metrics(metrics)
    # end
    metrics
    |> Enum.filter(fn m -> m != :error end)
    |> formatter.merge_metrics
  end

  # Helpers

  defp helper({key, {data, type, help}}, formatter) do
    format(data, Atom.to_string(key), type, help, formatter)
  end

  defp helper({key, {data, type}}, formatter) do
    format(data, Atom.to_string(key), type, nil, formatter)
  end

  defp helper({key, data}, formatter) do
    format(data, Atom.to_string(key), nil, nil, formatter)
  end

  defp map_to_data(map) do
    map
    |> Map.to_list
    |> Enum.filter(fn {a, _} -> a != :__struct__ && a != :total  end)
    |> list_to_data()
  end

  defp list_to_data(list) do
    list
    |> Enum.map(fn {key, value} -> {value, [type: key]} end)
  end
end