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.
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.
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.
identity | What is stored on the visitor's device | What you can ask |
|---|---|---|
none | nothing | views, top pages, referrers, sources |
session (default) | nothing | + sessions, bounce rate, session length, daily unique visitors |
device | a 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.
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.
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.
"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.
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.
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.