Current section

Files

Jump to
etop lib etop.ex
Raw

lib/etop.ex

defmodule Etop do
@sort_fields ~w(memory msgq reds reds_diff status default)a
@moduledoc """
A Top like implementation for Elixir Applications.
## Usage
# Start with default options
ex> Etop.start()
# Temporarily pause/stop
ex> Etop.pause()
# Restart when paused
ex> Etop.start
# Start logging to an exs file
ex> Etop.start file: "/tmp/etop.exs"
# Load the current exs file
ex> data = Etop.load
# Start then change number of processes and interval between collecting results
ex> Etop.start
ex> Etop.set_opts nprocs: 15, interval: 15_000
# or
ex> Etop.start
ex> Etop.pause
ex> Etop.start nprocs: 15, interval: 15_000
# Stop Etop, killing its GenServer
ex> Etop.stop
## Configuration
* `file` - Save the output to a file.
* `format` - the output file format. Values [:text, :exs]
* `interval (5000)` - the time (ms) between each run
* `nprocs (10)` - the number of processes to list
* `sort (reds_diff)` - the field to sort the process list. Values #{inspect(@sort_fields)}
"""
use GenServer
alias Etop.{Reader, Report}
require Logger
@name __MODULE__
@valid_opts ~w(freq length debug file os_pid cores format interval nprocs sort monitors reporting)a
@sortable ~w(memory message_queue_len reductions reductions_diff status)a
@sort_field_mapper @sort_fields
|> Enum.zip(@sortable)
|> Keyword.put(:default, :reductions_diff)
@type monitor_callback :: (any(), any() -> none()) | {atom(), atom()}
@type monitor_type :: :process | :summary
@type monitor_fields :: atom() | [atom()]
@type monitor :: {monitor_type(), monitor_fields(), any(), monitor_callback()}
@type monitors :: [monitor()]
###############
# Public API
@doc """
Add a new monitor.
Adds the given monitor to any existing monitors.
## Examples
iex> Etop.start()
iex> callback = &{&1, &2}
iex> Etop.monitor(:summary, [:load, :sys], 10.0, callback)
iex> Etop.add_monitor(:process, :reductions, 1000, callback)
iex> Etop.monitors() ==
...> [
...> {:process, :reductions, 1000, callback},
...> {:summary, [:load, :sys], 10.0, callback}
...> ]
true
"""
@spec add_monitor(monitor_type(), monitor_fields(), any(), monitor_callback()) ::
no_return()
def add_monitor(type, field, threshold, callback) do
GenServer.cast(@name, {:add_monitor, {type, field, threshold, callback}})
end
@doc """
Add a new monitor to an Etop state map.
"""
@spec add_monitor(
%{:monitors => monitors()},
monitor_type(),
monitor_fields(),
any(),
monitor_callback()
) :: %{:monitors => monitors()}
def add_monitor(state, type, field, threshold, callback) do
%{state | monitors: [{type, field, threshold, callback} | state.monitors]}
end
@doc """
Restart Etop if its halted.
"""
def continue(opts \\ []) do
if Process.whereis(@name) do
GenServer.call(@name, {:continue, opts})
else
{:error, :no_process}
end
end
@doc """
Load the current exs log file.
"""
def load do
GenServer.call(@name, :load)
end
@doc """
Load the given .exs file.
"""
def load(path) do
Etop.Report.load(path)
end
@doc """
Set a monitor.
Replaces any existing monitors with the given monitor.
## Examples
iex> Etop.start()
iex> callback = &{&1, &2}
iex> Etop.monitor(:summary, [:load, :sys], 10.0, callback)
iex> Etop.monitors() == [{:summary, [:load, :sys], 10.0, callback}]
true
"""
@spec monitor(monitor_type(), monitor_fields(), any(), monitor_callback()) :: no_return()
def monitor(type, field, threshold, callback) do
GenServer.cast(@name, {:monitor, {type, field, threshold, callback}})
end
@spec monitor(
%{:monitors => monitors()},
monitor_type(),
monitor_fields(),
any(),
monitor_callback()
) :: %{:monitors => monitors()}
def monitor(state, type, field, threshold, callback) do
set_opts(state, monitors: [{type, field, threshold, callback}])
end
@doc """
Get the current monitors.
## Examples
iex> Etop.start()
iex> callback = &{&1, &2}
iex> Etop.monitor(:summary, [:load, :sys], 10.0, callback)
iex> Etop.monitors() == [{:summary, [:load, :sys], 10.0, callback}]
true
"""
@spec monitors() :: [tuple()]
def monitors do
GenServer.call(@name, :monitors)
end
@doc """
Pause a running Etop session.
"""
def pause do
GenServer.call(@name, :pause)
end
@doc """
Remove all monitors.
## Examples
iex> Etop.start()
iex> Etop.monitor(:summary, [:load, :total], 10.0, &IO.inspect({&1, &2}))
iex> Etop.remove_monitors()
iex> Etop.monitors()
[]
"""
def remove_monitors do
GenServer.cast(@name, :remove_monitors)
end
@doc """
Remove a monitor.
## Examples
iex> Etop.start()
iex> Etop.monitor(:summary, [:load, :total], 10.0, &IO.inspect({&1, &2}))
iex> response = Etop.remove_monitor(:summary, [:load, :total], 10.0)
iex> {response, Etop.monitors()}
{:ok, []}
"""
@spec remove_monitor(monitor_type(), monitor_fields(), any()) :: :ok | :not_found
def remove_monitor(type, field, threshold) do
GenServer.call(@name, {:remove_monitor, {type, field, threshold}})
end
@doc """
Enable or disable reporting.
Disabling reporting stops reporting printing or logging reports but keeps the Etop collecting
data. This option can be used with a monitor to toggle logging when a threshold is reached.
## Examples
iex> Etop.start()
iex> Etop.reporting(false)
:ok
iex> Etop.start()
iex> enable_callback = fn _, _ -> Etop.reporting(true) end
iex> disable_callback = fn _, value ->
...> if value < 40.0, do: Etop.reporting(false)
...> end
iex> Etop.monitor(:summary, [:load, :total], 50.0, enable_callback)
iex> Etop.add_monitor(:summary, [:load, :total], 40.0, disable_callback)
:ok
"""
@spec reporting(boolean()) :: :ok | :already_reporting | :no_reporting
def reporting(enable?) do
GenServer.call(@name, {:reporting, enable?})
end
@doc """
Set Etop settings
"""
def set_opts(opts) do
if valid_opts?(opts) do
GenServer.cast(@name, {:set_opts, opts})
else
{:error, :invalid_opts}
end
end
@doc """
Start Etop Reporting.
"""
def start(opts \\ []) do
GenServer.start(__MODULE__, opts, name: @name)
end
@doc """
Stop Etop Reporting.
"""
def stop() do
GenServer.cast(@name, :stop)
end
@doc """
Get the status of the server
"""
def status do
GenServer.call(@name, :status)
end
def status! do
GenServer.call(@name, :status!)
end
######################
# GenServer Callbacks
def init(opts) do
send(self(), {:initialize, opts[:first_interval] || 1000})
if node = opts[:node] do
Node.connect(node)
end
monitors =
if monitors = opts[:monitors] || Application.get_env(:etop, :monitor),
do: List.flatten([monitors]),
else: []
{:ok,
set_file(
%{
cores: opts[:cores] || 1,
debug: opts[:debug] || false,
file: nil,
format: nil,
halted: Keyword.get(opts, :halted, false),
info_map: nil,
interval: opts[:interval] || opts[:freq] || 5000,
monitors: validate_monitors!(monitors),
node: opts[:node],
nprocs: opts[:nprocs] || opts[:length] || 10,
os_pid: opts[:os_pid],
stats: %{util: opts[:util], procs: nil, total: 0, load: nil},
timer_ref: nil,
sort: @sort_field_mapper[Keyword.get(opts, :sort, :default)],
reporting: opts[:reporting] || true
},
opts
)}
end
def handle_call({:remove_monitor, {which, fields, threshold}}, _, state) do
monitors =
Enum.reject(state.monitors, fn
{^which, ^fields, ^threshold, _} -> true
_ -> false
end)
if state.monitors == monitors do
reply(state, :not_found)
else
reply(%{state | monitors: monitors}, :ok)
end
end
def handle_call(:status, _, state) do
reply(state, %{state | stats: Map.delete(state.stats, :procs)})
end
def handle_call(:status!, _, state) do
reply(state, state)
end
def handle_call({:continue, opts}, _, %{halted: true} = state) do
%{set_opts(state, opts) | halted: false}
|> start_timer(state.interval)
|> reply(:ok)
end
def handle_call({:continue, _}, _, state) do
reply(state, :not_halted)
end
def handle_call(:load, _, %{format: :exs, file: file} = state) when is_binary(file) do
reply(state, Report.load(file))
end
def handle_call(:load, _, state) do
reply(state, {:error, :invalid_file})
end
def handle_call(:monitors, _, state) do
reply(state, state.monitors)
end
def handle_call(:pause, _, %{halted: true} = state) do
reply(state, :already_halted)
end
def handle_call(:pause, _, state) do
reply(cancel_timer(%{state | halted: true}), :ok)
end
def handle_call({:reporting, value}, _, %{reporting: existing} = state)
when value != existing do
reply(%{state | reporting: value}, :ok)
end
def handle_call({:reporting, true}, _, state) do
reply(state, :already_reporting)
end
def handle_call({:reporting, false}, _, state) do
reply(state, :not_reporting)
end
def handle_cast({:add_monitor, monitor}, state) do
monitors = state.monitors || []
noreply(%{state | monitors: [monitor | monitors]})
end
def handle_cast({:monitor, monitor}, state) do
state = if valid_monitor?(monitor), do: %{state | monitors: [monitor]}, else: state
noreply(state)
end
def handle_cast(:remove_monitors, state) do
noreply(%{state | monitors: []})
end
def handle_cast({:set_opts, opts}, state) do
state
|> set_opts(opts)
|> noreply()
end
def handle_cast(:stop, state) do
{:stop, :normal, state}
end
def handle_info({:cpu_info_result, info}, %{stats: stats} = state) do
info = sanitize_info_result(info)
stats = Map.put(stats, :util, info.util)
noreply(%{state | cores: info.cores, os_pid: info.os_pid, stats: stats})
end
def handle_info({:initialize, delay}, state) do
# Run the report in 1 second.
Process.send_after(self(), :collect, delay)
Reader.remote_cpu_info(state)
noreply(state)
end
def handle_info({:result, stats}, state) do
Logger.debug(fn -> "handle_info :result" end)
# Process the the main data and wait for info event {:info_response, info_map}
stats = sanitize_stats(state, stats)
state
|> Reader.handle_collect(stats)
|> Report.handle_report()
|> noreply()
end
def handle_info(:collect, %{halted: true} = state) do
noreply(state)
end
def handle_info(:collect, state) do
Logger.debug(fn -> "handle_info :collect" end)
# Start collecting the data. The summary and process data will be received by
# info message {:result, stats}
Reader.remote_stats(state)
noreply(state |> start_timer())
end
def handle_info(:stop, state) do
{:stop, :normal, state}
end
def handle_info(event, state) do
Logger.debug(fn -> "unexpected info event #{inspect(event)}" end)
noreply(state)
end
def terminate(reason, state) do
cancel_timer(state)
Logger.debug(fn -> "terminate #{inspect(reason)}" end)
:ok
end
############
# Helpers
def statistics(item), do: :erlang.statistics(item)
def sanitize_stats(state, stats) do
stats
|> Map.put(:node, state.node || "nonode@nohost")
|> Map.put(:procs, Enum.reject(stats.procs, &is_nil/1))
end
def sanitize_info_result(info) do
info
|> sanitize_info_result(:util)
|> sanitize_info_result(:cores, 1)
end
#########
# Private
defp cancel_timer(%{timer_ref: nil} = state), do: state
defp cancel_timer(%{timer_ref: ref} = state) do
Process.cancel_timer(ref)
%{state | timer_ref: nil}
end
defp get_file_format(opts) do
path = opts[:file]
cond do
val = opts[:format] -> val
path && Path.extname(path) == ".exs" -> :exs
true -> :text
end
end
defp maybe_set_file(state, opts) do
if Keyword.has_key?(opts, :file) do
set_file(state, opts)
else
state
end
end
defp set_file(state, opts) do
%{state | file: opts[:file], format: get_file_format(opts)}
end
defp set_opts(state, opts) do
if valid_opts?(opts) do
opts
|> Enum.reduce(state, fn {k, v}, state -> Map.put(state, k, v) end)
|> set_opts_sort(opts[:sort])
|> maybe_set_file(opts)
else
Logger.info("Invalid opts")
state
end
end
defp set_opts_sort(state, nil), do: Map.put(state, :sort, @sort_field_mapper[:default])
defp set_opts_sort(state, field), do: Map.put(state, :sort, @sort_field_mapper[field])
defp start_timer(%{interval: interval} = state) do
start_timer(state, interval)
end
defp start_timer(state, interval) when is_integer(interval) do
%{state | timer_ref: Process.send_after(self(), :collect, interval)}
end
defp reply(%{} = state, reply), do: {:reply, reply, state}
defp reply(state, _), do: raise("invalid state: #{inspect(state)}")
defp noreply(%{} = state), do: {:noreply, state}
defp noreply(state), do: raise("invalid state: #{inspect(state)}")
defp sanitize_info_result(info, field, default \\ nil)
defp sanitize_info_result(%{util: {{:ok, stat}, {:ok, statp}}} = info, :util, _) do
%{info | util: {stat, statp}}
end
defp sanitize_info_result(%{cores: {:ok, cores}} = info, :cores, _) do
%{info | cores: cores}
end
defp sanitize_info_result(info, field, default) do
Map.put(info, field, default)
end
defp valid_monitor?({which, field, threshold, callback}) do
which in ~w(process summary)a and (is_atom(field) or is_list(field)) and is_number(threshold) and
(is_nil(callback) or is_function(callback, 2) or is_tuple(callback))
end
defp valid_monitor?(nil), do: true
defp valid_monitor?(_), do: false
defp valid_monitors?([]), do: true
defp valid_monitors?(list) when is_list(list), do: Enum.all?(list, &valid_monitor?/1)
defp valid_monitors?(monitor), do: valid_monitor?(monitor)
defp validate_monitors!(monitors) do
unless valid_monitors?(monitors) do
raise "Invalid monitor #{inspect(monitors)}"
end
monitors
end
defp valid_opts?(opts) do
keys? =
opts
|> Keyword.keys()
|> Enum.all?(&(&1 in @valid_opts))
keys? and valid_sort_option?(opts[:sort]) and valid_monitors?(opts[:monitors])
end
defp valid_sort_option?(nil), do: true
defp valid_sort_option?(sort), do: sort in @sort_fields
end