Specfork

@specfork/web-analytics

Cookieless website analytics with a tracking script, hashed sessions, pageviews, custom events, referrers, UTM, dashboards, filters, realtime, public shares and retention.

saasanalytics Version 0.1.1 101 requirements Licence CC-BY-4.0 Specfork/web-analytics

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% vs cp for 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 DNT or Sec-GPC headers.

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 choose week or month (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 other replaces 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.