Token Tracker: how the dashboard gets its data

The path from SQLite through Phoenix to the Svelte SPA, and what actually happens when you click a filter.

The shape of it

There is no live connection of any kind. No websockets, no LiveView, no polling. The dashboard is a static Svelte SPA that makes ordinary GET requests to a JSON API, and every one of those requests is caused by a URL change.

SQLite (session_usage_hourly + devices)
   │  Ecto, one grouped query per request
TokenTracker.Dashboard.report/2      ← all aggregation lives here
   │  plain map
ReportController → Phoenix JSON
   │  GET /api/report?period=…&view=…&tz=…&device=[…]
SvelteKit load (+page.ts)            ← runs in the browser only
   │  { report: Promise }
+page.svelte → <svelte:boundary> → ReportView → BarChart / LineChart

The single most important design decision: the URL is the source of truth. Controls do not hold state. Clicking "1W" writes ?period=week, which is a navigation, which re-runs load, which issues a new request. The report follows the URL, and a pasted link reproduces exactly what the sender saw.

Why that matters

The comments in +page.svelte record the previous design: the page kept its own copy of the query and wrote to the URL with replaceState. That does not re-run load, so the address bar changed while the report did not.

Data at rest

The dashboard never touches raw JSONL transcripts or the usage_events table. It reads one pre-aggregated table, which is what makes a request cheap.

session_usage_hourly — the dashboard's only fact table
Column groupColumnsRole
Composite key device_id, session_key, hour_utc, project, agent, provider, model, pricing_tier Every dimension the UI can group or filter by is part of the primary key
Measures input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_write_tokens, session_starts Summed in SQL

Rows arrive by two routes, both outside the request path:

  • Local collection. Scheduler ticks on an interval, runs Collector.collect/0 over the agent history files, then Sessions.reconcile_local/2. For each dirty session, replace_snapshot/1 deletes that session's hourly rows and re-inserts them. Sessions are replaced wholesale, never patched.
  • Remote devices. Clients push revisioned session snapshots to the host over Erlang distribution (Sync.ClientSync.Server), which land in the same table under their own device_id.

So the dashboard is always reading a settled table. A collection happening mid-request cannot produce a half-written session — it swaps one session's rows inside a transaction.

The devices table is joined on every query, because the device dimension is devices.name while the other three are columns on the usage table itself.

Serving the app

The HTTP endpoint is not started by the OTP application. It is started by Runtime.activate/1, and only under two conditions:

def child_specs(%{role: "host", web_enabled: true} = config) do
  [{TokenTracker.Sync.Server, config}, {Scheduler, config}, TokenTrackerWeb.Endpoint]
end

A client node or a host with web.enabled = false runs the collector and sync loop with no web server at all. When it does start, Bandit binds to 127.0.0.1:4000 by default, and Runtime.parse_ip/1 rejects any bind address that isn't loopback — this dashboard is not addressable off the machine.

