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:
parent
03b098ad3e
commit
8adf2fadaf
7 changed files with 53 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue