The bounded record of defects the framework has emitted, and the fanout to whoever subscribed to hear about them.
Deduplication
Every emit goes through fingerprint. One thousand emits of the same bug
produce one class row whose occurrence counter reaches 1000 as each one
lands, with the first-seen capsule held for the class and last_seen_ms
moving forward. That is what makes the channel usable — a report system
that emitted a thousand rows per bug would train its readers to stop
reading it.
The counter and last-seen fields are read directly from the row on each
call to classes/1; both are updated by atomic ETS ops on the write path
(update_counter and update_element), so a listing is consistent under
concurrency down to the individual row.
A parallel bounded ring keeps the most recent 64 raw capsules, keyed by sequence, for the case where a triager wants to walk through occurrences rather than classes. Ring rather than unbounded because a capsule is packaged memory in a production app, and defect-report storage that grows without limit is worse than a bug it might have described.
Classes are bounded too, at 256. A fingerprint is only as stable as the
fingerprint_key its caller supplied, and app code supplies some, so a key
that carries per-occurrence data would otherwise add a class per emit for
the life of the app. Past the bound the least recently seen class is evicted
and counted (Mob.Diag.health/0, class_evictions).
Fanout without a mailbox on the hot path
Same reasoning as Mob.Agent.Receipts: an emit/1 is on the path of every
detected defect, and putting a GenServer in front of that path serialises
every writer through one mailbox. Subscribers are kept by
Mob.Diag.Subscribers, which monitors them and publishes the list to
:persistent_term; the write path reads that once and sends to each pid
directly. A subscriber lifecycle change is a rare event; a defect emit is not.
Observation
Post-mortems that the OS hands over only once (Mob.PostMortem.Journal)
stay on disk until someone has seen the specific capsule. The bus answers
that per capsule, never by sequence range: emit_delivered/1 tells the
journal's sweep how many subscribers a capsule was handed to, and
recent/1 passes the capsules it actually returns to
Mob.PostMortem.Journal.observed/1, which is one :persistent_term read
unless post-mortems are waiting. classes/1 and subscribe/1 observe
nothing: a class row does not show the specific occurrence, and a new
subscriber has seen nothing yet. emit/1 itself does no extra work.
No default sink
A subscriber is a pid, and no pid is subscribed until an app registers one.
Per the decision record, mob owns the format and the bus; it never owns a
destination. The dev sink in Mob.Defect.Sinks.Dev is what a connected
agent runs at its end after mix mob.connect.
Subscribers must not crash the emit path
A subscriber pid is a send/2 target on the emit path. send/2 never
blocks and never raises on a dead pid, so a dead subscriber does not take
down the emitter — its monitor prunes the pid from the cached list. But the
contents of what a subscriber does with the message must not affect the
emitter, which is the standard contract of message passing and not enforced
here.
emit/1 itself never raises: a failure to record is counted as lost in
Mob.Diag.health/0, because a defect reporter that crashes on the defect it
is reporting destroys the report.
Summary
Functions
How many distinct defect classes are held.
Every defect class currently held, newest first by last_seen_at.
Emit a capsule.
The most recent capsules (raw occurrences), newest first.
Subscribe pid (defaults to the caller) to defect emits.
The subscribers the write path will fan out to right now.
Unsubscribe pid (defaults to self()).
Functions
@spec class_count() :: non_neg_integer()
How many distinct defect classes are held.
@spec classes(pos_integer()) :: [map()]
Every defect class currently held, newest first by last_seen_at.
A class row carries the first capsule seen for that fingerprint plus the occurrence count and last-seen timestamp. Later occurrences are on the recent ring, not layered onto the class — that keeps the class row a bounded shape regardless of how noisy the defect gets.
@spec emit(Mob.Defect.Capsule.t()) :: Mob.Defect.Capsule.t()
Emit a capsule.
Records the class (incrementing occurrences), appends to the recent-ring,
and fans out to every subscribed pid as {:mob_defect, capsule}.
Returns the capsule, so this can sit at the end of a pipeline.
@spec recent(pos_integer()) :: [Mob.Defect.Capsule.t()]
The most recent capsules (raw occurrences), newest first.
Returning a capsule counts as observing it (see "Observation" above).
Subscribe pid (defaults to the caller) to defect emits.
Returns {:ok, ref} — the caller can keep the ref for its own bookkeeping,
but does not need it to unsubscribe (unsubscription is by pid).
Idempotent: subscribing an already-subscribed pid is a no-op.
The subscriber is monitored; its exit prunes it. A subscriber on another node
whose connection drops stops receiving and resumes when the node reconnects,
without re-subscribing (see Mob.Diag.Subscribers). From a connected node,
pass the pid to receive on — :rpc.call(node, Mob.Defect.Bus, :subscribe, [self()]). Without it, :rpc subscribes the short-lived process it runs the
call in, which receives nothing.
@spec subscribers() :: [pid()]
The subscribers the write path will fan out to right now.
@spec unsubscribe(pid()) :: :ok
Unsubscribe pid (defaults to self()).