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 negative number, if a job failed after you had already counted it:

AuroraMeter.track(org, :summaries, -1)

Be careful with that last one. It moves the buffered counter down. It is not a correction in the accounting sense: nothing records that you counted 1 and then took it back, so the counter and any durable record of the same work no longer agree. Use it for a number you own end to end, such as a job you enqueued and then abandoned. For a fact you have already invoiced, or will, you want the correction API rather than a smaller number, and there is a section on that below.

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 on a timer, every five seconds by default (flush_interval), 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

Buffering means the number lives in memory before it lives in Postgres, and memory does not survive a hard kill.

What a crash costs is everything not yet in an acknowledged flush batch. That is usually the last few seconds, because the flusher runs every five seconds by default. It is not a five second ceiling. If Postgres has been refusing writes for two minutes, two minutes of counting is sitting in memory and a hard kill takes all of it.

A restart keeps the flusher’s pending batch, and a clean shutdown flushes, so this is about crashes, hard kills and an unreachable database, not deploys. The flusher retries an uncertain batch, and a committed receipt stops the retry adding it twice, so the risk is losing counts rather than doubling them.

For a feature that earns you two cents a call, losing a handful in a crash is cheaper than the machinery to avoid it. 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.

This is the mechanism that changes most at 1.0. durable_features becomes a deprecated spelling of a clearer idea: you name where each feature’s commercial quantity comes from (feature_sources), and the ones that come from durable facts are written with AuroraMeter.record/4, which gives every fact an identity, refuses a conflicting reuse of that identity, and gives you AuroraMeter.correct/4 for the case above. Nothing you write today stops working: the old key keeps counting and warns once. The guide on durable events and corrections covers it in full, and appears here when 1.0 ships.

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

Two loops keep them in agreement, and both intervals are configurable. Nodes gossip their deltas to each other over PubSub every broadcast_interval, one second by default. Every flush_interval, five seconds by default, the flushing node writes its delta, reads back the row, and announces that total so the others re-base on it. The announcement is what heals a dropped gossip message.

So a number read on any node is the true total minus, at worst, what the other nodes added in the last broadcast_interval, or in the last flush_interval if a gossip message was dropped.

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 within one broadcast_interval. Lowering that interval tightens the overshoot and costs one PubSub message per node per tick. 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 on the flush 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.