Limited time 50% off everything

From the blog

Build a Real-Time Usage Dashboard with Phoenix LiveView

By Liam Killingback ·

Build a Real-Time Usage Dashboard with Phoenix LiveView

If you charge for usage, the dashboard is not a nice extra. It is the page that decides whether a customer trusts your invoice. When someone sees a bill for 412,000 API calls and the only place to check that number is the invoice itself, you get a support ticket. When they can open a Phoenix LiveView usage dashboard, watch the counter tick up while they run a test, and drill into yesterday’s spike, you get a customer who understands their own spend.

This guide builds that page. We will read a live counter out of ETS, blend it with daily rollups in Postgres, push updates over PubSub instead of polling, draw the chart with inline SVG (no chart library, no JavaScript framework), and add a quota bar with a projection of where the month is heading.

It assumes you already meter something. If you do not yet, start with Phoenix Usage Metering in Real Time with ETS, which builds the counter this article reads from, and Usage-Based Billing in Elixir and Phoenix for the full pipeline from event to Stripe invoice.

What a usage dashboard actually has to do

Four jobs, and they pull in different directions.

  1. Show the current period total, now. Not five minutes ago. If a developer fires ten requests at your API, they expect the number to move while they watch.
  2. Show history. A single number is not trustworthy. A daily bar or line chart is what turns “412,000” into “yes, that Tuesday deploy was expensive”.
  3. Show the limit and the trajectory. Customers on a plan want to know if they are about to blow through their quota, before it happens.
  4. Let them drill down. Which endpoint, which API key, which day.

The tension is that job 1 wants an in-memory counter and job 2 wants aggregated history in Postgres. The trick is to serve both from one function and never make the page query raw events.

The data model: raw events, daily rollups, and a hot counter

Three layers, each with a different job:

# priv/repo/migrations/..._create_usage_tables.exs
defmodule MyApp.Repo.Migrations.CreateUsageTables do
  use Ecto.Migration

  def change do
    create table(:usage_events) do
      add :account_id, references(:accounts, on_delete: :delete_all), null: false
      add :meter, :string, null: false
      add :quantity, :integer, null: false, default: 1
      add :metadata, :map, null: false, default: %{}
      timestamps(type: :utc_datetime_usec, updated_at: false)
    end

    create index(:usage_events, [:account_id, :meter, :inserted_at])

    create table(:usage_daily, primary_key: false) do
      add :account_id, references(:accounts, on_delete: :delete_all), null: false
      add :meter, :string, null: false
      add :day, :date, null: false
      add :count, :bigint, null: false, default: 0
    end

    create unique_index(:usage_daily, [:account_id, :meter, :day])
  end
end

usage_events is the audit trail and the drill-down source. usage_daily is what the dashboard chart reads. The ETS counter holds only what has happened since the last flush, usually a few seconds of traffic.

Never point the dashboard at usage_events with a sum() over a month. It works beautifully with 400 rows and falls over at 40 million, which is exactly when the customer cares most.

Step 1: one function that reads ETS plus Postgres

The current period total is the persisted rollup total plus whatever is still sitting in memory:

# lib/my_app/usage.ex
defmodule MyApp.Usage do
  import Ecto.Query

  alias MyApp.Repo
  alias MyApp.Metering.Counter

  @doc "Current billing period total: durable rollups plus the unflushed ETS delta."
  def current_period_total(account_id, meter, period) do
    persisted =
      Repo.one(
        from d in "usage_daily",
          where: d.account_id == ^account_id,
          where: d.meter == ^meter,
          where: d.day >= ^period.starts_on and d.day <= ^period.ends_on,
          select: coalesce(sum(d.count), 0)
      )

    persisted + Counter.read(account_id, meter)
  end
end

Counter.read/2 is a single :ets.lookup against a :set table with read_concurrency: true, so it costs microseconds:

# lib/my_app/metering/counter.ex
def read(account_id, meter) do
  case :ets.lookup(@table, {account_id, meter}) do
    [{_key, count}] -> count
    [] -> 0
  end
