Aurora Meter

Real-time usage metering, plan entitlements and Stripe-ready billing for Phoenix. Count, gate and bill on the BEAM.

Hex.pmHexDocsLicense: MITElixir

Live usage climbing from 62% to 96% of the included quota in a LiveView dashboard, updated over PubSub

A LiveView dashboard reading Aurora Meter's ETS counters. Every increment on the server shows up in the browser within a second, with no polling and no database read.

Every SaaS does the same three jobs: count what each customer uses, stop them at their plan's limit, and bill for the rest. On Phoenix you hand-roll all three. Aurora Meter is a library that does them with a few function calls:

# count: an ETS increment, nothing touches the database
AuroraMeter.track(org, :ai_generations)
# gate, run and meter atomically, so hard limits hold under concurrency
AuroraMeter.with_quota(org, :ai_generations, fn ->
generate_report()
end)
# => {:ok, result} | {:error, :limit_exceeded} | {:error, :not_entitled}
<.usage_meter tenant={@org} feature={:ai_generations} />

It is a dependency, not a hosted service. Your app keeps its data, and the free core is MIT with no email gate, no trial and no expiry.

Why it is fast

Increments hit a shared ETS table with :ets.update_counter/4: atomic, lock-free, and never serialised through a process mailbox. There is no GenServer per tenant. A single flusher persists absolute-value snapshots to Postgres on an interval and once more on shutdown, and a broadcaster fans live values out over Phoenix.PubSub. The database is touched by the flusher and by a one-time seed when a counter is first read, never on the write path.

Measured with the bundled benchmark (mix aurora_meter.bench 8 500000, dev laptop, Elixir 1.20 / OTP 29):

Load shapeThroughput
8 processes, distinct counters (realistic)~5.5M increments/s (0.3, cluster-wide rows; 7.9M in 0.2)
8 processes, one hot counter (worst case)~53k increments/s

The full run is in docs/evidence/phase-03/bench.md. Plan lookups are cached in ETS and evicted on every subscription write, on every node, so the entitlement check is also database-free per request.

Quick start

1. Add the dependency

def deps do
[{:aurora_meter, "~> 0.3"}]
end

2. Generate and run the migration

mix aurora_meter.gen.migration -r MyApp.Repo
mix ecto.migrate

3. Configure it and add it to your supervision tree, after the repo and PubSub:

# config/config.exs
config :aurora_meter,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
plans: MyApp.Plans
# lib/my_app/application.ex
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
AuroraMeter,
MyAppWeb.Endpoint
]

4. Declare your plans. A small DSL, validated at compile time. Hard caps, metered overage and feature flags live in one place:

defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :free do
price 0
limit :ai_generations, 50, :hard
feature :api_access, false
end
plan :pro do
price 2_000
limit :ai_generations, 1_000, :hard
feature :api_access, true
end
plan :scale do
price 2_000
metered :ai_generations, included: 1_000, unit_price: 2
feature :api_access, true
end
end

That is the whole setup. mix aurora_meter.install generates the migration and prints the config to add, and the getting started guide covers the rest.

What org is

Every call takes a tenant first. org is whatever identifies the customer you are metering: usually the organisation or account that owns the subscription, not the individual user. It must be stable (the same customer always resolves to the same value) and unique per customer, because it becomes the key for the ETS counters, the aurora_meter_counters rows and the PubSub topics.

Out of the box AuroraMeter.Tenant.Default accepts:

AuroraMeter.track("org_42", :ai_generations) # a string, used as-is
AuroraMeter.track(42, :ai_generations) # an integer, stored as "42"
AuroraMeter.track(:acme, :ai_generations) # anything with String.Chars

Pass an Ecto struct or any other term and tell Aurora Meter how to read the key:

