Counting sounds like the easy half. It is the half that decides whether your product stays fast, and the half where a wrong decision is invisible until the day it matters.
This guide is one line of code and everything you should know about it.
#The one line
AuroraMeter.track(org, :summaries)
That is it. Add one to this customer’s count of summaries, for the current period.
More than one at a time:
AuroraMeter.track(org, :summaries, 5)
And a correction, if you counted something you should not have:
AuroraMeter.track(org, :summaries, -1)
track/3 always returns :ok. It does not check anything, it does not refuse,
it does not care what plan the customer is on. Counting and permission are
separate jobs on purpose, and permission has its own guide:
Write your plans down, then enforce them.
#Where the number actually goes
This is the part worth understanding, because everything else in this guide follows from it.
The increment lands in an in-memory table (ETS) using an atomic counter operation. Nothing is asked for permission. No process mailbox is involved, no row is locked, nothing queues behind anything else. On the bundled benchmark that is roughly 5.5 million increments a second across distinct counters.
Then three background things happen on their own:
- A flusher writes the totals to Postgres every five seconds, and once more on a clean shutdown.
- A broadcaster sends live values over PubSub about once a second, which is what makes a live usage page possible without polling.
- A seeder loads the last written total the first time a cold counter is read, so a deploy does not reset anybody to zero.
The database is touched by the flusher and by that one-time seed. It is never
touched on the write path. That is the whole trick, and it is why you can put
track/3 inside a loop without thinking about it.
your request -> ETS counter (microseconds, every time)
|
| every 5 seconds
v
Postgres (the durable total)
|
| every 1 second
v
PubSub (live numbers on screen)
#What you are trading away
Five seconds of buffering means exactly what it sounds like.
If the machine dies, up to five seconds of counts die with it. A restart keeps the flusher’s pending batch, and a clean shutdown flushes, so this is about crashes and hard kills, not deploys.
For a feature that earns you two cents a call, losing a handful in a crash is cheaper than the machinery to never lose one. For a feature that earns you a dollar a call, it is not. So the choice is per feature, not global:
config :aurora_meter, durable_features: [:contract_reviews]
A durable feature writes a row to the database on every call, as well as counting in memory. Correct, slower, and a database write per request.
You can also decide at the call site:
AuroraMeter.track(org, :contract_reviews, 1, durable: true)
Pick durable for things you invoice individually. Leave everything else buffered.
#Reading the count back
AuroraMeter.usage(org, :summaries) # => 37
AuroraMeter.usage_all(org) # => %{summaries: 37, api_calls: 1_204}
AuroraMeter.remaining(org, :summaries) # => 163
remaining/2 answers :unlimited for a metered feature or a counter, because
there genuinely is no ceiling.
:unlimitedis an atom, not a very big number. Code likeremaining(org, f) > 0compares an atom with an integer, which is legal in Elixir today, quietly wrong, and will raise in a future version. Match on it.
For a dashboard card, skip all three and ask once:
AuroraMeter.quota(org, :summaries)
# %{feature: :summaries, kind: :hard, used: 37, limit: 200, included: 200,
# remaining: 163, overage: 0, percent: 19, enabled: true, unit_price: nil,
# period: %{start: ..., end: ..., source: :calendar}}
That one map has everything a card needs, whatever kind of feature it is. Put the numbers on screen is about drawing it.
#Charts
AuroraMeter.history(org, :summaries, days: 30)
# [%{date: ~D[2026-08-15], value: 41},
# %{date: ~D[2026-08-16], value: 0},
# ...]
Daily buckets, oldest first, and every day in the range is present. A quiet Sunday is a zero, not a missing entry, so your chart needs no gap handling and cannot accidentally join Saturday to Monday with a straight line.
History is on by default. If you do not want the extra table written:
config :aurora_meter, history: false
history/3 then returns an empty list rather than raising, so a chart on a
page you forgot about renders empty instead of crashing.
#Periods, and what resets
A counter belongs to a period. When the period rolls over, the next track/3
starts a fresh counter at zero. There is no reset job to schedule and nothing
to clean up.
AuroraMeter.period(org)
# %{start: ~U[2026-09-01 00:00:00Z], end: ~U[2026-10-01 00:00:00Z],
# source: :calendar}
By default a period is a calendar month in UTC. With Pro configured
(period_source: AuroraMeter.Pro.Period) it becomes the customer’s own Stripe
billing cycle, so somebody who subscribed on the 20th gets their allowance back
on the 20th rather than eleven days early.
#Counting across several servers
If you run more than one node, every node counts into its own memory and writes deltas rather than totals, so the nodes add up instead of overwriting each other. The row in Postgres is the cluster total.
Nodes also gossip their deltas to each other over PubSub every second and re-base on the stored total every five seconds. So a number read on any node is the true total minus, at worst, the other nodes’ last second of counting.
This needs the distributed PubSub you already run for LiveView. On a single node, nothing changes.
The consequence worth writing down: a hard limit is enforced against the local view, so a burst arriving on several nodes at once can overshoot a cap by whatever the other nodes admitted in that last second. If a cap must be exact to the unit under a coordinated burst, that is a database constraint’s job, not a meter’s. Ship it: the production checklist goes further into this.
#Counting things you do not charge for
Plenty of numbers are worth showing and not worth billing: requests served, webhooks delivered, files scanned. Declare those as counters:
plan :payg do
price 0
counter :api_requests
counter :pages_parsed
end
A counter is measured, never blocked and never billed. Its quota/2 reports
limit: nil, included: nil and percent: nil, because there is nothing for
it to be a percentage of.
Do not reach for metered ... included: 0 instead. That tells your dashboard
every request is overage awaiting an invoice, which is false when the money is
handled somewhere else.
#Testing code that counts
setup do
AuroraMeter.Test.reset!()
:ok
end
test "a summary is counted once" do
org = org_fixture()
{:ok, _} = Notewell.Summaries.summarise(org, upload_fixture())
assert AuroraMeter.usage(org, :summaries) == 1
end
reset!/0 empties the in-memory counters between tests. If your assertion
reads from the database rather than from memory, force the write first:
AuroraMeter.Test.flush!()
Otherwise you are waiting up to five seconds for a timer, which a test will not do for you.
AuroraMeter.Test.unique_tenant/1 gives you a fresh key per test, which is the
easy way to keep async tests from sharing a counter.
#What to read next
- Write your plans down, then enforce them turns these numbers into rules.
- Put the numbers on screen draws them.
- Give an allowance, charge for going over turns them into money.
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.