eta_observe (eta v0.1.0)
Copy MarkdownReading a process's state without asking it — the observability half of the DST
framework (design: docs/design.md).
An invariant runs against a system the scheduler has frozen. Every process it owns
is suspended, so anything that sends a message and waits cannot be served. That
rules out the natural way to observe a gen_server, and it does so in the worst
possible manner: a client API that catches its own call timeout answers plausibly
rather than failing. A registry status/1 that returns undefined on exit:_
leaves a split-brain invariant built on it computing over "no member believes it leads" and
passes, having checked nothing.
The fix is for the process to publish rather than be asked.
eta_transform's observability pass makes that automatic: a module declares what to expose and
every callback return republishes it, into the process dictionary, where
erlang:process_info/2 can read it from outside — including while suspended, in a
couple of microseconds, regardless of mailbox depth.
gen_server and gen_statem are both covered. For a state machine the observed
term is the data, not the state name: the state name is one atom a trace
already carries, and the data is what an invariant is about.
-ifdef(DST).
-compile({parse_transform, eta_transform}).
-endif.
-eta_observe({state, [leader, epoch]}).#{leader := L, epoch := E} = eta_observe:read(my_registry).Why the process dictionary
It is the only place that satisfies all of the constraints at once. ETS needs a
table, and a table needs a name — deriving one per process grows the atom table
without bound, which no long-running system can afford, while sharing an
existing table means putting foreign rows in a structure that is often load-bearing
(the registry's names table is its replication payload). persistent_term writes
trigger a global scan. The process dictionary needs no name, belongs to exactly the
process being observed, dies with it, and costs ~7ns to write a term already on the
heap.
Cost, and why it is acceptable
Publishing happens on every callback return, which is what makes staleness
impossible — there is no assignment site to forget, because the transform does not
track assignments. Measured at ~7ns per publish for a large state, because put/2
stores a reference rather than copying.
And it is absent from production entirely: the transform is applied under
-ifdef(DST), so a release build has no publishing in it at all. The -eta_observe
attribute itself is inert — an ordinary module attribute the compiler ignores.
What to declare
Two forms, and neither guesses.
{RecordName, Fields} publishes those fields of that record as a map. Name the
record. It is not inferred, so a state record called #st{} works the same as
one called #state{}, and a field that does not exist is a compile error rather
than a silently wrong element/2 offset. This is the form to prefer.
all publishes whatever the callback returned, whatever its shape, including a
map or a bare term. It costs nothing to write and makes every read/1 copy the
entire state to the reader, which is usually the wrong trade: read/1 is called
after every step of a simulation, and a state holding an inverted index or a
queue is not something to copy thousands of times.
There used to be a third form, a bare field list, which meant "these fields of
#state{}". It is gone. A module whose record was named anything else failed at
compile time complaining about a record it had never declared, and a reader had
to know the convention to see why.
This documentation is LLM-generated. See the AI disclosure in README.md.
Summary
Functions
-spec key() -> atom().
The process-dictionary key the transform publishes under.
What Target last published, or undefined if it has published nothing or is not
running.
Works on a suspended process: erlang:process_info/2 reads the target's heap
rather than asking it anything. That is the entire point — see the module doc.