Mob.Defect.Capsule (mob v0.8.3)

Copy Markdown View Source

A defect, in the shape a consumer can act on.

This is the format from decisions/2026-09-04-defect-reports-are-a-shipped-feature.md. The record is a struct in memory and mob.defect/1 JSON on the wire, and the two are the same shape — a struct field with a snake-case atom becomes a JSON key with the same spelling, so a reader of one can read the other without a schema translation.

What is fixed here vs. what a caller provides

A caller — the invariant registry, the differential comparator, the crash handler, whatever — supplies what makes this defect this defect: kind, owner, severity, evidence, and the fingerprint inputs. The capsule fills in what is the same across every defect: schema, id, detected_at, build, device, redaction. That split is deliberate. A detector that had to construct the build section itself would drift out of step with the rest of the reports the moment mob bumped its version, and a fingerprint the caller computed by hand would land in three different shapes.

Fingerprint

fingerprint groups occurrences into one triage item. One thousand phones hitting the same bug produces one thousand ids and one fingerprint, and that is what makes the report channel usable rather than a firehose.

The stable inputs are what defines the defect, and only those:

  • kind, owner
  • The caller's fingerprint_key — a small map like %{invariant: :parked_screen_alive, screen: MyScreen} — describing the defect class, not this occurrence

The excluded inputs are what changes across occurrences of the same defect:

  • id, detected_at, monotonic_us
  • build.*, device.*
  • evidence beyond what the caller put in fingerprint_key

A fingerprint that included the build version would open a new triage item every release, which is what the pre-shipped defect systems in this space do wrong.

Redaction

A capsule is written to a bounded ring buffer, and from there to whatever sink the app has wired up (dev-mode: the connected agent; production: whatever the app developer configured, if anything). The write and the possibility of a sink are the same event: by the time the caller has a capsule value, redaction has already happened. Sinks receive already-safe data, per the "redaction is a precondition" clause of the decision record.

For phase 1 the discipline is pushed onto the callers: an evidence map that arrives here is trusted to contain no application state, and the capsule tags it redaction: :applied on that basis. The invariant registry and the differential comparator both already promise that (see Mob.Invariant.Violation's moduledoc and Mob.Differential's docs), so the emit paths in phase 1 are trustworthy inputs by construction. Later phases (native crash, ApplicationExitInfo, incident-capsule from a raised exception) will need Mob.Agent.Receipt.summarize_error/3-style reduction, which happens before the value reaches new/1.

A caller that provably packages no state can pass redaction: :none to new/1, matching the schema. The default is :applied, and the default is the safe answer.

Bounded shapes

A defect from an app in the field has to fit through a pipe an app developer is willing to keep open. evidence and fingerprint_key are truncated during construction: strings above 4096 bytes are clipped and lists above 64 elements are cut, with a tag left in place so a consumer can see clipping happened rather than treat the visible half as the whole. The recursion depth cap protects the packager from a maliciously deep term — a defect reporter that can be crashed by the defect it is reporting is worse than none.

Summary

Functions

The build section, computed once per node and cached.

A one-line triage summary for a human reading a log.

The device section, computed once per node and cached.

A stable sha256 hex over the defect-defining fields only.

Build a capsule.

The JSON-shaped map for the wire.

Types

build_info()

@type build_info() :: %{
  mob: String.t() | nil,
  mob_dev: String.t() | nil,
  app: String.t() | nil,
  commit: String.t() | nil,
  dirty: boolean() | nil,
  otp: String.t() | nil,
  elixir: String.t() | nil,
  loaded_md5: map()
}

device_info()

@type device_info() :: %{
  platform: :ios | :android | :host,
  os: String.t() | nil,
  model: String.t() | nil,
  simulator: boolean() | nil,
  locale: String.t() | nil,
  scale: number() | nil
}

kind()

@type kind() ::
  :native_crash
  | :beam_crash
  | :anr
  | :oom
  | :user_kill
  | :invariant
  | :divergence
  | :deploy_mismatch
  | :perf_regression

