Build Cabin
KINDAback to the verdict
A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the collector, the sweep and the reports.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - have readyfree if you own the domain
Why A first-party collector is the shape the exception has room for, and a third-party host reintroduces every question you were avoiding.
Get it Add a subdomain of the site's own domain pointing at wherever this runs.
Verify
The collector endpoint answers on that hostname and the script loads from it - have readyfree
Why Switching apps rather than closing a tab is the case that unload never covered, and it is not reproducible in a desktop browser.
Get it Any phone that can reach the site.
Verify
Loading a page and switching to another app produces an event - decidefree
Why The retention figures and the opt-out wording differ, and picking after you have built the sweep means picking whatever the sweep already does.
Get it Decide which visitors you are designing for, read that regulator's current published guidance, and write the retention numbers and the objection mechanism into the configuration file with the date you checked. open ↗
Verify
The configuration carries the numbers and the date, and the notice is generated from it
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/analytics | Raw events with a short life, aggregates with a long one. |
SALT_ROTATE_HOURS | required | 24 | How often the hashing secret is replaced and the old one destroyed. |
RAW_MAX_AGE_HOURS | required | 24 | After this the raw events are deleted. The reports must not notice. |
AGGREGATE_MAX_AGE_MONTHS | required | 25 | Check the figure your regime publishes rather than trusting this default. |
COLLECTOR_ORIGIN | required | https://stats.example.com | Must be on the same registrable domain as the measured site. |
MAX_PAYLOAD_BYTES | required | 1024 | Each beacon. The keepalive budget is shared across every inflight request on the page. |
OPTOUT_URL | required | https://stats.example.com/objection | Reachable with no account and no payment. It is published in the notice. |
The build, in order
A beacon that actually arrives
The event reaches the collector from a phone that was switched away from, not closed.
It is the only event that fires when someone switches apps.
Files
public/collect.jstests/no-unload.test.jsA silent false is how a collector under-reports for months.
Files
public/collect.jsThe keepalive budget is shared across every inflight request on the page.
Files
public/collect.jstests/payload-size.test.js
done when · tick each as it passeswatch out- sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers.
An identifier that is useless by tomorrow
The visit hash cannot be linked across days, and there is nowhere to store an address.
Not on the way into the table, because it should never reach a table.
Files
src/identity.jstests/identity.test.jsThe destruction is the property the whole design rests on.
Files
src/salt.jstests/salt.test.jsThe enforcement is the absent column, not the careful code.
Files
migrations/001_init.sql
done when · tick each as it passeswatch out- Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce.
Aggregate on receipt, expire the rest
Deleting every raw row leaves the reports intact.
A nightly rollup job is a nightly window in which the raw rows must survive.
Files
src/ingest.jstests/aggregate.test.jsSo that 'we delete after a day' is a thing you can show.
Files
src/retention.jstests/retention.test.jsThe published number and the enforced number must be one number.
Files
config/retention.jsonsrc/notice.js
done when · tick each as it passeswatch out- Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift.
Objecting costs nothing, and nothing segments a person
Opting out is one click with no account, and no report groups by an attribute of the visitor.
So the harmless chart cannot be added without someone noticing.
Files
src/reports/dimensions.jstests/dimensions.test.jsYou gave up the ability to count people; say so everywhere.
Files
src/views/report.ejstests/wording.test.jsFree and simple is the standard, not a settings page.
Files
src/objections.jssrc/views/optout.ejs
done when · tick each as it passeswatch out- Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions.
Bots, and being honest about the numberproduct builder
What was filtered is visible, and the published compliance page comes from the running configuration.
A number that moved for an unknown reason is worse than a wrong number.
Files
src/bots.jssrc/views/report.ejsWhat you publish and what you enforce should be one file.
Files
src/compliance.jstests/compliance.test.jsThere should be nothing else left to export by then.
Files
src/export.js
done when · tick each as it passeswatch out- Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery.
That is the whole plan for Cabin. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Session replay.
- Cross-site or cross-device identity.
- Advertising attribution and conversion tracking.
- Hosting analytics for other people's sites.
- bot and identity resolution
- session replay privacy tooling
- enterprise governance and integrations
- high-volume ingestion and retention
- Server-side ingestion from your own access log, which removes the browser script entirely
- A public dashboard for one site, which is a read-only view and forces the wording to be honest
- Alerting on a traffic collapse, which is the one thing a small site owner actually wants a notification for
Need the files? The project pack on the verdict page hands your agent the whole brief · more analytics.