Running it: dashboard, audit log, alerts, rollups
Everything in Pro that is not a payment. These are the parts you reach for once the money works and you need to see it, explain it, and be told when it breaks.
This page assumes the free core’s Operations guide and does not restate it. Stale holds, pause and resume, replay, retention of core tables and what a database outage costs buffered usage are all core’s, and restating them here is how two pages come to disagree.
Two rules govern every section that changes something.
Look, then decide, then act, and the acting command names what you expect to be true.
Every mutation on this page carries an expected: map, a reason: and an
evidence:, and is shown after the read-only call that fills the map in. There
is no force: option.
A sentence here names the invariant it rests on, or it is not written.
Pro’s Correctness index is the invariant-by-invariant form, and the free core’s Guarantees page is the contract both packages are held to.
1. The billing page in one call
def mount(_params, _session, socket) do
org = socket.assigns.current_org
{:ok, assign(socket, org: org, dashboard: AuroraMeter.Pro.Dashboard.load(org))}
end
<AuroraMeter.Pro.Components.usage_dashboard tenant={@org} data={@dashboard} money={true} />
That gives you, in order: a money section (balance, spend this period, runway, spend chart) when the tenant has a credit ledger, then a quota card per feature, daily charts and monthly history.
A product with no ledger gets no money section, so you do not need to branch.
Dashboard.load(org, credits: false) or money={false} leaves it out of a page
that should not show it.
The components inherit your colours through currentColor and ship no
JavaScript and no stylesheet.
Counters are rendered honestly
A counter feature renders as a bare count with no cap and no progress bar,
with the status line "6 this period, paid from credits". It is deliberately
not described as overage: for a prepaid product nothing is billed at period end,
because the money left the ledger when the request ran.
Metered features with a unit price keep their overage wording. If you are asserting on dashboard text in your own tests, that distinction is the thing most likely to change under you.
2. The audit log
One row per API call: who, what route, what status, how long, and what it cost.
# router.ex
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Authenticate
plug AuroraMeter.Pro.Plugs.AuditLog,
tenant: &MyAppWeb.Audit.tenant/1,
actor: &MyAppWeb.Audit.actor/1,
metadata: &MyAppWeb.Audit.metadata/1
end
defmodule MyAppWeb.Audit do
def tenant(conn), do: conn.assigns[:current_org]
def actor(conn), do: conn.assigns[:current_user] && "user:#{conn.assigns.current_user.id}"
def metadata(conn), do: %{"api_key_id" => conn.assigns[:api_key_id]}
end
Everything host-shaped arrives as a function of the conn, so the package never
guesses at your schema.
A tenant resolver returning nil skips the write. That is how
unauthenticated requests stay out of the log, so put the plug after your
authentication, not before.
The plug records on register_before_send/2, so it never delays the response. A
resolver that raises, or a write that fails, is a Logger.warning and never an
error in the request being audited.
Reading it
AuroraMeter.Pro.AuditLog.list(org,
status_class: :server_error,
method: "POST",
path: "/api/v1/documents",
from: ~U[2026-03-01 00:00:00Z],
limit: 50
)
# %{data: [%AuroraMeter.Pro.Schema.AuditEvent{}, ...], has_more: true, next_cursor: "..."}
AuroraMeter.Pro.AuditLog.stats(org, from: ~U[2026-03-01 00:00:00Z])
# %{total: 4_153, by_status_class: %{success: 4_120, client_error: 31, server_error: 2},
# p50_ms: 42, p95_ms: 310}
AuroraMeter.Pro.AuditLog.any?(org) # has this tenant ever called the API?
Three things about those two calls are worth reading carefully, because this page got all three wrong before 1.0.0:
-
:status_classis an atom, one of:success(below 400),:client_error(4xx) or:server_error(5xx and above). An integer falls through the filter and narrows nothing, silently. -
The path filter is
:path, and it is a prefix. There is no:path_prefix. -
list/2answers%{data:, has_more:, next_cursor:}, not%{entries:, cursor:}.has_moreis exact, because the query reads one row past the page.
stats/2 takes the same filters as list/2 and ignores :limit and :before,
because stats are over the whole window rather than over one page. It has no
:days option: bound it with :from and :to. p50_ms and p95_ms are
discrete percentiles over the requests that recorded a duration, and are nil
when none did.
Paging is a keyset cursor on (inserted_at, id), and :limit is capped at 200
however large a number you pass.
Retention
config :aurora_meter_pro, audit_log_retention_days: 30
{"30 3 * * *", AuroraMeter.Pro.Retention.Pruner}
The audit log is one of five operational tables AuroraMeter.Pro.Retention
prunes, each with its own window and its own state predicate, and nothing
financial is on that list at any age. AuroraMeter.Pro.AuditLog.plan/1 counts
what a prune would remove without removing anything. See
Retention for the whole list, the windows and the sizing.
AuroraMeter.Pro.AuditLog.Pruner still works, still prunes the audit log and
only the audit log, and is deprecated: move the crontab entry to
AuroraMeter.Pro.Retention.Pruner, which runs on the same schedule, and do not
register both.
In tests
config :aurora_meter_pro, audit_log_writer: :sync
The default is :async, a task under Pro’s supervisor, which is right in
production and useless in a test, where the assertion runs before the write.
3. Alerts
config :aurora_meter_pro,
alert_thresholds: [80, 100],
alert_handler: &MyApp.Billing.quota_alert/1
defmodule MyApp.Billing do
def quota_alert(%{tenant_key: key, feature: feature, kind: kind,
percent: percent, usage: usage, limit: limit,
period_end: period_end}) do
org = MyApp.Orgs.get_by_key!(key)
case {kind, percent} do
{:hard, p} when p >= 100 -> MyApp.Mailer.limit_reached(org, feature, period_end)
{:metered, p} when p >= 100 -> MyApp.Mailer.overage_started(org, feature, usage - limit)
{_, p} -> MyApp.Mailer.approaching(org, feature, p)
end
end
end
Alerts are deduplicated per tenant, feature, period and threshold, so a customer sitting at 81% for a fortnight is emailed once, not every ten minutes.
If your handler fails, the dedup record is rolled back and the next sweep tries again. An alert that could not be delivered is not silently marked as delivered, which is worth knowing if your handler talks to a mail provider that is occasionally down.
Schedule it:
{"*/10 * * * *", AuroraMeter.Pro.Alerts}
4. Rollups and export
{"15 2 * * *", AuroraMeter.Pro.Rollup}
Daily and monthly aggregates, which is what the dashboard’s history charts read rather than scanning the event table.
A month bucket is the calendar month containing the billing period’s
midpoint, for any period source other than AuroraMeter.Period.Calendar. A
subscription renewing on the 25th produces a period from 25 August to
25 September: six days in August, twenty-five in September, so it is September’s.
A calendar-aligned installation is unchanged. The rule is stated here because a
reporting artefact whose attribution nobody wrote down is worse than one whose
rule is simple.
A day bucket from durable events is the day the usage happened
(occurred_at), not the day it was written, and a correction reduces its day.
Read a tenant’s buckets back with the bucket kind, which is "month" or
"day":
AuroraMeter.Pro.Rollup.history(org, "month")
The second argument is the bucket kind and nothing else. Passing a feature name returns an empty list rather than an error, which is the most expensive shape a wrong argument can have and is why this line is worth reading twice.
CSV, for customers who want their own numbers:
def export(conn, _params) do
csv = AuroraMeter.Pro.Export.usage_csv(conn.assigns.current_org, bucket_kind: "month")
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", ~s(attachment; filename="usage.csv"))
|> send_resp(200, csv)
end
usage_csv/2 reads :bucket_kind and nothing else. It accepts a :days option
and discards it, which is an API wart rather than a documentation one: it is
recorded in docs/api.md and is not fixed here, because changing it changes the
behaviour of a public function.
daily_csv(org, :generations, days: 30) narrows it to one feature. Its options
are handed straight to AuroraMeter.history/3, so :days, :from and :to
mean there exactly what they mean there.
5. The reconciler, and what to alert on
AuroraMeter.Pro.Reconcile.run()
#=> %{local_drift: [%{tenant_key: "org_42", feature: "generations", drift: 140}],
#=> items_open: 0}
run/1 answers a map. :local_drift is the list this check has always
returned, and :items_open is how many provider findings are waiting for a
person. The shape changed in 1.0.0.
This is a local over-reporting check: it flags acknowledgement-ledger values greater than the persisted usage counter, which is a physically impossible state. It does not detect unreported usage, query Stripe, or prove that an accepted meter event reached a paid invoice.
It flushes pending counters first by default, because comparing a ledger against a counter that has not caught up invents drift. That is a side effect, and it is the reason to call it from a health check rather than from a request.
Everything it cannot see belongs to AuroraMeter.Pro.Outbox.Reconciler, which
asks Stripe: read what it found with
AuroraMeter.Pro.Reconcile.list_items/1 and close a finding with
resolve_item/3. Reconciliation is the page for what each
kind of finding means and, more usefully, for what reconciliation cannot prove.
The short form of that: a summary read that fails confirms nothing and opens
nothing, because a provider that did not answer is not a difference.
What else deserves an alert
Not a dashboard. An actual page:
| Signal | Why it matters |
|---|---|
AuroraMeter.Pro.Reconcile.run/1 with a non-empty :local_drift |
acknowledgement ledger exceeds persisted usage |
AuroraMeter.Pro.Reconcile.run/1 with :items_open rising |
the provider half is finding differences nobody is closing |
[:aurora_meter, :pro, :outbox, :gauge] backlog or oldest age climbing |
usage is staged and not landing |
[:aurora_meter, :pro, :outbox, :stale_worker] |
a fenced-out worker tried to write. One is a lease expiry; a stream of them is two deliverers fighting |
[:aurora_meter, :flush, :error], repeatedly |
counts are piling up in memory and can be lost on restart |
[:aurora_meter, :credits, :settle] with overrun: true, often |
your estimates are wrong and customers are going negative |
| auto top-up disabled for many tenants at once | this is Stripe being unwell, not a lot of bad cards |
| open holds older than your longest job | orphaned reservations; money nobody can spend |
That last one has no telemetry event because only you know how long your work takes. It is a core mechanism and core’s operations guide is its runbook; the query is one line:
defmodule MyApp.Health.Holds do
def stale_count do
AuroraMeter.Clock.db_now()
|> DateTime.add(-6 * 3600, :second)
|> then(&AuroraMeter.Credits.pending_holds(older_than: &1, limit: 200))
|> length()
end
end
6. A health check worth having
The reconciliation that matters most is the one that asks whether the ledger still adds up:
defmodule MyApp.Health.Billing do
@moduledoc "Invariants that should be true at all times."
import Ecto.Query
def check do
reconcile = AuroraMeter.Pro.Reconcile.run()
%{
drift: reconcile.local_drift,
findings_open: reconcile.items_open,
stale_holds: MyApp.Health.Holds.stale_count(),
negative_balances: negative_balances(),
uncertain_items: length(AuroraMeter.Pro.Recovery.list_uncertain(state: :uncertain)),
quarantined_items: length(AuroraMeter.Pro.Recovery.list_uncertain(state: :quarantined)),
paused_workers: paused_workers()
}
end
defp negative_balances do
MyApp.Repo.aggregate(
from(b in "aurora_meter_credit_balances", where: b.balance < 0),
:count
)
end
# A paused worker looks exactly like a healthy one from outside. Section 8.
defp paused_workers do
~w(usage_reporter:global alerts:global rollup:global auto_top_up_sweeper:global)
|> Enum.filter(&AuroraMeter.Operations.paused?/1)
end
end
Every one of those should be zero or empty. Checking it once, by hand, proves very little; checking it every five minutes and alerting on a change is what turns “it was correct when I looked” into “I will know within minutes when it stops being correct”.
For a system that moves money, that is worth more than another round of code review.
Two of those figures are thresholds rather than invariants.
quarantined_items above zero is work the software has decided it must not guess
about, and it stays there until a person fixes the cause: it is a queue for you,
not an error. uncertain_items above zero for longer than a day is the one to
page on, because the uncertainty horizon has already passed for those items.
7. Recovery operations
Every guarded operation lives in AuroraMeter.Pro.Recovery and in
mix aurora_meter_pro.recovery. Recovery is the full procedure,
including what to establish at Stripe before each one. This section is the shape
they all share, because the shape is the safety.
Look. Nothing here writes, and no read writes an audit row:
AuroraMeter.Pro.Recovery.list_uncertain(state: :all, limit: 20)
AuroraMeter.Pro.Recovery.pending_payments()
Inspect one item. This is where the expected-state map comes from, and it also shows what other operators have already tried against the same row:
{:ok, detail} = AuroraMeter.Pro.Recovery.inspect_item(item_id)
Act, with the expectation filled in from the inspection:
AuroraMeter.Pro.Recovery.acknowledge_item(item_id,
expected: %{
state: :uncertain,
lease_token: 3,
subject_ref: "usage:api_calls:2026-09-01T00:00:00Z:0:5",
provider_ref: "evt_example"
},
reason: "verified at the provider",
evidence: "Stripe request log req_example at 2026-09-15T09:12Z: 200 for this identifier"
)
Four properties hold for every mutation in the module, and they are the whole argument for using it instead of a console:
- The expected-state map is compared against the row, and a mismatch is refused with the current values named. That is also what makes a mutation racing the worker it is correcting fail rather than overwrite. When it is refused, inspect again and read what changed; do not retry with a fresh expectation copied out of the refusal.
-
reason:andevidence:are required,evidencemust be at least 16 characters and must not repeatreason. -
There is no
force:option and there never will be. An operator who cannot fill in the expected-state map does not yet understand the state well enough to change it. -
Every mutating call writes one
aurora_meter_recovery_actionsrow, whether it applied or was refused. Three operators being refused on one item is exactly the signal that something is being misread. The three outbox operations write the update and the row in one transaction. The two payment operations cannot, because they run inside a session advisory lock on a pinned connection, so a crash in the two statements between them leaves the change applied and unrecorded: Recovery states that gap and why closing it would be a worse trade.
The software cannot verify what you tell it. Acknowledge an item that never
reached Stripe and that usage is treated as billed and never sent again.
Abandon an item that did reach Stripe and the window is staged again under a new
identity, and the customer is billed twice. The asymmetry points one way:
over-billing is the worse error, so when you are not sure, leave the item
uncertain. Uncertain work is visible, queryable and alarmable.
Authorisation is yours. This package ships no recovery web surface and cannot authenticate anyone.
8. Pausing a worker
Every Pro worker stops at its next batch boundary with no deploy and no configuration change. Look first, then act:
AuroraMeter.Operations.checkpoint("usage_reporter:global")
AuroraMeter.Operations.paused?("usage_reporter:global")
AuroraMeter.Operations.pause("usage_reporter:global")
AuroraMeter.Operations.resume("usage_reporter:global")
The operation name for every worker, and which of them keeps a cursor, is the table in the scheduler map.
Do not reach for an advisory lock to stop a worker. Older versions of this
documentation told an operator to take the "usage_reporter" lock by hand. That
recipe is gone and the reason is worth stating, because it is the more dangerous
half of the two possible errors: the reporter has held no advisory lock since
the outbox landed in 1.0.0, so taking one by hand serialises nothing and blocks
nothing. The reporter keeps running, the operator believes it has stopped, and
nothing anywhere says otherwise. AuroraMeter.Operations.pause/1 is the
mechanism, and paused?/1 is how you check that it worked.
The locks that are still live are the payment ones: "auto_top_up:<tenant_key>"
and "subscription:<tenant_key>", taken by the webhook handlers and by the
top-up worker. Taking one of those by hand does block real work, silently, for as
long as your console session holds the connection. AuroraMeter.Pro.Lock exists
so a host can serialise its own work on the same key deliberately; an operator
should not be taking one to stop anything.
pause/1 takes no expected-state map, because there is no state to
contradict. What it leaves behind is a checkpoint whose state is "paused",
and a paused worker looks exactly like a healthy one from outside: nothing runs,
nothing errors, and the backlog grows quietly. Record the pause somewhere a
person reads, and put paused?/1 in the health check of section 6.
9. When the database or Stripe is unavailable
Core’s operations guide has the buffered-metering and ledger half. This is Pro’s.
| Subsystem | While it is away | When it comes back |
|---|---|---|
AuroraMeter.Pro.UsageReporter |
Stages nothing. Its cursor stays where the last committed batch left it. |
It restages the same window under the same identity, because a window’s subject_ref carries its bounds, so a restaged window collapses into the existing outbox row rather than becoming a second charge (I15). |
AuroraMeter.Pro.Outbox.Deliverer |
Stops leasing. Leases already taken expire and become re-leasable. | The next lease carries a new fencing token, and a write from the old owner affects zero rows and is logged as a stale worker (I16). |
| An auto top-up attempt | Keeps its pending idempotency key and its immutable params. | The attempt is resumed with the same key, so the provider answers with one PaymentIntent rather than creating a second (I14). |
AuroraMeter.Pro.Webhook |
Returns 500. | Stripe redelivers. The handlers are idempotent on the provider’s own references, so a redelivery grants nothing twice. |
| A provider call that timed out after the request was sent |
Classified :uncertain, never as a failure. |
Nothing automatic. The software preserves the uncertainty; section 7 is how a person resolves it. |
What this page does not claim. It does not claim that nothing is lost and nothing is duplicated: it claims that each of those mechanisms is idempotent on an identity the row already carries, and it names the invariant behind each. Aurora Meter reports what it sent and what it heard back. It never reports that an invoice was finalised or paid.
10. The export outbox and unknown provider outcomes
One row per intent, with an identity, a state and a fencing token. The export outbox is the page: the row, the states, why the lease and the fence are two different things, the retry policy and quarantine. What an operator needs in their head is smaller.
| State | What it means |
|---|---|
pending |
Staged and waiting. Not sent. |
leased |
A deliverer is holding it right now. |
accepted |
The provider took the request. This is not a paid invoice, and it is not confirmed. |
confirmed |
The reconciler has seen the provider’s own aggregate account for it. Only the reconciler may say this. |
uncertain |
The answer never arrived, or the attempt cap or the uncertainty horizon was reached. It waits for a person or for reconciliation. |
rejected |
Terminal and visible, deliberately, rather than retried. |
quarantined |
A mapping, mode or period problem the software decided it must not guess about. An operator leaves it, with requeue_item/2, after fixing the cause. |
The outcome taxonomy behind those states is :accepted, {:retry, delay},
:uncertain and {:rejected, reason}. A timeout after the request was sent is
:uncertain, not a failure, because the request may have landed, and treating
it as a failure is how a window gets sent twice.
The uncertainty horizon is 23 hours from an item’s first attempt, which is
Stripe’s documented idempotency floor with an hour of margin. Past it an item is
not resent: it becomes uncertain. The trade is not symmetrical. Resending past
the horizon risks billing a customer twice, silently, in a way indistinguishable
from real usage; refusing risks leaving usage unbilled until somebody looks, which
is bounded, visible in the gauges and recoverable. Aurora Meter refuses.
Where next
-
Recovery for uncertain exports and pending payments:
AuroraMeter.Pro.Recoveryandmix aurora_meter_pro.recoveryare the guarded operations, every one of which records who did what, why, and on what evidence. Start withmix aurora_meter_pro.recovery list - The export outbox and Reconciliation for the two halves of getting usage onto an invoice and proving it got there
- Scheduler map for every worker, its schedule and its operation name
- Retention for what Pro deletes and what it never will
- Top-ups and Usage reporting for the two money paths
- The core’s Operations for stale holds, replay, backup and restore, and the database-outage table this page’s section 9 is the other half of
- The core’s Showing usage for building your own screens instead of using the bundled dashboard