lib/elixometer.ex

defmodule Elixometer do
  @moduledoc ~S"""
  A light wrapper around [exometer](https://github.com/Feuerlabs/exometer).

  Elixometer allows you to define metrics and subscribe them automatically
  to the default reporter for your environment.

  ## Configuration

  In one of your config files, set up an exometer reporter, and then register
  it to elixometer like this:

       config(:exometer_core, report: [reporters: [{:exometer_report_tty, []}]])
       config(:elixometer, reporter: :exometer_report_tty)

  ## Metrics

  Defining metrics in elixometer is substantially easier than in exometer.
  Instead of defining and then updating a metric, just update it. Also, instead
  of providing a list of atoms, a metric is named with a period separated
  bitstring. Presently, Elixometer supports timers, histograms, gauges,
  and counters.

  Timings may also be defined by annotating a function with a `@timed`
  annotation. This annotation takes a key argument, which tells elixometer
  what key to use. You can specify `:auto` and a key will be generated from
  the module name and method name.

  Updating a metric is similarly easy:

       defmodule ParentModule.MetricsTest do
         use Elixometer

         def counter_test(thingie) do
           update_counter("metrics_test.\#{thingie}.count", 1)
         end

         def timer_test do
           timed("metrics_test.timer_test.timings") do
             OtherModule.slow_method
           end
         end

         @timed(key: "timed.function")
         def function_that_is_timed do
           OtherModule.slow_method
         end

         @timed(key: :auto) # The key will be "parent_module.metrics_test.another_timed_function"
         def another_timed_function do
           OtherModule.slow_method
         end
       end

  """
  @type metric_name :: String.t() | String.Chars.t()

  defmodule App do
    @moduledoc false

    use Application

    def start(_type, _args_) do
      Elixometer.Supervisor.start_link()
    end
  end

  defmodule Config do
    @moduledoc false
    defstruct table_name: nil, counters: Map.new()
  end

  defmodule Timer do
    @moduledoc false
    defstruct method_name: nil, key: nil, units: :microsecond, args: nil, guards: nil, body: nil
  end

  @elixometer_table :elixometer
  alias Elixometer.Updater
  import Elixometer.Utils
  use GenServer

  defmacro __using__(_mod) do
    quote do
      import Elixometer
      Module.register_attribute(__MODULE__, :elixometer_timers, accumulate: true)
      @before_compile Elixometer
      @on_definition Elixometer
    end
  end

  def __on_definition__(env, _kind, name, args, guards, body) do
    mod = env.module
    timer_info = Module.get_attribute(mod, :timed)

    if timer_info do
      key =
        case timer_info[:key] do
          :auto ->
            # Convert a fully qualified module to an underscored representation.
            # Module.SubModule.SubSubModule will become
            # module.sub_module.sub_sub_module
            prefix =
              mod
              |> inspect
              |> String.replace(~r/([a-z])([A-Z])/, ~S"\1_\2")
              |> String.downcase()

            "#{prefix}.#{name}"

          other ->
            other
        end

      units = timer_info[:units] || :microsecond

      Module.put_attribute(mod, :elixometer_timers, %Timer{
        method_name: name,
        args: args,
        guards: guards,
        body: normalize_body(body),
        units: units,
        key: key
      })

      Module.delete_attribute(mod, :timed)
    end
  end

  # Elixir 1.5.0-rc changed on_definition/6 to always wrap body in a keyword
  # list (e.g. `[do: body]`). For backwards compatibility, this normalization
  # function wraps earlier versions' bodies in a keyword list, too.
  defp normalize_body(body) do
    case Version.compare(System.version(), "1.5.0-rc") do
      :lt -> [do: body]
      _ when is_nil(body) -> raise "timed function must have a body"
      _ -> body
    end
  end

  defp build_timer_body(%Timer{key: key, units: units, body: [do: body]}) do
    build_timer(key, units, body)
  end

  defmacro __before_compile__(env) do
    mod = env.module
    timers = Module.get_attribute(mod, :elixometer_timers)

    timed_methods =
      timers
      |> Enum.reverse()
      |> Enum.map(fn %Timer{} = timer_data ->
        Module.make_overridable(
          mod,
          [{timer_data.method_name, length(timer_data.args)}]
        )

        body = build_timer_body(timer_data)

        if length(timer_data.guards) > 0 do
          quote do
            def unquote(timer_data.method_name)(unquote_splicing(timer_data.args))
                when unquote_splicing(timer_data.guards) do
              unquote(body)
            end
          end
        else
          quote do
            def unquote(timer_data.method_name)(unquote_splicing(timer_data.args)) do
              unquote(body)
            end
          end
        end
      end)

    quote do
      (unquote_splicing(timed_methods))
    end
  end

  def init(_args) do
    table_name = :ets.new(@elixometer_table, [:set, :named_table, read_concurrency: true])
    :timer.send_interval(250, :tick)

    {:ok, %Config{table_name: table_name}}
  end

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  @spec get_metric_value(metric_name) :: {:ok, any} | {:error, :not_found}
  def get_metric_value(metric_name) do
    metric_name
    |> to_exometer_key
    |> :exometer.get_value()
  end

  @spec get_metric_value(metric_name, :exometer.datapoint()) :: {:ok, any} | {:error, :not_found}
  def get_metric_value(metric_name, data_point) do
    metric_val =
      metric_name
      |> to_exometer_key
      |> :exometer.get_value(data_point)

    case metric_val do
      {:ok, metric} -> {:ok, metric[data_point]}
      r = {:error, _reason} -> r
    end
  end

  @spec get_metric_values(metric_name) :: [
          {:exometer.name(), :exometer.type(), :exometer.status()}
        ]
  def get_metric_values(metric_name) do
    metric_name
    |> to_exometer_key
    |> get_values
  end

  @spec get_metric_values(metric_name, :exometer.datapoint()) ::
          {:ok, integer} | {:error, :not_found}
  def get_metric_values(metric_name, data_point) do
    metric_val =
      metric_name
      |> get_metric_values
      |> get_values_total(data_point)

    case metric_val do
      :not_found -> {:error, :not_found}
      total -> {:ok, total}
    end
  end

  defp to_exometer_key(metric_name) when is_list(metric_name), do: metric_name

  defp to_exometer_key(metric_name) when is_binary(metric_name) do
    String.split(metric_name, ".")
  end

  defp get_values(key) when is_list(key) do
    :exometer.get_values((key -- ["_"]) ++ [:_])
  end

  defp get_values_total(values, data_point) do
    Enum.reduce_while(values, 0, fn {_, attrs}, total ->
      case Keyword.get(attrs, data_point) do
        nil -> {:halt, :not_found}
        value -> {:cont, total + value}
      end
    end)
  end

  @doc """
  Updates a histogram with a new value. If the metric doesn't exist, a new metric
  is created and subscribed to.
  """
  @spec update_histogram(String.t(), number, pos_integer, boolean) :: :ok
  def update_histogram(name, delta, aggregate_seconds \\ 60, truncate \\ true)
      when is_bitstring(name) do
    Updater.histogram(name, delta, aggregate_seconds, truncate)
  end

  @doc """
  Updates and alternately creates spiral metric. A spiral metric is a metric maintains a series
  of internal slots that "age out" and are replaced by newer values. This is useful for
  maintaining QPS stats.
  """
  @spec update_spiral(String.t(), number, time_span: pos_integer, slot_period: pos_integer) :: :ok
  def update_spiral(name, delta, opts \\ [time_span: :timer.seconds(60), slot_period: 1000]) do
    Updater.spiral(name, delta, opts)
  end

  @doc """
  Updates a counter metric. If the metric doesn't exist, the metric is created
  and the metric is subscribed to the default reporter.

  If the value of the `:reset_seconds` option is greater than zero, the counter will be reset
  automatically at the specified interval.
  """
  @spec update_counter(String.t(), integer, reset_seconds: nil | integer) :: :ok
  def update_counter(name, delta, [reset_seconds: secs] \\ [reset_seconds: nil])
      when is_bitstring(name) and (is_nil(secs) or secs >= 1) do
    Updater.counter(name, delta, secs)
  end

  @doc """
  Clears a counter with the given name.
  """
  @spec clear_counter(metric_name) :: :ok | {:error, any}
  def clear_counter(metric_name) when is_bitstring(metric_name) do
    clear_counter(format(:counters, metric_name))
  end

  def clear_counter(metric_name) when is_list(metric_name) do
    :exometer.reset(metric_name)
  end

  @doc """
  Updates a gauge metric. If the metric doesn't exist, the metric is created
  and the metric is subscribed to the default reporter.
  """
  @spec update_gauge(String.t(), number) :: :ok
  def update_gauge(name, value) when is_bitstring(name) do
    Updater.gauge(name, value)
  end

  @doc """
  Updates a timer metric. If the metric doesn't exist, it will be created and
  subscribed to the default reporter.

  The time units default to *microseconds*, but you can also pass in any of
  the units in `t:System.time_unit/0`, with the exception of `pos_integer`.
  This includes `:second`, `:millisecond`, `:microsecond`, and `:nanosecond`.

  Note that nanoseconds are provided for convenience, but Erlang does not
  actually provide this much granularity.
  """
  defmacro timed(name, units \\ :microsecond, do: block) do
    build_timer(name, units, block)
  end

  defp build_timer(name, units, block) do
    quote do
      {elapsed_us, rv} = :timer.tc(fn -> unquote(block) end)
      Updater.timer(unquote(name), unquote(units), elapsed_us)
      rv
    end
  end

  def add_counter(metric_name, ttl_millis) do
    GenServer.cast(__MODULE__, {:add_counter, metric_name, ttl_millis})
  end

  def add_counter(metric_name) do
    GenServer.cast(__MODULE__, {:add_counter, metric_name, nil})
  end

  def metric_defined?(name) when is_bitstring(name) do
    name |> String.split(".") |> metric_defined?
  end

  def metric_defined?(name) do
    :ets.member(@elixometer_table, {:definitions, name})
  end

  def metric_subscribed?(name) do
    :ets.member(@elixometer_table, {:subscriptions, name})
  end

  def ensure_subscribed(name) do
    if not metric_subscribed?(name) do
      GenServer.call(__MODULE__, {:subscribe, name})
    end
  end

  def ensure_metric_defined(name, defn_fn) do
    if not metric_defined?(name) do
      GenServer.call(__MODULE__, {:define_metric, name, defn_fn})
    end

    :ok
  end

  @doc """
  Ensures a metric is correctly registered in Elixometer.
  This means that Elixometer knows about it and its metrics are
  subscribed to an exometer reporter
  """
  def ensure_registered(metric_name, register_fn) do
    ensure_metric_defined(metric_name, register_fn)
    subscribe(metric_name)
  rescue
    e in ErlangError -> e
  end

  @doc """
  Ensures that a metric is subscribed to an exometer reporter.
  """
  def subscribe(metric_name) do
    if not metric_subscribed?(metric_name) do
      GenServer.call(__MODULE__, {:subscribe, metric_name})
    end
  end

  def handle_call({:subscribe, metric_name}, _caller, state) do
    create_subscription(metric_name)
    {:reply, :ok, state}
  end

  def handle_call({:define_metric, metric_name, defn_fn}, _caller, state) do
    # we re-check whether the metric is defined here to prevent
    # a race condition in ensure_metric_defined
    if not metric_defined?(metric_name) do
      defn_fn.()
      :ets.insert(@elixometer_table, {{:definitions, metric_name}, true})
    end

    {:reply, :ok, state}
  end

  def handle_cast({:add_counter, metric_name, ttl_millis}, config) do
    new_counters = Map.put(config.counters, metric_name, ttl_millis)
    {:noreply, %Config{config | counters: new_counters}}
  end

  def handle_info(:tick, config) do
    Enum.each(
      config.counters,
      fn {name, millis} ->
        {:ok, [ms_since_reset: since_reset]} = :exometer.get_value(name, :ms_since_reset)

        if millis && since_reset >= millis do
          :exometer.reset(name)
        end
      end
    )

    {:noreply, config}
  end

  defp create_subscription(metric_name) do
    # If a metric isn't subscribed to our reporters, create a subscription in our
    # ets table and subscribe our metric to exometer's reporters.
    if not metric_subscribed?(metric_name) do
      cfg = Application.get_all_env(:elixometer)
      reporter = cfg[:reporter]
      interval = cfg[:update_frequency]
      subscribe_options = cfg[:subscribe_options] || []
      excluded_datapoints = cfg[:excluded_datapoints] || []
      bulk_subscribe? = cfg[:bulk_subscribe] || false

      if reporter do
        metric_name
        |> :exometer.info()
        |> get_datapoints()
        |> Enum.reject(&Enum.member?(excluded_datapoints, &1))
        |> do_subscribe(reporter, metric_name, interval, subscribe_options, bulk_subscribe?)
      end

      :ets.insert(@elixometer_table, {{:subscriptions, metric_name}, true})
    end
  end

  defp do_subscribe(
         data_points,
         reporter,
         metric_name,
         interval,
         subscribe_options,
         _bulk_subscribe? = true
       ) do
    :exometer_report.subscribe(reporter, metric_name, data_points, interval, subscribe_options)
  end

  defp do_subscribe(
         data_points,
         reporter,
         metric_name,
         interval,
         subscribe_options,
         _bulk_subscribe? = false
       ) do
    Enum.map(
      data_points,
      &:exometer_report.subscribe(reporter, metric_name, &1, interval, subscribe_options)
    )
  end

  defp get_datapoints(info) do
    case Keyword.fetch(info, :datapoints) do
      {:ok, datapoints} ->
        datapoints

      :error ->
        info
        |> Keyword.fetch!(:value)
        |> Enum.map(fn {k, _v} -> k end)
    end
  end
end