AuroraMeter. LiveView
(Aurora Meter v1.0.0)
View Source
Helpers for consuming live usage and credit updates in a LiveView (or any process).
These helpers take no authorization decision. They subscribe the calling
process to the topics of whatever tenant they are given, and which tenant a
socket may see is permanently the host's question. Resolve it from the
session, or from an assign an earlier on_mount set from the session, and
never from params: a tenant read from the URL is an IDOR with a topic
subscription attached.
# In your own on_mount, or in mount/3, from the session and not the params.
def mount(_params, session, socket) do
org = MyApp.Accounts.org_for_session!(session)
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, org: org)}
end
def handle_info({:aurora_meter, :usage, %{feature: feature, value: value}}, socket) do
{:noreply, update_meter(socket, feature, value)}
endThe two-mount lifecycle matters. LiveView mounts twice, once for the static
render with connected?(socket) == false and once for the socket. Subscribe
only on the connected mount: the static mount has no channel to deliver to,
and subscribing on both registers twice. A LiveView process that exits has its
registrations reaped by the PubSub registry, so there is nothing to undo in
terminate/2.
The contract
subscribe/1 subscribes to one topic, the tenant's usage topic, exactly as it
did in 0.4.0. subscribe/2 opts into the credits topic as well.
Topics. AuroraMeter.Broadcaster.topic/1 of the resolved tenant key,
which is "aurora_meter:tenant:" <> tenant_key, and
AuroraMeter.Credits.topic/1, which is "aurora_meter:credits:" <> tenant_key. Build them with topics/1 or with those functions rather than
with string concatenation of your own: the prefix is part of the supported
API, the way it is assembled is not.
Messages. On the usage topic:
{:aurora_meter, :usage, %{tenant_key: String.t(), feature: atom(),
value: integer(), period_start: DateTime.t()}}and, for a feature whose source is :events, {:aurora_meter, :event, ...};
plan transitions arrive as {:aurora_meter, :plan_transition, ...}. On the
credits topic, {:aurora_meter, :credits, ...} and
{:aurora_meter, :low_balance, ...}.
One usage message per touched counter per tick, at most one tick every
:broadcast_interval milliseconds (1000 by default). Match on the keys you
need: the payload map gains keys additively across releases, so a match on
the whole map will break where a match on three keys will not.
Three properties worth knowing before you render the number.
valueincludes units this node has reserved but not yet committed. Areserve/3orwith_quota/4call occupies quota immediately, and the broadcast reflects the counter as the entitlement check sees it. A meter can therefore tick up and then stay put when the reserved work commits.valueis this node's converged view, not a database read. Withcluster_sync: true(the default) the broadcast is node-local, so a browser connected to node B sees node B's view; other nodes' increments arrive within one:broadcast_interval, and a flush re-bases every node on the database total within one:flush_interval.- A day-bucket counter is never broadcast. Only period counters are.
Use
AuroraMeter.history/3for the daily series.
Switching tenant
switch_tenant/2 moves a live socket from one tenant to another, unsubscribing
exactly the topic set it subscribed. Phoenix.PubSub.unsubscribe/2 stops
routing but does not empty a mailbox, so a message broadcast a moment before
can still be delivered after the switch. That is why the usage payload carries
tenant_key and why handle_usage/2 and handle_credits/2 drop a message
whose key is not the socket's. A host matching the raw messages itself should
compare tenant_key for the same reason.
Compiled surface
subscribe/1,2, unsubscribe/1,2 and topics/1 need no LiveView and are
always compiled; a headless host gets them. on_mount/4, switch_tenant/2,
handle_usage/2 and handle_credits/2 are compiled only when
Phoenix.LiveView was available at the moment aurora_meter itself was
compiled. A host that adds LiveView afterwards needs
mix deps.compile aurora_meter --force; mix aurora_meter.install --check-support reports the mismatch.
History day buckets and the cluster gossip topic are internal.
Summary
Types
A topic family this module can subscribe to.
Functions
Re-reads feature's quota into socket.assigns[key] when message belongs
to this socket's tenant, and returns the socket unchanged when it does not.
Re-reads every feature on the tenant's plan into socket.assigns.quotas
when message belongs to this socket's tenant.
Folds a {:aurora_meter, :credits, ...} or {:aurora_meter, :low_balance, ...}
message into socket.assigns.aurora_meter_credits.
Folds a {:aurora_meter, :usage, ...} message into
socket.assigns.aurora_meter_usage, a
%{feature => %{value: integer(), period_start: DateTime.t()}} map.
An on_mount hook that resolves the tenant, assigns it and subscribes.
Every feature on tenant's plan as a quota map, in the order
AuroraMeter.Components.usage_summary/1 renders them.
Subscribes the calling process to tenant's live usage updates.
Subscribes the calling process to tenant's topics.
Moves a mounted socket from its current tenant to tenant.
The canonical topic strings for tenant, as a keyword list.
Unsubscribes the calling process from tenant's live usage updates.
Unsubscribes the calling process from tenant's topics.
Types
Functions
@spec assign_quota(term(), Phoenix.LiveView.Socket.t(), atom(), atom()) :: Phoenix.LiveView.Socket.t()
Re-reads feature's quota into socket.assigns[key] when message belongs
to this socket's tenant, and returns the socket unchanged when it does not.
def handle_info({:aurora_meter, :usage, _} = message, socket) do
{:noreply, AuroraMeter.LiveView.assign_quota(message, socket, :quota, :ai_generations)}
end
<.usage_meter quota={@quota} />This exists because a quota map is the only thing that makes
AuroraMeter.Components.usage_meter/1 update. LiveView re-renders a
function component only when the assigns handed to it change, so a meter
given a tenant and a feature name renders the figure it read first and then
never moves, however correctly the socket is subscribed. Putting the number
in an assign is the fix, and this is that line.
The tenant comes from socket.assigns.aurora_meter_tenant_key, which
on_mount/4 and switch_tenant/2 maintain, so a message that arrives after
a switch is dropped for the same reason handle_usage/2 drops it.
Optional in the same way handle_usage/2 is: assigning
AuroraMeter.quota(org, feature) yourself does exactly this.
@spec assign_quotas(term(), Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Re-reads every feature on the tenant's plan into socket.assigns.quotas
when message belongs to this socket's tenant.
def mount(_params, session, socket) do
org = MyApp.Accounts.org_for_session!(session)
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, org: org, quotas: AuroraMeter.LiveView.quotas(org))}
end
def handle_info({:aurora_meter, :usage, _} = message, socket) do
{:noreply, AuroraMeter.LiveView.assign_quotas(message, socket)}
end
<.usage_summary quotas={@quotas} />quotas/1 is the mount/3 half and is compiled without LiveView; this is
the message half, and it drops a message for another tenant exactly as
handle_usage/2 does.
@spec handle_credits(term(), Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Folds a {:aurora_meter, :credits, ...} or {:aurora_meter, :low_balance, ...}
message into socket.assigns.aurora_meter_credits.
The assign is the last credits payload with a :low_balance boolean beside
it, set true by a low-balance message and cleared by the next credits
message that carries an available at or above the threshold that fired.
A message whose tenant_key is not the socket's is dropped.
@spec handle_usage(term(), Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Folds a {:aurora_meter, :usage, ...} message into
socket.assigns.aurora_meter_usage, a
%{feature => %{value: integer(), period_start: DateTime.t()}} map.
Message first, to match handle_info/2:
def handle_info({:aurora_meter, :usage, _} = message, socket) do
{:noreply, AuroraMeter.LiveView.handle_usage(message, socket)}
endA message whose tenant_key is not the socket's is dropped and the socket
returned unchanged. That is the whole reason the key is on the payload: see
"Switching tenant" above.
Entirely optional. Matching the raw message yourself is fully supported and
is what docs/phoenix.md shows first.
@spec on_mount(term(), map(), map(), Phoenix.LiveView.Socket.t()) :: {:cont, Phoenix.LiveView.Socket.t()} | {:halt, Phoenix.LiveView.Socket.t()}
An on_mount hook that resolves the tenant, assigns it and subscribes.
Three argument forms, all resolved at mount:
| Form | Resolver |
|---|---|
{AuroraMeter.LiveView, {:subscribe, fun}} | fun.(session, socket) |
{AuroraMeter.LiveView, {:subscribe, assign: :current_org}} | socket.assigns[:current_org], set by an earlier hook in the same live_session |
{AuroraMeter.LiveView, :subscribe} | the :live_view_tenant config key, a {module, function} pair called with (session, socket) |
Any of them may carry options, for example
{:subscribe, [assign: :current_org, topics: [:usage, :credits]]}.
The hook assigns :aurora_meter_tenant, :aurora_meter_tenant_key and
:aurora_meter_topics, and subscribes only when connected?(socket).
When the resolver returns nil the hook returns {:halt, socket} with
:aurora_meter_denial assigned as :missing_tenant, and subscribes to
nothing. It does not redirect: it does not know your login route, and
guessing one is worse than letting your own on_mount chain decide. Put
your authentication hook before this one and it will never see a nil.
The bare :subscribe form raises ArgumentError when :live_view_tenant
is unset, rather than falling back to anything. A fallback here either
subscribes to the empty-string tenant or silently subscribes to nothing,
and both are worse than a mount-time error naming the fix.
Every feature on tenant's plan as a quota map, in the order
AuroraMeter.Components.usage_summary/1 renders them.
{:ok, assign(socket, quotas: AuroraMeter.LiveView.quotas(org))}
<.usage_summary quotas={@quotas} />Pass the quotas rather than the tenant whenever the page is live. A
function component is re-rendered only when the assigns handed to it change,
and a tenant does not change when usage does, so meters driven by a tenant
render once and then hold that figure for the life of the socket.
assign_quotas/2 refreshes this list from a usage message.
Returns [] for a tenant with no plan. Needs no LiveView.
Subscribes the calling process to tenant's live usage updates.
One topic, the usage topic, unchanged since 0.1.0. Use subscribe/2 to add
the credits topic.
Subscribes the calling process to tenant's topics.
opts[:topics] is a subset of [:usage, :credits] and defaults to
[:usage], which is what subscribe/1 does.
AuroraMeter.LiveView.subscribe(org, topics: [:usage, :credits])Returns :ok, or the first {:error, reason} a Phoenix.PubSub.subscribe/2
returned, having first unsubscribed anything it had already subscribed in the
same call. A partial failure therefore leaves no partial subscription.
Raises ArgumentError when tenant is nil or when opts[:topics] names a
topic this module does not know.
@spec switch_tenant(Phoenix.LiveView.Socket.t(), term()) :: Phoenix.LiveView.Socket.t()
Moves a mounted socket from its current tenant to tenant.
Unsubscribes exactly the topics recorded in :aurora_meter_topics, for the
old key, then subscribes the same set for the new one, then re-assigns
:aurora_meter_tenant, :aurora_meter_tenant_key and
:aurora_meter_topics. On a socket that is not connected it only
re-assigns, because nothing was subscribed.
Returns the socket unchanged when the resolved key is the one the socket already holds, so a re-render that calls it is neither a duplicate registration nor a gap in delivery.
Raises ArgumentError on a nil tenant, for subscribe/2's reason.
@spec topics( term(), keyword() ) :: [{topic_name(), String.t()}]
The canonical topic strings for tenant, as a keyword list.
iex> [usage: usage] = AuroraMeter.LiveView.topics("org_1")
iex> usage
"aurora_meter:tenant:org_1"For a host that runs its own Phoenix.PubSub.subscribe/2 and still wants the
supported topic names. Takes the same :topics option as subscribe/2.
@spec unsubscribe(term()) :: :ok
Unsubscribes the calling process from tenant's live usage updates.
Unsubscribes the calling process from tenant's topics.
Takes the same :topics option as subscribe/2. Unsubscribing from a topic
the process never subscribed to is not an error.
Routing stops when this returns; messages already in the mailbox are still
delivered, so a caller that cares filters on tenant_key.