Harlock.Sub (harlock v0.6.0)

Copy Markdown View Source

Subscriptions — long-running sources of events that the runtime starts and stops as the model changes.

An app declares its active subscriptions via the optional subs/1 callback. The runtime diffs the returned list against what's currently running on each render: new entries get started, removed entries get stopped. The list is keyed by structural identity, so the same sub spec returned twice produces only one running process.

Timers versus pushes

interval/2 is a timer: the runtime wakes itself up. telemetry/2 is the other shape — an external source pushes, and the runtime receives without that source knowing anything about rendering. Both are subscriptions, and both are represented as a linked process whose lifetime is the subscription, which is what lets one diffing loop manage them without special cases.

Specs are compared structurally

The runtime keys running subscriptions by the spec term itself, so returning the same spec from subs/1 twice starts one process, not two. A spec carrying a function is fine: a closure built at a fixed code location with equal captures compares equal across calls.

It follows that a transform capturing something which changes every render — a value out of the model, say — produces a different spec each time, and the subscription is stopped and restarted on every frame. Capture only what is stable, and derive the rest inside update/2.

Summary

Functions

Send msg to the runtime every ms milliseconds. The first fire happens after ms ms, not immediately.

Subscribe to log events, delivering each one to update/2.

Subscribe to :telemetry events and deliver each one to update/2.

Types

t()

@type t() ::
  {:interval, pos_integer(), any()}
  | {:telemetry, [[atom()]], function()}
  | {:logger, atom(), [atom()] | :all, function() | nil}

Functions

interval(ms, msg)

@spec interval(pos_integer(), any()) :: t()

Send msg to the runtime every ms milliseconds. The first fire happens after ms ms, not immediately.

logger(opts \\ [])

@spec logger(keyword()) :: t()

Subscribe to log events, delivering each one to update/2.

Options:

  • :level — minimum level, filtered by :logger before Harlock sees it (default :info)
  • :metadata — metadata keys to keep, or :all (default :all)
  • :transform — turn an entry into the app's message; without one the app receives {:log, entry}

An entry is %{level: level, msg: raw_msg, meta: metadata}. The message stays in Erlang's raw form; call Harlock.Sub.Logger.text/1 in update/2 to render it:

def subs(_m), do: [Sub.logger(level: :warning)]

def update({:log, entry}, m) do
  %{m | lines: [Harlock.Sub.Logger.text(entry) | m.lines]}
end

Do not log from update/2

A :logger handler runs inside the process that called Logger.info/1. If handling a delivered log event causes another log call, that call is delivered too, and the app loops until something falls over. Erlang's own recursion guard does not help here, because the log originates in the runtime rather than inside the handler.

The same reasoning bounds the rest: the handler must not block, since it stalls the caller, and it must not raise, since :logger removes a handler that does. So it builds one term and sends it — nothing else. Formatting is the app's job precisely because it allocates.

Narrowing :metadata is worth doing on a busy system. Every entry is copied into the runtime's mailbox, and log metadata can carry stacktraces and large structs, so metadata: [:module, :line, :request_id] moves materially less than :all.

Attachment timing matches telemetry/2: the handler is added on the first render, so logs emitted before that are not delivered.

telemetry(events, transform \\ nil)

@spec telemetry([atom()] | [[atom()]], function() | nil) :: t()

Subscribe to :telemetry events and deliver each one to update/2.

events is one event name ([:ecto, :repo, :query]) or a list of them. The transform turns an event into the message the app receives, and may take either the measurements alone or the full triple:

Sub.telemetry([:ecto, :repo, :query], &{:query, &1.query_time})

Sub.telemetry(
  [[:oban, :job, :stop], [:oban, :job, :exception]],
  fn event, _measurements, meta -> {:job, List.last(event), meta.worker} end
)

Omitting the transform delivers {event, measurements, metadata} unchanged.

What this costs the system being measured

A telemetry handler runs inside whichever process emitted the event, so the work done there is work stolen from the thing you are observing. The handler here does the minimum — apply the transform, send one message — and every aggregation happens on the runtime side. Keep transforms cheap for the same reason: a transform that formats a string or walks a list runs on the hot path of the emitter, not in your UI.

Handlers are also global and are attached under an id derived from the events and the runtime pid, so two apps watching the same event do not unsubscribe each other. The subscription detaches when the runtime stops it or when the runtime dies, since the owning process is linked.

Attachment happens on the first render

subs/1 is consulted while rendering, so a push-shaped subscription is not listening the instant Harlock.run/2 is called — anything emitted before that first render is not delivered. This rarely matters for a dashboard watching a running system, but it does mean a test that emits immediately after starting an app is racing the attach, and it makes this the wrong tool for capturing events from an app's own startup. interval/2 is unaffected: it starts its own clock rather than waiting on an external emitter.