lib/data_daemon/decorators.ex

defmodule DataDaemon.Decorators do
  @moduledoc ~S"""
  Decorators.
  """

  @doc false
  @spec enable :: term
  def enable do
    quote do
      @doc unquote(@moduledoc)
      defmacro __using__(opts \\ []) do
        tags = Keyword.put(opts[:tags] || [], :module, inspect(__CALLER__.module))

        quote location: :keep do
          @data_daemon unquote(__MODULE__)
          @base_tags unquote(tags)
          Module.register_attribute(__MODULE__, :metric, accumulate: true)
          Module.register_attribute(__MODULE__, :instrumented, accumulate: true)

          import DataDaemon.Decorators,
            only: [timing: 1, timing: 2, count: 1, count: 2, duration: 1, duration: 2]

          @on_definition {DataDaemon.Decorators, :on_definition}
          @before_compile {DataDaemon.Decorators, :before_compile}
        end
      end
    end
  end

  @doc ~S"""
  Measure function time.
  """
  @spec timing(String.t(), Keyword.t()) :: tuple
  def timing(metric, opts \\ []), do: {:timing, metric, opts}

  @doc ~S"""
  Alias for timing.

  See `timing/2`.
  """
  @spec duration(String.t(), Keyword.t()) :: tuple
  def duration(metric, opts \\ []), do: {:timing, metric, opts}

  @doc ~S"""
  Count function executions.
  """
  @spec count(String.t(), Keyword.t()) :: tuple
  def count(metric, opts \\ []), do: {:count, metric, opts}

  @doc false
  @spec on_definition(Macro.Env.t(), atom, atom, term, term, term) :: term
  def on_definition(env, kind, fun, args, guards, body) do
    instruments = Module.get_attribute(env.module, :metric)

    if instruments != [] do
      base_tags =
        env.module
        |> Module.get_attribute(:base_tags)
        |> Keyword.put(:function, "#{fun}/#{Enum.count(args)}")

      # Make sure timings are always last
      instruments =
        instruments
        |> Enum.map(fn {type, metric, opts} ->
          {type, metric, Keyword.update(opts, :tags, base_tags, &Keyword.merge(base_tags, &1))}
        end)
        |> Enum.sort_by(&if(elem(&1, 0) == :timing, do: 1, else: 0))

      body = if Keyword.keyword?(body), do: Keyword.get(body, :do), else: body

      attrs = extract_attributes(env.module, body)
      instrumented = {kind, fun, args, guards, body, attrs, instruments}
      Module.put_attribute(env.module, :instrumented, instrumented)
      Module.delete_attribute(env.module, :metric)
    end

    :ok
  end

  @doc false
  defmacro before_compile(env) do
    decorated = env.module |> Module.get_attribute(:instrumented) |> Enum.reverse()
    Module.delete_attribute(env.module, :instrumented)

    overrides =
      Enum.flat_map(decorated, fn {_, fun, args, _, _, _, _} ->
        args
        |> Enum.count(&(elem(&1, 0) != :\\))
        |> (&(&1..Enum.count(args))).()
        |> Enum.map(&{fun, &1})
      end)

    Enum.reduce(
      decorated,
      quote do
        defoverridable unquote(overrides)
      end,
      fn {kind, fun, args, guards, body, attrs, instruments}, acc ->
        body = [do: Enum.reduce(instruments, body, &instrument/2)]

        attrs =
          Enum.map(attrs, fn {attr, value} ->
            {:@, [], [{attr, [], [Macro.escape(value)]}]}
          end)

        func =
          if guards == [] do
            quote do: Kernel.unquote(kind)(unquote(fun)(unquote_splicing(args)), unquote(body))
          else
            quote do
              Kernel.unquote(kind)(
                unquote(fun)(unquote_splicing(args)) when unquote_splicing(guards),
                unquote(body)
              )
            end
          end

        quote do
          unquote(acc)
          unquote(attrs)
          unquote(func)
        end
      end
    )
  end

  @doc false
  defp instrument({:timing, metric, opts}, acc) do
    quote do
      start_time = :erlang.monotonic_time(:milli_seconds)
      result = unquote(acc)

      @data_daemon.timing(
        unquote(metric),
        :erlang.monotonic_time(:milli_seconds) - start_time,
        unquote(opts)
      )

      result
    end
  end

  defp instrument({:count, metric, opts}, acc) do
    quote do
      @data_daemon.increment(unquote(metric), unquote(opts))
      unquote(acc)
    end
  end

  @doc false
  defp extract_attributes(module, body) do
    body
    |> Macro.postwalk(%{}, fn
      {:@, _, [{attr, _, nil}]} = n, attrs ->
        attrs = Map.put(attrs, attr, Module.get_attribute(module, attr))
        {n, attrs}

      n, acc ->
        {n, acc}
    end)
    |> elem(1)
  end
end