Billing from recorded events

View Source

A worked example of a feature whose commercial quantity is the sum of durable events rather than an in-memory counter: an LLM gateway that gates a request on an estimate, does the work, and then records the tokens it actually used.

Everything in this guide is compiled and executed by test/aurora_meter/examples_test.exs.

The problem

Token usage has two properties that buffered metering handles badly.

The quantity is not known until the work is finished, so you cannot count it up front. And it is the number on the invoice, so losing the last few seconds of it because a node went away is a refund conversation, not a rounding error.

The two halves want different tools. The gate wants the fast in-memory reservation. The charge wants a row in a transaction.

The plan

defmodule Lumen.Plans do
  use AuroraMeter.Plans

  plan :free do
    price 0
    limit :tokens, 100_000, :hard
  end

  plan :studio do
    price 9_900
    metered :tokens, included: 2_000_000, unit_price: 1
  end
end

Nothing about the plan changes for an events-source feature. The source says where the quantity comes from; the plan still says what the customer is entitled to and what it costs.

The configuration

config :aurora_meter,
  plans: Lumen.Plans,
  feature_sources: %{tokens: :events}

That one line moves :tokens off the buffered path. From here on:

The call site

defmodule Lumen.Gateway do
  @moduledoc "Gate on an estimate, charge for what actually happened."

  @estimate 4_000

  def complete(org, prompt) do
    AuroraMeter.with_quota(org, :tokens, @estimate, fn ->
      response = Lumen.Model.run(prompt)

      {:ok, event, _outcome} =
        AuroraMeter.record(org, :tokens, response.tokens,
          id: response.request_id,
          occurred_at: response.finished_at,
          dimensions: %{"model" => response.model}
        )

      %{text: response.text, tokens: event.quantity}
    end)
  end
end

Three things are happening, and they are worth separating.

with_quota/4 is the gate and nothing more. It reserves the estimate so concurrent callers cannot all walk through a nearly full cap, and it releases the estimate when the callback returns. For an events-source feature the release happens on success as well as on failure: the estimate was never the charge.

record/4 is the charge. The id: is yours, so a retry after a timeout you never saw the answer to comes back {:ok, event, :duplicate} rather than charging twice. Use something the request already has: a request id, a job id, a provider's completion id.

occurred_at: is when the usage happened, not when you got round to recording it. A batch that finishes at 09:00:02 and is recorded at 09:00:37 belongs to 09:00:02, and if that instant fell in the previous period it is charged there.

The in-memory arithmetic over one successful call is +4_000 at admission, +response.tokens from the projection, -4_000 at release. The net is the recorded quantity, which is also the durable total.

What a retry does

{:ok, _event, :inserted}  = AuroraMeter.record(org, :tokens, 1_420, id: "req_9", occurred_at: at)
{:ok, _event, :duplicate} = AuroraMeter.record(org, :tokens, 1_420, id: "req_9", occurred_at: at)

The second call writes nothing: no row, no total, no export intent. Send the same payload under the same id as often as you like.

Change the payload and reuse the id and you get {:error, {:conflict, existing}} instead, with the event that is already stored. That is deliberate: two different facts under one identity is a bug in the caller, and silently keeping the first would hide it.

Reading it back

AuroraMeter.usage(org, :tokens)
#=> 1_420

AuroraMeter.Events.total(org, :tokens, AuroraMeter.period(org).start)
#=> 1_420

usage/2 is the in-memory view and can lag by a broadcast interval on a cluster. Events.total/3 is what was committed. For a dashboard, use the first; for anything that has to agree with an invoice, use the second.

Correcting what you recorded

A model returns a token count that turns out to be wrong. A customer is charged for a run they were promised free. Whatever the reason, the fix is never to edit the row: a recorded fact is what the invoice was built from, and a support conversation six months later is about what happened, not about what the table says now.

AuroraMeter.correct/4 appends a second fact that reduces the first.

AuroraMeter.correct(org, "req_9", 420,
  id: "credit_req_9",
  metadata: %{"ticket" => "SUP-118", "operator" => "ana"}
)
#=> {:ok, %AuroraMeter.Event{kind: :correction, quantity: 420}, :inserted}

AuroraMeter.Events.total(org, :tokens, AuroraMeter.period(org).start)
#=> 1_000

The magnitude is the size of the reduction, always positive. Both rows stay in AuroraMeter.Events.stream/1 for ever, and the correction carries the original's feature, period, plan attribution and dimensions, so a correction issued in October against a September fact changes September's invoice.

Three rules are worth knowing before you build an operator tool on this.

Corrections are bounded. The corrections of one original can never add up to more than the original. Two operators crediting the same request at the same moment cannot between them give back more than was charged: the second gets {:error, {:invalid, [quantity: :exceeds_original]}}.

The correction's id: is yours, and repeating it is a duplicate. That holds even when the original is by then fully corrected, so a retry of a full reversal is safe rather than an error.

Changing a dimension or a time is replace/4, not correct/4. Passing dimensions: or occurred_at: to correct/4 is refused, because a correction inherits both:

AuroraMeter.replace(org, "req_9", %{
    quantity: 1_420,
    occurred_at: finished_at,
    dimensions: %{"model" => "opus"}
  },
  id: "fix_req_9"
)
#=> {:ok, %{correction: %AuroraMeter.Event{}, replacement: %AuroraMeter.Event{}}, :inserted}

That is one transaction containing two rows: a full reversal of req_9 and a new fact under fix_req_9~r. Retry it under the same id: and both come back as duplicates.

Moving an existing feature over

If :tokens has been buffered in production, do not simply add the configuration line. That period's usage would end up split between a counter row that stops growing and an event total that starts, and no part of the system would add them up for you.

The order is: deploy the record/4 calls first, while the feature is still :buffered. The facts are stored and their export intents are marked {:ineligible, :feature_buffered}, so you can look at what would have been sent before anything is billed from it. Then schedule a cutover at a period boundary in Aurora Meter Pro, and flip feature_sources and delete the track/4 calls in the same deploy. A call site you missed then raises on that node, which is a great deal better than counting quietly.

See metering for the source table and entitlements for what with_quota/4 does on each path.