end

That single function is now the only thing the LiveView calls for the headline number, and it is correct whether the flush ran one second ago or fifty.

Step 2: fill the gaps in the daily series

Charts lie when days are missing. A customer who sent nothing on Sunday should see a zero, not a line that skips straight from Saturday to Monday:

def daily_series(account_id, meter, period) do
  counts =
    Repo.all(
      from d in "usage_daily",
        where: d.account_id == ^account_id,
        where: d.meter == ^meter,
        where: d.day >= ^period.starts_on and d.day <= ^period.ends_on,
        select: {d.day, d.count}
    )
    |> Map.new()

  today = Date.utc_today()
  last_day = Enum.min([period.ends_on, today], Date)

  period.starts_on
  |> Date.range(last_day)
  |> Enum.map(fn day -> {day, Map.get(counts, day, 0)} end)
end

Note the series stops at today rather than running to the end of the period. Trailing zeros for days that have not happened yet make the chart look like a cliff, and every support conversation that follows starts with “why did my usage drop to nothing”.

Step 3: push updates with PubSub, do not poll

The naive version of a real-time dashboard is :timer.send_interval(1000, self(), :refresh). It works, and it means every open tab hits Postgres every second forever, including the tab someone left open on a second monitor three days ago.

Broadcast from the flush instead. The flush job already knows exactly which accounts changed:

# lib/my_app/metering/flush.ex
defmodule MyApp.Metering.Flush do
  alias MyApp.{Repo, Usage}
  alias MyApp.Metering.Counter

  @doc "Called every few seconds by a GenServer or an Oban cron job."
  def run do
    now = DateTime.utc_now()
    day = DateTime.to_date(now)

    for {{account_id, meter}, count} <- Counter.take_all(), count > 0 do
      upsert_daily(account_id, meter, day, count)

      Phoenix.PubSub.broadcast(
        MyApp.PubSub,
        "usage:#{account_id}",
        {:usage_updated, meter}
      )
    end
  end

  defp upsert_daily(account_id, meter, day, count) do
    Repo.insert_all(
      "usage_daily",
      [%{account_id: account_id, meter: meter, day: day, count: count}],
      on_conflict: [inc: [count: count]],
      conflict_target: [:account_id, :meter, :day]
    )
  end
end

Counter.take_all/0 resets the ETS counters as it reads them (:ets.take/2 per key, or a match-and-reset with :ets.update_counter/4), so nothing is double counted. The on_conflict: [inc: ...] is the important bit: two flushes on the same day add up instead of overwriting.

Broadcasting once per flush rather than once per metered request matters. A busy account might generate 5,000 events between flushes. Sending 5,000 PubSub messages to a LiveView that can only render 60 frames a second is a good way to grow an unbounded mailbox. One message every few seconds is plenty, and it doubles as natural throttling.

Step 4: the LiveView

Now the page itself. Subscribe on connect, assign once, and recompute on broadcast:

# lib/my_app_web/live/usage_live.ex
defmodule MyAppWeb.UsageLive do
  use MyAppWeb, :live_view

  alias MyApp.{Billing, Entitlements, Usage}

  @meter "api_calls"

  @impl true
  def mount(_params, _session, socket) do
    account = socket.assigns.current_scope.account
    period = Billing.current_period(account)

    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, "usage:#{account.id}")
    end

    {:ok,
     socket
     |> assign(account: account, period: period)
     |> assign_usage()
     |> stream(:events, Usage.recent_events(account.id, @meter, limit: 25))}
  end

  @impl true
  def handle_info({:usage_updated, @meter}, socket) do
    {:noreply, assign_usage(socket)}
  end

  def handle_info({:usage_updated, _other_meter}, socket), do: {:noreply, socket}

  defp assign_usage(socket) do
    %{account: account, period: period} = socket.assigns

    total = Usage.current_period_total(account.id, @meter, period)
    limit = Entitlements.quota(account, @meter)

    assign(socket,
      total: total,
      limit: limit,
      series: Usage.daily_series(account.id, @meter, period),
      projected: Usage.project(total, period)
    )
  end
