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_exceededis 403, not 429. It is an entitlement outcome for the period, not a rate limit, so aRetry-Afterheader 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.
#What to read next
- Put the numbers on screen is the longer version of the components and the live updates.
-
Running it: operations and recovery is what
--obanwires up.
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.