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)}
end

The 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.

  • value includes units this node has reserved but not yet committed. A reserve/3 or with_quota/4 call 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.
  • value is this node's converged view, not a database read. With cluster_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/3 for 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

topic_name()

@type topic_name() :: :usage | :credits

A topic family this module can subscribe to.

Functions

assign_quota(arg1, socket, assign, feature)

@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.

assign_quotas(arg1, socket)

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.

handle_credits(arg1, socket)

@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.

handle_usage(arg1, socket)

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)}
end

A 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.

on_mount(name, params, session, socket)

@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:

FormResolver
{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.

quotas(tenant)

@spec quotas(term()) :: [map()]

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.

subscribe(tenant)

@spec subscribe(term()) :: :ok | {:error, term()}

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.

subscribe(tenant, opts)

@spec subscribe(
  term(),
  keyword()
) :: :ok | {:error, term()}

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.

switch_tenant(socket, tenant)

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.

topics(tenant, opts \\ [])

@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.

unsubscribe(tenant)

@spec unsubscribe(term()) :: :ok

Unsubscribes the calling process from tenant's live usage updates.

unsubscribe(tenant, opts)

@spec unsubscribe(
  term(),
  keyword()
) :: :ok

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.