end

Two details that are easy to get wrong.

The connected?/1 guard means the dead render (the first HTTP pass) does not subscribe, so you do not leak a subscription for a page that is about to be replaced by the websocket mount.

The second handle_info clause matters more than it looks. Once you meter three or four things, an account’s topic carries traffic for meters this page does not display, and an unmatched handle_info crashes the LiveView. Match the meter you render and ignore the rest.

The quota lookup comes from your entitlements layer. If you do not have one yet, plan-based feature gating and entitlements in Phoenix covers modelling the plan catalog as data so that both the enforcement path and this dashboard read the same source of truth. A dashboard that shows a limit different from the one the API enforces is worse than no dashboard.

Step 5: chart it with inline SVG

You do not need Chart.js for a usage chart. A function component that turns the series into a polyline is about twenty lines, renders server side, works with LiveView diffing, and adds zero kilobytes of JavaScript:

# lib/my_app_web/components/usage_chart.ex
attr :series, :list, required: true
attr :width, :integer, default: 640
attr :height, :integer, default: 140

def usage_chart(assigns) do
  assigns = assign(assigns, :points, points(assigns.series, assigns.width, assigns.height))

  ~H"""
  <svg
    viewBox={"0 0 #{@width} #{@height}"}
    class="w-full h-36 text-indigo-500"
    preserveAspectRatio="none"
    role="img"
    aria-label="Daily usage for the current billing period"
  >
    <polyline points={@points} fill="none" stroke="currentColor" stroke-width="2" />
  </svg>
  """
end

defp points([], _w, _h), do: ""

defp points(series, w, h) do
  max = Enum.reduce(series, 1, fn {_day, count}, acc -> max(acc, count) end)
  last = max(length(series) - 1, 1)

  series
  |> Enum.with_index()
  |> Enum.map_join(" ", fn {{_day, count}, i} ->
    x = i / last * w
    y = h - count / max * (h - 4) - 2
    "#{Float.round(x * 1.0, 1)},#{Float.round(y * 1.0, 1)}"
  end)
end

preserveAspectRatio="none" lets the SVG stretch to the container width, and currentColor means the line follows your Tailwind text colour, so dark mode is free. For bars instead of a line, map the same series to <rect> elements. The point is that the shape of the data lives in Elixir, where you can test it, rather than in a JSON blob handed to a JS library.

Step 6: the quota bar and an honest projection

The number customers actually want is not “how much have I used”, it is “am I going to be fine”.

@doc "Straight-line projection of the period total based on elapsed days."
def project(total, period) do
  today = Date.utc_today()
  elapsed = Date.diff(today, period.starts_on) + 1
  days = Date.diff(period.ends_on, period.starts_on) + 1

  cond do
    elapsed <= 0 -> total
    elapsed >= days -> total
    true -> round(total / elapsed * days)
  end
end

Render it next to the bar:

<div class="rounded-lg border p-6">
  <p class="text-sm text-zinc-500">API calls this period</p>
  <p class="text-4xl font-semibold tabular-nums"><%= Number.Delimit.number_to_delimited(@total, precision: 0) %></p>

  <div :if={@limit} class="mt-4">
    <div class="h-2 w-full rounded bg-zinc-200">
      <div
        class={["h-2 rounded", @total / @limit > 0.9 && "bg-red-500" || "bg-indigo-500"]}
        style={"width: #{min(100, round(@total / @limit * 100))}%"}
      />
    </div>
    <p class="mt-2 text-sm text-zinc-500">
      <%= round(@total / @limit * 100) %>% of <%= @limit %> included calls.
      On current pace you will finish the period at about <%= @projected %>.
    </p>
  </div>

  <.usage_chart series={@series} />
</div>

