feat: add line charts, keyword tracking, and referrer domains to analytics
- Add SVG line charts for session duration, engagement, and bounce rate trends (28 days) - Track referrer_domain and referrer_query on PageSession (new migration) - Refactor classify_referrer() to extract domain and search engine query params - Surface internal search keywords (ShopSearchRequest) on shop analytics - Show top referrer domains and search engine queries on both analytics pages - Add wide bar row CSS modifier for longer domain labels
This commit is contained in:
parent
6acebd8835
commit
1c6bcbe94d
8 changed files with 597 additions and 39 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Column, Integer, BigInteger, Boolean, Float, SmallInteger
|
||||
from sqlalchemy import Column, Integer, BigInteger, Boolean, Float, SmallInteger, Unicode
|
||||
|
||||
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ class PageSession(RBase, Base):
|
|||
is_ring_entry = Column(Boolean, nullable=True)
|
||||
ring_position = Column(SmallInteger, nullable=True)
|
||||
referrer_class = Column(SmallInteger, nullable=True) # 0-4
|
||||
referrer_domain = Column(Unicode(128), nullable=True) # e.g. "google.com"
|
||||
referrer_query = Column(Unicode(256), nullable=True) # search engine query
|
||||
device_class = Column(SmallInteger, nullable=True) # 0-2
|
||||
is_owner = Column(Boolean, nullable=True) # shop owner/editor viewing own product
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"""add referrer_domain and referrer_query to page_session
|
||||
|
||||
Revision ID: f3086e09b052
|
||||
Revises: b3f7a2c8d1e5
|
||||
Create Date: 2026-02-26 16:53:40.340946
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f3086e09b052'
|
||||
down_revision = 'b3f7a2c8d1e5'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _column_exists("mps_page_session", "referrer_domain"):
|
||||
op.add_column(
|
||||
"mps_page_session",
|
||||
sa.Column("referrer_domain", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
if not _column_exists("mps_page_session", "referrer_query"):
|
||||
op.add_column(
|
||||
"mps_page_session",
|
||||
sa.Column("referrer_query", sa.Unicode(256), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_page_session", "referrer_query")
|
||||
op.drop_column("mps_page_session", "referrer_domain")
|
||||
|
|
@ -3499,6 +3499,16 @@ textarea {
|
|||
}
|
||||
}
|
||||
|
||||
.analytics-bar-row--wide .analytics-bar-label {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.analytics-bar-row--wide .analytics-bar-label {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.price-history-current td {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,37 @@
|
|||
|
||||
{% block content -%}
|
||||
|
||||
{% macro line_chart(data, max_value, label, color) %}
|
||||
{% if data and max_value > 0 %}
|
||||
<div class="analytics-chart-svg">
|
||||
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
|
||||
{% for pct in [25, 50, 75] %}
|
||||
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
|
||||
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
|
||||
{% endfor %}
|
||||
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
|
||||
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 8 }},140 8,140"
|
||||
fill="{{ color }}" opacity="0.15" />
|
||||
<polyline
|
||||
points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
|
||||
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
||||
{% for b in data %}
|
||||
<circle cx="{{ loop.index0 * 20 + 8 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
|
||||
r="3" fill="{{ color }}">
|
||||
<title>{{ b.label }}: {{ b.tooltip }}</title>
|
||||
</circle>
|
||||
{% endfor %}
|
||||
{% for b in data %}
|
||||
{% if loop.index0 % 7 == 0 %}
|
||||
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
|
||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<section class="analytics-page">
|
||||
|
||||
<h3>Analytics</h3>
|
||||
|
|
@ -68,6 +99,25 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Trend Line Charts (28 days) --- #}
|
||||
{% if daily_session_duration_max > 0 %}
|
||||
<h4>Session Duration Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Average time visitors spend per session.</p>
|
||||
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
|
||||
{% endif %}
|
||||
|
||||
{% if daily_engagement %}
|
||||
<h4>Engagement Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
|
||||
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
|
||||
{% endif %}
|
||||
|
||||
{% if daily_bounce %}
|
||||
<h4>Bounce Rate Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
|
||||
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
|
||||
{% endif %}
|
||||
|
||||
{# --- Ring Consumed breakdown --- #}
|
||||
<h4>Ring Consumed</h4>
|
||||
<p class="analytics-hint">Unique products viewed via the ring as a percentage of all {{ ring_size }} ring products.</p>
|
||||
|
|
@ -375,6 +425,73 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Top Referrer Domains (21d) --- #}
|
||||
{% if top_referrer_domains %}
|
||||
<h4>Top Referrer Domains (last 21 days)</h4>
|
||||
<p class="analytics-hint">External sites sending visitors to your shop.</p>
|
||||
<div class="analytics-bar-rows">
|
||||
{% for d in top_referrer_domains %}
|
||||
<div class="analytics-bar-row analytics-bar-row--wide">
|
||||
<span class="analytics-bar-label">{{ d.domain }}</span>
|
||||
<span class="analytics-bar-track">
|
||||
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
|
||||
</span>
|
||||
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Search Engine Queries (21d) --- #}
|
||||
{% if top_referrer_queries %}
|
||||
<h4>Search Engine Queries (last 21 days)</h4>
|
||||
<p class="analytics-hint">What people searched before finding your shop.</p>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Query</th>
|
||||
<th class="analytics-num">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for q in top_referrer_queries %}
|
||||
<tr>
|
||||
<td>{{ q.query }}</td>
|
||||
<td class="analytics-num">{{ q.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Internal Search Keywords (21d) --- #}
|
||||
{% if top_keywords %}
|
||||
<h4>Top Search Keywords (last 21 days)</h4>
|
||||
<p class="analytics-hint">What visitors search for inside your shop.</p>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Keywords</th>
|
||||
<th class="analytics-num">Searches</th>
|
||||
<th class="analytics-num">Avg Hits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k in top_keywords %}
|
||||
<tr>
|
||||
<td>{{ k.keywords }}</td>
|
||||
<td class="analytics-num">{{ k.search_count }}</td>
|
||||
<td class="analytics-num">{{ k.avg_hits }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}{# end overview.total_views check #}
|
||||
|
||||
{# --- Comment Sentiment (21d) --- #}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,37 @@
|
|||
|
||||
{% block content -%}
|
||||
|
||||
{% macro line_chart(data, max_value, label, color) %}
|
||||
{% if data and max_value > 0 %}
|
||||
<div class="analytics-chart-svg">
|
||||
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
|
||||
{% for pct in [25, 50, 75] %}
|
||||
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
|
||||
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
|
||||
{% endfor %}
|
||||
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
|
||||
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 8 }},140 8,140"
|
||||
fill="{{ color }}" opacity="0.15" />
|
||||
<polyline
|
||||
points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
|
||||
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
||||
{% for b in data %}
|
||||
<circle cx="{{ loop.index0 * 20 + 8 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
|
||||
r="3" fill="{{ color }}">
|
||||
<title>{{ b.label }}: {{ b.tooltip }}</title>
|
||||
</circle>
|
||||
{% endfor %}
|
||||
{% for b in data %}
|
||||
{% if loop.index0 % 7 == 0 %}
|
||||
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
|
||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<section class="analytics-page">
|
||||
|
||||
<p><a href="/s/{{ request.shop.uuid_str }}/analytics">← Shop Analytics</a> · <a href="{{ product_url }}">View Product</a></p>
|
||||
|
|
@ -64,6 +95,25 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Trend Line Charts (28 days) --- #}
|
||||
{% if daily_session_duration_max > 0 %}
|
||||
<h4>Session Duration Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Average time visitors spend per session.</p>
|
||||
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
|
||||
{% endif %}
|
||||
|
||||
{% if daily_engagement %}
|
||||
<h4>Engagement Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
|
||||
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
|
||||
{% endif %}
|
||||
|
||||
{% if daily_bounce %}
|
||||
<h4>Bounce Rate Trend (last 28 days)</h4>
|
||||
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
|
||||
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
|
||||
{% endif %}
|
||||
|
||||
{# --- Views Over Time --- #}
|
||||
<h4>Views Over Time</h4>
|
||||
<div class="analytics-overview well">
|
||||
|
|
@ -168,6 +218,47 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Top Referrer Domains (21d) --- #}
|
||||
{% if top_referrer_domains %}
|
||||
<h4>Top Referrer Domains (last 21 days)</h4>
|
||||
<p class="analytics-hint">External sites sending visitors to this product.</p>
|
||||
<div class="analytics-bar-rows">
|
||||
{% for d in top_referrer_domains %}
|
||||
<div class="analytics-bar-row analytics-bar-row--wide">
|
||||
<span class="analytics-bar-label">{{ d.domain }}</span>
|
||||
<span class="analytics-bar-track">
|
||||
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
|
||||
</span>
|
||||
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Search Engine Queries (21d) --- #}
|
||||
{% if top_referrer_queries %}
|
||||
<h4>Search Engine Queries (last 21 days)</h4>
|
||||
<p class="analytics-hint">What people searched before finding this product.</p>
|
||||
<div class="analytics-table-wrap">
|
||||
<table class="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Query</th>
|
||||
<th class="analytics-num">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for q in top_referrer_queries %}
|
||||
<tr>
|
||||
<td>{{ q.query }}</td>
|
||||
<td class="analytics-num">{{ q.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Ring Entries --- #}
|
||||
{% if ring_entry_count > 0 %}
|
||||
<h4>Ring Entry Sessions (last 21 days)</h4>
|
||||
|
|
|
|||
|
|
@ -3188,43 +3188,76 @@ class TestProductViewCount(unittest.TestCase):
|
|||
class TestSignalClassifiers(unittest.TestCase):
|
||||
"""Test classify_referrer and classify_device functions."""
|
||||
|
||||
# --- classify_referrer ---
|
||||
# --- classify_referrer (returns (class, domain, query) tuple) ---
|
||||
|
||||
def test_referrer_direct_empty(self):
|
||||
self.assertEqual(classify_referrer("", "example.com"), 0)
|
||||
self.assertEqual(classify_referrer("", "example.com"), (0, None, None))
|
||||
|
||||
def test_referrer_direct_none(self):
|
||||
self.assertEqual(classify_referrer(None, "example.com"), 0)
|
||||
self.assertEqual(classify_referrer(None, "example.com"), (0, None, None))
|
||||
|
||||
def test_referrer_search_google(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://www.google.com/search?q=foo", "example.com"), 1
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://www.google.com/search?q=foo", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 1)
|
||||
self.assertEqual(domain, "www.google.com")
|
||||
self.assertEqual(query, "foo")
|
||||
|
||||
def test_referrer_search_duckduckgo(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://duckduckgo.com/?q=bar", "example.com"), 1
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://duckduckgo.com/?q=bar", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 1)
|
||||
self.assertEqual(domain, "duckduckgo.com")
|
||||
self.assertEqual(query, "bar")
|
||||
|
||||
def test_referrer_search_no_query(self):
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://www.google.com/", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 1)
|
||||
self.assertEqual(domain, "www.google.com")
|
||||
self.assertIsNone(query)
|
||||
|
||||
def test_referrer_social_twitter(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://twitter.com/user/status/123", "example.com"), 2
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://twitter.com/user/status/123", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 2)
|
||||
self.assertEqual(domain, "twitter.com")
|
||||
self.assertIsNone(query)
|
||||
|
||||
def test_referrer_social_reddit(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://www.reddit.com/r/test", "example.com"), 2
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://www.reddit.com/r/test", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 2)
|
||||
self.assertEqual(domain, "www.reddit.com")
|
||||
self.assertIsNone(query)
|
||||
|
||||
def test_referrer_internal(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://example.com/some/page", "example.com"), 3
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://example.com/some/page", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 3)
|
||||
self.assertEqual(domain, "example.com")
|
||||
self.assertIsNone(query)
|
||||
|
||||
def test_referrer_unknown_external(self):
|
||||
self.assertEqual(
|
||||
classify_referrer("https://randomsite.org/page", "example.com"), 4
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://randomsite.org/page", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 4)
|
||||
self.assertEqual(domain, "randomsite.org")
|
||||
self.assertIsNone(query)
|
||||
|
||||
def test_referrer_yahoo_query(self):
|
||||
cls, domain, query = classify_referrer(
|
||||
"https://search.yahoo.com/search?p=beats", "example.com"
|
||||
)
|
||||
self.assertEqual(cls, 1)
|
||||
self.assertEqual(query, "beats")
|
||||
|
||||
# --- classify_device ---
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,209 @@ def _daily_buckets(dbsession, base_filter, days=28):
|
|||
return buckets
|
||||
|
||||
|
||||
def _daily_avg_duration(dbsession, base_filter, days=28):
|
||||
"""Bucket page sessions into daily avg wall_clock_ms for a line chart.
|
||||
|
||||
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
|
||||
"""
|
||||
now = now_timestamp()
|
||||
cutoff = now - days * DAY_MS
|
||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
||||
|
||||
rows = (
|
||||
dbsession.query(
|
||||
day_offset_expr.label("day_offset"),
|
||||
func.avg(PageSession.wall_clock_ms).label("avg_ms"),
|
||||
)
|
||||
.filter(base_filter, PageSession.created_timestamp > cutoff)
|
||||
.group_by(day_offset_expr)
|
||||
.all()
|
||||
)
|
||||
avgs = {r.day_offset: r.avg_ms or 0 for r in rows}
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
buckets = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
d = today - timedelta(days=i)
|
||||
val = avgs.get(i, 0)
|
||||
buckets.append({
|
||||
"day_offset": i,
|
||||
"label": d.strftime("%b %-d"),
|
||||
"value": val,
|
||||
"tooltip": _fmt_ms(val),
|
||||
})
|
||||
return buckets
|
||||
|
||||
|
||||
def _daily_engagement(dbsession, base_filter, days=28):
|
||||
"""Bucket sessions into daily engagement ratio (active_ms / wall_clock_ms).
|
||||
|
||||
Returns oldest-first list with value as 0-1 float.
|
||||
"""
|
||||
now = now_timestamp()
|
||||
cutoff = now - days * DAY_MS
|
||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
||||
|
||||
rows = (
|
||||
dbsession.query(
|
||||
day_offset_expr.label("day_offset"),
|
||||
func.avg(
|
||||
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
||||
).label("ratio"),
|
||||
)
|
||||
.filter(base_filter, PageSession.created_timestamp > cutoff)
|
||||
.group_by(day_offset_expr)
|
||||
.all()
|
||||
)
|
||||
ratios = {r.day_offset: r.ratio or 0 for r in rows}
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
buckets = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
d = today - timedelta(days=i)
|
||||
val = ratios.get(i, 0)
|
||||
buckets.append({
|
||||
"day_offset": i,
|
||||
"label": d.strftime("%b %-d"),
|
||||
"value": val,
|
||||
"tooltip": _fmt_pct(val),
|
||||
})
|
||||
return buckets
|
||||
|
||||
|
||||
def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
|
||||
"""Bucket sessions into daily bounce rate (visible < 7s / total).
|
||||
|
||||
all_sessions_filter must NOT include the 7s visible floor.
|
||||
Returns oldest-first list with value as 0-1 float.
|
||||
"""
|
||||
now = now_timestamp()
|
||||
cutoff = now - days * DAY_MS
|
||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
||||
|
||||
rows = (
|
||||
dbsession.query(
|
||||
day_offset_expr.label("day_offset"),
|
||||
func.count().label("total"),
|
||||
func.sum(case(
|
||||
(PageSession.visible_ms < 7000, 1), else_=0
|
||||
)).label("bounced"),
|
||||
)
|
||||
.filter(all_sessions_filter, PageSession.created_timestamp > cutoff)
|
||||
.group_by(day_offset_expr)
|
||||
.all()
|
||||
)
|
||||
rates = {}
|
||||
for r in rows:
|
||||
if r.total and r.total > 0:
|
||||
rates[r.day_offset] = (r.bounced or 0) / r.total
|
||||
else:
|
||||
rates[r.day_offset] = 0
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
buckets = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
d = today - timedelta(days=i)
|
||||
val = rates.get(i, 0)
|
||||
buckets.append({
|
||||
"day_offset": i,
|
||||
"label": d.strftime("%b %-d"),
|
||||
"value": val,
|
||||
"tooltip": _fmt_pct(val),
|
||||
})
|
||||
return buckets
|
||||
|
||||
|
||||
def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
|
||||
"""Top internal search keywords for a shop, last N days."""
|
||||
from ..models.shop_search_request import ShopSearchRequest
|
||||
cutoff = now_timestamp() - days * DAY_MS
|
||||
rows = (
|
||||
dbsession.query(
|
||||
ShopSearchRequest.keywords,
|
||||
func.count().label("search_count"),
|
||||
func.sum(ShopSearchRequest.hit_count).label("total_hits"),
|
||||
)
|
||||
.filter(
|
||||
ShopSearchRequest.shop_id == shop_id,
|
||||
ShopSearchRequest.created_timestamp > cutoff,
|
||||
)
|
||||
.group_by(ShopSearchRequest.keywords)
|
||||
.order_by(func.count().desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"keywords": r.keywords,
|
||||
"search_count": r.search_count,
|
||||
"total_hits": r.total_hits or 0,
|
||||
"avg_hits": round((r.total_hits or 0) / max(r.search_count, 1), 1),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
|
||||
"""Top referrer domains, excluding internal and direct."""
|
||||
cutoff = now_timestamp() - days * DAY_MS
|
||||
rows = (
|
||||
dbsession.query(
|
||||
PageSession.referrer_domain,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.filter(
|
||||
base_filter,
|
||||
PageSession.created_timestamp > cutoff,
|
||||
PageSession.referrer_domain.isnot(None),
|
||||
PageSession.referrer_class != 3, # exclude internal
|
||||
PageSession.referrer_class != 0, # exclude direct
|
||||
)
|
||||
.group_by(PageSession.referrer_domain)
|
||||
.order_by(func.count().desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
total = sum(r.cnt for r in rows) or 1
|
||||
return [
|
||||
{
|
||||
"domain": r.referrer_domain,
|
||||
"count": r.cnt,
|
||||
"pct": _fmt_pct(r.cnt / total),
|
||||
"pct_raw": r.cnt / total * 100,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
|
||||
"""Top search queries from external search engines."""
|
||||
cutoff = now_timestamp() - days * DAY_MS
|
||||
rows = (
|
||||
dbsession.query(
|
||||
PageSession.referrer_query,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.filter(
|
||||
base_filter,
|
||||
PageSession.created_timestamp > cutoff,
|
||||
PageSession.referrer_query.isnot(None),
|
||||
PageSession.referrer_query != "",
|
||||
)
|
||||
.group_by(PageSession.referrer_query)
|
||||
.order_by(func.count().desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"query": r.referrer_query,
|
||||
"count": r.cnt,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@view_config(route_name="shop_analytics", renderer="analytics.j2")
|
||||
@shop_editor_required()
|
||||
def shop_analytics(request):
|
||||
|
|
@ -149,6 +352,15 @@ def shop_analytics(request):
|
|||
# --- Daily views (28-day bar chart) ---
|
||||
daily_views = _daily_buckets(db, _view_base, 28)
|
||||
|
||||
# --- Line chart trends (28 days) ---
|
||||
daily_session_duration = _daily_avg_duration(db, _view_base, 28)
|
||||
daily_engagement = _daily_engagement(db, _view_base, 28)
|
||||
_all_sessions_base = and_(
|
||||
PageSession.shop_id == shop.id,
|
||||
_not_owner,
|
||||
)
|
||||
daily_bounce = _daily_bounce_rate(db, _all_sessions_base, 28)
|
||||
|
||||
# --- Section 1: Overview Strip (last 7 days) ---
|
||||
|
||||
ov = db.query(
|
||||
|
|
@ -517,11 +729,22 @@ def shop_analytics(request):
|
|||
|
||||
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
||||
|
||||
# --- Internal Search Keywords (21d) ---
|
||||
top_keywords = _top_search_keywords(db, shop.id, 21, 21)
|
||||
|
||||
# --- Referrer Domains & Search Queries (21d) ---
|
||||
top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
|
||||
top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
"ring_size": ring_size,
|
||||
"daily_views": daily_views,
|
||||
"daily_views_max": daily_views_max,
|
||||
"daily_session_duration": daily_session_duration,
|
||||
"daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
|
||||
"daily_engagement": daily_engagement,
|
||||
"daily_bounce": daily_bounce,
|
||||
"top_products": top_products,
|
||||
"ring_entries": ring_entries,
|
||||
"engagement": _ranked(eng_raw, _fmt_pct),
|
||||
|
|
@ -532,6 +755,9 @@ def shop_analytics(request):
|
|||
"video_products": video_products,
|
||||
"sentiment": sentiment,
|
||||
"price_changes": price_changes,
|
||||
"top_keywords": top_keywords,
|
||||
"top_referrer_domains": top_referrer_domains,
|
||||
"top_referrer_queries": top_referrer_queries,
|
||||
"traffic": [
|
||||
{
|
||||
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
|
||||
|
|
@ -596,6 +822,15 @@ def product_analytics(request):
|
|||
)
|
||||
daily_views = _daily_buckets(db, _pf_base, 28)
|
||||
|
||||
# --- Line chart trends (28 days) ---
|
||||
daily_session_duration = _daily_avg_duration(db, _pf_base, 28)
|
||||
daily_engagement_data = _daily_engagement(db, _pf_base, 28)
|
||||
_all_product_sessions = and_(
|
||||
PageSession.product_id == product.id,
|
||||
_not_owner,
|
||||
)
|
||||
daily_bounce = _daily_bounce_rate(db, _all_product_sessions, 28)
|
||||
|
||||
# --- Overview ---
|
||||
ov = db.query(
|
||||
func.count().label("views"),
|
||||
|
|
@ -766,6 +1001,10 @@ def product_analytics(request):
|
|||
|
||||
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
||||
|
||||
# --- Referrer Domains & Search Queries (21d) ---
|
||||
top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
|
||||
top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_url": product_url,
|
||||
|
|
@ -773,6 +1012,10 @@ def product_analytics(request):
|
|||
"views_over_time": views_over_time,
|
||||
"daily_views": daily_views,
|
||||
"daily_views_max": daily_views_max,
|
||||
"daily_session_duration": daily_session_duration,
|
||||
"daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
|
||||
"daily_engagement": daily_engagement_data,
|
||||
"daily_bounce": daily_bounce,
|
||||
"video": video,
|
||||
"traffic": traffic,
|
||||
"devices": devices,
|
||||
|
|
@ -780,4 +1023,6 @@ def product_analytics(request):
|
|||
"engagement": engagement,
|
||||
"sentiment": sentiment,
|
||||
"price_changes": price_changes,
|
||||
"top_referrer_domains": top_referrer_domains,
|
||||
"top_referrer_queries": top_referrer_queries,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,42 +29,59 @@ _SOCIAL_DOMAINS = {
|
|||
}
|
||||
|
||||
|
||||
def classify_referrer(referrer, request_host):
|
||||
"""Classify a Referer header into 0-4.
|
||||
# Search engine query parameter names by domain root
|
||||
_SEARCH_QUERY_PARAMS = {
|
||||
"google": "q", "bing": "q", "yahoo": "p", "duckduckgo": "q",
|
||||
"baidu": "wd", "yandex": "text", "ecosia": "q", "qwant": "q",
|
||||
"startpage": "query", "brave": "q",
|
||||
}
|
||||
|
||||
0 = direct (no referrer)
|
||||
1 = search engine
|
||||
2 = social media
|
||||
3 = internal (same host)
|
||||
4 = unknown (other external)
|
||||
|
||||
def classify_referrer(referrer, request_host):
|
||||
"""Classify a Referer header into (class, domain, query).
|
||||
|
||||
class: 0=direct, 1=search, 2=social, 3=internal, 4=unknown
|
||||
domain: hostname string or None
|
||||
query: search engine query string or None
|
||||
"""
|
||||
if not referrer:
|
||||
return 0
|
||||
return 0, None, None
|
||||
try:
|
||||
# Extract domain from referrer URL
|
||||
# e.g. "https://www.google.com/search?q=foo" -> "google"
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(referrer)
|
||||
host = (parsed.hostname or "").lower()
|
||||
except Exception:
|
||||
return 4
|
||||
return 4, None, None
|
||||
|
||||
domain = host or None
|
||||
|
||||
# Internal: same host as request
|
||||
if request_host and host == request_host.lower():
|
||||
return 3
|
||||
return 3, domain, None
|
||||
|
||||
# Strip www. and extract root domain
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
# Get the second-level domain: "search.google.com" -> "google"
|
||||
parts = host.split(".")
|
||||
root = parts[-2] if len(parts) >= 2 else host
|
||||
# Strip www. and extract root domain for classification
|
||||
clean = host
|
||||
if clean.startswith("www."):
|
||||
clean = clean[4:]
|
||||
parts = clean.split(".")
|
||||
root = parts[-2] if len(parts) >= 2 else clean
|
||||
|
||||
if root in _SEARCH_DOMAINS:
|
||||
return 1
|
||||
query = None
|
||||
param = _SEARCH_QUERY_PARAMS.get(root, "q")
|
||||
try:
|
||||
qs = parse_qs(parsed.query)
|
||||
vals = qs.get(param, [])
|
||||
if vals:
|
||||
query = vals[0][:256]
|
||||
except Exception:
|
||||
pass
|
||||
return 1, domain, query
|
||||
|
||||
if root in _SOCIAL_DOMAINS:
|
||||
return 2
|
||||
return 4
|
||||
return 2, domain, None
|
||||
|
||||
return 4, domain, None
|
||||
|
||||
|
||||
def classify_device(viewport_width):
|
||||
|
|
@ -215,7 +232,10 @@ def beacon_view(request):
|
|||
request_host = request.host
|
||||
if ":" in request_host:
|
||||
request_host = request_host.split(":")[0]
|
||||
ps.referrer_class = classify_referrer(referrer, request_host)
|
||||
ref_class, ref_domain, ref_query = classify_referrer(referrer, request_host)
|
||||
ps.referrer_class = ref_class
|
||||
ps.referrer_domain = ref_domain[:128] if ref_domain else None
|
||||
ps.referrer_query = ref_query[:256] if ref_query else None
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue