Changelog
View SourceAll notable changes to Aurora Meter are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.4.0] - 2026-09-11
The first release carrying code since 0.3.0 — 0.3.1 and 0.3.2 were
documentation and package metadata only. It brings the prepaid credit ledger,
the counter feature kind and the money series, together with a large body of
correctness work from auditing all three against a live Stripe sandbox.
Schema versions 3 through 6. Existing installs add one migration
(mix aurora_meter.gen.migration -r MyApp.Repo --from 3 generates it):
def up, do: AuroraMeter.Migration.up(from: 3)
def down, do: AuroraMeter.Migration.down(to: 3)Version 3 is the credit ledger tables, version 4 adds promotional_after to
every ledger entry, version 5 a partial index for the open-hold sweep, and
version 6 idempotent flush receipts. All are required by this release.
- Database flushes now commit immutable batches and receipts atomically.
Retrying an uncertain commit cannot count the same usage twice, even with
concurrent writers or gossip. A pending batch survives a Flusher restart;
later usage is flushed in a subsequent batch.
Flusher.flush/0returns{:error, reason}on failure. Custom storage adapters needflush_batch/3. - Promotional expiry replays consumption chronologically: spending before a later grant existed cannot consume that grant or shield it from expiry.
Added
Prepaid credit ledger —
AuroraMeter.Credits:grant/3(idempotent per reference;:paid,:promotionalor:adjustment),hold/4,settle/3,release/1,debit/4,with_credits/4(hold, run, settle or release — also on raise),balance/1,available/1,sufficient?/2,history/2,set_low_balance_threshold/2,expire_due/1,subscribe/1andtopic/1. Amounts are integer micro-dollars; every write is aFOR UPDATErow lock plus an append-onlyaurora_meter_credit_transactionsentry, so concurrent holds cannot overspend. Promotional credit is consumed first and can expire. Requires the Ecto storage. See docs/credits.md and ADR 0005.AuroraMeter.Credits.Money—from_cents/1,to_cents/2,from_decimal/1andformat/2for converting at the edges of the ledger.Integer features in the plans DSL:
feature :seats, 5declares a plan value (always entitled, never metered) read withAuroraMeter.feature_value/3orAuroraMeter.Plans.feature_value/3;quota/2reports them askind: :featurewith avalue.Telemetry:
[:aurora_meter, :credits, kind]for every ledger entry (withduplicateandoverrunin the metadata) and[:aurora_meter, :credits, :low_balance]once per crossing; PubSub{:aurora_meter, :credits, ...}and{:aurora_meter, :low_balance, ...}onAuroraMeter.Credits.topic/1.Config:
:credits_currency,:credits_overdraft_tolerance,:credits_low_balance_threshold,:credits_low_balance_handler.AuroraMeter.Test—fund!/3,drain!/1,credit_balance/1.AuroraMeter.Schema.CreditBalanceandAuroraMeter.Schema.CreditTransaction.counterfeature kind in the plans DSL:counter :requestsdeclares a feature that is measured but never blocked and never billed, for products whose money lives in the credit ledger rather than in subscription overage.check/2is:ok,entitled?/2istrue,remaining/2is:unlimited, andreserve/3admits unconditionally while still incrementing the counter.AuroraMeter.quota/2reportskind: :counterwithlimit: nil,included: nilandpercent: nil— a counter has no denominator, so a renderer must treatnilas "no bar" and can never render "0% of 0".AuroraMeter.Components.usage_meter/1renders it as a bare count with no progress bar. Replacesmetered(included: 0, unit_price: 0), which made every unit read as overage against an allowance of zero. See ADR 0006 and docs/plans.md.Money series from the credit ledger —
AuroraMeter.Credits.spend_history/2returns[%{date, spent, granted, net, balance_after}], zero-filled across the whole range and sorted oldest first, so a chart renders it with no gap handling. Options::days(default 30) or:from/:to,:bucket(:daydefault, or:month) and:kinds. Buckets are UTC;spentandgrantedare positive magnitudes;balance_afteris the balance at the last entry in the bucket andnilwhen the bucket has none.:holdand:releaseare excluded everywhere (they moveheld, notbalance) and are rejected if passed in:kinds.AuroraMeter.Credits.spend_total/2—%{spent, granted, net, from, to}over the same range.AuroraMeter.Credits.summary/1— balance, held, promotional, currency,spent_this_period/granted_this_periodover the configured period, anddaily_burn/runway_daysfrom the trailing 30 days. Both arenilwhen there is nothing honest to report (runway_daysalso when burn is zero).AuroraMeter.Credits.Money.format_compact/1—"$1.2k","$0.07","$0.000015"for short axis labels, never rounding a sub-cent amount away to"$0.00".Money components (LiveView optional, as before):
AuroraMeter.Components.spend_chart/1(attrs:points,:height,:label,:show_grants) andAuroraMeter.Components.credit_summary/1(attr:summary). Inline SVG,<title>tooltips, no JavaScript, andcurrentColorthroughout so they inherit the host's design system. Amounts render as dollars viaMoney.format/2; a zero-spend bucket renders a baseline bar, never a gap.The
AuroraMeter.Planfeature_configtype gains{:counter}, and the DSL exportscounter: 1for paren-free declarations viaimport_deps: [:aurora_meter].AuroraMeter.Credits.reverse/4— takes credit back for money that has already left the payment provider (a refund, a chargeback). Unlikedebit/3it is never refused for want of balance, because refusing would only make the ledger disagree with reality; the balance may go negative, which is the honest record of a debt. Still idempotent on the reference.AuroraMeter.Credits.grant_with_status/3— reports new-or-duplicate from inside the balance row's lock. Callers were probing for the reference beforehand and racing: two concurrent deliveries of one payment both found nothing, both called themselves new, and the host announced the payment twice.AuroraMeter.Credits.pending_holds/1— open holds older than:older_than, oldest first, optionally filtered by reference prefix. A hold is taken before the row that remembers it exists, and those two cannot be one write, so a process killed in between leaves money reserved against a tenant with nothing pointing at it. Only the host can tell such a hold from work that is still running, so the ledger's part is to list them.promotional_afteron every ledger entry (schema version 4), so the promotional figure can be rebuilt from the log likebalanceandheldalready could. It is consumed before paid credit and clamped to the balance after every entry, so it moves for reasons no singleamountexplains; with no snapshot the balance row was the only copy and nothing could tell a clamp from a bug.
Changed
AuroraMeter.quota/2maps gain avaluekey (nilexcept for integer features), andkindmay now be:featureor:counter. Callers that already handledpercent: nil(boolean, integer and undeclared features) need no change.AuroraMeter.Entitlements.reserve/3gains an optional fourth argument, the captured period start.AuroraMeter.Counter.release/4andrebase/2likewise gain optional arguments. The existing arities still work unchanged.rebase/3clearsremoteonly for this node's own flush. A total announced by another node is a database total that node saw, and this one may have applied gossiped deltas since.Test-database migrations are pinned to the version they add. Unpinned,
up()meant "everything known today", so a database created before a later version existed and one created after it ran the same migration and ended with different schemas — which is how the test database came to be missing the version 5 index.
Fixed
The flusher, entitlement and plan-validation items affect code that shipped in
0.3.x. The rest concern the credit ledger, the money series and the counter
kind, all of which are new here — they are recorded because the behaviour is
worth knowing, not because a published version carried the bug.
A refusal no longer rolls back the caller's transaction. Every refusal in the ledger — an already-settled hold, a duplicate reference, a balance that cannot cover a debit, a grant a hold has spoken for — is decided before anything is written, and every one of them answered with
repo.rollback/1. In a nested transaction that marks the whole transaction,mode: :savepointor not: Postgres aborts back to the outermostBEGIN. A host that wrapped a ledger call in its own transaction lost its own writes to a duplicate delivery, and its next statement on that connection failed too. Refusals return{:error, reason}and the transaction commits having done nothing, which is what rolling back a write-free transaction amounted to anyway. The returned tuples are unchanged, so callers that already matched on them need no edit.Worth knowing if you are testing this yourself: the bug is invisible under an
Ecto.Adapters.SQL.SandboxDataCase, because the sandbox holds a transaction of its own and the abort unwinds no further than its savepoint. The regression test lives incredits_concurrency_test.exs, unsandboxed, for that reason.with_quota/4releases its reservation on an exit, not only on a raise. An exit is how gated work usually fails — aGenServer.call, aTask.awaitor a database checkout all time out by exiting — and an exit unwinds straight past arescue, so the reservation was counted for good and a hard limit ratcheted down every time a call timed out.reserveand release now use the same billing period.with_quota/4captured the period so work spanning a boundary released from the counter it reserved in, but only the release was given the captured value;reserveaskedPeriod.current/1again on its own way in. The day bucket behindbump_history/4had the same fault against the clock.A refund no longer eats promotional credit or reads as spend. Reversals were written as plain negative debits, indistinguishable from spending: the sign-up bonus was quietly consumed,
expire_due/1found nothing left to reclaim and the trial grant stayed live for ever, while the customer saw refunded money in their spend chart and in the burn rate the runway estimate divides by. Reversals carrycategory: :reversal, count againstgrantedrather than spend, and leavepromotionalalone.Expiry respects holds and grant boundaries. A promotional grant expired credit a pending hold had reserved — taking the balance below
held, so the settle that followed went negative, a debt the tenant silently repaid out of their next top-up. A grant now expires only its own remainder, with promotional spend attributed soonest-expiring-first.The flusher no longer bills usage twice, or drops usage it counted. Its two writes are no longer all-or-nothing under one
rescue(a failure in the second restored deltas for both, including the batch that had already committed); exits are caught as well as exceptions; and a failed write is checked against what the row actually holds before its delta goes back, since a statement that times out client-side can have committed server-side a moment earlier. On a cluster that check only runs while no gossiped delta has moved this node's view, whichCounter.remote_since_rebase/1now reports — without it, a clustered node discarded real usage on every flush failure.Series.kinds/1refuses:grant. A grant passed as a spend kind was scored twice with opposite signs:spentcame back negative, which its own type forbids and which renders as a dollar amount with a minus sign.The
meteredplan validator guardedunit_pricewith>= 0alone, and every atom sorts above every number in Elixir — sometered :x, included: 1000with no price compiled and validated cleanly.
[0.3.2] - 2026-09-08
Documentation and package metadata only; no code or schema changes.
Changed
- Aurora Meter now has its own home at https://aurorameter.com. The package links, README, NOTICE and description point there for the product, pricing and Pro, and keep the PhxTemplates links for the templates built on the core.
[0.3.1] - 2026-09-08
Documentation only; no code or schema changes.
Changed
- Explained what the tenant argument (
orgin every example) is and what a good key looks like: in the README ("Whatorgis"), theAuroraMeterandAuroraMeter.Tenantmodule docs, the metering and entitlements guides, the getting-started guide and the installer's quickstart output.
[0.3.0] - 2026-09-07
No migration required (schema version stays 2).
Added
- Cluster-wide counters. Every node still meters into its own ETS table,
but the flusher now writes deltas (
value = value + Δ) and re-bases on the total Postgres returns, so nodes add up instead of overwriting each other. Nodes exchange deltas over PubSub every:broadcast_intervaland announce flushed totals every:flush_interval; a value read on any node is the true total minus at most the other nodes' last tick of increments. See docs/clustering.md and ADR 0004. AuroraMeter.Cluster— the supervised process behind it; configcluster_sync: true(default).AuroraMeter.Storage.add_counters/1andadd_history/1(new required callbacks on the behaviour) alongside the absoluteupsert_*.AuroraMeter.Test—reset!/0,flush!/0,broadcast!/0,unique_tenant/1,checkout/1,simulate_node/3,simulate_flush/2and ause AuroraMeter.Testmacro, replacing the boilerplate the testing guide used to ask hosts to copy.- One-step installer. With
igniterin your deps,mix igniter.install aurora_meter(ormix aurora_meter.install) writes the config, addsAuroraMeterto your supervision tree after the repo and PubSub, creates a starter plans module and generates the migration. Without Igniter the task keeps printing the steps. - Telemetry:
[:aurora_meter, :cluster, :apply]and[:aurora_meter, :flush, :error];[:aurora_meter, :flush]gainsdelta_sum,[:aurora_meter, :broadcast]gainsdeltas.
Changed
- ETS counter rows are now
{key, value, pending_flush, pending_gossip}(anyone reading:aurora_meter_countersdirectly needs the new shape). - With
cluster_syncon, tenant usage broadcasts are node-local: each node informs its own LiveViews from its own converged view. - A failed flush no longer crashes the flusher: taken deltas are restored and re-marked dirty, the error is logged and reported via telemetry.
AuroraMeter.check/2is documented as advisory (a read then a compare); usereserve/3orwith_quota/4to enforce a hard limit atomically.
[0.2.0] - 2026-09-07
Schema version 2. Existing installs add one migration:
def up, do: AuroraMeter.Migration.up(from: 2)
def down, do: AuroraMeter.Migration.down(to: 2)(mix aurora_meter.gen.migration -r MyApp.Repo --from 2 generates it.)
Added
- Usage history — UTC day buckets are maintained next to the period counter
(same ETS hot path, flushed to the new
aurora_meter_historytable) and read back withAuroraMeter.history/3, giving charts without durable events. Off withconfig :aurora_meter, history: false. AuroraMeter.quota/2— one dashboard-ready map per feature: kind, used, limit / included, remaining, overage, percent and the current period.AuroraMeter.period/1— the tenant's current billing window.- Subscription cache —
AuroraMeter.Subscriptionsmemoises the plan lookup in ETS (:subscription_cache_ttl, default 5 s) and evicts on everyStorage.put_subscription/1, locally and across nodes via PubSub.check/2,reserve/3andwith_quota/4no longer touch the database per call. - Versioned migrations —
AuroraMeter.Migration.up/1anddown/1take:version,:fromand:to; every version is idempotent. - Telemetry —
[:aurora_meter, :reserve]with%{qty}and the outcome (:ok,:limit_exceeded,:not_entitled) in metadata. AuroraMeter.Schema.Subscription.entitled_statuses/0andentitled?/1.
Fixed
- The flusher now traps exits, so the final flush actually runs on shutdown; a deploy no longer drops up to one flush interval of usage.
- Live updates could be lost when a flush landed between a
trackand the next broadcast tick; the broadcaster now keeps its own touched set. - A subscription in a non-entitled status (
canceled,unpaid,incomplete, ...) kept granting its plan; it now falls back to the default plan. mix aurora_meter.gen.migrationfailed in a host app because the repo was never loaded; it now callsMix.Ecto.ensure_repo/2first.usage_meter/1shows the included allowance and overage for metered features and the enabled state for boolean features instead of a bare count.
[0.1.0] - 2026-07-11
Initial release of the free core.
Added
- Metering —
AuroraMeter.track/4,usage/2,usage_all/1. ETS-backed atomic counters (:ets.update_counter), never touching the database on the hot path; ~8M increments/sec aggregate. IntervalFlusherpersists absolute-value snapshots (idempotent); intervalBroadcasterfans live values over PubSub. Per-feature:durablemode also writes a raw event row. - Entitlements —
check/2,allowed?/2,entitled?/2,remaining/2,reserve/2,3, andwith_quota/3,4(atomic reserve + release-on-raise; correct hard-limit enforcement under concurrency). Hard limits block; metered features allow overage; undeclared features are permissive. - Plans — a compile-time DSL (
use AuroraMeter.Plans) withplan,price,limit,metered, andfeature, validated at compile time. - Subscriptions — local
subscribe/2andplan/1, with a configurable default plan. - Billing seam —
AuroraMeter.Billing.Providerbehaviour +Noopdefault + aBillingfacade, so the core works standalone and Pro plugs in. - LiveView —
usage_meter/1andusage_summary/1components (behind the optional LiveView deps) andAuroraMeter.LiveView.subscribe/1. - Storage —
AuroraMeter.Storagebehaviour + Ecto/Postgres adapter;AuroraMeter.Migrationandmix aurora_meter.gen.migration/install. - Config —
NimbleOptions-validated configuration (fail fast at boot). Telemetry —
[:aurora_meter, :track | :flush | :broadcast].- Bench —
mix aurora_meter.bench.