- MPS-6: referrer analytics (domain, query, trend line charts) - MPS-7: sandbox mode creative filter system - MPS-8: user S3 bucket + artifact storage - MPS-9: shop S3 mirror bucket - architecture.md: system diagram, request flow, data pipeline, S3 layout - JAVASCRIPT.md: add sandbox.js, signals.js, MediaPipe SDK entries - sandbox-mode.md: mark S3 upload as implemented - mps-2.md: document referrer_domain + referrer_query columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
MPS-2: Anonymous Signal Gathering & View Count
Philosophy
Collect every honest signal we can about how people experience content — without recording who they are. No cookies, no IPs, no fingerprints, no user agent strings. A viewer's identity is never captured. Only the aggregate truth of what happened on the page.
The raw signals feed four derived scores: Engagement, Attention, Learning, and Passive Consumption. These scores are computed from simple math now and can be refined with machine learning later — the raw data is the same either way.
A view counts after 7 seconds of wall-clock time on the page with the tab visible. This respects all four pillars: truthful (they were really here), free (no judgment on how they consume), harmonious (same bar for a 30-second clip and a 2-hour film), and loving (assumes good intent from the visitor).
What We Collect
All signals are gathered in JavaScript memory during a page session and sent as
a single JSON summary via navigator.sendBeacon() on page unload. One beacon
per page visit. Nothing streams, nothing phones home mid-session.
Presence Signals
| Signal | How | Why |
|---|---|---|
wall_clock_ms |
Date.now() delta from page load to unload |
Total time on page |
visible_ms |
Page Visibility API (visibilitychange) — accumulate time while visible |
Tab in foreground vs backgrounded |
active_ms |
Accumulate time while mouse/touch/keyboard events fire (debounce 2s idle gap) | Human is present and interacting |
idle_ms |
wall_clock_ms - active_ms |
Derived, not tracked separately |
Mouse/touch activity: Listen for mousemove, touchstart, keydown —
debounced to a boolean "active" flag that goes idle after 2 seconds of silence.
We track whether the user is active, never where on the page. No
coordinates, no element targets.
Scroll Signals
| Signal | How | Why |
|---|---|---|
scroll_depth_max |
window.scrollY / document.body.scrollHeight — track max (0.0-1.0) |
How far down the page they went |
scroll_direction_changes |
Count sign changes in scroll delta | Reading (few reversals) vs scanning (many) |
Media Playback Signals
Piggyback on existing timeupdate, play, pause, ended, seeked,
ratechange events already wired in watch.js.
| Signal | How | Why |
|---|---|---|
media_duration_ms |
media.duration * 1000 on loadedmetadata |
Length of the content |
media_play_ms |
Accumulate time between play and pause/ended events |
Actual playback time |
media_play_count |
Count play events |
Replays = love |
media_pause_count |
Count pause events (user-initiated, not ended) |
Interaction with content |
media_seek_count |
Count seeked events |
Skipping around (study or skip?) |
media_seek_back_count |
Count seeks where currentTime decreased |
Rewinding = learning |
media_speed |
Last value from ratechange event, default 1.0 |
2x = efficient, 0.5x = studying |
media_completed |
Boolean: did ended event fire at least once |
Watched/listened to the end |
media_percent_played |
media_play_ms / media_duration_ms (can exceed 1.0 on replay) |
Completion without judging speed |
Context Signals (server-side, from the HTTP request)
| Signal | How | Why |
|---|---|---|
is_ring_entry |
Boolean: full page load vs SPA transition | Front door vs ring navigation |
ring_position |
Integer: how many SPA transitions from entry | Depth in the ring |
referrer_class |
Enum: direct, search, social, internal, unknown |
Where traffic comes from — derived from Referer header domain, NOT the full URL |
referrer_domain |
String (128 chars) | Referer hostname (e.g. "www.google.com") — added in MPS-6 |
referrer_query |
String (256 chars) | Search engine query parameter — added in MPS-6 |
device_class |
Enum: mobile, tablet, desktop |
Screen size bucket only, not user agent — derived from viewport width sent in beacon |
referrer_class is computed server-side by matching the Referer header domain
against known patterns (google/bing/ddg → search, twitter/facebook/reddit →
social, same domain → internal, everything else → unknown). The raw Referer URL
is never stored.
device_class is derived from viewport width sent in the beacon payload:
≤768 → mobile, ≤1024 → tablet, else desktop. No user agent parsing.
Storage
Per-session table: mps_page_session
One row per page visit. This is the raw truth.
id UUID PK
shop_id FK → mps_shop
product_id FK → mps_product
created_timestamp BigInteger (ms) — when beacon was received
-- presence
wall_clock_ms Integer
visible_ms Integer
active_ms Integer
-- scroll
scroll_depth_max Float (0.0-1.0)
scroll_direction_changes Integer
-- media
media_duration_ms Integer (NULL if no media)
media_play_ms Integer (NULL if no media)
media_play_count SmallInteger
media_pause_count SmallInteger
media_seek_count SmallInteger
media_seek_back_count SmallInteger
media_speed Float
media_completed Boolean
media_percent_played Float
-- context
is_ring_entry Boolean
ring_position SmallInteger
referrer_class SmallInteger (enum: 0=direct 1=search 2=social 3=internal 4=unknown)
referrer_domain Unicode(128) — extracted domain (e.g. "www.google.com")
referrer_query Unicode(256) — search engine query (e.g. "lo-fi beats")
device_class SmallInteger (enum: 0=mobile 1=tablet 2=desktop)
No foreign key to any user/account table. No IP column. No session cookie column. The row is an anonymous fact about what happened, nothing more.
Aggregate columns on mps_product
Updated asynchronously (on beacon receipt or periodic batch):
view_count(BigInteger, server_default="0") — incremented when a session hits the 7-second visible threshold
Retention
Keep individual mps_page_session rows for 90 days. After that, aggregate
into mps_product_daily_stats (one row per product per day with averages and
sums) and delete the raw sessions. The daily rollup lives forever.
Derived Scores
Computed from raw signals, either on-read (in Python properties) or in the daily rollup. These are the creator-facing metrics.
Engagement (0.0 – 1.0)
engagement = active_ms / wall_clock_ms
High = clicking, scrolling, pausing, seeking — actively interacting with the content. Low = tab open but idle. This is the "lean-in" metric.
Attention (0.0 – 1.0)
attention = visible_ms / wall_clock_ms
High = tab in foreground the entire time. Low = they switched tabs. Combined with engagement: high attention + low engagement = focused passive watching. High attention + high engagement = deeply studying.
Learning Signal (heuristic score)
Indicators of active study, not passive consumption:
media_seek_back_count > 0— they rewound to re-hear somethingmedia_speed < 1.0— slowed down to absorbscroll_direction_changes > 3— re-reading sectionsmedia_pause_count > 2— pausing to think or take notesengagement > 0.7— high interaction throughout
Score: count of true indicators (0-5). A session with 3+ is "learning mode."
Passive Consumption (heuristic score)
Indicators of lean-back enjoyment:
attention > 0.7— tab is in foregroundengagement < 0.3— but not interacting muchmedia_percent_played > 0.69— played most/all of the contentmedia_play_count == 1— straight through, no replaysmedia_pause_count == 0— never paused
Score: count of true indicators (0-5). A session with 3+ is "passive consumption" — and this is a GOOD signal. It means the content is worth playing through without interruption.
Machine Learning Hook
The raw mps_page_session rows are feature vectors. Every field is a number.
When ML is added later, it can:
- Cluster sessions into behavioral profiles beyond the 4 heuristic scores
- Detect anomalies (bot patterns: zero scroll + zero active + high play count)
- Predict which products will retain based on early session patterns
- Surface "hidden gems" — products with high learning/attention scores but low view counts
The collection schema doesn't change. ML consumes the same rows and produces richer derived scores that feed into dashboards. The raw truth stays raw.
View Count Display
Show the human-readable view count on product and content pages, next to the upload date under the title h1:
Track Title
uploaded to ShopName
Created 3 days ago · 420 views
Formatting
- 0: don't show view count at all (no "0 views")
- 1: "1 view"
- 2-999: exact number ("420 views")
- 1,000-999,999: "1.2K views"
- 1,000,000+: "1.8M views"
Three-layer update (SPA compatibility)
- Templates (
content.j2,product.j2): render in.product-meta-dates - watch.py (
watch_json): includeview_countandhuman_view_count - watch.js (
updatePageContent): update the view count span during SPA nav
JavaScript Collector (signals.js)
New file. Lightweight, no dependencies. Loaded on all product/content pages.
Page load:
→ Record start time
→ Attach debounced mousemove/touchstart/keydown → active flag
→ Attach visibilitychange → accumulate visible_ms
→ Attach scroll → track depth + direction changes
→ Attach media events (play/pause/ended/seeked/ratechange)
→ Generate random session token (in-memory only, never persisted)
Page unload (beforeunload / visibilitychange → hidden):
→ Bundle all signals into JSON
→ POST via navigator.sendBeacon('/signals/beacon')
→ Fallback: fetch() with keepalive: true
The beacon payload is ~300 bytes of JSON. One request per page visit. No polling, no websockets, no streaming.
Server Endpoint
POST /signals/beacon — accepts JSON, validates fields, computes
referrer_class from the Referer header, inserts one mps_page_session row.
If visible_ms >= 7000, also increments product.view_count. Returns 204.
Rate limiting: max 1 beacon per product per client per 60 seconds, enforced
by a short-lived server-side cache keyed on (session_token, product_id).
The session token is random per page load — it prevents double-counting
from accidental double-fires, not tracking users across pages.
Files Changed
| File | Change |
|---|---|
models/page_session.py |
New model: MpsPageSession |
models/product.py |
Add view_count column, human_view_count property |
models/__init__.py |
Import new model |
views/signals.py |
New: beacon endpoint |
views/watch.py |
Include view_count / human_view_count in JSON |
views/product.py |
Pass view count to template context |
views/content.py |
Pass view count to template context |
static/js/signals.js |
New: client-side signal collector |
templates/content.j2 |
Show view count in .product-meta-dates |
templates/product.j2 |
Show view count in .product-meta-dates |
templates/snippets/base_styles.j2 |
Load signals.js on product pages |
static/js/watch.js |
Update view count in updatePageContent() |
scripts/alembic/versions/ |
Migration: mps_page_session table + view_count column |
tests/test_functional.py |
Beacon endpoint tests, view count display tests |
tests/test_models.py |
human_view_count formatting tests |
What This Does NOT Collect
- IP addresses
- User agent strings (device class is viewport width only)
- Cookies or persistent identifiers
- Mouse/touch coordinates
- Clicked element identifiers
- Any form input content
- Referrer URLs (only domain + search query parameter — see MPS-6)
- Cross-page session linking (each page visit is an island)
A viewer could visit every product in a shop and there would be no way to connect those visits to the same person. That is by design.