Current section

Files

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

lib/mix/tasks/mob.audit_otp.ex

defmodule Mix.Tasks.Mob.AuditOtp do
@shortdoc "Reports which OTP libs your bundled app actually uses"
@moduledoc """
Walks an OTP runtime tree and tells you which libraries are dead weight.
Reads each `.beam` file's `imports` chunk to build a call graph, seeds
reachability from your app's modules + the OTP runtime essentials
(kernel/stdlib/elixir/logger/sasl), and reports:
* Libraries with zero reachable modules → safe to strip entirely
* Duplicate library versions → keep only the newest
* Foreign apps from another project's release tree → cache cruft
* Per-lib breakdown of reachable vs total module count + KB
## Usage
mix mob.audit_otp # audit the most recent release tree
mix mob.audit_otp --root path/to/otp # audit a specific tree
mix mob.audit_otp --json # machine-readable output
mix mob.audit_otp --trace-json path/to/trace.json # cross-reference against trace data
# Multiple traces are UNIONED — a lib is trace-strippable only if
# NONE of the captures observed any of its modules. Much safer
# than a single window for production stripping decisions.
mix mob.audit_otp \
--trace-json /tmp/boot.json \
--trace-json /tmp/ui.json \
--trace-json /tmp/auth.json
## Where the audit reads from
Looks for an OTP root in this order:
1. `--root` arg if given
2. `_build/mob_release/<App>.app/otp` (latest release build)
3. The iOS device cache at `~/.mob/cache/otp-ios-device-*`
## What this is NOT (yet)
Read-only. Does not modify the bundle. The companion `mix mob.release
--slim` will use the same audit to drive auto-stripping; this task is
the dry-run that lets you see what would happen.
"""
use Mix.Task
alias MobDev.OtpAudit
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [
root: :string,
json: :boolean,
app: :string,
trace_json: [:string, :keep]
]
)
root = resolve_root(opts[:root])
app_name = opts[:app] || infer_app_name()
project_deps = infer_project_deps()
trace_paths = Keyword.get_values(opts, :trace_json)
trace_input = union_trace_jsons(trace_paths)
Mix.shell().info("Auditing OTP tree: #{root}")
if app_name, do: Mix.shell().info("App entry point: #{app_name}")
if project_deps,
do: Mix.shell().info("Project deps (allow-listed): #{length(project_deps)} apps"),
else: :ok
if trace_input,
do:
Mix.shell().info(
"Trace input: #{MapSet.size(trace_input)} unique modules observed across " <>
"#{length(trace_paths)} trace#{if length(trace_paths) == 1, do: "", else: "s"}"
),
else: :ok
Mix.shell().info("")
report =
OtpAudit.audit(root,
app_name: app_name && String.to_atom(to_string(app_name)),
project_deps: project_deps,
trace_input: trace_input
)
if opts[:json] do
report
|> Jason.encode!(pretty: true)
|> IO.puts()
else
print_report(report)
end
end
defp resolve_root(nil) do
candidates =
Path.wildcard("_build/mob_release/*.app/otp") ++
Path.wildcard(Path.expand("~/.mob/cache/otp-ios-device-*"))
case Enum.find(candidates, &File.dir?/1) do
nil ->
Mix.raise("""
No OTP tree found.
Run `mix mob.release --ios` first, or pass `--root path/to/otp`.
Searched:
#{Enum.map_join(candidates, "\n", &" #{&1}")}
""")
path ->
path
end
end
defp resolve_root(path), do: path
defp infer_app_name do
case Mix.Project.get() do
nil -> nil
_ -> Mix.Project.config()[:app]
end
end
# Returns the project's runtime-dep apps, or nil when not in a Mix
# project context. `_build/dev/lib/` is Mix's view of every app the
# project needs (top-level deps + transitive closure + the app itself);
# using that as the source means we get exactly what Mix would have
# installed, no extra closure walking needed.
defp infer_project_deps do
case Mix.Project.get() do
nil ->
nil
_ ->
case File.ls("_build/dev/lib") do
{:ok, libs} -> Enum.map(libs, &String.to_atom/1)
_ -> nil
end
end
end
# Reads one or more JSON trace files via OtpAudit.union_trace_jsons/2.
# In the CLI a failed read should be a hard error (typo in path,
# missing file the user explicitly asked for) — `Mix.raise` on the
# first failure, don't silently degrade.
defp union_trace_jsons(paths) do
OtpAudit.union_trace_jsons(paths, fn path, reason ->
Mix.raise("Could not read --trace-json #{path}: #{inspect(reason)}")
end)
end
defp print_report(r) do
h1 = IO.ANSI.bright()
dim = IO.ANSI.faint()
yellow = IO.ANSI.yellow()
red = IO.ANSI.red()
reset = IO.ANSI.reset()
Mix.shell().info("#{h1}=== Per-library breakdown ==={reset}")
Mix.shell().info("(libs sorted by total size; ✗ = nothing reachable, ◐ = partly used)")
Mix.shell().info("")
for lib <- r.libs do
icon =
cond do
lib.modules_reachable == 0 -> "#{red}#{reset}"
lib.modules_reachable < lib.modules_total -> "#{yellow}#{reset}"
true -> "✓"
end
version = if lib.version, do: "-#{lib.version}", else: ""
Mix.shell().info(
" #{icon} #{String.pad_trailing("#{lib.name}#{version}", 30)}" <>
" #{format_kb(lib.kb_total)}" <>
" #{lib.modules_reachable}/#{lib.modules_total} modules"
)
end
Mix.shell().info("")
Mix.shell().info("#{h1}=== Strippable (no reachable modules) ==={reset}")
if r.strippable_libs == [] do
Mix.shell().info(" #{dim}none — every shipped lib has at least one used module#{reset}")
else
for name <- r.strippable_libs do
lib = Enum.find(r.libs, &(&1.name == name))
Mix.shell().info(" #{red}#{name}#{reset} #{format_kb(lib.kb_total)}")
end
end
if r.trace_strippable_libs do
Mix.shell().info("\n#{h1}=== Trace-strippable (no traced modules) ==={reset}")
Mix.shell().info("(libs whose modules never appeared in the trace — strong strip signal)")
static = MapSet.new(r.strippable_libs)
trace_set = MapSet.new(r.trace_strippable_libs)
both = MapSet.intersection(static, trace_set) |> Enum.sort()
trace_only = MapSet.difference(trace_set, static) |> Enum.sort()
cond do
r.trace_strippable_libs == [] ->
Mix.shell().info(" #{dim}none — every lib had at least one module called#{reset}")
true ->
if both != [] do
Mix.shell().info("\n #{dim}both static + trace (high confidence):#{reset}")
for name <- both do
lib = Enum.find(r.libs, &(&1.name == name))
Mix.shell().info(" #{red}#{name}#{reset} #{format_kb(lib.kb_total)}")
end
end
if trace_only != [] do
Mix.shell().info(
"\n #{dim}trace-only (static graph reaches them; trace says never called):#{reset}"
)
for name <- trace_only do
lib = Enum.find(r.libs, &(&1.name == name))
Mix.shell().info(
" #{yellow}#{name}#{reset} #{format_kb(lib.kb_total)} " <>
"(#{lib.modules_reachable}/#{lib.modules_total} statically reachable)"
)
end
end
end
end
if map_size(r.duplicates) > 0 do
Mix.shell().info("\n#{h1}=== Duplicate library versions ==={reset}")
Mix.shell().info("(only the highest version is reachable; older ones are cache cruft)")
for {name, dupe_paths} <- r.duplicates, dupe_path <- dupe_paths do
Mix.shell().info(" #{yellow}#{name}#{reset} obsolete: #{Path.basename(dupe_path)}")
end
end
if r.foreign_apps != [] do
Mix.shell().info("\n#{h1}=== Foreign apps in lib/ ==={reset}")
Mix.shell().info("(other projects' code in your release — clean your OTP cache)")
for path <- r.foreign_apps do
Mix.shell().info(" #{red}#{Path.basename(path)}#{reset}")
end
end
duplicate_kb = duplicate_kb(r)
foreign_kb = foreign_kb(r)
estimated_savings = r.strippable_kb + duplicate_kb + foreign_kb
Mix.shell().info("\n#{h1}=== Summary ==={reset}")
Mix.shell().info(" Total shipped: #{format_kb(r.total_kb)}")
Mix.shell().info(" Reachable: #{format_kb(r.reachable_kb)}")
Mix.shell().info(" Strippable libs: #{format_kb(r.strippable_kb)}")
Mix.shell().info(" Duplicate versions: #{format_kb(duplicate_kb)}")
Mix.shell().info(" Foreign apps: #{format_kb(foreign_kb)}")
Mix.shell().info(" #{h1}Total potential savings: #{format_kb(estimated_savings)}#{reset}\n")
end
defp duplicate_kb(r) do
r.duplicates
|> Enum.flat_map(fn {_name, paths} -> paths end)
|> Enum.map(&dir_size_kb/1)
|> Enum.sum()
end
defp foreign_kb(r) do
r.foreign_apps |> Enum.map(&dir_size_kb/1) |> Enum.sum()
end
defp dir_size_kb(path) do
case System.cmd("du", ["-sk", path], stderr_to_stdout: true) do
{out, 0} -> out |> String.split() |> List.first() |> String.to_integer()
_ -> 0
end
end
defp format_kb(kb) when kb >= 1024, do: "#{Float.round(kb / 1024, 1)} MB"
defp format_kb(kb), do: "#{kb} KB"
end