Mob.Defect (mob v0.8.2)

Copy Markdown View Source

Entry points that convert what the framework's detectors produce into capsules on the defect bus.

The framework's detectors have their own shapes — an invariant violation is a Mob.Invariant.Violation, a divergence is the {:divergence, %{...}} a comparator returns. Rather than teach each detector to build a capsule, those detectors call the appropriate function here and this module supplies the fingerprint key, the owner, and the evidence shape.

Centralising the mapping is what keeps owner: :mob from being copied into five places, and what makes the fingerprint key for the same kind of defect identical across detectors — the whole point of a fingerprint is that a divergence detected on iOS from mob_dev and the same divergence detected on-device from a fallback path land on the same triage item.

Nothing propagates on the return path

Every function here returns the capsule it built, but the emit is a side-effect on the bus and the caller does not need to do anything with the return value. That matches the shape of Mob.Agent.Receipts.record/1 and lets a caller drop the emit onto an existing pipeline without restructuring around a result.

Summary

Functions

Emit a defect for one Android ApplicationExitInfo entry.

Emit a defect for an erl_crash.dump a scan turned up.

Emit a defect for a differential-comparator divergence.

Emit a defect for a confirmed invariant violation.

Emit a defect for one MetricKit diagnostic delivered on iOS.

Functions

emit_appexit_reason(entry)

@spec emit_appexit_reason(map()) :: Mob.Defect.Capsule.t()

Emit a defect for one Android ApplicationExitInfo entry.

Owner is :mob — an ApplicationExitInfo record describes the death of this app's own process from the OS's point of view, and mob owns the process during a mob-app lifetime.

The entry map comes from Mob.PostMortem.Android.sweep/0's drain of the native queue. Its shape:

%{
  reason_code: 6,       # ApplicationExitInfo.REASON_* integer
  pid: 12345,
  timestamp_ms: 1_726_050_000_000,
  process_name: "com.example.app",
  description: "remote process crash"
}

Reason code → kind mapping (numeric constants from Android's android.app.ApplicationExitInfo public API, hard-coded here rather than depending on the JNI shim to name them):

Reason(s)kindseverity
REASON_CRASH_NATIVE (5), REASON_CRASH (4), REASON_SIGNALED (2):native_crash:fatal
REASON_ANR (6):anr:critical
REASON_LOW_MEMORY (3), REASON_EXCESSIVE_RESOURCE_USAGE (9):oom:fatal
REASON_USER_STOPPED (11), REASON_USER_REQUESTED (10), REASON_EXIT_SELF (1), REASON_DEPENDENCY_DIED (12):user_kill:info
anything else (unknown / rare):user_kill:info

The :info default for unrecognised reason codes is deliberate. A novel reason a future Android version invents is not a defect to page a triager on; downgrading to routine is safer than upgrading to critical. The specific REASON_* mappings above match Android's documented severity semantics.

Fingerprint groups by (kind + process_name + reason_code) — the same class of exit for the same process across boots becomes one triage row.

emit_beam_crash(finding)

@spec emit_beam_crash(map()) :: Mob.Defect.Capsule.t()

Emit a defect for an erl_crash.dump a scan turned up.

Owner is :mob — the BEAM is what mob depends on and what mob's own invariants and scheduling assume; a hard crash there is a framework concern before it is an application one. kind: :beam_crash.

Severity comes from what the slogan implies: OOM and out-of-memory variants are :fatal; a normal termination is :info; everything else defaults to :critical.

The fingerprint key is the normalized slogan (see Mob.PostMortem.BeamCrashDump.normalize_slogan/1) rather than the raw one, because raw slogans embed the exact runtime data that varied per crash — a {badarg, ...} from io:put_chars/2 carries a 4KB binary literal that would otherwise open one triage row per unique input.

Evidence carries the human-facing fields plus the on-disk path so a triager can open the dump in crashdump_viewer when they want more than the header. The dump file itself is NOT read into evidence — eight kilobytes is the ceiling and the header is what fits.

emit_divergence(map, opts \\ [])

@spec emit_divergence(map(), keyword() | map()) :: Mob.Defect.Capsule.t()

Emit a defect for a differential-comparator divergence.

Real entrypoint, pending an in-tree caller. Mob.Differential.compare/3 is a pure comparator; the caller that runs it — currently mob_dev, via MobDev.Differential.run/3 — invokes this after a :divergence result to put the class onto the bus. A follow-up mob_dev change wires that up after this ships to Hex; nothing in mob itself calls this yet.

Owner is :mob — the differential comparator asserts one design, both platforms, which is a framework-level promise. kind: :divergence.

The fingerprint key includes the fixture (so the same divergence in two fixtures reports separately), the reason (:type, :label, :frame, :child_count), and the path to the diverging node. The ios and android values are on evidence, not on the fingerprint key — a fixture's divergence in kind (say, always :type at [0, 2]) is the same class even if the tree values change slightly across releases.

emit_invariant_violation(v)

@spec emit_invariant_violation(Mob.Invariant.Violation.t()) :: Mob.Defect.Capsule.t()

Emit a defect for a confirmed invariant violation.

Owner is :mob — invariants are checks the framework makes about itself, and by construction they cannot fail for the app. kind: :invariant.

The fingerprint key is %{invariant: name, screen: screen_module}, so a violation of the same invariant on the same screen groups across occurrences, and the same invariant on a different screen — a leak class that appears in two places — reports separately.

emit_metrickit_payload(payload)

@spec emit_metrickit_payload(map()) :: Mob.Defect.Capsule.t()

Emit a defect for one MetricKit diagnostic delivered on iOS.

Owner is :mob — a MetricKit payload names an event the OS attributes to this app process, and mob owns the process during a mob-app lifetime.

The payload map comes from Mob.PostMortem.IOS.sweep/0's drain of the native queue. Its shape:

%{
  kind: :native_crash | :anr | :perf_regression,
  top_frame: %{binary: "MyApp", offset: 123456},
  timestamp_ms: 1_726_050_000_000,
  raw_json: <<...>>  # MXDiagnosticPayload.JSONRepresentation
}

The fingerprint key is the top stack frame — a crash in the same place across builds groups into one triage row. The frame's binary name is not the human function name (MetricKit gives mangled symbols only, no user data); it is the safe identifier the OS gives us, which is what we can safely put on the wire.

Severity is picked from the diagnostic kind:

  • :native_crash:fatal (the app terminated abnormally)
  • :anr:critical (the main thread stalled past the OS's hang threshold; the app may or may not have recovered)
  • :perf_regression:warning (CPU / disk exception; the app kept running, this is a heads-up)