FSL.Monitor (fsl v0.2.0)

Copy Markdown View Source

A live registry of the machines currently running: one row each, updated as they move.

It exists so that a running system can be watched from outside — a terminal table, a web view, a control command — without polling the machines themselves or instrumenting them by hand. A row is created the first time a machine reports, and recycled when its slot is cleared.

The registry is optional and inert until started. Every reporting helper checks whether it is running and returns immediately if it is not, so a production run that does not want it pays nothing.

A row

ColumnHolds
scenariothe machine's module name
statethe state it is in now
eventwhat caused the last transition
event_typethat event's category, from FSL.Host.event_type/1
commandthe last command the machine issued
command_typethat command's category
accountwho the run is about, from FSL.Host.account/2
slotthe key the row is filed under
depth0 for a machine, 1 for a sub-FSM below its parent

Rows are keyed by slot id: an integer when a caller manages numbered slots, {parent_slot, name} for a machine started with spawn_fsm, and the machine's pid otherwise. Clearing a parent's slot clears its children's rows too.

Columns an application adds

The columns above are the machine's. An application that wants more declares them, with their defaults, when it starts the registry:

FSL.Monitor.start(columns: [medias: "n/a", server: "none"])

and writes them with note/2:

FSL.Monitor.note(:medias, "AV")

The registry never learns what those keys mean. Defaults are worth choosing: "n/a" states that this run negotiated nothing, where an empty string reads as "not measured yet".

A row is flatrow.medias, not row.extra.medias. Consumers declare the columns they display by plain key, and a nested map would make every one of them aware of which half a column came from.

Writing to it

FSL.Runner reports every transition. The verbs an embedding supplies report their commands with note_command/2, which is what fills the command column; FSL.Host.account/2 supplies the account column on every report.

Reading it

calls/0 returns every row, ordered so that a sub-FSM follows its parent.

For a live view, subscribe/1 is better than polling: it returns the current snapshot and registers the caller for {:fsl_monitor, {:updated, slot, row}} on every change and {:fsl_monitor, {:cleared, slot}} when a slot is recycled. Subscribers are monitored, so one that dies is dropped without an unsubscribe/1. See subscribe/1.

Example: what this looks like in a SIP application

Elixip is one embedding of FSL, where a machine is a SIP scenario and a run is a call. Its host declares three columns of its own — the media the call negotiated, the media server it uses, and the destination it dialled — and its session verbs report commands such as send_INVITE. A kelixip server joins these rows with its own, and serves the result over a control API so an operator sees, live, which call is in which state and what moved it there.

Summary

Types

One row: the machine's own columns, plus whatever the embedding declared.

The category of a command or an event.

Functions

Every row, ordered so that a sub-FSM follows its parent.

Returns a specification to start this module under a supervisor.

Remove a row so its slot is reused by the next machine that reports under it. Also removes the rows of any sub-FSMs filed beneath it.

Write one of the embedding's own columns on the current scenario's row.

Set the account column of the current machine's row.

Record the last command the current machine issued, with its category.

Record where a machine is now. Called by FSL.Runner on every transition; an embedding does not normally call it.

Start the registry unlinked, for a caller that decides at run time that it wants one — a CLI given a --monitor flag, for instance. Idempotent: an already-running registry is reused, and its columns are the ones declared by whoever started it first.

Start the registry under a supervisor. Takes the same options as start/1.

Subscribe pid to call changes and return the snapshot — the same rows calls/0 would give, taken inside the call that registers the subscriber.

Stop a subscription. Rarely needed — a subscriber that dies is dropped on its own — and there for a process that stops caring without stopping.

Types

call_info()

@type call_info() :: %{required(atom()) => term()}

One row: the machine's own columns, plus whatever the embedding declared.

command_type()

@type command_type() ::
  :sip | :media | :http | :db | :scenario | :control | atom() | nil

The category of a command or an event.

