Runtime diagnostics that run inside a Mob app's BEAM. Designed to be invoked via Erlang RPC from a developer's machine to inspect the actual state of a deployed app.
Pairs with mob_dev's tooling — mix mob.verify_strip calls into
verify_loaded_modules/0. Kept in the mob library (not mob_dev)
so the functions are present in every shipped app, not just at build
time on the developer's machine.
Don't expand the API surface here without thinking — anything added is permanently shipped to every Mob app and a permanent target for remote-execution if dist credentials leak.
Summary
Functions
Whether the framework's own diagnostics are working, so an empty answer from one of them can be told apart from a broken one.
Snapshot of what's currently loaded in the running BEAM, plus what's shipped-but-never-loaded (the empirical strip candidates).
Captures unique MFAs called during a tracing window from a running app.
Force-load every .beam file under the running app's OTP tree and
report any that fail. Used by mix mob.verify_strip to validate
that an aggressive strip didn't remove a module something else
needed.
Types
@type load_report() :: %{ total: non_neg_integer(), loaded: non_neg_integer(), failed: [load_failure()], elapsed_us: non_neg_integer(), otp_root: String.t() | nil }
@type loaded_snapshot() :: %{ loaded: [module()], loaded_count: non_neg_integer(), shipped_count: non_neg_integer(), unloaded_in_bundle: [module()], otp_root: String.t() | nil, captured_at: DateTime.t() }
Functions
@spec health() :: map()
Whether the framework's own diagnostics are working, so an empty answer from one of them can be told apart from a broken one.
:rpc.call(node, Mob.Diag, :health, [])Per store (Mob.Agent.Receipts, Mob.Defect.Bus, Mob.Invariant,
Mob.PostMortem.Registry, Mob.RenderStats):
owner— the owning pid, ornilwhile down. The tables are then held byMob.Diag.Heir, still written to, until the owner the heir restarts takes them back. An owner that staysnilwith its tables at the heir failed to restart, and the reason was logged. The store's next write after amobupgrade starts one too.tables— each table's size and whether the:owner, the:heir, another process (:other) holds it, or it is:missinglost— writes that failed and were dropped. Non-zero means the store's answers are incomplete.resets— times the tables had to be recreated after being lost (the owner and heir both died). Everything recorded before was lost with them.owner_starts— owner processes started so farstate_vsn— current and expected state versions (differ briefly after a hot push)framework_vsn— theMob.Diag.Storeversion the store was set up under (nilfor 0.9.5) and the running one. They differ after amobupgrade until the store's next write sets it up again.store— store-specific counts, e.g. evictions (:stalewhile the state is in a shape this code cannot read)
Plus the heir; the subscriber lists of Mob.Defect.Bus and
Mob.Event.Trace: per topic, how many receive (topics) and how many are
waiting for their node to reconnect (parked); and listener —
Mob.Listener's pid and undeliverable, the native events that arrived for
a screen that had died (each discrete one also has an :undeliverable
receipt). Counts and pids only, never recorded content. Read-only: it starts
nothing and repairs nothing.
@spec loaded_snapshot() :: loaded_snapshot()
Snapshot of what's currently loaded in the running BEAM, plus what's shipped-but-never-loaded (the empirical strip candidates).
In interactive mode (Mob's default), a module is loaded only when something calls into it. So the loaded set after a representative user session is "what the app actually needs." Anything in the bundle but not in the loaded set is a strong strip candidate.
Better than tracing for our purposes: zero overhead, no rate-limit worries, no risk of mailbox-overflowing a busy app.
Workflow:
- Deploy the app
- User exercises every flow they care about
- RPC
Mob.Diag.loaded_snapshot/0from a Mix task - Cross-reference
:unloaded_in_bundlewith the static audit: shipped + statically-reachable + never-loaded = high-confidence strip candidates.
Caveats: a flow that wasn't exercised won't show up. Run after a thorough session, not after just opening the app.
@spec mfa_trace(non_neg_integer()) :: %{ mfas: [{module(), atom(), arity()}], modules: [module()], mfa_count: non_neg_integer(), module_count: non_neg_integer(), duration_ms: non_neg_integer(), captured_at: DateTime.t() }
Captures unique MFAs called during a tracing window from a running app.
Wraps :erlang.trace_pattern/3 + :erlang.trace/3 for duration_ms,
then collects the unique {mod, fun, arity} set into ETS for retrieval.
Useful for empirical reachability beyond what loaded_snapshot/0
shows — loaded_snapshot/0 answers "which modules are loaded";
mfa_trace/1 answers "which functions actually got called during
this window." The MFA grain matters for Pass 4 (OpenSSL feature
surgery) where the question is "does the app call crypto:rsa_* at
all" not just "is the :crypto module loaded."
Returns:
%{
mfas: [{:crypto, :crypto_one_time_aead, 6}, ...],
modules: [:crypto, :ssl, ...],
mfa_count: 1247,
module_count: 89,
duration_ms: 30_000,
captured_at: ~U[...]
}Limits:
:erlang.trace/3is process-global. One trace at a time — overlapping calls clobber each other.- Holds an ETS table during the window. ~100k events / 60s on an active app, dedup keeps the unique set small.
- Tracing has a measurable runtime cost (~1.5–2× slowdown). Don't leave a trace running indefinitely.
@spec verify_loaded_modules() :: load_report()
Force-load every .beam file under the running app's OTP tree and
report any that fail. Used by mix mob.verify_strip to validate
that an aggressive strip didn't remove a module something else
needed.
Walks all entries in :code.get_path/0, finds the OTP root from
the first matching .../otp/lib/... path, and enumerates .beam
files under it.
Returns load_report/0. Failures usually mean a stripped lib
contained a transitive dependency of a kept module.