The Phoenix seams: a plug, a hook and four components

A plug that refuses a request with the right status and your own body, a LiveView hook that keeps a page live and switches tenant without leaking a subscription, the four components and the one rule that decides whether a usage bar ever moves, and the installer flags.

Free core 14 min Intermediate

Aurora Meter does not know about Phoenix, and Phoenix does not know about Aurora Meter. The pieces in this guide are the seams between them: a plug that refuses a request, a LiveView hook that keeps a page live, four components that draw the numbers, and an installer that writes the configuration for you.

All of it is in the free core, and all of it is behind an optional dependency, so a project without Phoenix never compiles any of it.

#The plug

pipeline :api do
  plug :accepts, ["json"]
  plug MyAppWeb.Auth
  plug AuroraMeter.Plug.EnsureEntitled,
    feature: :api_calls,
    tenant: {MyAppWeb.Tenancy, :current_org}
end

:feature and :tenant are both required and neither has a default. The tenant is either a {module, function} pair or a one argument function, and it is given the conn.

Because Phoenix expands plug calls at compile time, a bad option is a compile error in your application rather than a 500 on the day the route is first hit.

On the way through it assigns :aurora_meter_tenant and, unless you pass assign_quota: false, :aurora_meter_quota from AuroraMeter.quota/2, so your controller can render the numbers without asking again.

On a refusal it halts with a status and an empty body, and puts the reason in conn.private[:aurora_meter_denial]:

Reason Status
:missing_tenant 401
:not_entitled 403
:limit_exceeded 403
:unavailable 503

:limit_exceeded is 403, not 429. It is an entitlement outcome for the period, not a rate limit, so a Retry-After header would be telling the client something that is not true.

An empty body is rarely what you want to ship, so write your own:

plug AuroraMeter.Plug.EnsureEntitled,
  feature: :api_calls,
  tenant: {MyAppWeb.Tenancy, :current_org},
  on_denied: {MyAppWeb.Errors, :quota_json}

defmodule MyAppWeb.Errors do
  import Plug.Conn

  def quota_json(conn, reason) do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(403, Jason.encode!(%{error: reason, upgrade: "/billing"}))
    |> halt()
  end
end

Your callback receives the conn and the reason and must return a halted conn. Returning anything else raises, naming your callback, rather than letting the request continue into a controller that assumed it had been stopped.

Two errors deliberately are not swallowed into the 503 path: AuroraMeter.UndeclaredFeatureError and AuroraMeter.Period.InvalidPeriodError are re raised with their original stacktrace. Both mean your configuration is wrong, and a 503 would hide that until somebody read a log.

#The LiveView hook

live_session :app, on_mount: [{MyAppWeb.Auth, :ensure_user},
                              {AuroraMeter.LiveView, {:subscribe, assign: :current_org}}] do
  live "/usage", UsageLive
end

The hook resolves the tenant, assigns :aurora_meter_tenant, :aurora_meter_tenant_key and :aurora_meter_topics, and subscribes when the socket is connected. It has three forms:

Form Where the tenant comes from
{:subscribe, assign: :current_org} an assign an earlier hook in the same live_session set
{:subscribe, fun} your function, called with the session and the socket
:subscribe the live_view_tenant config key, a {module, function} pair

Options ride along in the same keyword list, so {:subscribe, [assign: :current_org, topics: [:usage, :credits]]} subscribes to both topics. The default is [:usage].

A nil tenant halts the mount with :aurora_meter_denial assigned as :missing_tenant, and subscribes to nothing. It does not redirect: where an unauthenticated visitor should go is your application’s decision.

Then handle the messages:

def handle_info({:aurora_meter, :usage, _} = message, socket) do
  {:noreply, AuroraMeter.LiveView.assign_quota(message, socket, :quota, :ai_summaries)}
end

#Switching tenant without leaking a subscription

def handle_event("switch_org", %{"id" => id}, socket) do
  org = MyApp.Accounts.get_org!(id)
  {:noreply, AuroraMeter.LiveView.switch_tenant(socket, org)}
