lib/opencensus/trace.ex

defmodule Opencensus.Trace do
  @moduledoc """
  Macros to help Elixir programmers use OpenCensus tracing.
  """

  @doc """
  Wrap the given block in a child span with the given label/name and optional attributes.

  Sets `Logger.metadata/0` with `Opencensus.Logger.set_logger_metadata/0` after changing the span
  context tracked in the process dictionary.

  No attributes:

  ```elixir
  with_child_span "child_span" do
    :do_something
  end

  with_child_span "child_span", %{} do
    :do_something
  end
  ```

  Custom attributes:

  ```elixir
  with_child_span "child_span", [:module, :function, %{"custom_id" => "xxx"}] do
    :do_something
  end
  ```

  Automatic insertion of the `module`, `file`, `line`, or `function`:

    ```elixir
  with_child_span "child_span", [:module, :function, %{}] do
    :do_something
  end
  ```

  Collapsing multiple attribute maps (last wins):

    ```elixir
  with_child_span "child_span", [:function, %{"a" => "b", "c" => "d"}, %{"c" => "e"}] do
    :do_something
  end
  ```
  """
  defmacro with_child_span(label, attributes \\ quote(do: %{}), do: block) do
    line = __CALLER__.line
    module = __CALLER__.module
    file = __CALLER__.file
    function = format_function(__CALLER__.function)

    computed_attributes =
      compute_attributes(attributes, %{
        line: line,
        module: module,
        file: file,
        function: function
      })

    quote do
      parent_span_ctx = :ocp.current_span_ctx()

      new_span_ctx =
        :oc_trace.start_span(unquote(label), parent_span_ctx, %{
          :attributes => unquote(computed_attributes)
        })

      _ = :ocp.with_span_ctx(new_span_ctx)
      Opencensus.Logger.set_logger_metadata()

      try do
        unquote(block)
      after
        _ = :oc_trace.finish_span(new_span_ctx)
        _ = :ocp.with_span_ctx(parent_span_ctx)
        Opencensus.Logger.set_logger_metadata()
      end
    end
  end

  defp compute_attributes(attributes, default_attributes) when is_list(attributes) do
    {atoms, custom_attributes} = Enum.split_with(attributes, &is_atom/1)

    default_attributes = compute_default_attributes(atoms, default_attributes)

    case Enum.split_with(custom_attributes, fn
           ## map ast
           {:%{}, _, _} -> true
           _ -> false
         end) do
      {[ca_map | ca_maps], []} ->
        ## custom attributes are literal maps, merge 'em
        {:%{}, meta, custom_attributes} =
          List.foldl(ca_maps, ca_map, fn {:%{}, _, new_pairs}, {:%{}, meta, old_pairs} ->
            {:%{}, meta,
             :maps.to_list(:maps.merge(:maps.from_list(old_pairs), :maps.from_list(new_pairs)))}
          end)

        {:%{}, meta,
         :maps.to_list(:maps.merge(:maps.from_list(custom_attributes), default_attributes))}

      {_ca_maps, _other_calls} ->
        [f_ca | r_ca] = custom_attributes

        quote do
          unquote(
            List.foldl(r_ca ++ [Macro.escape(default_attributes)], f_ca, fn ca, acc ->
              quote do
                Map.merge(unquote(acc), unquote(ca))
              end
            end)
          )
        end
    end
  end

  defp compute_attributes(attributes, _default_attributes) do
    attributes
  end

  defp compute_default_attributes(atoms, default_attributes) do
    List.foldl(atoms, %{}, fn
      :default, _acc ->
        default_attributes

      atom, acc ->
        Map.put(acc, atom, Map.fetch!(default_attributes, atom))
    end)
  end

  defp format_function(nil), do: nil
  defp format_function({name, arity}), do: "#{name}/#{arity}"

  @doc """
  Drop-in replacement for `Task.async/1` that propagates the process' span context.

  Does NOT start a new span for what's inside. Consider `with_child_span/3`.
  """
  @spec async((() -> any())) :: Task.t()
  def async(fun) when is_function(fun, 0) do
    async(:erlang, :apply, [fun, []])
  end

  @doc """
  Drop-in replacement for `Task.async/3` that propagates the process' span context.

  Does NOT start a new span for what's inside. Consider `with_child_span/3`.
  """
  @spec async(module(), atom(), [term()]) :: Task.t()
  def async(module, function_name, args)
      when is_atom(module) and is_atom(function_name) and is_list(args) do
    original_span_ctx = :ocp.current_span_ctx()

    wrapper = fn ->
      :ocp.with_span_ctx(original_span_ctx)
      apply(module, function_name, args)
    end

    Task.async(wrapper)
  end

  @doc """
  Drop-in replacement for `Task.await/2`.
  """
  @spec await(Task.t(), :infinity | pos_integer()) :: term()
  defdelegate await(task, timeout \\ 5000), to: Task
end