What this is
Swappr’s admin dashboard has two kinds of charts:
- “What is true right now?” — total users, active listings, mutual matches, recently-active chats, MRR/ARR. Recomputed against the live collections.
- “How has it trended?” — DAU/MAU, new users, trial→paid, churn, top boroughs. Read from a per-day snapshot table.
These two questions need two different machineries. Running the trend queries live every page-load would crush Mongo with month-long lastSeenAt scans on every admin browse. Computing the “right now” totals from yesterday’s snapshot would make the dashboard misleading immediately after launch. Swappr ships both paths, side by side.
This page documents that split, the rollup pipeline that backs the trend surface, and the limitations of the launch-slice implementation (QUESTIONS.md §10.2, SWAPPR_BACKEND_PLAN.md §5 + §13).
These are product analytics for the admin dashboard — they complement, not replace, the operational dashboards (worker queue health, error rates, latency p95s). Operational metrics live in your observability stack, not in analytics_daily.
The two paths at a glance
| Surface | Path | Source | Staleness |
|---|
/admin/analytics/overview | Live + cache | Live countDocuments/aggregations over users, current_homes, matches, conversations, subscriptions. | ≤ 60 seconds. |
/admin/analytics/cohorts | Rollup | Range query against analytics_daily. | Up to 24 hours. |
/admin/analytics/locations | Rollup | Latest row’s topBoroughs from analytics_daily. | Up to 24 hours. |
The overview path (live + 60s cache)
getOverview() is intentionally simple:
- Cache hit? Return the cached snapshot verbatim.
computedAt will reflect the original compute time, so the client can see exactly how stale the snapshot is.
- Cache miss? Issue every counter query in parallel (
Promise.all over countDocuments and the MRR aggregation), build the snapshot, write it to the cache with TTL = 60s, return it.
There is no per-tenant or per-user dimension here — every admin sees the same shared snapshot. The cache is a single key.
export const OVERVIEW_CACHE_TTL_SEC = 60;
computedAt is the truthful “as of” timestamp. Clients can poll the endpoint and detect a fresh compute by comparing computedAt deltas across calls.
How each counter is computed
| Field | Query | Notes |
|---|
totalUsers | User.countDocuments({}) | Raw row count, no ban/delete filter. |
totalActiveListings | CurrentHome.countDocuments({ status: 'LIVE', ownerTenancyApproved: true }) | Snapshot, not historical. |
totalMatches | Match.countDocuments({ savedByA: { $ne: null }, savedByB: { $ne: null } }) | ”Mutual save” proxy — see MVP limitations. |
activeChats | Conversation.countDocuments({ lastMessageAt: { $gte: now − 7d } }) | 7-day activity window. |
mrr | count(Subscription{ status: 'ACTIVE' }) × app_config.pricing.monthlyPence | Pence. Single-tier assumption. |
arr | mrr × 12 | Convenience field. |
The rollup path (nightly)
The cohorts and locations surfaces read from analytics_daily — one row per UTC date, populated by the nightly rollup worker.
Schedule
The job is a BullMQ scheduled job that runs 02:00 UTC daily and computes yesterday’s row. The cron is deliberately staggered with the other heavy nightly jobs so they do not pile up:
| UTC time | Job | What it scans |
|---|
| 02:00 | analytics-rollup | All of the live collections (count + group). |
| 03:00 | retention-cleanup | Tenancy verifications past 30-day reviewedAt. |
| 04:00 | gdpr-erasure | Pending erasure requests. |
The job
The handler is intentionally a thin shell — yesterdayKey → compute → upsert → log. Pure logic lives in the ./analytics-rollup.ts handler; Mongo specifics live in ./analytics-rollup.adapter.ts. The store is a port (AnalyticsRollupStore), so unit tests inject an in-memory fake.
Idempotency
Upsert is keyed on date (a unique index). Re-running the job for the same day overwrites the row in place — no duplicate analytics_daily rows can exist for a given UTC date. The worker can therefore be re-kicked safely after a failure, and a backfill loop can run a date range without special-casing collisions.
Failure semantics
The handler does not swallow errors from compute or upsert. A partial row would silently skew the trend chart. Any exception propagates to the BullMQ wrapper, which applies its retry policy. A failed nightly run is loud, observable, and self-healing on retry.
How each rollup metric is computed
| Field | Computation | Window |
|---|
dau | User.countDocuments({ lastSeenAt: ∈ [day-start, day-end) }) | 24h. |
mau | User.countDocuments({ lastSeenAt: ∈ [day-end − 30d, day-end) }) | Trailing 30d ending at the row’s day-end. |
newUsers | User.countDocuments({ createdAt: ∈ [day-start, day-end) }) | The day itself. |
totalActiveListings | Live count of current_homes with status: 'LIVE' AND ownerTenancyApproved: true. | Point-in-time. |
totalMatches | Live count of matches with savedByA AND savedByB non-null. | Point-in-time, mutual-save proxy. |
activeChats | Conversation.countDocuments({ lastMessageAt: ∈ [day-end − 7d, day-end) }) | Trailing 7d. |
trialToPaid | Subscription.countDocuments({ status: 'ACTIVE', trialEndsAt: ∈ [day-end − 30d, day-end) }) | Trial-end conversion proxy. |
churn30d | Subscription.countDocuments({ status ∈ {CANCELLED, EXPIRED}, updatedAt: ∈ [day-end − 30d, day-end) }) | updatedAt-as-statusChangedAt proxy. |
mrr | count(Subscription{ status: 'ACTIVE' }) × monthlyPence. | Pence, single-tier. |
arr | mrr × 12. | Convenience. |
topBoroughs | current_homes group-by outward-area, sorted desc by count, capped at 10. | Point-in-time supply only. |
Borough derivation
topBoroughs does not use official London-borough names. It groups by the UK postcode outward-area code, stripping any alpha subdistrict letter so the geography aligns with Royal Mail areas rather than councils:
| Postcode | borough |
|---|
E1 6AN | E1 |
SE15 4SW | SE15 |
W1A 1AA | W1 |
W1B 5AA | W1 |
This is good enough for “where are listings concentrated?” but the field is a key, not a label. Front-ends that want council names should map the area to a council via a static lookup.
Staleness contract
| Surface | Worst-case staleness | Why |
|---|
overview | 60 seconds | Cache TTL. computedAt exposes the actual age. |
cohorts | ~24 hours | Yesterday’s rollup ran at 02:00 UTC. |
locations | ~24 hours | Same — reads the latest rollup. |
A client that needs “right now” totals must use overview. A client that needs trend lines must use cohorts. These are not interchangeable.
The most recent date in cohorts is generally yesterday, not today. There is no zero-fill: days for which the rollup has not yet produced a row are simply absent from items. A response with 29 rows is normal on the day after launch — today’s row will appear at 02:00 UTC tomorrow.
MVP limitations
These are documented in analytics-rollup.adapter.ts and flagged in each endpoint page. Listed once here as the canonical reference.
topBoroughs.demandHits is hard-zero
The spec calls for demandHits to count user_preferences whose desired-radius covers each borough’s centroid (i.e. “how many people want to live here”). That requires:
- A per-borough centroid lookup table (lat/lng per outward area).
- A
$geoWithin aggregation per borough, ideally driven off an indexed geo column.
Neither ships in the launch slice. The rollup populates demandHits: 0 for every row, so ranking is listingCount only. A one-time log line at worker boot reminds operators:
[analytics-rollup] TODO(human-review): topBoroughs.demandHits is hard-zero
until the per-borough centroid table ships (QUESTIONS.md §10.2).
churn30d uses updatedAt as a proxy
The subscriptions collection has no dedicated statusChangedAt field. Any subscription whose current status is CANCELLED or EXPIRED and whose updatedAt falls in the trailing 30 days is counted as churn.
False positives are possible if a CANCELLED row was touched for an unrelated reason — e.g. a tax-metadata refresh, a webhook backfill, a manual admin edit. Acceptable for the dashboard. Not acceptable for accounting: do not reconcile financial reports against this number.
totalMatches uses the “mutual save” proxy
A “match” in totalMatches is any matches row where both savedByA and savedByB are non-null — i.e. both sides have saved each other. When the matching engine introduces an explicit status: 'MUTUAL' discriminator, this query should switch to that field. Until then the proxy holds.
trialToPaid does not count trial drops
TRIALING → ACTIVE (conversion) is counted; TRIALING → CANCELLED (drop) is not. This is correct by definition — a trial drop is not a conversion — but the metric is conversion count, not trial-outcome breakdown. If you want the conversion rate you also need the count of TRIALING cohorts started in the same window; that is not currently a stored metric.
Not in this surface
To set expectations on what analytics_daily deliberately does not capture:
- No PII. Rows are aggregate counts only — no user ids, no emails, no postcodes (only the outward-area key).
- No tenant dimension. Counters are platform-wide. The launch product is single-tenant.
- No per-cohort drill-down.
dau is a count; there is no list of which users were active. The audit log + user-search APIs serve that need.
- No operational metrics. Queue depth, error rates, latency p95s — those live in your observability stack (Datadog/Grafana/etc.), not here.
See also