FSL sets :scenario and :control for its own vocabulary; every other value comes from FSL.Host.event_type/1 or from whatever an embedding passes to note_command/2. The atoms below are the ones a SIP application uses, listed as an illustration rather than as a closed set: any atom is valid, and FSL.Diagram draws an unfamiliar one as coming from the peer.

Functions

calls()

@spec calls() :: [call_info()]

Every row, ordered so that a sub-FSM follows its parent.

Each row carries its :slot, the key it was filed under, so that a caller that assigns those slots can join this view with records of its own — a server that keys its instances by id, for instance. A renderer that displays named columns ignores it.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear(slot_id)

@spec clear(term()) :: :ok

Remove a row so its slot is reused by the next machine that reports under it. Also removes the rows of any sub-FSMs filed beneath it.

note(key, value)

@spec note(atom(), term()) :: :ok

Write one of the embedding's own columns on the current scenario's row.

The registry does not know what the key means — that is the point — so a host adds a column by declaring it at start and writing it here. No-op if the monitor is not running, so it stays free when monitoring is off.

note_account(username)

@spec note_account(String.t()) :: :ok

Set the account column of the current machine's row.

For a run that learns who it is about only once it is under way — after an identity is verified, or once it knows which conversation it joined. Reported values from FSL.Host.account/2 will not overwrite it as long as that callback answers "" afterwards.

No-op if the registry is not running.

note_command(type, command)

@spec note_command(command_type(), String.t() | atom()) :: :ok

Record the last command the current machine issued, with its category.

This is what an embedding's verbs call, so that the command column shows what the machine last did rather than only where it is:

def send_message(ctx, text) do
  FSL.Monitor.note_command(:chat, "send_message")
  # …
end

The category decides which lane the command is drawn on in a sequence diagram (FSL.Diagram). It also feeds FSL.Journal, so a command is recorded whether or not the registry is running.

report(call_id, scenario, username, state, event, event_type \\ nil)

@spec report(pid(), String.t(), String.t(), String.t(), String.t(), command_type()) ::
  :ok

Record where a machine is now. Called by FSL.Runner on every transition; an embedding does not normally call it.

call_id is the slot the row is filed under. An empty username leaves the account column as it was, which is how a run that named itself once is not overwritten by every later transition.

start(opts \\ [])

@spec start(keyword()) :: {:ok, pid()}

Start the registry unlinked, for a caller that decides at run time that it wants one — a CLI given a --monitor flag, for instance. Idempotent: an already-running registry is reused, and its columns are the ones declared by whoever started it first.

A supervised owner should use start_link/1 instead.

Options

  • :columns — the columns this application adds, as a keyword list of name: default. See the moduledoc.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start the registry under a supervisor. Takes the same options as start/1.

children = [
  {FSL.Monitor, columns: [medias: "n/a", server: "none"]},
  # …
]

subscribe(pid)

@spec subscribe(pid()) :: [call_info()]

Subscribe pid to call changes and return the snapshot — the same rows calls/0 would give, taken inside the call that registers the subscriber.

Returning it is the contract and not a convenience. A subscriber needs both: the rows that already exist, and the changes from now on. Taking them in two calls leaves a window, and only one order of the two is even survivable — subscribe first, then snapshot, so a change landing in between arrives as a push and in the snapshot, a duplicate upsert that is idempotent and harmless. Snapshot-first loses it outright, and the row then stays stale until the call happens to change again. That reads like a tidying opportunity and it is a data-loss bug, so the window is removed rather than documented.

pid is monitored: a subscriber that dies is dropped, with no unsubscribe/1 needed. In a library that matters more than it did in one application — subscribing is the normal way to use this, and a MapSet that only ever grows means every later change send/2s into the void.

unsubscribe(pid)

@spec unsubscribe(pid()) :: :ok

Stop a subscription. Rarely needed — a subscriber that dies is dropped on its own — and there for a process that stops caring without stopping.