Analytics

beta

Audience measurement at the gateway: page views, sessions and daily visitors without touching the site — cookieless by default, and honest about what it cannot tell you.

Analytics

Doorman already sees every request to the site. Turning that into an audience figure costs the project nothing: no script tag, no <head> edit, no third party, no build change. One flag on the site record and the numbers start.

doorman site set griller analytics.enabled=true
doorman stats griller --days 30

It is not the request log. That log answers who was here, one line per request, with the IP address and the user agent in it — personal data with a thirty-day clock on it. This answers how is the site doing, and by default it stores no identifier at all: the day files hold counts and nothing else, which is why they have no retention limit unless you set one.

The three rungs

analytics.identity decides how much doorman may remember, and there are only three answers. Each rung buys one more question and costs one more thing.

identityWhat is stored on the visitor's deviceWhat you can ask
nonenothingviews, top pages, referrers, sources
session (default)nothing+ sessions, bounce rate, session length, daily unique visitors
devicea first-party cookie, only with consent+ returning visitors, weekly retention cohorts

session is the interesting one. A session is identified by a hash of the site, the day, the IP address and the user agent, salted with thirty-two random bytes that are rotated at UTC midnight and overwritten in place. Yesterday's salt is not archived anywhere, so yesterday's numbers cannot be recomputed from an address and today's cannot be matched to yesterday's — by anyone, us included. That is the same construction Plausible and Umami use, and it is what lets a session identifier exist without asking anyone's permission.

The price is stated rather than hidden: uniques are a daily figure and there is no monthly one. Counting the same person on Tuesday and Thursday requires something that survives the night, which is a cookie, which is consent. That is the device rung, and it is the only thing behind the consent contract.

What you get back

doorman stats griller --days 30
RANGE           2026-07-14 → 2026-08-12  (identity: session)
VIEWS           1840
SESSIONS        612
DAILY VISITORS  498
BOUNCE RATE     61.6%
AVG SESSION     1m 35s
BOTS            41

PATH                    VIEWS
/                       900
/menu                   410

REFERRER                VIEWS
google.com              120

DEVICE                  VIEWS
mobile                  1100
desktop                 740

note: dailyVisitors is the sum of each day's distinct-visitor count, not the
      number of distinct visitors over the range. The salt those counts were
      derived from is rotated at UTC midnight and not retained, so the two
      figures are not the same number and the second one is not recoverable.
note: known crawlers are excluded from every figure except 'bots'.

Those note: lines come from the instance, not from the CLI, and they are the point of the report as much as the numbers are. Every figure here has a limit that follows from how it was collected. A report that presents a partial figure as a whole one is worse than no report — so the limits travel with the numbers, into whatever dashboard reads the API.

Ranges are UTC days: --days N, or --from/--to as YYYY-MM-DD. A malformed one is a 400, not a silently ignored filter. --top N widens the dimension lists. --json gives you the instance's own document, unchanged.

An empty dimension prints nothing at all. Five headings for one populated dimension reads as four kinds of missing data.

Returning visitors and cohorts

Only on the device rung, and only for the visitors who said yes:

doorman site set griller analytics.identity=device
doorman stats griller --cohorts --weeks 12
COHORT    STARTING     SIZE   W0   W1   W2   W3
2026-W32  2026-08-03     88   88   31   12    9

note: returning and cohort figures cover the 34.3% of sessions that consented
      to analytics storage. The rest are counted as visits but cannot be
      counted as people.

Counts, not percentages: the cohort's own size is in SIZE, and a twelve-week matrix of 43% columns is unreadable at terminal width.

The coverage note is the honest denominator. Doorman records how many sessions granted, refused and never answered, so a cohort can be presented as the consented share rather than as the audience. A cohort counts consented devices, not people: one person on a phone and a laptop is two, and clearing cookies makes a returning visitor a new one. The activity window is 32 weeks from a device's first sighting.

Flip identity back to session or none and the device records stop being written and are swept on the next retention pass.

Every setting

"analytics": {
  "enabled": false,
  "identity": "session",     // none | session | device
  "sessionMinutes": 30,      // inactivity that ends a session; clamped 1–1440
  "countBots": false,        // a crawler is traffic, not audience
  "retentionDays": 0,        // 0 = keep the rollups indefinitely; clamped 1–730 otherwise
  "maxKeys": 1000,           // distinct keys per dimension per day before the tail folds
                             // into "(other)"; clamped 50–20000
  "consent": {
    "cookie": "doorman_consent",  // the cookie your site's banner writes
    "mirror": true,               // keep a server-set copy of the decision
    "mirrorDays": 180,            // clamped 1–400
    "honourDays": 365             // how old a decision may be before it stops counting
  }
}

Every bound is enforced by the instance in SaveSite, not by the CLI or the endpoint — one place to change when a bound moves, and no second opinion to drift from it.

retentionDays: 0 means forever, and that is allowed precisely because there is nobody in the day files to keep anything about. Set a window anyway if your customer's policy says everything has one; it deletes whole day files, and it sweeps the device records on the same pass.

What is counted

Page views only — a navigation, not an asset and not an XHR — and never doorman's own surfaces. An audience count that includes the gate page counts the lock, not the room. Gate redirects and crawler 403s are counted, under their real status: "how many people bounced off the gate" is a question worth being able to answer.

Referrers are recorded as the host, never the URL. A full referrer is a page someone was reading a moment ago, which is a great deal more than a rollup needs to know. Same-site navigation is (direct). utm_source wins over the referring host when it is present. Device class is mobile/tablet/desktop from the user agent — coarse on purpose.

Bots are detected by user agent and excluded from every figure except bots. Leaving them in is how a site comes to believe it has an audience in Ashburn.

What it is not

  • Not a tag manager. Doorman counts what it proxies. It runs no third-party script and sends nothing anywhere; the numbers stay on your volume.
  • Not a replacement for product analytics. There are no events, no funnels and no user properties, because there is no client-side SDK to send them.
  • Not derived from the request log. The two are independent: measurement works with the request log off, which is the configuration most sites should be in.

Where the numbers live

sites/<slug>/analytics/YYYY-MM-DD.json, one file per UTC day per site, plus visitors.jsonl on the device rung. Deleting a site deletes them with it. The salt lives once per instance at analytics/salt, owner-readable only.

Views are aggregated in memory and flushed on a timer, so a request never waits on a file — and asking for a range that includes today flushes first, because a dashboard that shows zero for the current day looks broken rather than eventually consistent.

Next

  • Consent — the cookie contract that unlocks the device rung, the standard it borrows, and the evidence behind each rule.
  • Admin APIGET /sites/{slug}/stats and /stats/cohorts for a dashboard of your own.