Current section

Files

Jump to
mob_dev lib mix tasks mob.trace_otp.ex
Raw

lib/mix/tasks/mob.trace_otp.ex

defmodule Mix.Tasks.Mob.TraceOtp do
@shortdoc "Run the Elixir characterization harness under trace, dump touched MFAs"
@moduledoc """
Runs `MobDev.OtpTrace.Harness` under full call tracing and reports
the runtime modules + MFAs actually exercised.
This is the empirical complement to `mix mob.audit_otp` (which uses
static reachability). Combined, they give a high-confidence answer
to "what does any Elixir runtime actually need."
## Usage
mix mob.trace_otp # Run all phases, summary report
mix mob.trace_otp --phase otp # Just one phase
mix mob.trace_otp --json out.json # Machine-readable dump
# Trace a real running app on a connected device for 30s.
# First run `mix mob.connect` in another terminal.
mix mob.trace_otp --remote pigeon_ios_defd4bdc@127.0.0.1 --duration 30000
Local mode (default) runs a synthetic Elixir/OTP characterization
harness inside the host BEAM. Remote mode wraps `:erlang.trace_pattern`
on a connected device node and captures the MFAs hit during a real
user session — drive the app interactively while the trace runs.
## Phases
language — pattern match, comprehensions, structs, protocols
collections — Enum, List, Map, MapSet, Stream, Range
strings — String, Binary, charlist, codepoints
processes — spawn, send/receive, monitor, link, Task
otp — GenServer, Supervisor, Application, Logger
data — ETS, persistent_term, Date/Time, System
errors — raise/rescue, throw/catch, exit, exception structs
all — every phase (default)
## Output
Plain mode prints a summary + sorted module list. `--json` writes a
JSON file with the full MFA set, suitable for cross-referencing
against the static audit (`mix mob.audit_otp`) to find modules
shipped-but-never-called.
"""
use Mix.Task
alias MobDev.OtpTrace
alias MobDev.OtpTrace.Harness
@phases ~w(language collections strings processes otp data errors all)a
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [phase: :string, json: :string, remote: :string, duration: :integer]
)
Mix.Task.run("loadpaths")
Mix.Task.run("compile")
if opts[:remote] do
run_remote(opts)
else
run_local(opts)
end
end
# ── Local synthetic-harness trace ────────────────────────────────────────────
defp run_local(opts) do
phase = parse_phase(opts[:phase] || "all")
Code.ensure_all_loaded([
Harness,
Harness.HarnessStruct,
Harness.HarnessProto,
Harness.HarnessGS,
Harness.HarnessApp
])
Mix.shell().info("Running Elixir characterization harness under trace…")
Mix.shell().info(" Phase: #{phase}\n")
result = OtpTrace.capture(fn -> apply(Harness, phase, []) end)
if opts[:json] do
write_json(result, opts[:json])
Mix.shell().info("Wrote #{opts[:json]}")
else
print_summary(result, phase)
end
end
# ── Remote (real-app) trace ──────────────────────────────────────────────────
defp run_remote(opts) do
node_str = opts[:remote]
duration = opts[:duration] || 30_000
node = String.to_atom(node_str)
ensure_distribution_started!()
Mix.shell().info("Tracing #{node_str} for #{duration / 1000}s…")
Mix.shell().info(" Drive the app on the device while the window is open.\n")
case :rpc.call(node, Mob.Diag, :mfa_trace, [duration]) do
{:badrpc, reason} ->
Mix.shell().error("RPC failed: #{inspect(reason)}")
Mix.shell().error("""
Is the device connected? Try `mix mob.connect` in another
terminal first, then re-run this command.
""")
exit({:shutdown, 1})
result ->
if opts[:json] do
write_remote_json(result, opts[:json])
Mix.shell().info("Wrote #{opts[:json]}")
else
print_remote_summary(result, node)
end
end
end
defp print_remote_summary(result, node) do
h1 = IO.ANSI.bright()
dim = IO.ANSI.faint()
reset = IO.ANSI.reset()
Mix.shell().info("#{h1}=== Remote trace summary ==={reset}")
Mix.shell().info(" Node: #{node}")
Mix.shell().info(" Duration: #{result.duration_ms / 1000}s")
Mix.shell().info(" Modules touched: #{result.module_count}")
Mix.shell().info(" Unique MFAs: #{result.mfa_count}")
Mix.shell().info("\n#{h1}=== Modules touched (sorted) ==={reset}\n")
{elixir, erlang} =
result.modules
|> Enum.split_with(&String.starts_with?(to_string(&1), "Elixir."))
Mix.shell().info(" #{dim}Elixir modules (#{length(elixir)}):#{reset}")
for m <- elixir, do: Mix.shell().info(" #{inspect(m)}")
Mix.shell().info("\n #{dim}Erlang modules (#{length(erlang)}):#{reset}")
for m <- erlang, do: Mix.shell().info(" #{inspect(m)}")
Mix.shell().info("")
end
# mix doesn't start distribution by default. Without it, :rpc.call
# returns {:badrpc, :nodedown} even though the target node is alive
# and registered in EPMD. Self-name into a unique node so multiple
# invocations don't collide.
defp ensure_distribution_started! do
if Node.alive?() do
:ok
else
name = :"mob_trace_otp_#{System.unique_integer([:positive])}@127.0.0.1"
case Node.start(name, :longnames) do
{:ok, _} ->
:ok
{:error, reason} ->
Mix.raise("Failed to start distribution: #{inspect(reason)}")
end
end
Node.set_cookie(:mob_secret)
end
defp write_remote_json(result, path) do
payload = %{
mfas:
result.mfas
|> Enum.map(fn {m, f, a} -> [to_string(m), to_string(f), a] end),
modules: Enum.map(result.modules, &to_string/1),
mfa_count: result.mfa_count,
module_count: result.module_count,
duration_ms: result.duration_ms,
captured_at: DateTime.to_iso8601(result.captured_at)
}
File.write!(path, Jason.encode!(payload, pretty: true))
end
defp parse_phase(name) do
atom = String.to_existing_atom(name)
if atom in @phases do
atom
else
Mix.raise("Unknown phase: #{name}. Valid: #{Enum.join(@phases, ", ")}")
end
rescue
ArgumentError ->
Mix.raise("Unknown phase: #{name}. Valid: #{Enum.join(@phases, ", ")}")
end
defp print_summary(result, phase) do
h1 = IO.ANSI.bright()
dim = IO.ANSI.faint()
reset = IO.ANSI.reset()
Mix.shell().info("#{h1}=== Trace summary ==={reset}")
Mix.shell().info(" Phase exercised: #{phase}")
Mix.shell().info(" Modules touched: #{MapSet.size(result.modules)}")
Mix.shell().info(" Unique MFAs: #{MapSet.size(result.mfas)}")
Mix.shell().info(" Wall time: #{MobDev.Duration.format_us(result.elapsed_us)}")
Mix.shell().info("\n#{h1}=== Modules touched (sorted) ==={reset}")
{elixir, erlang} =
result.modules
|> Enum.sort()
|> Enum.split_with(fn m -> String.starts_with?(to_string(m), "Elixir.") end)
Mix.shell().info("\n #{dim}Elixir modules (#{length(elixir)}):#{reset}")
for m <- elixir do
Mix.shell().info(" #{inspect(m)}")
end
Mix.shell().info("\n #{dim}Erlang modules (#{length(erlang)}):#{reset}")
for m <- erlang do
Mix.shell().info(" #{inspect(m)}")
end
Mix.shell().info("")
end
defp write_json(result, path) do
payload = %{
modules: result.modules |> Enum.sort() |> Enum.map(&to_string/1),
mfas:
result.mfas
|> Enum.sort()
|> Enum.map(fn {m, f, a} -> [to_string(m), to_string(f), a] end),
elapsed_us: result.elapsed_us,
module_count: MapSet.size(result.modules),
mfa_count: MapSet.size(result.mfas)
}
File.write!(path, Jason.encode!(payload, pretty: true))
end
end