Everything the endpoint serves
RouteHandlerReturns
/_app/*Plug.StaticHashed JS/CSS/fonts, immutable, one year
/theme.js, /robots.txtPlug.Staticno-cache
GET /api/reportReportControllerThe entire dashboard payload
GET /api/systemSystemControllerDevice roster and sync health
GET /api/healthzHealthController{"ok": true}
/api/* (other)NotFoundControllerJSON 404, so a typo never returns HTML
GET /*pathSpaControllerpriv/static/index.html

The catch-all is what makes deep links work: /?period=week and any other path return the same HTML shell, and the client router takes over. SecurityHeaders sets a strict CSP, adds cache-control: no-store to every /api/ response, and for the SPA shell computes SHA-256 hashes of its inline scripts so the CSP can allow exactly those and nothing else.

Boot and the first fetch

+layout.ts is one line — export const ssr = false — and the build uses adapter-static with fallback: 'index.html', output to ../priv/static. Nothing is rendered on the server. The sequence is:

  1. Browser gets the static shell, which paints a #initial-loading banner from inline CSS.
  2. The SPA hydrates; +layout.svelte's onMount removes that banner and applies the saved theme.
  3. +page.ts load runs. It calls depends(reportDependency) — the string 'tracker:report' — registering a hand-rollable invalidation key.
  4. It builds the query with reportSearch(url, browserTimeZone) and fires fetch('/api/report' + search).

The time zone comes from Intl.DateTimeFormat().resolvedOptions().timeZone unless the URL carries a tz. This is one of the reasons SSR is off — the server has no idea what zone the viewer is in, and the whole report is bucketed by local time.

The promise is returned, not awaited

load returns { report: Promise<ReportResponse> } without awaiting it. Navigation therefore completes immediately and the page's <svelte:boundary> owns the pending and failed states. Awaiting inside load would make a failure replace the whole dashboard with the error page; instead a failure is an inline notice with a retry button, and the controls stay usable.

Inside a /api/report request

ReportController.show/2 is four lines. Everything real is in TokenTracker.Dashboard.report/2, which runs this pipeline:

1. Validate

validate_params/1 rejects anything outside day|week|month, device|project|agent|model, a real IANA zone, and filter arrays of at most 10 values of at most 160 characters. Failures return 400 with a message. Filter params arrive as a JSON array in a single query parameter; the server Jason.decodes it and falls back to treating the raw string as one value.

The QueryParams plug wraps fetch_query_params so a malformed query string returns {"error":"malformed query"} as JSON rather than crashing into an HTML error page.

2. Build the time window

How each period buckets
PeriodBucketsBucket keyLabel
day24 hours, floored to the hourUTC ISO-8601%-I %p in the viewer's zone
week7 local daysLocal ISO date%b %-d
month30 local daysLocal ISO date%b %-d

Day and month boundaries are computed as local midnight and shifted to UTC, with explicit handling for DST: an ambiguous local midnight takes the first instant, a gap takes the instant after it. Rows land in buckets via bucket_key/2, which converts each row's hour_utc to the report zone once, at the point the row is read.

3. Query

Four queries, each doing its aggregation in SQLite rather than in Elixir:

  • query_rows/2 — one row per (hour, provider, model, pricing_tier, dimension), with the token columns summed. Filters are applied as WHERE … IN, so rows that will be discarded are never materialized.
  • query_sessions/3 — sessions per dimension, counted as COUNT(DISTINCT device_id || ':' || session_key). A session spans many hourly rows and often several models, so summing a per-row column would multiply-count it.
  • query_options/1 — the filter menus. One grouped scan yields every distinct (device, project, agent, model) combination in the window, and the four menus are derived from that in memory.
  • any_usage?/1 — whether the window has any usage at all, ignoring filters, which is what separates "no data yet" from "your filters match nothing".

4. Price

The distinct (provider, model) pairs go to Pricing.load/1, which resolves rates from a models.dev catalog cached on disk for 24 hours with ETag revalidation. Decoding that multi-megabyte catalog dominated response time, so the decoded term is memoized in :persistent_term, keyed on a SHA-256 of the file's bytes — mtime is only second-resolution and would serve a superseded catalog after a same-second replacement. Cost is per row, using the row's stored pricing_tier so long-context pricing is applied as it was at collection time.

The loader is injectable via :dashboard_pricing_loader, which is how the tests avoid the network.

5. Assemble

build_report/6 walks the priced rows once, accumulating totals keyed by dimension and by bucket. (A comment notes the previous version rescanned the row set per bucket and again per series, growing as buckets × series × rows.) It then takes the top 10 dimensions by tokens; everything else collapses into a single Other series whose session count is a fresh distinct count across the collapsed values — not the sum of theirs, since one session can touch several.

The response:

{
  "report": {
    "period", "view", "timeZone",
    "series":  [{ key: "series-0", label, tokens, isOther, count }],
    "bars":    [{ key, label, datetime, tokens, cost, segments: [{key, tokens}] }],
    "totals":  [{ key, label, counters: {input, output, reasoning,
                                          cacheRead, cacheWrite, sessions},
                  tokens, cost, isOther, count }],
    "combined": { …same shape… },
    "options":  { devices, projects, agents, models },
    "hasUsage", "hasMatches", "truncated"
  },
  "stale": bool, "staleDevices": [names],
  "pricing": { source, fetchedAt, missingModels, warning }
}

Series get opaque keys (series-0…) and bars reference them, so the chart never re-derives which segment belongs to which series.

What happens when you change a filter

This is the loop worth internalizing. Every control does the same thing.

onclick → params.period = 'week'          // runed/kit useSearchParams
       → URL becomes ?period=week         // a real navigation, noScroll
       → SvelteKit re-runs +page.ts load  // because a read searchParam changed
       → reportSearch(url, tz)            // rebuilds the query
       → fetch('/api/report?…')           // new promise on data.report
       → navigating.to is truthy          // ReportView dims, aria-busy
       → boundary re-awaits, charts animate to the new figures

Checkboxes go through toggleFilter, which reads the current array, adds or removes one value, enforces the 10-value cap client-side, and assigns back to params[key] — the same navigation.

Dependency granularity

reportSearch reads every parameter by name via searchParams.get(...). That is deliberate and load-bearing: SvelteKit records a load dependency per parameter accessed through get. Iterating searchParams or stringifying it would register a dependency on the whole URL and re-run load on any change at all.

Validation on both ends

The client applies the same rules the server enforces — effectivePeriod, effectiveView, filterValues — and the buttons render from those resolved values rather than the raw parameters. A hand-edited link with ?period=forever renders a day report with "1D" highlighted, instead of a day report with "1W" highlighted or a 400. Out-of-range values are replaced, not rejected.

Filter menus deliberately ignore the filters

query_options/1 applies the window but not apply_filters. If the menus only listed values surviving the current selection, filtering to one device would remove every other device from the menu and you could never widen the selection again. The client also unions in the currently-selected values before sorting, so a selection stays visible even if it has no rows in the new window.

The one control that doesn't fetch

Bars vs. lines is a rendering choice with no bearing on the data. reportSearch never reads chart, so it registers no dependency on it — toggling writes ?chart=lines to the URL, load does not re-run, no request is made, and +page.svelte simply swaps BarChart for LineChart against the report already in hand.

There is a test asserting exactly this: two URLs differing only by chart must produce an identical request string.

Pending, stale, and failed

Which mechanism drives which state
StateDriven byWhat you see
First loadboundary's pending snippetSkeleton rows
Subsequent loadnavigating.topending propPrevious report dims to 0.62 and animates to the new figures
Request failedboundary's failed snippetInline notice + "Try again"; controls stay live
Data is lateresponse.staleNamed devices that haven't reported in 15 min
No rows anywherehasUsage: false"No usage yet"
No rows after filteringhasMatches: false"No matching usage"
>10 seriestruncated: trueNote explaining the Other bucket

Two subtleties in the failure path:

  • Retry needs both halves. Resetting the boundary alone would re-await the promise that already rejected. So retry() calls invalidate(reportDependency) first — that re-runs load and produces a fresh promise — and only then calls reset(). It resets in a finally, so a retry that also fails reports the new failure rather than leaving a dead button.
  • Multiple boundaries, one reset. The header total, the time zone label, and each filter panel all await the same report in their own boundaries, but only the report boundary has a reset to hand. A retryToken counter is bumped on retry and the others are wrapped in {#key retryToken}, so they rebuild together instead of staying stuck on their failure text.

stale is computed server-side by stale_devices/2: for a local device the freshest of last_collection_at and its newest snapshot; for a remote device the freshest of last_sync_at and its newest snapshot. Anything older than 15 minutes, or never seen, is named.

The system page, for contrast

/system uses the conventional shape and is worth comparing:

export const load: PageLoad = async ({ fetch }) => {
  const response = await fetch('/api/system');
  if (!response.ok) error(response.status, 'failed to load system status');
  return (await response.json()) as SystemResponse;
};

It awaits, and it throws error(). So navigation blocks until the data arrives and a failure renders +error.svelte for the whole route. That is fine here — there are no controls to keep alive and nothing to preserve on screen. It also takes no parameters, so nothing re-runs it.

Dashboard.system/0 joins the device roster against per-device usage and snapshot recency, and reports the outbox depth, last_collection_at, last_sync_at, and — deliberately vague — "Synchronization needs attention" rather than the raw error text.

Dev vs. installed

Same code, two wirings
DevelopmentInstalled
HTML + assetsVite dev server on 127.0.0.1SpaController + Plug.Static from priv/static
/apiVite proxy → http://127.0.0.1:4000Same origin, same port
Build stepmix assets.buildvite build../priv/static

Because the proxy makes /api same-origin in dev too, no code anywhere knows an API base URL. Every fetch is a root-relative path. The release ships the compiled dashboard inside the Mix release, which is why the target machine needs no Node or pnpm.

Notes and one loose end

  • Everything is per-request. No server-side report cache, and /api responses carry cache-control: no-store. The speed comes from the pre-aggregated table, SQL-side grouping, the single pass in build_report, and the memoized pricing catalog.
  • Series colors are keyed on identity, not rank (ReportView.svelte), so a series keeps its color when the ordering changes and the transition reads as movement. Hash collisions across 10 series and 11 colors are likely rather than rare, so a taken slot walks to the next free one.
  • Charts have a table equivalent. The visual bars are aria-hidden; SeriesTable carries the same figures for assistive technology.
One piece of drift

web/src/lib/api.ts declares qualities: string[] on both Bar and Total, but Dashboard.build_report/6 never emits that field and nothing in the UI reads it. It's a leftover in the type definitions — harmless at runtime, but the types currently promise something the API doesn't send.