MobDev.OtpAudit (mob_dev v0.5.4)

Copy Markdown View Source

Reachability analysis for the bundled OTP runtime tree of a Mob app.

Walks every .beam file under an OTP root, extracts the imports chunk to learn who calls whom, and computes the transitive closure starting from the app's entry-point modules. Anything not reachable is a strip candidate — modules, whole libs, or duplicate library versions.

Used by mix mob.audit_otp (read-only report) and the planned mix mob.release --slim flag (auto-strip based on the report).

Entry points

Reachability seeding is deliberately generous:

  • App's start/2 callback module (read from each .app file's mod key)
  • All exported functions of kernel and stdlib (BEAM startup needs them)
  • elixir, logger, eex (runtime support that gets called via macros)

Anything reachable from those is kept; the rest is candidate-for-strip.

Output

Returns a map with:

  • :libs — every lib found, with reachable/total module counts and KB
  • :duplicates — libs that appear multiple times (only newest is kept)
  • :foreign_apps — non-OTP, non-app code in the lib dir (other projects)
  • :strippable_libs — libs with zero reachable modules
  • :total_kb / :reachable_kb / :strippable_kb — size summary

All sizes are post-strip — i.e. what mix mob.release actually ships, not raw OTP source.

Summary

Functions

Run the audit. otp_root is the directory containing lib/ and erts-*/ (typically the runtime tree extracted into the app bundle, or the cache the release packaging copies from).

Reads one or more JSON trace files written by mix mob.trace_otp --json and unions their modules atoms into a single MapSet suitable for :trace_input.

Types

lib_name()

@type lib_name() :: String.t()

lib_report()

@type lib_report() :: %{
  name: lib_name(),
  version: String.t() | nil,
  path: String.t(),
  modules_total: non_neg_integer(),
  modules_reachable: non_neg_integer(),
  modules_traced: non_neg_integer() | nil,
  kb_total: non_neg_integer(),
  kb_reachable: non_neg_integer(),
  unreachable_modules: [module_atom()],
  untraced_modules: [module_atom()] | nil,
  is_app_under_test?: boolean()
}

module_atom()

@type module_atom() :: atom()

report()

@type report() :: %{
  otp_root: String.t(),
  app_name: String.t() | nil,
  libs: [lib_report()],
  duplicates: %{required(lib_name()) => [String.t()]},
  foreign_apps: [String.t()],
  foreign_app_names: [lib_name()],
  strippable_libs: [lib_name()],
  trace_strippable_libs: [lib_name()] | nil,
  total_kb: non_neg_integer(),
  reachable_kb: non_neg_integer(),
  strippable_kb: non_neg_integer()
}

Functions

audit(otp_root, opts \\ [])

@spec audit(
  String.t(),
  keyword()
) :: report()

Run the audit. otp_root is the directory containing lib/ and erts-*/ (typically the runtime tree extracted into the app bundle, or the cache the release packaging copies from).

Options

  • :app_name — the application's atom name (e.g. :air_cart_max). Used to seed reachability from the app's modules. If omitted, every lib with no mod callback is treated as a potential entry point (broader, finds less to strip).

  • :project_deps — list of atoms naming the project's deps (the transitive closure, as Mix sees them). When given, the foreign-app classifier uses it as the authoritative set of "non-OTP libs that are supposed to be in this bundle." Anything in the bundle whose name isn't OTP-shipped, Elixir-shipped, the app under test, or in :project_deps is classified as foreign and quarantined into report.foreign_apps. The mix mob.audit_otp task auto-detects this from the current Mix.Project.

    When omitted, the classifier falls back to a narrow name-pattern heuristic (test_, toy_, mob_test, scratch_) — sufficient for tests, less accurate in real bundles.

  • :trace_input — a MapSet (or list) of module() atoms that were observed at runtime during a trace window. Comes from MobDev.OtpTrace.capture/1 (local synthetic harness) or Mob.Diag.mfa_trace/1 (remote device trace). When given, the report grows two fields per lib (modules_traced, untraced_modules) and one top-level field (trace_strippable_libs) listing libs whose modules were ALL absent from the trace — i.e., empirically never called.

    Static reachability misses dynamic dispatch (apply/3, :erlang.load_nif, runtime config). Trace data catches everything that actually ran. The intersection strippable_libs ∩ trace_strippable_libs is the high-confidence strip set; trace_strippable_libs \ strippable_libs is the "static graph reaches it but nothing actually called it" set that lets you strip partly-used libs like megaco / snmp / diameter.

union_trace_jsons(paths, on_read_error \\ &default_trace_read_warn/2)

@spec union_trace_jsons([Path.t()], (Path.t(), term() -> any())) :: MapSet.t() | nil

Reads one or more JSON trace files written by mix mob.trace_otp --json and unions their modules atoms into a single MapSet suitable for :trace_input.

Returns:

  • nil when given an empty list (the audit will take its no-trace branch).
  • nil when every read failed — better than handing back an empty set, which would let the trace-augmented expansion strip every partly-used lib in the bundle. A warning is emitted via on_read_error.(path, reason) for each failure so callers can route them through their own logging.
  • MapSet.t/0 of module() atoms otherwise.

Multi-trace union is the right shape for "this lib is never called" claims: a 60-second window only exercises one slice of the app. Unioning boot + UI + auth + idle captures lets users say "across ALL captured sessions, this lib was never touched" — a much stronger signal than any single trace.

Example

iex> MobDev.OtpAudit.union_trace_jsons(["priv/boot.json", "priv/ui.json"])
#MapSet<[:kernel, :Elixir.Enum, :Elixir.Map, ...]>