Current section
Files
Jump to
Current section
Files
lib/dala_dev/debugger.ex
defmodule DalaDev.Debugger do
@moduledoc """
Advanced debugging tools for remote Elixir nodes.
Provides process inspection, state introspection, remote code evaluation,
and memory analysis for dala Elixir cluster debugging.
## Examples
# Inspect a process on a remote node
{:ok, info} = DalaDev.Debugger.inspect_process(
:"dala_qa@192.168.1.5",
MyApp.Worker
)
# Get supervision tree
{:ok, tree} = DalaDev.Debugger.get_supervision_tree(node)
# Evaluate code on remote node
{:ok, result} = DalaDev.Debugger.eval_remote(
node,
"MyApp.Config.get(:api_key)"
)
# Get memory report
{:ok, report} = DalaDev.Debugger.memory_report(node)
"""
alias DalaDev.Device
@type node_ref :: node() | Device.t() | String.t()
@type process_ref :: pid() | atom() | {atom(), atom()} | module()
@doc """
Inspect a process on a remote node.
Returns detailed information including:
- Process dictionary
- Current state (if GenServer/GenStateMachine)
- Message queue
- Links and monitors
- Memory usage
Options:
- `:timeout` - RPC timeout in ms (default: 10_000)
"""
@spec inspect_process(node_ref(), process_ref(), keyword()) ::
{:ok, map()} | {:error, term()}
def inspect_process(node_ref, process_ref, opts \\ []) do
node = resolve_node(node_ref)
timeout = Keyword.get(opts, :timeout, 10_000)
:rpc.call(node, __MODULE__, :inspect_process_local, [process_ref], timeout)
end
@doc """
Get the supervision tree of a node.
Returns a tree structure showing all supervisors and their children.
"""
@spec get_supervision_tree(node_ref(), keyword()) ::
{:ok, map()} | {:error, term()}
def get_supervision_tree(node_ref, opts) do
node = resolve_node(node_ref)
timeout = Keyword.get(opts, :timeout, 15_000)
:rpc.call(node, __MODULE__, :get_supervision_tree_local, [], timeout)
end
def get_supervision_tree(node_ref) do
get_supervision_tree(node_ref, [])
end
@doc """
Evaluate Elixir code on a remote node.
The code string is evaluated using `Code.eval_string/1` on the remote node.
Options:
- `:timeout` - RPC timeout in ms (default: 30_000)
- `:bindings` - Variables to bind in the evaluation context
"""
@spec eval_remote(node_ref(), String.t(), keyword()) ::
{:ok, any()} | {:error, term()}
def eval_remote(node_ref, code, opts \\ []) do
node = resolve_node(node_ref)
timeout = Keyword.get(opts, :timeout, 30_000)
bindings = Keyword.get(opts, :bindings, [])
:rpc.call(node, __MODULE__, :eval_remote_local, [code, bindings], timeout)
end
@doc """
Get a detailed memory report for a node.
Returns memory breakdown including:
- Total memory
- Process memory
- Binary memory
- ETS memory
- Atom memory
"""
@spec memory_report(node_ref(), keyword()) ::
{:ok, map()} | {:error, term()}
def memory_report(node_ref, opts \\ []) do
node = resolve_node(node_ref)
timeout = Keyword.get(opts, :timeout, 10_000)
:rpc.call(node, __MODULE__, :memory_report_local, [], timeout)
end
@doc """
Trace messages sent to/from a process.
Options:
- `:duration` - Tracing duration in ms (default: 5_000)
- `:timeout` - RPC timeout in ms (default: duration + 1_000)
"""
@spec trace_messages(node_ref(), process_ref(), keyword()) ::
{:ok, [map()]} | {:error, term()}
def trace_messages(node_ref, process_ref, opts \\ []) do
node = resolve_node(node_ref)
duration = Keyword.get(opts, :duration, 5_000)
timeout = Keyword.get(opts, :timeout, duration + 1_000)
:rpc.call(node, __MODULE__, :trace_messages_local, [process_ref, duration], timeout)
end
@doc false
def inspect_process_local(process_ref) do
pid = resolve_pid(process_ref)
case Process.info(pid, [
:dictionary,
:message_queue_len,
:memory,
:reductions,
:links,
:monitors,
:group_leader,
:status,
:current_function,
:current_location
]) do
info when is_list(info) ->
info_map = Map.new(info)
# Try to get GenServer/GenStateMachine state
state = get_process_state(pid)
%{
pid: inspect(pid),
dictionary: format_dict(Map.get(info_map, :dictionary, [])),
message_queue_len: Map.get(info_map, :message_queue_len, 0),
memory: Map.get(info_map, :memory, 0),
reductions: Map.get(info_map, :reductions, 0),
links: Map.get(info_map, :links, []),
monitors: Map.get(info_map, :monitors, []),
status: Map.get(info_map, :status, :unknown),
current_function: format_mfa(Map.get(info_map, :current_function)),
current_location: Map.get(info_map, :current_location),
state: state
}
|> then(fn result -> {:ok, result} end)
nil ->
{:error, :process_not_found}
end
rescue
e -> {:error, Exception.message(e)}
end
@doc false
def get_supervision_tree_local do
case Process.whereis(:supervisor) do
nil ->
# Try to get all supervisors
supervisors =
Process.list()
|> Enum.filter(fn pid ->
info = Process.info(pid, [:dictionary])
dict = Keyword.get(info || [], :dictionary, [])
Keyword.get(dict, :"$initial_call") |> is_supervisor?()
end)
%{
supervisors: Enum.map(supervisors, &inspect/1),
note: "Basic supervisor detection (no :supervisor process found)"
}
|> then(fn result -> {:ok, result} end)
sup_pid ->
tree = build_supervision_tree(sup_pid)
{:ok, tree}
end
rescue
e -> {:error, Exception.message(e)}
end
@doc false
def eval_remote_local(code, bindings) do
try do
{result, _bindings} = Code.eval_string(code, bindings)
{:ok, result}
rescue
e -> {:error, Exception.message(e)}
end
end
@doc false
def memory_report_local do
mem = :erlang.memory()
report = %{
total: format_bytes(Keyword.get(mem, :total, 0)),
processes: format_bytes(Keyword.get(mem, :processes_used, 0)),
system: format_bytes(Keyword.get(mem, :system, 0)),
atom: format_bytes(Keyword.get(mem, :atom_used, 0)),
binary: format_bytes(Keyword.get(mem, :binary, 0)),
code: format_bytes(Keyword.get(mem, :code, 0)),
ets: format_bytes(Keyword.get(mem, :ets, 0)),
raw: mem
}
{:ok, report}
end
@doc """
Gets the state of a process.
Similar to `:sys.get_state/1` from Erlang/OTP, this function retrieves
the internal state of a process. The process must be a system process
(e.g., a GenServer, GenStateMachine, or other process that implements
the sys protocol).
## Parameters
- `pid_or_name` - A PID or registered name of the process
## Returns
- The process state, or `nil` if the process is not found or doesn't have state
## See Also
- [Erlang sys:get_state/1](https://www.erlang.org/doc/apps/stdlib/sys.html#get_state/1)
"""
@spec get_process_state_local(pid() | atom() | {atom(), atom()}) :: term() | nil
def get_process_state_local(pid_or_name) do
pid = resolve_pid_safe(pid_or_name)
get_process_state(pid)
end
defp resolve_pid_safe(pid) when is_pid(pid), do: pid
defp resolve_pid_safe(name) when is_atom(name) do
case Process.whereis(name) do
nil -> nil
pid -> pid
end
end
defp resolve_pid_safe({mod, fun}) when is_atom(mod) and is_atom(fun) do
case Process.whereis(mod) do
nil -> nil
pid -> pid
end
end
defp resolve_pid_safe(mod) when is_atom(mod) do
case Process.whereis(mod) do
nil -> nil
pid -> pid
end
end
@doc false
def trace_messages_local(process_ref, duration) do
pid = resolve_pid(process_ref)
# Check if process is alive
if Process.alive?(pid) do
# Set up tracing
:erlang.trace(pid, true, [:send, :receive, :timestamp])
# Collect messages for duration
messages =
collect_trace_messages(duration, pid, [])
|> Enum.reverse()
# Stop tracing
:erlang.trace(pid, false, [:send, :receive])
{:ok, messages}
else
{:ok, []}
end
end
# ── Private helpers ─────────────────────────────────────────
defp resolve_node(node) when is_atom(node), do: node
defp resolve_node(%Device{node: node}) when not is_nil(node),
do: node
defp resolve_node(str) when is_binary(str) do
String.to_atom(str)
end
defp resolve_pid(pid) when is_pid(pid), do: pid
defp resolve_pid(name) when is_atom(name) do
case Process.whereis(name) do
nil -> raise "Process not found: #{inspect(name)}"
pid -> pid
end
end
defp resolve_pid({mod, fun}) when is_atom(mod) and is_atom(fun) do
case Process.whereis(mod) do
nil -> raise "Module not found: #{inspect(mod)}"
pid -> pid
end
end
defp resolve_pid(mod) when is_atom(mod) do
case Process.whereis(mod) do
nil -> raise "Module not found: #{inspect(mod)}"
pid -> pid
end
end
defp get_process_state(pid) do
# Try to get state from GenServer or GenStateMachine
try do
# This is a best-effort attempt
case :sys.get_state(pid) do
state when not is_nil(state) -> inspect(state)
_ -> nil
end
rescue
_ -> nil
catch
:exit, _ -> nil
end
end
defp is_supervisor?({:supervisor, _mod, _arity}), do: true
defp is_supervisor?(_), do: false
defp build_supervision_tree(sup_pid) do
# Simplified - in reality would use :supervisor.get_childspecs/1
%{
pid: inspect(sup_pid),
children: []
}
end
defp collect_trace_messages(0, _pid, acc), do: acc
defp collect_trace_messages(time_left, pid, acc) do
receive do
{:trace, ^pid, :send, msg, to} ->
collect_trace_messages(
time_left - 1,
pid,
[%{type: :send, message: inspect(msg), to: inspect(to)} | acc]
)
{:trace, ^pid, :receive, msg} ->
collect_trace_messages(
time_left - 1,
pid,
[%{type: :receive, message: inspect(msg)} | acc]
)
after
100 ->
acc
end
end
defp format_dict(dict) when is_list(dict) do
Enum.map(dict, fn {k, v} -> "#{inspect(k)}: #{inspect(v)}" end)
end
defp format_mfa({mod, fun, arity}), do: "#{inspect(mod)}.#{fun}/#{arity}"
defp format_mfa(other), do: inspect(other)
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1024 * 1024,
do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes) when bytes < 1024 * 1024 * 1024,
do: "#{Float.round(bytes / (1024 * 1024), 1)} MB"
defp format_bytes(bytes),
do: "#{Float.round(bytes / (1024 * 1024 * 1024), 1)} GB"
end