Two things to be careful about here. Use tabular-nums on any number that updates live, otherwise the digits jiggle horizontally on every tick and the page feels broken. And be honest about the projection: a straight-line estimate on day two of a month is close to meaningless, so either hide it for the first few days or label it as an estimate. Overstating precision on a page about money costs you trust you cannot buy back.

Step 7: drill-down with LiveView streams

The table under the chart is where support tickets go to die. Use streams so the list does not sit in the socket’s memory:

def handle_event("load_more", _params, socket) do
  %{account: account, cursor: cursor} = socket.assigns
  {events, next_cursor} = Usage.recent_events(account.id, "api_calls", after: cursor, limit: 25)

  {:noreply,
   socket
   |> assign(cursor: next_cursor)
   |> stream(:events, events)}
end

Keyset pagination on inserted_at rather than OFFSET, because the offset query gets slower exactly as the table grows. Show the endpoint, the timestamp, the quantity, and the API key label. That is usually enough for a customer to answer their own question, which is the entire return on building this page.

Multi-node: the honest caveat

ETS is node local. If you run three Fly machines, Counter.read/2 on the node serving the LiveView sees only that node’s unflushed delta, so the headline number can undercount by up to one flush interval of traffic from the other nodes.

Three ways out, in increasing order of effort:

  1. Flush often and accept it. With a five second flush, the worst case error is five seconds of traffic. For a dashboard about a monthly bill, that is invisible. This is the right answer for most teams.
  2. Ask the cluster. Broadcast a read request over Phoenix.PubSub or use :erpc.multicall/4 across Node.list() and sum the local counters. Accurate, and it adds a network round trip to every render.
  3. Own the counter per account. One process per account in a distributed registry (Horde or :pg) so every node routes increments to the same place. Accurate and fast to read, at the cost of a real distribution problem to operate.

Start at option 1. Move only when a customer actually complains, which mostly happens if you also enforce hard quotas off the same counter, since undercounting there means giving away usage.

Testing it

The dashboard is a pure function of two data sources, so it tests cleanly:

test "renders the live total and updates on broadcast", %{conn: conn, account: account} do
  insert_daily(account, "api_calls", Date.utc_today(), 1_200)

  {:ok, view, html} = live(conn, ~p"/usage")
  assert html =~ "1,200"

  MyApp.Metering.Counter.increment(account.id, "api_calls", 5)
  Phoenix.PubSub.broadcast(MyApp.PubSub, "usage:#{account.id}", {:usage_updated, "api_calls"})

  assert render(view) =~ "1,205"
end

Also test points/3 directly with a flat series, a single point, and an empty list. Charts break on edge cases, not on the happy path, and a divide by zero in a function component takes the whole LiveView down.

Skip the plumbing

Everything above is maybe a day of work the first time and a week of small corrections after that: double counted flushes, a projection that panics customers on the first of the month, a chart that divides by zero when a new account opens the page.

Or skip the plumbing: Aurora Meter meters usage on an ETS hot path, rolls it up to Postgres, enforces plan quotas, and reports to Stripe for you. The core is free and MIT licensed, and Aurora Meter Pro adds the reconciliation and reporting pieces. If you want the surrounding SaaS as well (auth, billing, admin, transactional email), phx_saas ships those already wired together, so the usage page is the feature you build rather than the fifth thing on the list.

Summary

A Phoenix LiveView usage dashboard is four moving parts:

  • Read from two places. Durable daily rollups in Postgres, plus the unflushed ETS delta, behind one function.
  • Push, do not poll. Broadcast once per flush on a per account topic, and ignore meters the page does not render.
  • Render server side. Inline SVG for the chart, streams for the drill-down table, tabular-nums so live digits stay still.
  • Be honest. Label projections as estimates, show the same quota the API enforces, and say clearly how fresh the number is.

Get those right and the page stops being a reporting feature. It becomes the thing that makes usage-based pricing feel fair, which is the only reason customers accept it.

Want the whole stack rather than one page? The Builder Pass gives you unlimited access to every PhxTemplates starter, so you can pull the pieces you need and ship the product instead of the plumbing.