owner()

@type owner() ::
  :mob | :mob_dev | :mob_new | {:plugin, atom() | String.t()} | :app | :unknown

redaction()

@type redaction() :: :applied | :none

repro()

@type repro() :: %{available: boolean(), minimized: boolean(), steps: [term()]}

severity()

@type severity() :: :fatal | :critical | :warning | :info

t()

@type t() :: %Mob.Defect.Capsule{
  build: build_info(),
  detected_at: String.t(),
  device: device_info(),
  evidence: map(),
  fingerprint: String.t(),
  id: String.t(),
  kind: kind(),
  owner: owner(),
  redaction: redaction(),
  repro: repro(),
  schema: String.t(),
  severity: severity()
}

Functions

build_info()

@spec build_info() :: build_info()

The build section, computed once per node and cached.

Versions do not change during a session. Application.spec/2 is cheap on its own, but the write path pays it on every emit — the small cost adds up under a burst of confirmed violations from a runaway teardown, and the cached value is trivially cheaper. Same reasoning as device_info/0.

describe(c)

@spec describe(t()) :: String.t()

A one-line triage summary for a human reading a log.

Deliberately compact and free of any application state — everything shown here has already been through the capsule's redaction contract, so this is safe to log even when the sink is a remote agent over dist.

device_info()

@spec device_info() :: device_info()

The device section, computed once per node and cached.

platform, os, model, and the fields that will populate around them do not change during a session — the OS reports the same model on every call, and OS version changes only across upgrades that require a relaunch. Every emit was paying two NIF hops for those on the write path, which on a teardown-heavy sampling point (:on_screen_stop) is real cost added to every confirmed violation. Read once, cache in :persistent_term, done.

:persistent_term.put/2 is expensive (it triggers a global GC) but that cost pays for reads that are the cheapest thing on the BEAM, and this is a one-shot at first emit — a use-once, read-many pattern which is exactly the case persistent_term is documented for.

fingerprint(kind, owner, fingerprint_key)

@spec fingerprint(kind(), owner(), map()) :: String.t()

A stable sha256 hex over the defect-defining fields only.

kind, owner, and fingerprint_key are hashed with a canonical representation of the map (keys sorted, atoms unified with their string form) so two callers that produce the same defect land on the same fingerprint regardless of key insertion order. Time, id, build, device, and extra evidence are excluded — a fingerprint is a defect class, not an occurrence.

Exposed for tests and for callers that want to correlate a defect against something they compute themselves. Called by new/1, so ordinary construction does not need to call this directly.

new(opts)

@spec new(keyword() | map()) :: t()

Build a capsule.

Required:

  • :kind — see kind/0
  • :owner — see owner/0
  • :severity — see severity/0
  • :fingerprint_key — the map that identifies the defect class. Kept separately from :evidence so the fingerprint stays stable when evidence gains fields between releases.

Optional:

  • :evidence — kind-specific detail. Defaults to %{}. Truncated during construction.
  • :redaction:applied (default) or :none. :none is only legal for a capsule that provably carries no application state.
  • :repro — a repro map. Defaults to %{available: false, minimized: false, steps: []} since phase 1 does not attempt reproduction.
  • :build / :device — override the framework-populated sections (tests use this to make snapshots deterministic).
  • :now_ms — Unix milliseconds for detected_at. Defaults to System.system_time(:millisecond); tests use this to make timestamps deterministic.

to_json(c)

@spec to_json(t()) :: map()

The JSON-shaped map for the wire.

Structurally identical to the struct — a struct field with a snake-case atom becomes a JSON key of the same name. Owners get their string form: {:plugin, :foo} becomes "plugin:foo", matching the schema.

Callers that want bytes rather than a map pass this to Jason.encode!/1. Jason is mob's only runtime dep, so a caller inside the tree can always encode; a caller outside can too but is not obliged to.