analytics: exclude shop owner traffic from sales funnel metrics

add is_owner flag to PageSession — set via data attribute when
the viewer can_edit_shop. beacon stores the flag, view_count
skips increment for owners, and all analytics queries filter
owner sessions out of funnel metrics. owner sessions are still
recorded for separate analysis. existing NULL data treated as
non-owner.
This commit is contained in:
russell@unturf.com 2026-02-18 07:26:03 -05:00
parent 03b098ad3e
commit 8adf2fadaf
7 changed files with 53 additions and 5 deletions

View file

@ -42,6 +42,7 @@ class PageSession(RBase, Base):
ring_position = Column(SmallInteger, nullable=True)
referrer_class = Column(SmallInteger, nullable=True) # 0-4
device_class = Column(SmallInteger, nullable=True) # 0-2
is_owner = Column(Boolean, nullable=True) # shop owner/editor viewing own product
def __init__(self):
self.id = uuid.uuid1()

View file

@ -0,0 +1,26 @@
"""add is_owner to page_session
Revision ID: b3f7a2c8d1e5
Revises: a1b2c3d4e5f6
Create Date: 2026-02-18 06:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b3f7a2c8d1e5"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"PageSession",
sa.Column("is_owner", sa.Boolean(), nullable=True),
)
def downgrade():
op.drop_column("PageSession", "is_owner")

View file

@ -6,6 +6,7 @@
var productId = root.getAttribute('data-signal-product-id');
var shopId = root.getAttribute('data-signal-shop-id');
if (!productId || !shopId) return;
var isOwner = root.hasAttribute('data-signal-is-owner');
// Per-session random token (never persisted)
var sessionToken = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
@ -211,7 +212,8 @@
scroll_direction_changes: scrollDirectionChanges,
viewport_width: window.innerWidth || 0,
is_ring_entry: isRingEntry,
ring_position: ringPosition
ring_position: ringPosition,
is_owner: isOwner
};
// Only include media fields if there was media

View file

@ -30,7 +30,7 @@
{% block content -%}
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}" data-signal-product-id="{{ product.id }}" data-signal-shop-id="{{ product.shop_id }}">
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}" data-signal-product-id="{{ product.id }}" data-signal-shop-id="{{ product.shop_id }}"{% if request.user and request.user.can_edit_shop(request.shop) %} data-signal-is-owner="1"{% endif %}>
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}"{% if request.user and request.user.can_edit_shop(request.shop) %} data-watch-is-mod="1"{% endif %}>{% endif %}

View file

@ -28,7 +28,7 @@
{% block content -%}
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}" data-signal-product-id="{{ product.id }}" data-signal-shop-id="{{ product.shop_id }}">
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}" data-signal-product-id="{{ product.id }}" data-signal-shop-id="{{ product.shop_id }}"{% if request.user and request.user.can_edit_shop(request.shop) %} data-signal-is-owner="1"{% endif %}>
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}"{% if request.user and request.user.can_edit_shop(request.shop) %} data-watch-is-mod="1"{% endif %}>{% endif %}

View file

@ -1,5 +1,5 @@
from pyramid.view import view_config
from sqlalchemy import func, case, and_, cast, Float
from sqlalchemy import func, case, and_, or_, cast, Float
from . import shop_editor_required
from ..models.page_session import PageSession
@ -85,12 +85,16 @@ def shop_analytics(request):
cuts = _cutoffs()
cutoff_7d, cutoff_14d, cutoff_21d = cuts["7d"], cuts["14d"], cuts["21d"]
# Exclude owner/editor traffic from all funnel metrics
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
def _view_filter(cutoff):
"""Sessions that count as views (7s visible) after cutoff."""
return and_(
PageSession.shop_id == shop.id,
PageSession.visible_ms >= 7000,
PageSession.created_timestamp > cutoff,
_not_owner,
)
def _session_filter(cutoff):
@ -99,6 +103,7 @@ def shop_analytics(request):
PageSession.shop_id == shop.id,
PageSession.wall_clock_ms >= 7000,
PageSession.created_timestamp > cutoff,
_not_owner,
)
# --- Section 1: Overview Strip (last 7 days) ---
@ -113,6 +118,7 @@ def shop_analytics(request):
_ring_base = and_(
PageSession.shop_id == shop.id,
PageSession.ring_position.isnot(None),
_not_owner,
)
def _ring_seen(cutoff=None):
@ -187,12 +193,14 @@ def shop_analytics(request):
PageSession.shop_id == shop.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
_not_owner,
).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).all()
ring_total = db.query(func.count()).filter(
PageSession.shop_id == shop.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
_not_owner,
).scalar() or 1
# --- Section 4: Engagement Leaders (active_ms / wall_clock_ms) ---
@ -312,6 +320,7 @@ def shop_analytics(request):
PageSession.media_duration_ms.isnot(None),
PageSession.media_duration_ms > 0,
PageSession.created_timestamp > cutoff_21d,
_not_owner,
)
video_agg = db.query(
@ -508,12 +517,15 @@ def product_analytics(request):
cutoff_7d = cuts["7d"]
cutoff_21d = cuts["21d"]
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
def _pf(cutoff):
"""Product view filter: 7s visible on this product after cutoff."""
return and_(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
PageSession.created_timestamp > cutoff,
_not_owner,
)
def _sf(cutoff):
@ -522,6 +534,7 @@ def product_analytics(request):
PageSession.product_id == product.id,
PageSession.wall_clock_ms >= 7000,
PageSession.created_timestamp > cutoff,
_not_owner,
)
# --- Overview ---
@ -538,6 +551,7 @@ def product_analytics(request):
).filter(
PageSession.product_id == product.id,
PageSession.created_timestamp > cutoff_7d,
_not_owner,
).one()
bounce_rate = None
if bounce_q.total and bounce_q.total > 0:
@ -546,6 +560,7 @@ def product_analytics(request):
lifetime_views = db.query(func.count()).filter(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
_not_owner,
).scalar() or 0
overview = {
@ -570,6 +585,7 @@ def product_analytics(request):
PageSession.media_duration_ms.isnot(None),
PageSession.media_duration_ms > 0,
PageSession.created_timestamp > cutoff_21d,
_not_owner,
)
vagg = db.query(
func.avg(PageSession.media_percent_played).label("avg_pct"),
@ -639,6 +655,7 @@ def product_analytics(request):
PageSession.product_id == product.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
_not_owner,
).scalar() or 0
# --- Engagement (21d) ---

View file

@ -219,12 +219,14 @@ def beacon_view(request):
ps.device_class = classify_device(body.get("viewport_width"))
ps.is_ring_entry = bool(body.get("is_ring_entry")) if body.get("is_ring_entry") is not None else None
ps.ring_position = _clamp_int(body.get("ring_position"), -32768, 32767)
ps.is_owner = bool(body.get("is_owner")) if body.get("is_owner") is not None else None
request.dbsession.add(ps)
# Increment view_count if visible >= 7 seconds (atomic SQL UPDATE)
# Skip increment for shop owner/editor traffic
visible_ms = ps.visible_ms or 0
if visible_ms >= 7000:
if visible_ms >= 7000 and not ps.is_owner:
from ..models.product import Product
request.dbsession.execute(
sa.update(Product)