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