Packages
mob_dev
0.6.21
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
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