Count what customers use

The one line you put on your hot path, where the number actually goes, what happens when a server dies mid count, and how to read the totals back for a dashboard or a chart.

Free core 12 min Beginner

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:

  1. A flusher writes the totals to Postgres every five seconds, and once more on a clean shutdown.
  2. A broadcaster sends live values over PubSub about once a second, which is what makes a live usage page possible without polling.
  3. 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.

:unlimited is an atom, not a very big number. Code like remaining(org, f) > 0 compares 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.

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.