These docs are a work in progress: some pages may still be incomplete or contain inaccuracies.
fastmon Docs

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 queries

Three 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 stitch before forwarding. The application only ever sees pre-derived FM-* 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

ComponentWhat it does
Beacon scriptVanilla JS, ~10 KB compressed. Observes Web Vitals, batches, posts.
EU edgeStrips 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.
CollectorPublic ingest at /c/{collector_hash}. Validation, rate-limit, server-side sanitization.
API/v1/...: authenticated REST. Powers the dashboard.
DashboardSingle-page app. Reads via the API. Static assets only.
Configuration DBUsers, organizations, sites, settings, billing.
Analytics storeBeacon events, aggregated metrics, error logs.
Session storeSessions, rate limits, short-lived caches.

Backend and frontend live in independent repos and deploy independently.

Storage tiers

StoreWhat lives hereNotes
Configuration DBUsers, orgs, sites, settings, billingSingle primary, async access.
Analytics storeBeacon events, aggregates, error logsColumnar, queries scoped per org.
Session storeSessions, rate limits, short-lived cachesEU-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

PathPurposeAuth
GET /s/{source_hash}.jsPublic beacon script delivery. Strong cache, ETag.Public
POST /c/{collector_hash}Public beacon ingestion.Public
/v1/...Authenticated REST API.Cookie or token
/openapi.jsonOpenAPI 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

DataWhereRetention
Beacon events (raw and rolled-up)Analytics storedata_retention_days per site (90 default).
Site, org, member configurationConfiguration DBUntil deletion.
Active sessions (auth)Session storeSliding window, default 30 days idle.
Rate-limit countersSession storePer-window TTL, < 5 min.
Raw IP / User-AgentNever persistedDestroyed 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.

On this page