Architecture
How a beacon becomes a dashboard. The data flow, the stores, and what each one does.
Fastmon is two services (an API backend and a single-page dashboard) on top of three data stores. Knowing the shape helps when a metric goes missing, when you're debugging slow ingestion, or when compliance asks how the thing actually works.
High-level diagram
visitor browser
│
▼
beacon.js (PerformanceObserver)
│
POST /c/{collector_hash}
▼
┌─────────────────────────────────────┐
│ EU edge │
│ strips IP + User-Agent, │
│ derives country, computes stitch │
└─────────────────────────────────────┘
│ FM-* headers, no raw IP / UA
▼
collector ──── validate, rate-limit ─────┐
│ │
per-process buffer (~30 s) │
│ │
▼ │
analytics store │
dashboard ────────┘
│
▼
analytics queriesThree spots in the pipeline matter if you want to understand the behavior:
- Browser → EU edge. The edge strips the visitor's IP and User-Agent at the network boundary, derives the 2-letter country from the IP, and computes the bounded, pseudonymous
stitchbefore forwarding. The application only ever sees pre-derivedFM-*headers, never a raw IP or UA. See Privacy. - Edge → collector. A few batched
POSTs per page-view, spread over its lifecycle (load,pagehide/visibilitychange, periodic flush). See The RUM Beacon. - Collector → analytics store. Beacons land in a per-process buffer and flush every few seconds via batched insert. That's why your first visitor takes ~30 s to show up in the dashboard.
Components
| Component | What it does |
|---|---|
| Beacon script | Vanilla JS, ~10 KB compressed. Observes Web Vitals, batches, posts. |
| EU edge | Strips IP and User-Agent at the network boundary, derives country from the IP, computes the bounded stitch. Raw IP and UA never reach the application. |
| Collector | Public ingest at /c/{collector_hash}. Validation, rate-limit, server-side sanitization. |
| API | /v1/...: authenticated REST. Powers the dashboard. |
| Dashboard | Single-page app. Reads via the API. Static assets only. |
| Configuration DB | Users, organizations, sites, settings, billing. |
| Analytics store | Beacon events, aggregated metrics, error logs. |
| Session store | Sessions, rate limits, short-lived caches. |
Backend and frontend live in independent repos and deploy independently.
Storage tiers
| Store | What lives here | Notes |
|---|---|---|
| Configuration DB | Users, orgs, sites, settings, billing | Single primary, async access. |
| Analytics store | Beacon events, aggregates, error logs | Columnar, queries scoped per org. |
| Session store | Sessions, rate limits, short-lived caches | EU-jurisdiction in-memory store. |
All three are operated in the EU with versioned, idempotent migrations applied on deploy. For data residency, subprocessors, and the US CLOUD Act position, see Privacy → Where the data lives.
Routing layout
| Path | Purpose | Auth |
|---|---|---|
GET /s/{source_hash}.js | Public beacon script delivery. Strong cache, ETag. | Public |
POST /c/{collector_hash} | Public beacon ingestion. | Public |
/v1/... | Authenticated REST API. | Cookie or token |
/openapi.json | OpenAPI document for the v1 API. | Public |
The two public roots stay outside the /v1 prefix on purpose: short URLs keep the beacon payload smaller, and the cache rules are different (the beacon script is aggressively cached; API responses typically aren't).
What's kept where, briefly
| Data | Where | Retention |
|---|---|---|
| Beacon events (raw and rolled-up) | Analytics store | data_retention_days per site (90 default). |
| Site, org, member configuration | Configuration DB | Until deletion. |
| Active sessions (auth) | Session store | Sliding window, default 30 days idle. |
| Rate-limit counters | Session store | Per-window TTL, < 5 min. |
| Raw IP / User-Agent | Never persisted | Destroyed at the EU edge before ingest. The stored row carries at most the bounded, pseudonymous stitch. See Privacy. |
Why some things look the way they do
Why a 30-second buffer on ingest?
Because per-row inserts at this scale would melt any analytics database. Batching into a per-process buffer that flushes every few seconds is the standard pattern, and the analytics store batches those inserts again on its side. The combined price: a ~30 s delay between a beacon arriving and it showing up in queries.
Why separate source_hash and collector_hash?
So the script template (and the cache around it) can change without affecting the data pipeline. A beacon upgrade is just "roll a new template"; existing collector endpoints stay valid. More on this under The RUM Beacon: cache and rollouts.
Why a columnar analytics store?
Analytics queries read a few columns (one metric, a couple of dimensions) across many rows. Columnar storage scans only those columns from disk instead of every row, and timing data compresses very well in that layout. Vectorized aggregation makes percentiles and group-bys faster still. Postgres could serve the same queries; it would just be noticeably slower and more expensive per query.
Query freshness: live vs. aggregated
Analytics queries route by time range. Anything within the last 6 hours reads the raw beacon stream directly (fresh, with the ~30 s ingest delay above, no other latency). Longer ranges (24 h, 7 d, 30 d, custom) read from hourly pre-aggregated tables that the backend rebuilds once per hour. Same numbers, sub-100 ms response, no scan-cost growth as your data retention grows.
The crossover is invisible from the dashboard or the API; you just see fast queries. The only user-visible consequence is that the most recent completed hour can lag by up to an hour before it folds into the aggregated view; the 6 h live window covers that gap, so live dashboards stay accurate.
Why a separate session store?
Sessions and rate-limit counters need millisecond reads on every request, but no long-term durability. Keeping them out of the configuration DB keeps the auth path fast and cleanly isolated.
Related
- Concepts: the customer-facing data model.
- The RUM Beacon: the client-side half.
- Privacy: what we don't keep.