Build Cabin

KINDAback to the verdict

0%0 of 16 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort weekendthe files for this build are in the project pack

CollectorOne endpoint on your own domain, no third-party host, no identifier shared between sitesBeaconsendBeacon with a keepalive fetch fallback, fired on visibility changeStorageAn append-only raw table with a life measured in hours, plus aggregates written on receiptIdentityA daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightlyReportsVisits, pageviews, paths and referrer hosts, with no segment describing a person

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/analyticsRaw events with a short life, aggregates with a long one.
SALT_ROTATE_HOURSrequired24How often the hashing secret is replaced and the old one destroyed.
RAW_MAX_AGE_HOURSrequired24After this the raw events are deleted. The reports must not notice.
AGGREGATE_MAX_AGE_MONTHSrequired25Check the figure your regime publishes rather than trusting this default.
COLLECTOR_ORIGINrequiredhttps://stats.example.comMust be on the same registrable domain as the measured site.
MAX_PAYLOAD_BYTESrequired1024Each beacon. The keepalive budget is shared across every inflight request on the page.
OPTOUT_URLrequiredhttps://stats.example.com/objectionReachable with no account and no payment. It is published in the notice.

The build, in order

  1. A beacon that actually arrives

    The event reaches the collector from a phone that was switched away from, not closed.

    1. It is the only event that fires when someone switches apps.

      Files public/collect.jstests/no-unload.test.js

    2. A silent false is how a collector under-reports for months.

      Files public/collect.js

    3. The 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 passes
    watch 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.
  2. An identifier that is useless by tomorrow

    The visit hash cannot be linked across days, and there is nowhere to store an address.

    1. Not on the way into the table, because it should never reach a table.

      Files src/identity.jstests/identity.test.js

    2. The destruction is the property the whole design rests on.

      Files src/salt.jstests/salt.test.js

    3. The enforcement is the absent column, not the careful code.

      Files migrations/001_init.sql

    done when · tick each as it passes
    watch 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.
  3. Aggregate on receipt, expire the rest

    Deleting every raw row leaves the reports intact.

    1. A nightly rollup job is a nightly window in which the raw rows must survive.

      Files src/ingest.jstests/aggregate.test.js

    2. So that 'we delete after a day' is a thing you can show.

      Files src/retention.jstests/retention.test.js

    3. The published number and the enforced number must be one number.

      Files config/retention.jsonsrc/notice.js

    done when · tick each as it passes
    watch 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.
  4. 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.

    1. So the harmless chart cannot be added without someone noticing.

      Files src/reports/dimensions.jstests/dimensions.test.js

    2. You gave up the ability to count people; say so everywhere.

      Files src/views/report.ejstests/wording.test.js

    3. Free and simple is the standard, not a settings page.

      Files src/objections.jssrc/views/optout.ejs

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more analytics.