Mob.Diag (mob v0.9.7)

Copy Markdown View Source

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

load_failure()

@type load_failure() :: %{module: module(), reason: term()}

load_report()

@type load_report() :: %{
  total: non_neg_integer(),
  loaded: non_neg_integer(),
  failed: [load_failure()],
  elapsed_us: non_neg_integer(),
  otp_root: String.t() | nil
}

loaded_snapshot()

@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

health()

@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, or nil while down. The tables are then held by Mob.Diag.Heir, still written to, until the owner the heir restarts takes them back. An owner that stays nil with its tables at the heir failed to restart, and the reason was logged. The store's next write after a mob upgrade starts one too.
  • tables — each table's size and whether the :owner, the :heir, another process (:other) holds it, or it is :missing
  • lost — 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 far
  • state_vsn — current and expected state versions (differ briefly after a hot push)
  • framework_vsn — the Mob.Diag.Store version the store was set up under (nil for 0.9.5) and the running one. They differ after a mob upgrade until the store's next write sets it up again.
  • store — store-specific counts, e.g. evictions (:stale while 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.

loaded_snapshot()

@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:

  1. Deploy the app
  2. User exercises every flow they care about
  3. RPC Mob.Diag.loaded_snapshot/0 from a Mix task
  4. Cross-reference :unloaded_in_bundle with 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.

mfa_trace(duration_ms \\ 30000)

@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/3 is 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.

verify_loaded_modules()

@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.