end

It unsubscribes exactly the topic set it recorded for the old key and subscribes the same set for the new one. Switching to the tenant you are already on returns the socket unchanged.

Unsubscribing does not empty a mailbox, so a message for the old tenant can still arrive after the switch. Every helper here carries the tenant key in the payload and drops a message that is not for this socket, so you do not have to write that check yourself.

#The components

Four, and they are plain function components you can style however you like.

<.usage_meter quota={@quota} label="AI summaries" />
<.usage_summary quotas={@quotas} />
<.spend_chart points={@points} height={160} />
<.credit_summary summary={@summary} />

usage_meter/1 has a rule worth stating precisely, because getting it wrong produces a bar that renders once and then never moves again.

You give it either quota, or both tenant and feature. Neither is declared required, because the requirement is one or the other; passing both, or neither, or a tenant without a feature, raises.

Prefer quota={@quota}. That is the live form: the assign changes when usage changes, so LiveView’s change tracking re renders the bar. The tenant form reads the quota inside the component at render time, which means the component’s output depends on data that is not in its assigns, and a tenant assign does not move when usage does.

AuroraMeter.LiveView.assign_quota/4 and assign_quotas/2 exist to give you the assign, and AuroraMeter.Credits.spend_history/2 and AuroraMeter.Credits.summary/1 feed the other two.

The components are written in LiveView 1.0 body interpolation. Both packages declare {:phoenix_live_view, "~> 1.0", optional: true}. On 0.20 the interpolation renders as literal text, which is why the floor is 1.0 and not something more generous.

#The installer

mix aurora_meter.install --repo MyApp.Repo --feature-policy deny --events-source tokens:events
Switch What it does
--repo, -r your Ecto repo; inferred when you leave it out
--pubsub your PubSub; defaults to your application’s
--plans the plans module to create; never clobbers one you already have
--feature-policy writes undeclared_feature_policy: deny, raise, warn or allow
--events-source writes feature_sources; repeat it, as tokens:events
--oban wires the optional workers into your Oban configuration
--check-support prints the support report and writes nothing
--dry-run prints the change set without applying it

Three of those are written once and never changed on a second run: --feature-policy, --events-source and anything --oban would touch. The installer tells you it kept your value rather than overwriting a decision you made after the first run. The repo, pubsub and plans module names are overwritten each time, because they are names rather than decisions.

A refusal writes nothing and exits non zero: an unknown feature source, a feature named twice, a policy that is not one of the four, or --oban without Oban in your dependencies. That last one names the line to add to mix.exs, and distinguishes “no Oban at all” from “Oban added after aurora_meter was compiled”, whose fix is mix deps.compile aurora_meter --force.

#Checking a host before you commit to it

mix aurora_meter.install --check-support

It prints one line per dependency with the resolved version, the floor and a verdict, then a trailer that is worth repeating here: a floor is a compatibility guarantee, not a recommendation, and an absent optional dependency is fine, because every integration behind one is compiled only when it is present.

Two verdicts are failures rather than information. BELOW FLOOR is what it sounds like. NOT COMPILED IN means the dependency is present but the Aurora Meter module behind it was not compiled, which happens when you add an optional dependency after Aurora Meter, and it is the reason this check exists at all.

It connects to no database, so the Postgres row always reads as not checked, with a note that gen_random_uuid() needs Postgres 13 or the pgcrypto extension.

Pro has its own, with two switches:

mix aurora_meter_pro.install --repo MyApp.Repo

It writes every credential as {:system, "VAR"} and never a literal, and makes no network call. It refuses, and exits non zero, if scheduling its expiry worker would duplicate one core already schedules.

Keep going

The core is free. Pro is the Stripe half.

Everything the free MIT package does is yours with no account and no expiry. Aurora Meter Pro adds Stripe checkout, metered usage reporting, prepaid top-ups, dashboards and alerts, and every plan starts with a free trial.