defmodule MyApp.Tenant do
@behaviour AuroraMeter.Tenant
@impl true
def to_key(%MyApp.Accounts.Org{id: id}), do: "org_#{id}"
def to_key(%MyApp.Accounts.Scope{org_id: id}), do: "org_#{id}"
def to_key(key) when is_binary(key), do: key
end
# config/config.exs
config :aurora_meter, tenant: MyApp.Tenant

Then AuroraMeter.track(current_org, :ai_generations) and <.usage_meter tenant={@current_org} ... /> work with the struct you already have in your assigns. Plans are attached to the same key with AuroraMeter.subscribe(org, :pro), so subscribe with exactly the term you meter with.

The three jobs

AuroraMeter.subscribe(org, :pro) # assign a plan (local)
# Meter
AuroraMeter.track(org, :ai_generations) # +1
AuroraMeter.track(org, :ai_generations, 5) # +5
AuroraMeter.usage(org, :ai_generations) # => 6
AuroraMeter.remaining(org, :ai_generations) # => 994
AuroraMeter.history(org, :ai_generations, days: 30)
# => [%{date: ~D[2026-08-08], value: 0}, ..., %{date: ~D[2026-09-06], value: 6}]
# Entitle
AuroraMeter.check(org, :ai_generations) # :ok | {:error, :limit_exceeded | :not_entitled}
AuroraMeter.entitled?(org, :api_access) # plan grants the feature, ignores quota
AuroraMeter.with_quota(org, :ai_generations, fn -> run_generation() end)
# Dashboard-ready snapshot
AuroraMeter.quota(org, :ai_generations)
# => %{kind: :hard, used: 6, limit: 1_000, remaining: 994, percent: 0, period: %{...}}

with_quota/4 reserves first (increment, compare, roll back on breach), runs the function, and rolls the reservation back if the function raises. The reservation is the usage, so two concurrent calls cannot both squeeze through the last unit of a hard limit.

Live usage in LiveView

Two drop-in components read the live ETS counters over PubSub. LiveView and Phoenix.HTML are optional dependencies; the core runs headless without them.

# mount/3
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
<.usage_meter tenant={@org} feature={:ai_generations} />
<.usage_summary tenant={@org} />

Telemetry

[:aurora_meter, :track], [:aurora_meter, :reserve] and [:aurora_meter, :flush] events carry quantities and outcomes, ready for Telemetry.Metrics and LiveDashboard. See the telemetry guide.

Guarantees and limits

Read this before you rely on it. The design choices are recorded as ADRs.

Free vs Pro

The MIT core is the entire metering and entitlements engine. Aurora Meter Pro is a separate commercial package for when Stripe should start charging.

CapabilityCore (MIT)Pro
ETS real-time counters
Plan DSL and atomic with_quota gate
LiveView usage components
Postgres persistence, daily history, telemetry
Stripe Checkout and webhook sync
Metered usage reported to Stripe Billing Meters
Subscription-aligned billing periods
Historical rollups and CSV export
Hosted real-time dashboards
Quota alerts and reconciliation

Pro plugs into the same config (provider: AuroraMeter.Pro.Stripe) and adds no runtime dependency to the core. Details, docs and pricing live at aurorameter.com (pricing, Pro preview). Aurora Meter is built by the team behind PhxTemplates, whose Aurora API Starter ships a complete metered-API SaaS on this core.

How it compares

Upgrading from 0.1

0.2 adds the aurora_meter_history table (schema version 2):

mix aurora_meter.gen.migration -r MyApp.Repo --from 2
mix ecto.migrate

See the changelog for everything else that changed.

Documentation

Guides: Getting started · Configuration · Metering · Entitlements · Plans · Telemetry · Testing

API reference on HexDocs.

Contributing

Issues and pull requests are welcome. mix check runs the formatter, compiler warnings as errors, Credo, Dialyzer, the test suite and the docs build. Tests need a local Postgres; mix test.setup creates the database.

License

MIT, see LICENSE. The aurora_meter_pro package is licensed separately under a commercial licence.