We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
OpenMeter Alternative for Elixir: Metering Usage on the BEAM
By Liam Killingback ·
OpenMeter Alternative for Elixir: Metering Usage on the BEAM
You shipped an API. You priced it per thousand requests, or per document processed, or per million tokens. Now you have to count. Accurately, per customer, in real time, without slowing down the very request you are counting, and in a shape Stripe will actually invoice.
Search for tooling and you land on OpenMeter inside two minutes. It is the best known open-source usage metering product, and it deserves the attention. Then you scroll to the SDK list: TypeScript, Python, Go, and no Elixir. So the real question is not “which SDK do I install”, it is “what is the right OpenMeter alternative for Elixir, and do I need a metering service at all when I am already running on the BEAM?”
This is an honest answer to that. First, what OpenMeter genuinely does well. Then the five jobs every metering pipeline has to do, and how many of them OTP hands you for free. Then working code for a BEAM-native pipeline: idempotent ingest, ETS aggregation on the hot path, durable flush with Oban, period queries, and reporting to Stripe. And at the end, a small Req client for talking to OpenMeter over HTTP if you decide the service is still the right call.
This post is part of our wider guide to usage-based billing in Elixir and Phoenix.
What OpenMeter actually does
OpenMeter is a usage metering and billing layer built around a streaming pipeline. You send it events, it aggregates them into meters, and it exposes the totals for dashboards, quota checks and invoicing. Concretely:
-
Ingest. You POST events in CloudEvents format. Each event carries an
id, asource, atype, asubject(your customer identifier), a timestamp, and an arbitrary JSONdatapayload. - Meters. You define a meter as configuration: which event type it consumes, which aggregation it applies (sum, count, min, max, unique count), which field in the payload holds the value, and which fields it can group by.
- Query. You ask for usage by subject and time window, with configurable windowing, and it answers from a column store rather than from your OLTP database.
- Billing integration. It can sync those totals into Stripe and similar systems, so the numbers on the invoice come from the same pipeline as the numbers on your dashboard.
The architecture behind that is a Kafka-compatible broker for the event stream plus ClickHouse for storage and aggregation. That is a serious, well chosen design for the problem, and it is why OpenMeter scales to volumes most SaaS products will never reach.
Where OpenMeter genuinely wins
It would be dishonest to write this post as though the answer is always “build it yourself”. OpenMeter beats a hand-rolled pipeline in several real situations.
- Very high event volume. If you are ingesting hundreds of thousands of events per second across many tenants, a Kafka plus ClickHouse pipeline is the correct tool and Postgres is not.
- Late and out-of-order events. Stream processors are built for this. Re-aggregating a window because an event arrived forty minutes late is a solved problem there and an annoying one in a home-grown system.
- Analytical queries over raw events. “Show me p95 tokens per request by model, by customer, by day, for the last quarter.” ClickHouse answers that in milliseconds over billions of rows. Postgres will make you work for it.
- You are polyglot. If your Elixir service is one of five services in four languages that all need to meter, a shared metering service is the right boundary. Do not build the same counter five times.
- You want the billing layer too. OpenMeter has grown well past raw metering into entitlements and billing. That is a lot of product to get for free.
If two or more of those describe you, run OpenMeter and talk to it over HTTP. The code for that is near the end of this post.
The Elixir problem is narrower than it looks
There is no official Elixir SDK, and at the time of writing there is no well maintained community one either. That sounds like a blocker and mostly is not, because the ingest API is plain HTTP with a JSON body. A Req call covers it in about fifteen lines.
The deeper issue is different. A metering service is a distributed system that you now operate: a broker, a column store, a collector, their upgrades, their backups, and their failure modes. For a Phoenix app doing a few thousand metered events per second, that is a large amount of infrastructure to run in order to do integer addition. And the BEAM happens to be unusually good at integer addition under concurrency.
So the practical question is which of the five jobs of a metering pipeline you actually need someone else to do.
The five jobs of a metering pipeline
Every metering system, from a spreadsheet to a Kafka cluster, does the same five things:
- Ingest an event exactly once, even when the client retries.
- Aggregate it into a per-customer, per-period counter, fast.
- Persist that counter durably, so a restart does not erase revenue.
- Expose the current total for quota checks and dashboards, in real time.
- Report the period total to your billing provider, idempotently.
Here is the interesting part. On the BEAM, jobs 2 and 4 are close to free, job 3 is Postgres, and job 5 is an Oban worker. Only job 1 needs real care, and it needs the same care whichever tool you pick.
Job 1: idempotent ingest
Every metered event needs a client-supplied identifier, and every event needs to be rejected the second time it arrives. Retries happen: a timeout on the client, a redelivered webhook, an Oban job that ran twice. Charging twice for one API call is the kind of bug that shows up in a support ticket rather than in your test suite.
Use two layers, cheap then durable. First a fast in-memory check, then a database constraint that is the actual source of truth.
# lib/my_app/metering/dedupe.ex
defmodule MyApp.Metering.Dedupe do
@table :metering_dedupe
def child_spec(_opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, []}}
end
def start_link do
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
Task.start_link(fn -> Process.sleep(:infinity) end)
end
@doc "Returns true the first time an id is seen, false on every repeat."
def fresh?(event_id) do
:ets.insert_new(@table, {event_id, System.system_time(:second)})
end
@doc "Drop ids older than the retry window so the table does not grow forever."
def sweep(max_age_seconds \\ 3600) do
cutoff = System.system_time(:second) - max_age_seconds
:ets.select_delete(@table, [{{:_, :"$1"}, [{:<, :"$1", cutoff}], [true]}])
end
end
:ets.insert_new/2 is atomic, so two concurrent requests carrying the same id cannot both win. That handles the common case at memory speed. The durable guarantee comes from a unique index on the raw event table:
# priv/repo/migrations/..._create_usage_events.exs
create table(:usage_events) do
add :event_id, :string, null: false
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :meter, :string, null: false
add :value, :integer, null: false, default: 1
add :metadata, :map, null: false, default: %{}
add :occurred_at, :utc_datetime_usec, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:usage_events, [:event_id])
create index(:usage_events, [:account_id, :meter, :occurred_at])
The context function then does the boring, correct thing:
# lib/my_app/metering.ex
defmodule MyApp.Metering do
alias MyApp.Metering.{Counter, Dedupe}
def record(account_id, meter, value \\ 1, opts \\ []) do
event_id = Keyword.get_lazy(opts, :event_id, &Ecto.UUID.generate/0)
if Dedupe.fresh?(event_id) do
Counter.increment(account_id, meter, value)
:ok
else
{:error, :duplicate}
end
end
end
Note what is not happening there: no database write on the request path. The hot path touches ETS only. Persistence happens on a timer, which is job 3.
Jobs 2 and 4: aggregate and expose, with ETS
This is where the BEAM stops being merely adequate and becomes the reason to skip the service entirely. :ets.update_counter/4 is an atomic increment against a shared table with no process in the way, so there is no GenServer to serialise through and no mailbox to overflow.
# lib/my_app/metering/counter.ex
defmodule MyApp.Metering.Counter do
use GenServer
@table :metering_counters
@flush_every :timer.seconds(30)
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(_opts) do
Process.flag(:trap_exit, true)
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
schedule_flush()
{:ok, %{}}
end
@doc "Atomic, lock free, callable from any process."
def increment(account_id, meter, value \\ 1) do
key = {account_id, meter, period()}
:ets.update_counter(@table, key, {2, value}, {key, 0})
end
@doc "Current period usage, straight out of memory."
def current(account_id, meter) do
case :ets.lookup(@table, {account_id, meter, period()}) do
[{_key, count}] -> count
[] -> 0
end
end
@impl true
def handle_info(:flush, state) do
flush()
MyApp.Metering.Dedupe.sweep()
schedule_flush()
{:noreply, state}
end
@impl true
def terminate(_reason, _state) do
flush()
:ok
end
defp flush do
entries = :ets.tab2list(@table)
:ets.delete_all_objects(@table)
MyApp.Metering.Flush.persist(entries)
end
defp schedule_flush, do: Process.send_after(self(), :flush, @flush_every)
defp period do
%{year: y, month: m} = DateTime.utc_now()
"#{y}-#{String.pad_leading(to_string(m), 2, "0")}"
end
end
The table is :public and named, so the GenServer owns it for lifecycle purposes but never sits on the write path. That single design choice is what a metering service usually needs a broker to achieve. Trapping exits in init/1 is what makes terminate/2 run on a graceful shutdown, so a normal deploy flushes instead of dropping the last window.
Exposing the total is now a memory lookup, which means quota enforcement can live inline in a plug without adding a query per request:
# lib/my_app_web/plugs/meter_api_call.ex
defmodule MyAppWeb.Plugs.MeterApiCall do
import Plug.Conn
alias MyApp.Metering
alias MyApp.Metering.Counter
def init(opts), do: opts
def call(%{assigns: %{current_account: account}} = conn, _opts) do
limit = MyApp.Plans.limit(account.plan, :api_calls)
if Counter.current(account.id, "api_calls") >= limit do
conn
|> put_status(429)
|> Phoenix.Controller.json(%{error: "monthly API quota exceeded"})
|> halt()
else
event_id = conn |> get_req_header("idempotency-key") |> List.first()
Metering.record(account.id, "api_calls", 1, event_id: event_id)
conn
end
end
def call(conn, _opts), do: conn
end
If you want the full version of this pattern, including the multi-node story, we walked through it in Phoenix usage metering in real time with ETS.
Job 3: persist, without losing revenue
Memory is fast, and memory is a liability. A deploy, an OOM kill, or a crash in the counter’s supervision tree takes the last thirty seconds of counts with it. There are two mitigations and you want both.
The first is to flush with an upsert that adds rather than replaces, so a partially applied flush is not a partially lost month:
# lib/my_app/metering/flush.ex
defmodule MyApp.Metering.Flush do
import Ecto.Query
alias MyApp.Metering.UsageRollup
alias MyApp.Repo
def persist([]), do: :ok
def persist(entries) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
rows =
Enum.map(entries, fn {{account_id, meter, period}, count} ->
%{
account_id: account_id,
meter: meter,
period: period,
count: count,
inserted_at: now,
updated_at: now
}
end)
Repo.insert_all(UsageRollup, rows,
on_conflict: from(r in UsageRollup, update: [inc: [count: fragment("EXCLUDED.count")]]),
conflict_target: [:account_id, :meter, :period]
)
:ok
end
end
The second is the terminate/2 flush above. Between them, only a hard kill exposes you, and only for the length of one flush interval. Shorten the interval if the revenue per event is high, lengthen it if the write volume is.
Reading a period total is then an ordinary query, with the in-memory delta added on top so the dashboard never looks stale:
def total_for_period(account_id, meter, period) do
persisted =
Repo.one(
from r in UsageRollup,
where: r.account_id == ^account_id and r.meter == ^meter and r.period == ^period,
select: r.count
) || 0
persisted + Counter.current(account_id, meter)
end
Job 5: report to your billing provider
The final hop is telling Stripe what happened. Do it from an Oban worker, never from the request, and make the call idempotent by deriving a stable identifier from the account, meter and window.
# lib/my_app/workers/report_usage.ex
defmodule MyApp.Workers.ReportUsage do
use Oban.Worker, queue: :billing, max_attempts: 10, unique: [period: 3600]
alias MyApp.Billing.Stripe
alias MyApp.Metering
@impl Oban.Worker
def perform(%Oban.Job{args: %{"account_id" => id, "meter" => meter, "period" => period}}) do
account = MyApp.Accounts.get!(id)
total = Metering.total_for_period(id, meter, period)
Stripe.report_meter_event(%{
event_name: meter,
identifier: "#{id}-#{meter}-#{period}",
customer: account.stripe_customer_id,
value: total
})
end
end
Stripe’s Billing Meters API replaced the older subscription usage records, and the details matter enough to deserve their own post: Stripe metered billing in Elixir covers meter creation, the high-throughput event stream, and reconciling when totals drift.
Talking to OpenMeter from Elixir anyway
If you read the “where OpenMeter wins” section and recognised yourself, the missing SDK is a twenty minute problem, not a reason to change your decision. The ingest endpoint takes CloudEvents JSON over HTTPS:
# lib/my_app/open_meter.ex
defmodule MyApp.OpenMeter do
defp client do
Req.new(
base_url: System.get_env("OPENMETER_URL", "https://openmeter.cloud"),
headers: [
{"authorization", "Bearer " <> System.fetch_env!("OPENMETER_TOKEN")},
{"content-type", "application/cloudevents+json"}
],
retry: :transient,
max_retries: 3
)
end
@doc "Ingest one metered event."
def ingest(subject, type, data, opts \\ []) do
body = %{
specversion: "1.0",
id: Keyword.get_lazy(opts, :id, &Ecto.UUID.generate/0),
source: "my_app",
type: type,
subject: subject,
time: DateTime.utc_now() |> DateTime.to_iso8601(),
data: data
}
case Req.post(client(), url: "/api/v1/events", json: body) do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:ok, %{status: status, body: body}} -> {:error, {status, body}}
{:error, reason} -> {:error, reason}
end
end
@doc "Query a meter for one subject over a window."
def query(meter_slug, subject, from, to) do
params = [subject: subject, from: DateTime.to_iso8601(from), to: DateTime.to_iso8601(to)]
case Req.get(client(), url: "/api/v1/meters/#{meter_slug}/query", params: params) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
other -> {:error, other}
end
end
end
Wrap ingest/4 in an Oban worker rather than calling it inline, because a metered request now depends on a third-party HTTP call and you do not want somebody else’s outage to become a 500 on your own API. That is worth sitting with for a moment: the instant metering leaves the node, the hot path gains a network dependency, a queue, and a backpressure question. In the ETS version, none of those exist.
The honest comparison
| OpenMeter Cloud | OpenMeter self-hosted | BEAM-native (this post) | |
|---|---|---|---|
| Infrastructure to run | none | Kafka-compatible broker plus ClickHouse | the app and Postgres you already run |
| Elixir SDK | none official, HTTP is fine | none official, HTTP is fine | not applicable |
| Hot-path cost | network call, so queue it | network call, so queue it | microseconds, in process |
| Late and out-of-order events | handled by the stream processor | handled by the stream processor | you handle it |
| Analytics over raw events | excellent | excellent | Postgres, so adequate to a point |
| Practical ceiling | very high | very high | tens of thousands of events per second per node |
| Cost at low volume | per-event pricing | infrastructure and operations | close to zero |
| Failure blast radius | external dependency | external dependency | contained in your app |
The rows that decide it for most teams are the first and the last. If your metering volume fits comfortably on the nodes you already run, adding a broker and a column store buys resilience you did not need and hands you two more systems to be paged about.
When to pick which
Pick OpenMeter if event volume is genuinely large, if several services in several languages meter into the same product, if you need real analytics over raw events, or if you want the billing and entitlement layers as a package.
Pick BEAM-native if you are a Phoenix app metering your own usage, if your volume is in the thousands of events per second rather than hundreds of thousands, if you would rather own a few hundred lines of code than two more pieces of infrastructure, and if you want quota checks to cost a memory lookup instead of a network round trip.
There is a third path worth naming. Start BEAM-native, keep every call site behind a context function like MyApp.Metering.record/4, and if you outgrow it, swap the implementation for the OpenMeter client without touching a single caller. The interface is the expensive thing to change later. The implementation behind it is not.
The operational details that bite
Whichever you choose, these are the parts that decide whether the invoice is right.
- Multiple nodes. Two Fly machines each keep their own ETS table, which is fine for counting and wrong for quota enforcement, since each node sees only its own share. Either enforce quotas against the Postgres rollup behind a short cache, or shard accounts to nodes with a consistent hash so one account’s counter lives in exactly one place.
- Clock skew and period boundaries. Derive the period from a single source, ideally the database, and be explicit about the timezone. A customer on a monthly plan should not get a surprise from an event landing at 00:00:01 UTC.
-
Backfills and disputes. Keep the raw
usage_eventsrows for at least a full billing cycle. When a customer questions an invoice, “here are the events” is a far better answer than “here is a counter”. - Reconciliation. Compare your rollups against what the billing provider recorded, on a schedule, and alert on drift. Metering bugs are silent by nature, because nobody files a ticket about being undercharged.
- Free-tier abuse. Quota checks that only run after the work is done are a denial-of-wallet vector on AI endpoints. Check before you call the expensive thing, then record after it succeeds.
Skip the plumbing
If you would rather not maintain the counter, the flush, the dedupe table and the Stripe reporter yourself, that is precisely what Aurora Meter is: a Phoenix-native metering library that keeps counts on an ETS hot path, flushes durably to Postgres, and reports to Stripe Billing Meters for you. The core is free and MIT licensed, and Aurora Meter Pro adds the hosted dashboard, entitlements and reconciliation on top.
If you are still assembling the app around the billing, phx_saas ships the auth, Stripe subscriptions, transactional email and Oban wiring that this pipeline plugs into, so you can spend the week on your product instead of on scaffolding. And if you expect to build more than one of these, the Builder Pass gives you the whole catalogue for a single lifetime price.
Summary
There is no Elixir SDK for OpenMeter, and that is the least interesting fact about this decision. The important one is that four of the five jobs a metering pipeline performs are things the BEAM already does natively: atomic shared counters in ETS, supervised flushing, durable rollups in Postgres, and idempotent background reporting with Oban. A metering service earns its place at high volume, across many languages, or when you want analytics over raw events. Below that line, a few hundred lines of Elixir buys you lower latency, a smaller blast radius, and no extra infrastructure to operate.
Start with the context function. Keep record/4 as the seam. Then choose the implementation that matches the volume you actually have, rather than the volume you hope to have.