@specfork/web-analytics
Cookieless website analytics with a tracking script, hashed sessions, pageviews, custom events, referrers, UTM, dashboards, filters, realtime, public shares and retention.
Web analytics
A team adds its websites, embeds one small script, and sees who visits, from where, on which pages and devices, without cookies and without storing IP addresses. Visitors are counted with a salted hash that rotates daily, so nobody can be followed across periods or sites. Behaviour derived from Umami's open-source code (MIT), written fresh and stack-free. Features that exist only in Umami Cloud (plans, usage limits, white-labelling) were not used. Not affiliated with or endorsed by Umami Software, Inc. The name is used only to credit the source.
| Area | Prefix | Covers |
|---|---|---|
| sites.md | SITE | Sites, settings, tracking snippet, moving between teams, permissions |
| tracker.md | TRK | Tracking script: options, automatic pageviews, click events, JS API, opt-out |
| collect.md | COL | Collect endpoint, validation, limits, batch, client IP, bot and IP exclusion |
| privacy.md | PRIV | What is stored and never stored, session hashing, salt rotation, location |
| sessions.md | SES | Sessions, visits, visitors, devices, identify, session properties |
| events.md | EVT | Pageviews, URL and referrer parsing, UTM, custom events and properties |
| metrics.md | MET | Stats formulas, comparison, time series, breakdowns, channels, export |
| query.md | QRY | Time ranges, units, time zones, filters, property filters, segments |
| realtime.md | LIVE | Active visitors and the last-30-minutes view |
| sharing.md | SHR | Public read-only dashboards by link |
| retention.md | RET | Retention, purge, reset, site deletion, erasure by visitor |
Every site belongs to one team (@specfork/teams); all analytics data belongs to exactly one site.
All times are stored in UTC; API names are snake_case.
Collect
Status: Draft · Prefix: COL
Purpose
The public endpoint that receives pageviews, events and identify calls, and what it drops.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| COL-1 | POST /api/collect, no authentication, JSON body {type: event|identify, payload}. CORS: any origin, methods POST, OPTIONS, request headers Content-Type, X-Analytics-Cache, preflight cached 24 h. |
preflight from any origin → 204 |
| COL-2 | payload: site (UUID, required), url (absolute URL or path; required for event), referrer? (URL or path), hostname?, title?, screen? (^\d{1,5}x\d{1,5}$), language?, name?, data? (object), tag?, distinct_id?, timestamp? (unix seconds), user_agent?, ip?, browser?, os?, device?. Unknown fields ignored; a malformed field → 400 naming it. |
screen: "big" → 400 screen |
| COL-3 | Unknown or deleted site → 400 unknown_site; nothing stored. |
random UUID → 400 |
| COL-4 | name or tag starting with =, +, -, @, tab or carriage return → 400 (spreadsheet formula guard). |
name: "=SUM(A1)" → 400 |
| COL-5 | timestamp sets created_at (back-fill from servers); more than 60 s in the future → 400; absent → receive time. |
timestamp now + 1 h → 400 |
| COL-6 | Success → 200 {cache, session_id, visit_id}. cache is a token signed (HMAC-SHA256) with an instance secret holding {site, session_id, visit_id, last_event_at}; sent back in X-Analytics-Cache, it saves lookups. A token with a bad signature, another site, or issued before the current salt period began (PRIV-4) is ignored and the ids are recomputed. A paused site (SITE-6) → 200 {disabled: true}, nothing stored. |
tampered token → same session as without token |
| COL-7 | user_agent, ip, browser, os, device in the payload (server-side senders) replace the values taken from the request; they are handled exactly like request values (PRIV-2). |
payload ip from Germany → country DE |
| COL-8 | Batch: POST /api/collect/batch with an array of ≤ 500 COL-1 bodies, each processed independently in order; → 200 {size, processed, errors, details: [{index, error}]}. More than 500 → 400, nothing processed. |
3 items, 1 invalid → processed 2, errors 1 |
| COL-9 | Bots: a request whose user agent is empty or matches the instance's crawler list (search and social crawlers, headless browsers, HTTP libraries, uptime monitors) is not stored and gets 200 {ignored: true}. Operator switch bot_filter (default on). |
UA Googlebot/2.1 → ignored |
| COL-10 | Excluded IPs: instance list plus the site's excluded_ips (≤ 50 entries), each an IPv4/IPv6 address or CIDR range; IPv4-mapped IPv6 addresses compare as IPv4. A match → 200 {ignored: true}, nothing stored. |
site excludes 10.0.0.0/8, IP 10.1.2.3 → ignored |
| COL-11 | Client IP: by default the connection's peer address; the operator may name one trusted proxy header (first address of X-Forwarded-For, for= of Forwarded, or a single-value CDN header). Other forwarding headers are ignored. A port suffix is stripped. |
spoofed X-Real-IP, no header configured → peer IP used |
| COL-12 | Collect answers within 200 ms p95 at 100 requests/s on reference hardware; stored events appear in queries within 10 s. | manual: load test |
Out of scope
- Web-vitals, replay and heatmap payload types; per-site rate limits and quotas.
Decisions
- Only one operator-named header is trusted for the client IP; the source took the first of a dozen proxy headers, so any visitor could spoof it.
- Excluded IPs answer like bots (200, ignored) instead of the source's 403, so senders don't retry or learn the list; a per-site list was added to the instance one.
- Events dated in the future are refused; the source accepted any timestamp.
Events
Status: Draft · Prefix: EVT
Purpose
What one pageview or custom event stores, and how URLs, referrers, campaigns and properties are parsed.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| EVT-1 | Event: {id, site_id, session_id, visit_id, type: pageview|custom, created_at, hostname, url_path, url_query?, page_title?, referrer_domain?, referrer_path?, referrer_query?, utm_source?, utm_medium?, utm_campaign?, utm_content?, utm_term?, click_id?, name?, tag?}. |
pageview → name null |
| EVT-2 | type = custom when name is present, else pageview. An identify request creates no event. |
{url, name: "signup"} → custom |
| EVT-3 | URL: url is resolved against https://<hostname> (or https://localhost); url_path = path plus #fragment, percent-decoded where valid; url_query = query without ?. hostname = payload hostname, else the URL's host. With strip_trailing_slash (SITE-1) a trailing / is removed except for the root /. |
/a/?x=1#t → path /a/#t, query x=1 |
| EVT-4 | Lengths: url_path, url_query, page_title, referrer fields ≤ 500; hostname ≤ 100; name, tag ≤ 50; UTM values ≤ 255. Longer values are truncated, not refused. |
600-char path → 500 kept |
| EVT-5 | Referrer: parsed like EVT-3 (a path-only referrer resolves against the event's host); referrer_domain lower-cased, punycode, port and leading www. removed. A referrer on the event's own domain (also without www.) is a self-referral: referrer_domain null, path kept. |
www.ex.com page from ex.com/x → domain null |
| EVT-6 | Campaigns: utm_source, utm_medium, utm_campaign, utm_content, utm_term read from the page URL's query. click_id: the ad platform whose click parameter is present: google (gclid), meta (fbclid), microsoft (msclkid), tiktok (ttclid), linkedin (li_fat_id), x (twclid); the value is dropped (PRIV-8). |
?gclid=abc → click_id google |
| EVT-7 | Custom event names are case-sensitive, 1–50 chars after truncation; tag groups traffic (A/B variants, sections) and applies to pageviews and events. |
Signup ≠ signup |
| EVT-8 | Event properties: {event_id, key, type: string|number|boolean|date|array, string_value?, number_value?, date_value?}, stored only for custom events. |
pageview with data → no properties |
| EVT-9 | Flattening: nested objects become dotted keys (a.b); a string in ISO 8601 date-time form is a date; numbers keep 4 decimals; booleans store true/false; arrays store their JSON text and are dropped if it exceeds 500 chars; strings truncated to 500; keys to 500. More than 50 keys after flattening → 400 too_many_properties. |
{a: {b: 1}} → key a.b, number 1 |
| EVT-10 | Event data views per range and filters: event names with counts and visitors; per name, its property keys with counts; per key, top 100 values with counts, and for numbers sum, avg, min, max. |
event buy, key qty → sum of quantities |
| EVT-11 | Activity list: the site's events in a range, newest first, paged (default 20, max 100), searchable by path, title, event name or referrer domain (case-insensitive substring). | search pricing → only matching events |
Out of scope
- Revenue and currency fields, link and pixel events, web-vitals events.
Decisions
- Property count limit (50) is enforced; the source documented it but never checked.
- A self-referral also matches across a
www.difference, as the source did.
Metrics
Status: Draft · Prefix: MET
Purpose
The numbers a site dashboard shows: summary stats, charts, breakdown tables, channels and export.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| MET-1 | GET /api/sites/{id}/stats for a range and filters (QRY) → {pageviews, visitors, visits, bounces, total_time}: pageviews = pageview events; visitors = distinct sessions with ≥ 1 pageview; visits = distinct visits with ≥ 1 pageview; total_time in seconds (MET-3). Custom events count in none of these. With the default daily salt (PRIV-4) a person returning on another day is another session, so multi-day visitors add up daily uniques. |
3 pageviews + 2 events in 1 visit → pageviews 3, visits 1; same browser Mon and Tue → visitors 2 |
| MET-2 | Bounce: a visit with exactly one pageview and no custom event. bounce_rate = min(bounces, visits) / visits × 100, shown rounded; visits = 0 → null. |
4 visits, 1 bounce → 25 % |
| MET-3 | Visit duration = last pageview time − first pageview time of the visit, in whole seconds (single-pageview visit → 0); total_time = sum over visits; avg_visit_duration = total_time / visits. |
pageviews at 0 s and 90 s → 90 s |
| MET-4 | compare: prev (same length, ending where the range starts) or yoy (range minus one year). The response adds comparison with the same fields; the dashboard shows each value's change (absolute, and percent when the previous value > 0). |
7-day range, prev → the 7 days before |
| MET-5 | GET .../series → {pageviews: [{t, y}], visitors: [{t, y}]} per unit bucket in the request time zone; buckets with no data are returned with y = 0; visitors counts distinct sessions per bucket. |
last 7 days by day → 7 points each |
| MET-6 | GET .../breakdown?type= with type one of path, entry, exit, title, hostname, query, referrer, channel, utm_source, utm_medium, utm_campaign, utm_content, utm_term, event, tag, browser, os, device, screen, language, country, region, city; other → 400. Rows {value, visitors} (views added for page types), visitors descending, empty values left out, limit default 10 (max 500), offset, optional search (substring). |
type=browser → Chrome first |
| MET-7 | entry and exit = first and last pageview path of each visit; query splits stored query strings into key=value rows; referrer excludes empty and self-referrals; region and city rows also carry country; event counts custom events only. |
visit /a → /b → entry /a, exit /b |
| MET-8 | Channels: each visit takes the channel of its earliest event that classifies; none → direct. Rules in order: click_id set → paid_ads; utm_medium contains affiliate → affiliate; utm_medium or utm_source contains sms → sms; referrer is an AI assistant → ai; search engine (or utm_medium contains organic) → *_search; social network → *_social; webmail (or utm_medium contains mail) → email; marketplace → *_shopping; video site → *_video; any other external referrer or utm_medium in referral, app, link → referral. * = paid when utm_medium contains cpc, ppc, paid or retargeting, else organic. |
google.com referrer, utm_medium=cpc → paid_search |
| MET-9 | Channel domain lists (suffix or label match, operator-editable): AI: chatgpt.com, claude.ai, perplexity.ai, gemini.google.com, copilot.microsoft.com; search: google., bing.com, duckduckgo.com, yandex., baidu.com, ecosia.org, search.brave.com; social: facebook.com, instagram.com, x.com, t.co, twitter.com, linkedin., reddit., news.ycombinator.com, bsky.app, threads.net, tiktok., pinterest.; webmail: mail.google.com, outlook., mail.yahoo., proton.me; marketplace: amazon., ebay., etsy.com, aliexpress.com; video: youtube., twitch.. | referrer chatgpt.com → ai |
| MET-10 | Dashboard (manual layout): summary row (visitors, visits, pageviews, bounce rate, avg visit duration, each with change), chart (MET-5), then tables: pages/entry/exit, referrers/channels, browsers/OS/devices, countries (with world map)/regions/cities, languages/screens, events. | manual: overview page |
| MET-11 | Export: one archive of CSV files (pages, referrers, channels, events, browsers, OS, devices, countries) for the range and filters, each row value, visitors. Cells starting with =, +, -, @, tab or carriage return are prefixed with '. |
export → 8 CSVs; =x cell → '=x |
Out of scope
- Funnels, journeys, cohort retention, goals, revenue and attribution reports.
Decisions
- Channel rules written once, in one order; the source had two differing versions (
p%vscpfor paid) per database engine. - A visit's channel comes from its earliest classifying event, as in the source.
Privacy
Status: Draft · Prefix: PRIV
Purpose
Count visitors without cookies and without keeping anything that identifies a person.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PRIV-1 | No cookies, local storage, fingerprinting scripts or cross-request identifiers are used on the tracked site (TRK-9); a visitor is recognised only by the server-side hash of PRIV-3. | manual: browser storage empty |
| PRIV-2 | The IP address and the user-agent string are used only in memory while handling one request (hash, location, device fields) and are never written to the database, queues, caches or logs; collect access logs omit both. | grep stored data and logs for test IP → no hits |
| PRIV-3 | session_id = first 128 bits of HMAC-SHA256(key = current salt, message = site_id, IP, user agent, distinct_id or empty, joined by a separator), formatted as a UUID. |
same IP+UA, two sites → two different ids |
| PRIV-4 | Salt: ≥ 256 random bits, one per instance, replaced at the start of every rotation period in UTC. salt_rotation (operator setting): day (default) | week (ISO week, Monday) | month. |
default: same visitor before/after 00:00 UTC → 2 sessions; month → 1 |
| PRIV-5 | Only the current salt exists. At rotation the old salt is destroyed and never written to backups, so past hashes can't be recomputed or linked to a person; the salt is chosen by receive time, not timestamp. |
manual: no salt history in storage |
| PRIV-6 | Location: country (ISO 3166-1 alpha-2), region (ISO 3166-2, CC-XX), city (English name), from a local IP geolocation database or the operator's trusted CDN geo headers. Loopback, private and invalid IPs → all null. Coordinates and postal codes are never stored. |
IP 192.168.1.5 → country null |
| PRIV-7 | Per session only coarse device fields are kept (SES-4): browser and OS family without full version, device class, screen size, language. | stored browser Chrome, not Chrome 126.0.1 |
| PRIV-8 | store_query = false (SITE-1): UTM values and click-id kinds (EVT-6) are extracted, then the query string is discarded (page and referrer). Click-id values are never stored. |
?email=a@b.c&utm_source=x, store off → only utm_source kept |
| PRIV-9 | The same person on two sites can't be linked: ids differ per site (PRIV-3) and no query joins sessions across sites. | manual: no cross-site endpoint |
| PRIV-10 | distinct_id and identify properties (SES-6) are personal data the site chose to send: shown only to site members, never on public shares (SHR-5), erasable by distinct_id (RET-5). |
share viewer requests session list → no distinct ids |
Out of scope
- Consent banners (not needed without cookies); server-side honouring of
DNTorSec-GPCheaders.
Decisions
- Salts are random and destroyed at rotation; the source derived the salt from the period's start date plus the app secret, so anyone with the secret could recompute every hash.
- Default rotation is
day(owner's decision): a visitor can't be linked across days, at the cost of counting a returning visitor once per day in multi-day ranges. Operators who need exact weekly or monthly counts chooseweekormonth(the source's period). - Click-id values (ad platform identifiers) are reduced to which platform sent the visit; the source stored the raw values.
Query
Status: Draft · Prefix: QRY
Purpose
The time range, bucket unit, time zone and filters every analytics request takes; saved segments.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| QRY-1 | Every analytics read takes start_at, end_at (unix ms, both inclusive, start_at ≤ end_at), unit (minute|hour|day|month|year) and timezone (IANA, default the signed-in user's @specfork/auth:AUTH-1 time zone, else the team's @specfork/teams:TEAM-13; invalid → 400). Buckets and calendar boundaries are computed in that time zone. |
timezone=Mars/Base → 400 |
| QRY-2 | Presets: today, last 24 hours (default), this week, last 7 days, this month, last 30 days, last 90 days, this year, last 6 months, last 12 months, all time (from the site's first event), custom. Weeks start on the user's week_start (@specfork/auth:AUTH-1), Monday on public shares. The chosen preset is remembered per user. |
open dashboard first time → last 24 hours |
| QRY-3 | Minimum unit by range length: ≤ 60 min → minute; ≤ 30 days → hour; ≤ 7 calendar months → day; longer → month. A requested unit finer than the minimum is replaced by the minimum; year is allowed for any range > 24 months. |
90-day range, unit=minute → day |
| QRY-4 | Stepping a range back or forward moves it by its own length; calendar presets move by their calendar unit (this month → previous month). Ranges never extend past now. | this month (Mar) back → Feb 1–28/29 |
| QRY-5 | start_at is raised to the site's reset_at and retention cutoff (RET-1) before querying. |
reset yesterday, range last 7 days → only since reset |
| QRY-6 | Filter: {field, op, value}. Event fields: path, entry, exit, title, query, hostname, referrer, event, tag, utm_source, utm_medium, utm_campaign, utm_content, utm_term, channel. Session fields: browser, os, device, screen, language, country, region, city, distinct_id. Unknown field → 400. |
{field: "color"} → 400 |
| QRY-7 | Operators: eq, neq (value is a list: any of / none of), contains, not_contains (case-insensitive substring), regex, not_regex (case-insensitive; invalid pattern → 400). Default eq. |
path contains BLOG → /blog/x matches |
| QRY-8 | Combining: match = all (default, AND) or any (OR). Event filters restrict which events count; session filters restrict which sessions count; a referrer filter never matches self-referrals. |
country eq DE + path eq /a, all → German views of /a |
| QRY-9 | Property filters: {scope: event|session, key, type, op, value}; ops by type: string eq, neq, contains, not_contains; number eq, neq, gt, gte, lt, lte; boolean is_true, is_false; date before, after; any type is_set, not_set. Op not valid for the type → 400. |
number qty gt 2 → only events with qty > 2 |
| QRY-10 | Segment: saved filters per site {id, site_id, name (1–200), filters, property_filters, match, created_at}, visible to all site members; segment=<id> adds its filters to the request's (request match wins if given). A segment of another site → 404. |
apply "Germany mobile" → stats narrowed |
| QRY-11 | Filter value suggestions: for a field and range, up to 100 distinct values ordered by frequency, optional substring search. |
field=country → country codes by traffic |
| QRY-12 | Filters, range, unit, compare and segment live in the dashboard URL, so a copied URL reopens the same view. | paste URL in new tab → same view |
Out of scope
- Behavioural cohorts (sessions that did X in another range), nested filter groups.
Decisions
- Readable operator names (
contains,not_regex) instead of the source's two-letter codes. - Year buckets only for ranges over two years; the source's unit rules differed between preset and custom ranges.
Realtime
Status: Draft · Prefix: LIVE
Purpose
Who is on the site right now and what they just did.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| LIVE-1 | GET /api/sites/{id}/active → {visitors}: distinct sessions with any event in the last 5 minutes. The site dashboard header shows it and refreshes every 10 s. |
event 4 min ago → counted; 6 min ago → not |
| LIVE-2 | GET /api/sites/{id}/realtime covers the last 30 minutes (from the start of the current minute − 30): per-minute pageviews and visitors series, totals {views, visitors, events, countries}, top paths, referrers and countries with counts. |
2 sessions from DE and FR → countries 2 |
| LIVE-3 | Its feed lists the newest 100 items, newest first: {kind: session_start|pageview|event, at, session_id, path, name?, referrer_domain?, country, browser, os, device}; a session's first event in the window also yields one session_start. |
new visitor views 2 pages → 1 start + 2 pageviews |
| LIVE-4 | Realtime takes filters (QRY-6) but not a range or compare; an open realtime view updates every 10 s without reload, and a collected event shows up within 10 s. | send pageview → appears ≤ 10 s |
| LIVE-5 | distinct_id is never part of the feed. |
identified visitor → feed shows no distinct id |
Out of scope
- Push transports (WebSocket, SSE) as a requirement; polling meets LIVE-4.
Decisions
- 5-minute active window and 30-minute realtime window, as in the source.
Retention
Status: Draft · Prefix: RET
Purpose
How long analytics data lives, and how it is removed.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| RET-1 | retention_months per site: 1–120 or null (keep forever, the default). Instance setting max_retention_months (default null = no cap) caps it; a larger value → refused. Cutoff = start of today (UTC) − retention_months. |
instance cap 24, site 36 → refused |
| RET-2 | A purge runs at least daily and deletes the site's events, event properties, session links and sessions whose events are all before the cutoff; session properties go with their session. | 13-month-old event, retention 12 → gone after purge |
| RET-3 | Queries never return data before the cutoff or reset_at, even if the purge hasn't run yet (QRY-5). |
before purge, range 2 years → only last 12 months |
| RET-4 | Reset: deletes all of the site's sessions, events and properties and sets reset_at = now; the site, its settings, shares and segments stay; tracking continues. Requires typing the site's name to confirm. |
reset → stats 0, new pageview counted |
| RET-5 | Erasure: DELETE /api/sites/{id}/visitors?distinct_id= deletes every session linked to that distinct_id (SES-6) with their events, properties and links; → {sessions_deleted}. |
erase u1 with 3 sessions → 3 |
| RET-6 | Deleting a site deletes its analytics data, shares and segments at once; its tracking id then gets unknown_site (COL-3). Deleting a team deletes its sites (@specfork/teams:TEAM-9). |
delete site, send pageview → 400 |
Out of scope
- Data import and raw data export (beyond MET-11); archiving to cold storage.
Decisions
- Designed here: the source had no retention setting (its hosted service capped free history separately) and no erasure by visitor.
Sessions
Status: Draft · Prefix: SES
Purpose
How events group into sessions (visitors) and visits, and what a session records.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SES-1 | Session: {id ([PRIV-3](#PRIV-3)), site_id, browser?, os?, device?, screen?, language?, country?, region?, city?, distinct_id?, created_at}, created by its first accepted request. Device and location fields come from that first request and are not changed later. |
2nd request from new screen size → session keeps first screen |
| SES-2 | A visitor is a distinct session id: same site, network address, browser and distinct_id within one salt period (PRIV-4). Another browser, network or period counts as another visitor; with the default daily salt a visit running across 00:00 UTC continues as a new session and visit. |
same person on phone and laptop → 2 visitors; browsing 23:50–00:10 UTC → 2 sessions |
| SES-3 | Visit: visit_id (random UUID) groups a session's events; a new visit starts when the session has no event in the last 30 minutes. Back-filled events (COL-5) join the visit whose events lie within 30 min of their timestamp, else start one. |
events at 10:00, 10:20, 10:55 → 2 visits |
| SES-4 | Device fields: browser (family, e.g. Chrome, Safari, Firefox, Edge, Samsung, Opera, in-app and iOS variants named apart), os (e.g. Windows 10/11, macOS, iOS, Android, Linux, ChromeOS), device: mobile | tablet | laptop | desktop | other (a desktop UA with screen width ≤ 1920 → laptop), screen WxH (≤ 11), language (BCP 47, ≤ 35). Unknown → null. |
iPad UA → tablet; desktop UA, 1440x900 → laptop |
| SES-5 | Stored lengths: browser, os, device, region ≤ 20; city ≤ 50; distinct_id ≤ 50; longer values are truncated. |
60-char distinct id → first 50 kept |
| SES-6 | Identify (type: identify): distinct_id? and data?. With distinct_id, the hash includes it, so later events of this browser go to the identified session, and a link {site_id, distinct_id, session_id} is stored so all sessions of one distinct_id can be listed. Repeating the same identify writes no new link. |
identify u1 twice → 1 link |
| SES-7 | Session properties: data flattened like event properties (EVT-9) and upserted per (session, key): the latest value wins. |
identify {plan: free} then {plan: pro} → pro |
| SES-8 | Session list for a range and filters (QRY): {id, distinct_id?, browser, os, device, country, region, city, first_at, last_at, visits, views, events}, newest last_at first, paged 20 by default (max 100). |
25 sessions → 2 pages |
| SES-9 | Session detail: its fields, properties, all sessions sharing its distinct_id, and its events in time order grouped by visit. |
open session → events by visit |
Out of scope
- Merging anonymous history into an identified user retroactively; user-level (cross-device) reports.
Decisions
- Visits end after 30 minutes of inactivity; the source ended a visit 30 minutes after it started (and hourly without its cache token), which split long visits.
- Device class
otherreplaces the source's long tail (console, smart TV, wearable, embedded).
Sharing
Status: Draft · Prefix: SHR
Purpose
Let anyone with a link see a site's dashboard read-only, without an account.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SHR-1 | Share: {id, site_id, slug, name (≤ 200), sections, allow_filters (default true), theme? (light|dark), created_at, updated_at}. slug: [A-Za-z0-9]{8,50}, unique across the instance, random 16 chars if not given; taken → taken. A site may have several shares. |
slug abc → refused (short) |
| SHR-2 | sections ⊆ overview, events, sessions, realtime; default overview + events. |
create without sections → overview, events |
| SHR-3 | /share/{slug} opens the dashboard read-only, without sign-in, limited to its sections; unknown slug → 404. Pages carry noindex. |
open link logged out → overview shown |
| SHR-4 | Opening a share issues a signed share token {share_id, site_id, sections}; analytics endpoints accept it only for that site and for a section it includes, and check the share still exists on every call. Other sites, settings, export, segments editing and erasure → 403. |
share token asks another site → 403 |
| SHR-5 | Through a share, distinct_id, identify properties and session detail (SES-9) are never returned; the sessions section shows the session list without them. |
sessions section → list with no distinct ids |
| SHR-6 | allow_filters = false: filter, property-filter and segment parameters are ignored server-side. |
filter param on locked share → unfiltered numbers |
| SHR-7 | Deleting a share or changing its slug stops the old link and its tokens at once. Create, edit and delete: admin+ (SITE-7). | delete share, reload → 404 |
Out of scope
- Password-protected shares, shared multi-site boards, custom branding.
Decisions
- Shares never expose visitor identifiers, whatever the sections; the source showed whatever the section included.
- Fewer sections (four) than the source because reports are out of scope.
Sites
Status: Draft · Prefix: SITE
Purpose
The websites a team measures, their settings, and who may do what with them.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SITE-1 | Site: {id (UUID, also the public tracking id), team_id, name (1–100, trimmed), domain, paused (default false), store_query (default true), strip_trailing_slash (default false), excluded_ips ([COL-10](#COL-10)), retention_months? ([RET-1](#RET-1)), reset_at?, created_by, created_at, updated_at}. |
create without name → refused |
| SITE-2 | domain: a bare host name, lower-cased and punycode-encoded, labels [a-z0-9-_] ≤ 63 chars, a TLD of ≥ 2 chars, ≤ 500 chars total; localhost with an optional port is allowed. A scheme, path, query or IP address → bad_domain. domain labels the site; it doesn't restrict collection (TRK-6 does). |
https://ex.com/a → bad_domain; Ex.COM → ex.com |
| SITE-3 | After create, the site shows its embed snippet: a script tag loading the tracker from this instance with the site id set (TRK-2). The id never changes. |
create → snippet contains site id |
| SITE-4 | A user lists the sites of every team they belong to: search by name or domain (case-insensitive substring), sort by name or created_at, page size default 20, max 100. Sites of other teams → 404, never 403. |
non-member GET site → 404 |
| SITE-5 | Move to another team per @specfork/teams:TEAM-14; its data, shares and segments move with it. |
admin in source, member in target → 403 |
| SITE-6 | paused = true: the collect endpoint stores nothing for the site and answers {disabled: true} (COL-6); dashboards keep working. |
pause, send pageview → no new event |
| SITE-7 | Resource rows (@specfork/teams:TEAM-10): view dashboards, sessions, realtime, event properties, export, apply segments: viewer+ (@specfork/teams:TEAM-11); create site, edit name and domain, create and edit segments (QRY-10): member+; edit other settings, pause, shares (SHR), reset, erasure, delete (RET): admin+. |
member edits retention_months → 403; viewer creates segment → 403 |
| SITE-8 | Overview of all sites the user can see: per site, visitors, visits, pageviews (MET-1) and a pageviews series for the chosen range (QRY-1). |
3 sites → 3 cards with totals |
Out of scope
- Personal (team-less) sites, per-team site quotas, plans and billing.
- Short links, tracking pixels, heatmaps, session replay, Core Web Vitals, revenue, funnels, journeys, retention cohorts, goals, attribution, boards and annotations: separate specs if wanted.
Decisions
- Sites always belong to a team (a person uses a one-member team); the source let users own sites directly, which doubled every permission check.
- Destructive actions are admin+; the source let any non-viewer member delete a site.
- The source's view-only role is team
viewer: dashboards, sessions, realtime and export, no writes.
Tracker
Status: Draft · Prefix: TRK
Purpose
The small script a site embeds to send pageviews and events to the collect endpoint (COL).
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| TRK-1 | Served at /script.js (operator may rename the path, and the collect path, to dodge blockers); ≤ 6 KB gzipped; Cache-Control: public, max-age=86400; no dependencies; works in the last two versions of major browsers. |
GET /script.js → JS, cache header set |
| TRK-2 | Options are attributes of its own script tag: data-site (required), data-host (collect origin, default the script's origin), data-auto-track (default true), data-domains (comma list), data-respect-dnt (default false), data-exclude-search (default false), data-exclude-hash (default false), data-tag (≤ 50), data-distinct-id, data-before-send (name of a global function). |
tag without data-site → sends nothing |
| TRK-3 | Payload of every send: site, hostname, url (absolute page URL, minus query and/or fragment when excluded), referrer (same-origin referrers reduced to their path), title, screen (<width>x<height> of the screen), language (browser language), tag?, distinct_id?. |
page /a?x=1, exclude-search → url without ?x=1 |
| TRK-4 | With auto-track on: one pageview when the document has loaded; then, on every history push or replace that changes the URL, the previous URL becomes referrer and a pageview is sent 300 ms later (so the new title is read). Same URL → nothing. |
SPA route /a → /b → 2 pageviews, 2nd referrer /a |
| TRK-5 | Declarative events: a click on an element (or its descendant) carrying data-event="<name>" sends custom event <name>; each data-event-<key>="v" attribute adds string property <key>. For a same-tab link, navigation waits until the send settles (≤ 1 s); new-tab and modifier-key clicks don't wait. |
click <a data-event="signup" data-event-plan="pro"> → event signup, {plan: "pro"} |
| TRK-6 | Nothing is sent when: no data-site; data-domains is set and the page host isn't in it (exact match); data-respect-dnt is on and the browser's Do Not Track is 1 or yes; local storage key analytics.disabled is set on this browser; or the server answered disabled: true (SITE-6). |
DNT on + respect-dnt → no request |
| TRK-7 | JS API on window.webAnalytics: track() = pageview; track(name, data?) = custom event; track(object) sends the object as the payload; track(fn) sends fn(default payload); identify(distinct_id?, data?) or identify(object) sends type: identify (SES-6). All return a promise and never throw. |
track("buy", {qty: 2}) → event buy with qty 2 |
| TRK-8 | data-before-send: the named function gets (type, payload) and returns the payload to send (sync or async); a falsy return cancels the send. |
hook returns null → no request |
| TRK-9 | The script sets no cookies and writes nothing to browser storage. It keeps the server's cache token (COL-6) in memory only and returns it in header X-Analytics-Cache; identify clears it first. |
manual: no cookies or storage after visit |
| TRK-10 | Sends are POST JSON with keepalive so they survive unload, credentials omitted; network errors are swallowed silently. |
collect down → page shows no error |
Out of scope
- Outbound-link and file-download auto events, scroll depth, Core Web Vitals, heatmap and replay recording.
Decisions
- Generic attribute and global names (
data-site,webAnalytics) instead of the product's. - Same-tab link clicks wait at most 1 s; the source waited for the request with no limit.
From Specfork/web-analytics at 08282c5, licence CC-BY-4.0.