lib/ezcodemanager.ex

defmodule EZProfiler.Manager do

  @moduledoc """
  This module requires the `ezprofiler` escript, see...

      https://github.com/nhpip/ezprofiler.git
      https://hex.pm/packages/ezprofiler

  A module that provides the ability to perform code profiling programmatically within an application rather than via the `ezprofiler` CLI.
  This maybe useful in environments where shell access maybe limited. Instead the output can be redirected to a logging subsystem for example.

  Use of this module still requires the `ezprofiler` escript, but it will be automatically initialized in the background.

  `ezprofiler` can be downloaded from https://github.com/nhpip/ezprofiler or added to `deps` in `mix.exs` along with this package:

      defp deps do
        [
          {:ezprofiler, git: "https://github.com/nhpip/ezprofiler.git"},
          {:ezprofiler_deps, git: "https://github.com/nhpip/ezprofiler_deps.git"}
        ]
      end

  This profiling mechanism supports two modes of operation, `synchronous` and `asynchronous`

  In synchronous mode the user starts profiling and then calls a blocking call to wait for the results.

  In asynchronous mode the results are sent as a message, this will be a `handle_info/2` in the case of a `GenServer`

  ## Synchronous Example

        EZProfiler.Manager.start_ezprofiler(%EZProfiler.Manager.Configure{ezprofiler_path: :deps})
        ...
        ...
        with :ok <- EZProfiler.Manager.enable_profiling(),
             :ok <- EZProfiler.Manager.wait_for_results(),
             {:ok, filename, results} <- EZProfiler.Manager.get_profiling_results(true)
        do
            {:ok, filename, results}
        else
          rsp ->
            rsp
        end
        ...
        ...
        EZProfiler.Manager.stop_ezprofiler()

  ## Asynchronous Example as a GenServer

        ## Your handle_cast
        def handle_cast(:start_profiling, state) do
          EZProfiler.Manager.start_ezprofiler(%EZProfiler.Manager.Configure{ezprofiler_path: :deps})
          EZProfiler.Manager.enable_profiling()
          EZProfiler.Manager.wait_for_results_non_block()
          {:noreply, state}
        end

        def handle_info({:ezprofiler, :results_available, filename, results}, state) do
          EZProfiler.Manager.stop_ezprofiler()
          do_something_with_results(filename, results)
          {:noreply, state}
        end

        def handle_info({:ezprofiler, :results_available}, state) do
          {:ok, filename, results} = EZProfiler.Manager.get_profiling_results(true)
          EZProfiler.Manager.stop_ezprofiler()
          {:noreply, state}
        end

        def handle_info({:ezprofiler, :timeout}, state) do
          # Ooops
          EZProfiler.Manager.stop_ezprofiler()
          {:noreply, state}
        end

  """

  defmodule Configure do

    @moduledoc """
    The configuration struct for code based code-profiling.

    """

    @type t :: %EZProfiler.Manager.Configure{node: String.t() | nil,
                                             cookie: String.t() | nil,
                                             mf: String.t(),
                                             directory: String.t(),
                                             profiler: String.t(),
                                             sort: String.t(),
                                             cpfo: String.t() | boolean(),
                                             ezprofiler_path: String.t() | atom()}

    defstruct [
      node: nil,
      cookie: nil,
      mf: "_:_",
      directory: "/tmp/",
      profiler: "eprof",
      sort: "mfa",
      cpfo: "false",
      ezprofiler_path: :system
    ]

  end

  alias EZProfiler.Manager.Configure

  @type display :: boolean()
  @type filename :: String.t()
  @type profile_data :: String.t()
  @type wait_time :: integer()
  @type profiling_cfg :: Configure.t()
  @type label :: atom() | String.t()
  @type self :: pid()

  @doc """
  Starts and configures the `ezprofiler` escript. Takes the `%EZProfiler.Manager.Configure{}` struct as configuration.

  Most fields map directly onto the equivalent arguments for starting `ezprofiler`.

  The exception to this is `ezprofiler_path` that takes the following options:

        :system - if `ezprofiler` is defined via the `PATH` env variable.
        :deps - if `ezprofiler` is included as an application in `mix.ezs`
        path - a string specifying the full path for `ezprofiler`

  ## Example

        %EZProfiler.Manager.Configure{
          cookie: nil,
          cpfo: "false",
          directory: "/tmp/",
          ezprofiler_path: :system,
          mf: "_:_",
          node: nil,
          profiler: "eprof",
          sort: "mfa"
        }

  """
  @spec start_ezprofiler(profiling_cfg()) :: {:ok, :started} | {:error, :timeout} | {:error, :not_started}
  def start_ezprofiler(profiling_cfg = %Configure{} \\ %Configure{}) do
    Code.ensure_loaded(__MODULE__)

    Map.from_struct(profiling_cfg)
    |> Map.replace!(:node, (if is_nil(profiling_cfg.node), do: node() |> Atom.to_string(), else: profiling_cfg.node))
    |> Map.replace!(:ezprofiler_path, find_ezprofiler(profiling_cfg.ezprofiler_path))
    |> Map.to_list()
    |> Enum.filter(&(not is_nil(elem(&1, 1))))
    |> Enum.reduce({nil, []}, fn({:ezprofiler_path, path}, {_, acc}) -> {path, acc};
                                (opt, {path, opts}) -> {path, [make_opt(opt) | opts]} end)
    |> flatten()
    |> do_start_profiler()
  end

  @doc """
  Stops the `ezprofiler` escript. The equivalent of hitting `q` in the CLI.

  """
  def stop_ezprofiler(), do:
    Kernel.apply(EZProfiler.ProfilerOnTarget, :stop_profiling, [node()])

  @doc """
  Enables code profiling. The equivalent of hitting `c` or `c label` in the CLI.

  """
  @spec enable_profiling(label() | none()) :: :ok
  def enable_profiling(label \\ :any_label), do:
    Kernel.apply(EZProfiler.ProfilerOnTarget, :allow_code_profiling, [node(), label, self()])

  @doc """
  Disables code profiling. The equivalent of hitting `r` in the CLI.

  """
  def disable_profiling(), do:
    Kernel.apply(EZProfiler.ProfilerOnTarget, :reset_profiling, [node()])

  @doc """
  Waits `timeout` seconds (default 60) for code profiling to complete.

  """
  @spec wait_for_results(wait_time() | 60) :: :ok | {:error, :timeout}
  def wait_for_results(wait_time \\ 60) do
    timeout = wait_time * 1000
    receive do
      :results_available -> :ok
      {:results_available, _file, _data} = msg -> msg
    after
      timeout -> {:error, :timeout}
    end
  end

  @doc """
  This is an asynchronous version of `wait_for_results/1`. This will cause a message to be sent to the process id specified as the first argument.

  If no pid is specified the result is sent to `self()`

  Three messages can be received:

      {:ezprofiler, :results_available, filename, results}
      {:ezprofiler, :results_available}  # Needs to call `get_profiling_results/1`
      {:ezprofiler, :timeout}

  In the case of a `GenServer` these will be received by `handle_info/2`

  """
  @spec wait_for_results_non_block(pid() | self(), wait_time() | 60) :: :ok
  def wait_for_results_non_block(pid \\ nil, wait_time \\ 60) do
    pid = if pid, do: pid, else: self()
    case wait_for_results(0) do
      :ok -> send(pid, {:ezprofiler, :results_available})
      {:results_available, file, data} -> send(pid, {:ezprofiler, :results_available, file, data})
      _ -> do_wait_for_results_non_block(pid, wait_time)
    end
    :ok
  end

  @doc """
  Returns the resulting code profiling results. If the option `display` is set to true it will also output the `stdout`.

  On success it will return the tuple `{:ok, filename, result_string}`

  """
  @spec get_profiling_results(display() | false) :: {:ok, filename(), profile_data()} | {:error, atom()}
  def get_profiling_results(display \\ false) do
    send({:main_event_handler, :ezprofiler@localhost}, {:get_results_file, self()})
    receive do
      {:profiling_results, filename, results} ->
        if display, do: IO.puts(results)
        {:ok, filename, results}
      {:no_profiling_results, error} ->
        {:error, error}
    after
      2000 -> {:error, :timeout}
    end
  end

  defp do_start_profiler({profiler_path, opts}) do
    pid = self()
    spawn(fn ->
            try do
              filename = "/tmp/#{random_filename()}"
              spawn(fn -> wait_for_start(pid, filename) end)
              System.cmd(System.find_executable(profiler_path), ["--inline", filename | opts])
            rescue
              e ->
                send(pid, {__MODULE__, {:error, e}})
            end
    end)
    receive do
      {__MODULE__, rsp} -> rsp
    after
      5000 -> {:error, :timeout}
    end
  end

  defp do_wait_for_results_non_block(pid, wait_time) do
    spawn(fn ->
      Kernel.apply(EZProfiler.ProfilerOnTarget, :change_code_manager_pid, [node(), self()])
      case wait_for_results(wait_time) do
        :ok -> send(pid, {:ezprofiler, :results_available})
        {:results_available, file, data} -> send(pid, {:ezprofiler, :results_available, file, data})
        _ -> send(pid, {:ezprofiler, :timeout})
      end
    end)
  end

  defp find_ezprofiler(:system) do
    System.find_executable("ezprofiler")
  end

  defp find_ezprofiler(:deps) do
    path = Mix.Dep.cached()
           |> Enum.find(&(&1.app == :ezprofiler))
           |> Map.get(:opts)
           |> Keyword.get(:dest)
    "#{path}/ezprofiler"
  end

  defp find_ezprofiler(path) do
    path
  end

  defp flatten({path, cfg}), do:
    {path, List.flatten(cfg)}

  defp make_opt({:cpfo, v}) when v in [true, "true"], do:
    ["--cpfo"]

  defp make_opt({:cpfo, _v}), do:
    []

  defp make_opt({k, v}), do:
    ["--#{k}", (if is_atom(v), do: Atom.to_string(v), else: v)]

  defp wait_for_start(pid, filename) do
    if do_wait_for_start(filename, 10),
      do: send(pid, {__MODULE__, {:ok, :started}}),
      else: send(pid, {__MODULE__, {:error, :not_started}})
  end

  defp do_wait_for_start(_filename, 0), do:
    false

  defp do_wait_for_start(filename, count) do
    Process.sleep(500)
    File.exists?(filename) || do_wait_for_start(filename, count - 1)
  end

  defp random_filename() do
    for _ <- 1..10, into: "", do: <<Enum.at('abcdefghijklmnopqrstuvwxyz', :crypto.rand_uniform(0, 26))>>
  end

end