زمانی که یک کاربر وارد سایت شما می‌شود، هر کلیک و هر اسکرول او یک داده‌ی ارزشمند است؛ ولی همین داده‌ها اگر بدون معماری درست ذخیره شوند، در چند ماه به بزرگ‌ترین بدهی فنی پروژه تبدیل می‌شوند. در این مقاله، تصمیم‌های مهندسی پشت مدل‌های PageView و Interaction را با جزئیاتی که در مستندات رسمی جنگو پیدا نمی‌کنید، بررسی می‌کنیم.

چرا PageView و Interaction را از Visit جدا می‌کنیم؟

سؤال اولی که هر معمار داده از خودش می‌پرسد این است: چرا این دو مدل را جدا نگه داریم وقتی می‌شود همه‌ی اطلاعات را در یک جدول بزرگ ذخیره کرد؟ پاسخ در سه سطح نهفته است: کارایی، دقت تحلیلی، و نگهداشت‌پذیری.

سطح اول، کارایی. هر Visit (سشن بازدید) ممکن است شامل چندین PageView (بازدید از یک صفحه) و هر PageView ممکن است شامل چندین Interaction (تعامل با صفحه) باشد. اگر همه‌ی این سطوح را در یک جدول ذخیره کنید، حجم ردیف‌ها چند برابر می‌شود و کوئری‌های پایه مثل «تعداد سشن‌های امروز» به یک full scan سنگین تبدیل می‌شوند.

سطح دوم، دقت تحلیلی. وقتی PageView و Interaction جدا هستند، می‌توانید تحلیل‌های لایه‌ای انجام دهید: «چند درصد از کاربران بعد از دیدن صفحه‌ی محصول، روی دکمه‌ی خرید کلیک کردند؟» این سؤال در یک جدول تخت، پاسخ سریع و دقیق ندارد. اگر با ساختار طراحی مدل Visitor و Visit در جنگو آشنا باشید، این جداسازی را به‌عنوان یک اصل تکرارشونده در معماری داده می‌شناسید.

سطح سوم، نگهداشت‌پذیری. جدول Interaction با سرعت بالایی رشد می‌کند. اگر از ابتدا آن را جدا نگه دارید، می‌توانید آن را مستقل از بقیه پاک‌سازی یا آرشیو کنید. اگر همه‌ی داده‌ها در یک جدول باشد، پاک‌سازی انتخابی تقریباً غیرممکن است.

جداسازی PageView و Interaction از Visit، یک انتخاب سلیقه‌ای نیست؛ یک تصمیم معماری است که هزینه‌ی نگهداری سیستم را در طول سال‌های آینده تعیین می‌کند.

طراحی مدل PageView؛ یک ردیف برای هر صفحه

مدل PageView، نگه‌دارنده‌ی هر بازدید از یک صفحه‌ی مشخص در یک سشن است. این مدل، پل ارتباطی بین Visit و Interaction است و نقش تعیین‌کننده‌ای در تحلیل رفتار دارد.

# analytics/models.py

from django.db import models


class PageView(models.Model):
    visit = models.ForeignKey(
        "analytics.Visit",
        on_delete=models.CASCADE,
        related_name="page_views",
    )
    visitor = models.ForeignKey(
        "analytics.Visitor",
        on_delete=models.CASCADE,
        related_name="page_views",
    )

    path = models.CharField(max_length=500, db_index=True)
    url = models.CharField(max_length=2000)
    title = models.CharField(max_length=500, blank=True)

    entered_at = models.DateTimeField(db_index=True)
    left_at = models.DateTimeField(null=True, blank=True)
    duration_seconds = models.PositiveIntegerField(default=0)

    is_entry = models.BooleanField(default=False, db_index=True)
    is_exit = models.BooleanField(default=False, db_index=True)
    scroll_depth = models.PositiveIntegerField(default=0)

    # اطلاعات زمینه‌ای صفحه
    referrer_within_site = models.CharField(max_length=500, blank=True)
    load_time_ms = models.PositiveIntegerField(null=True, blank=True)

    class Meta:
        db_table = "analytics_page_view"
        indexes = [
            models.Index(fields=["path", "entered_at"]),
            models.Index(fields=["visit", "entered_at"]),
            models.Index(fields=["visitor", "entered_at"]),
            models.Index(fields=["is_entry", "entered_at"]),
        ]
        ordering = ["-entered_at"]

سه تصمیم کلیدی در این مدل وجود دارد که هر کدام از تجربه‌ی پروژه‌های واقعی می‌آید.

تصمیم اول، ذخیره‌ی visitor به‌طور مستقیم، به‌علاوه‌ی visit. می‌شد فقط visit را ذخیره کرد و از طریق join به Visitor رسید. ولی این کار کوئری‌های مستقیم را کند می‌کند. با ذخیره‌ی مستقیم visitor، کوئری‌هایی مثل «همه‌ی صفحات بازدیدشده توسط یک کاربر خاص» بدون join اجرا می‌شوند. اگر با الگوی denormalization هدفمند آشنا هستید، این تصمیم را می‌شناسید.

تصمیم دوم، فیلد is_entry و is_exit. این دو فیلد بولین، در نگاه اول ساده به‌نظر می‌رسند ولی کاربردهای گسترده‌ای دارند. با is_entry می‌توانید تحلیل کنید کاربر از کدام صفحه وارد شده. با is_exit می‌توانید صفحه‌های پرخروج را شناسایی کنید. این دو فیلد به‌جای محاسبه‌ی مجدد در هر کوئری، از ابتدا ذخیره می‌شوند.

تصمیم سوم، فیلد referrer_within_site. این فیلد، مسیر داخلی قبلی را ذخیره می‌کند (مثلاً «از /product/ به /cart/ آمده»). این اطلاعات در تحلیل مسیر کاربر حیاتی است، ولی به‌سختی از فیلد referrer_url در Visit استخراج می‌شود. اگر با طبقه‌بندی Referrer در جنگو آشنا هستید، این الگو برایتان آشناست: هرجا بتوانید denormalization معنادار انجام دهید، کوئری‌های آینده را ساده‌تر کرده‌اید.

نکته‌ی ظریف دیگر، فیلد load_time_ms است. این فیلد، زمان بارگذاری صفحه را از دید کاربر ذخیره می‌کند. مقدار این فیلد از PerformanceNavigationTiming در مرورگر گرفته می‌شود و می‌تواند در تحلیل Core Web Vitals استفاده شود. مستندات این API در مفهوم Web Beacon در ویکی‌پدیا توضیح داده شده است.

طراحی مدل Interaction؛ پیچیدگی در سادگی

مدل Interaction، نگه‌دارنده‌ی هر تعامل کاربر با یک صفحه است. این مدل باید هم ساده باشد (چون حجم ردیف‌هایش زیاد است)، هم انعطاف‌پذیر (چون انواع تعامل‌ها در طول زمان تغییر می‌کنند).

class Interaction(models.Model):
    EVENT_CHOICES = [
        ("click", "Click"),
        ("scroll", "Scroll"),
        ("form_submit", "Form Submit"),
        ("form_abandon", "Form Abandon"),
        ("download", "Download"),
        ("outbound", "Outbound Link"),
        ("video_play", "Video Play"),
        ("video_complete", "Video Complete"),
        ("custom", "Custom"),
    ]

    page_view = models.ForeignKey(
        PageView,
        on_delete=models.CASCADE,
        related_name="interactions",
    )
    visit = models.ForeignKey(
        "analytics.Visit",
        on_delete=models.CASCADE,
        related_name="interactions",
    )
    visitor = models.ForeignKey(
        "analytics.Visitor",
        on_delete=models.CASCADE,
        related_name="interactions",
    )

    event_type = models.CharField(
        max_length=30,
        choices=EVENT_CHOICES,
        db_index=True,
    )
    target = models.CharField(max_length=500, blank=True)
    selector = models.CharField(max_length=255, blank=True)
    metadata = models.JSONField(default=dict, blank=True)
    occurred_at = models.DateTimeField(db_index=True)

    # مقدار عددی برای تجمیع سریع
    value = models.IntegerField(null=True, blank=True)

    class Meta:
        db_table = "analytics_interaction"
        indexes = [
            models.Index(fields=["visit", "occurred_at"]),
            models.Index(fields=["event_type", "occurred_at"]),
            models.Index(fields=["page_view", "event_type"]),
            models.Index(fields=["visitor", "event_type", "occurred_at"]),
        ]
        ordering = ["-occurred_at"]

این مدل، سه ستون فقرات دارد که هر کدام پاسخ یک نیاز تحلیلی است.

ستون اول، سه کلید خارجی همزمان. page_view، visit و visitor. این triple، به شما اجازه می‌دهد تا از هر زاویه‌ای کوئری بزنید: تحلیل رفتار در یک صفحه، در یک سشن، یا برای یک کاربر در طول ماه. بهای این denormalization، حجم بیشتر است؛ ولی این بها را در تحلیل‌های آینده پس می‌گیرید.

ستون دوم، تفکیک target و selector. target توصیف متنی است (مثلاً «دکمه‌ی خرید»)، ولی selector یک شناسه‌ی CSS یا data-attribute است (مثلاً [data-action="add-to-cart"]). تفکیک این دو، به شما اجازه می‌دهد اگر متن دکمه تغییر کرد، همچنان بتوانید تحلیل‌های تاریخی را ادامه دهید.

ستون سوم، فیلد value. این فیلد اختیاری، به شما اجازه می‌دهد یک مقدار عددی به تعامل بچسبانید. مثلاً برای رویداد scroll، مقدار value می‌تواند عمق اسکرول (بین ۰ تا ۱۰۰) باشد. با این فیلد، می‌توانید AVG و MAX را مستقیماً روی جدول Interaction بگیرید، بدون اینکه به JSON دسترسی پیدا کنید. این تصمیم، کوئری‌های آماری را چند برابر سریع‌تر می‌کند.

Interaction جدولی است که در آن، هر بایت اضافه، در طول یک سال چند گیگابایت می‌شود. هر فیلد را با دقت انتخاب کنید، ولی برای آنچه در تحلیل‌های آینده لازم می‌شود، سه بار فکر نکنید.

JSONField برای metadata؛ فرصت‌ها و خطرها

استفاده از JSONField در جنگو، یک انتخاب دو لبه است. از یک سو، انعطاف بی‌نظیری می‌دهد: می‌توانید بدون migration، ساختار داده را تغییر دهید. از سوی دیگر، کوئری‌های JSON در اکثر دیتابیس‌ها کندتر از کوئری‌های ستونی هستند.

سه قاعده را در استفاده از JSONField رعایت کنید.

قاعده‌ی اول، فیلدهای پرکوئری را از JSON بیرون بیاورید. اگر می‌خواهید روی یک مقدار خاص فیلتر کنید (مثلاً «همه‌ی کلیک‌هایی که روی یک محصول خاص بوده»)، آن مقدار باید یک ستون جدا باشد، نه داخل JSON. مثال:

# بد
Interaction.objects.filter(metadata__product_id=42)

# خوب
Interaction.objects.filter(product_id=42)

قاعده‌ی دوم، ساختار JSON را مستند کنید. چون JSON هیچ schema ندارد، در طول زمان ساختارهای مختلفی وارد آن می‌شوند. یک فایل documentation بنویسید که برای هر event_type، ساختار مورد انتظار metadata را مشخص کند. اگر با الگوی services.py در جنگو کار می‌کنید، این validation را می‌توانید در همان لایه انجام دهید.

قاعده‌ی سوم، اندازه را محدود کنید. JSON بزرگ (بیشتر از ۱ کیلوبایت)، هم ذخیره‌سازی را کند می‌کند، هم بکاپ را سنگین. حد بالای منطقی برای metadata، حدود ۲ کیلوبایت است. برای داده‌های حجیم‌تر (مثل اسکرین‌شات یا snapshot از DOM)، از یک مدل جداگانه یا از storage خارجی استفاده کنید.

کاربرد JSONمناسبنامناسب
ذخیره‌ی viewport در لحظه‌ی کلیک✓
فیلتر بر اساس مقدار داخل JSON✗
ذخیره‌ی UTM parameters✓
ذخیره‌ی کل DOM در لحظه‌ی کلیک✗
ذخیره‌ی نسخه‌ی A/B تست✓
ذخیره‌ی محتوای فرم کاربر✗

طبقه‌بندی event_type؛ چارچوبی که سال‌ها دوام می‌آورد

انتخاب event_typeهای اولیه، یکی از تصمیم‌های سرنوشت‌ساز این سیستم است. اگر از ابتدا طبقه‌بندی درستی داشته باشید، در آینده نیازی به migrationهای دردناک نخواهید داشت.

پیشنهاد می‌کنم پنج دسته‌ی اصلی داشته باشید:

دسته‌ی اول، تعاملات کلیک. click، outbound، download. این تعاملات، نشان‌دهنده‌ی تصمیم کاربر هستند. اگر با الگوی ثبت اسکرول و کلیک کاربر آشنا هستید، می‌دانید که این تعاملات از پرتکرارترین‌ها هستند.

دسته‌ی دوم، تعاملات حرکتی. scroll، time_on_section. این تعاملات، نشان‌دهنده‌ی توجه کاربر هستند، نه تصمیم او.

دسته‌ی سوم، تعاملات فرمی. form_submit، form_abandon، field_focus. این تعاملات، در تحلیل قیف (funnel) حیاتی هستند.

دسته‌ی چهارم، تعاملات مدیا. video_play، video_pause، video_complete. این تعاملات، در سایت‌های آموزشی یا محتوایی بسیار مفیدند.

دسته‌ی پنجم، تعاملات سفارشی. custom. این دسته، به شما اجازه می‌دهد در آینده هر تعامل جدیدی را بدون تغییر schema اضافه کنید.

نکته‌ی مهم: هرگز event_type را با نام‌های خیلی خاص پر نکنید (مثل click_buy_button_on_product_page_v2). این نوع نام‌گذاری، در طول زمان جدول را شلوغ می‌کند. جزئیات را در target و metadata بگذارید.

ایندکس‌گذاری دو جدول پرحجم

هر دو جدول PageView و Interaction با سرعت بالایی رشد می‌کنند. بدون ایندکس‌گذاری دقیق، کوئری‌های تحلیلی به کابوس تبدیل می‌شوند.

ایندکس‌های PageView:

اول، (path, entered_at). این ایندکس، برای کوئری «پربازدیدترین صفحه در بازه‌ی زمانی» ضروری است. ترتیب این دو فیلد حیاتی است: اول path (کاردینالیتی متوسط)، بعد entered_at. این ترتیب باعث می‌شود دیتابیس بتواند برای هر مسیر، بازه‌ی زمانی را سریع scan کند.

دوم، (visit, entered_at). این ایندکس، برای نمایش سفر یک سشن به‌کار می‌رود. اگر با الگوی ساخت پنل ادمین با کارت‌های quick view کار کرده‌اید، می‌دانید که این کوئری بسیار پرتکرار است.

سوم، (visitor, entered_at). برای کوئری‌های تحلیل کاربر در طول زمان. در پنل تحلیل کاربر، این ایندکس تفاوت بین ۵۰ میلی‌ثانیه و ۵۰۰ میلی‌ثانیه است.

ایندکس‌های Interaction:

اول، (visit, occurred_at). این ایندکس، برای نمایش ترتیب تعاملات در یک سشن است. بدون آن، هر بازدیدکننده‌ی سشن با یک sort بزرگ مواجه می‌شود.

دوم، (event_type, occurred_at). برای تحلیل روند یک نوع خاص از تعامل. مثلاً «تعداد کلیک روی دکمه‌ی خرید در ۳۰ روز گذشته». این ایندکس معمولاً در گزارش‌های تجاری استفاده می‌شود.

سوم، (visitor, event_type, occurred_at). برای کوئری «همه‌ی کلیک‌های یک کاربر خاص در یک ماه گذشته». این ایندکس، در بازبینی رفتار کاربران VIP یا مشتریان خاص بسیار مفید است.

نکته‌ی هشدار: هر ایندکس اضافه، سرعت درج را پایین می‌آورد. در جدولی که روزانه صدها هزار ردیف می‌گیرد، هر ایندکس اضافه، بار قابل توجهی روی دیتابیس وارد می‌کند. توصیه می‌کنم با حداقل ایندکس‌ها شروع کنید و بر اساس slow query log، ایندکس‌های جدید اضافه کنید. اگر می‌خواهید رویکرد کامل بهینه‌سازی را ببینید، بهینه‌سازی جنگو برای ترافیک بالا راهنمای دقیقی است.

مسیر نوشتن؛ جایی که کارایی فدا می‌شود

در ترافیک بالا، نوشتن در این دو جدول می‌تواند به گلوگاه اصلی تبدیل شود. سه راهبرد عملی را در نظر بگیرید.

راهبرد اول، نوشتن غیرهمگام. به‌جای نوشتن مستقیم در دیتابیس، رویدادها را در یک صف (Redis یا RabbitMQ) بگذارید و یک worker مستقل آن‌ها را در دسته‌های بزرگ در دیتابیس وارد کند. این الگو، نوشتن را از درخواست کاربر جدا می‌کند و تأخیر پاسخ را کاهش می‌دهد.

# در api/track.py
import json
import redis
from django.conf import settings

redis_client = redis.Redis.from_url(settings.REDIS_URL)


@require_POST
def track(request):
    payload = json.loads(request.body or b"{}")
    payload["_session"] = request.session.session_key
    redis_client.lpush("analytics:events", json.dumps(payload))
    return JsonResponse({"ok": True})

و در یک worker:

# management/commands/process_events.py

import json
import time
import redis
from django.core.management.base import BaseCommand
from django.db import transaction
from analytics.models import Interaction, PageView
from analytics.services import build_interaction


class Command(BaseCommand):
    def handle(self, *args, **options):
        r = redis.Redis.from_url(settings.REDIS_URL)
        while True:
            batch = [r.rpop("analytics:events") for _ in range(500)]
            batch = [json.loads(b) for b in batch if b]
            if not batch:
                time.sleep(1)
                continue
            with transaction.atomic():
                for payload in batch:
                    build_interaction(payload)

راهبرد دوم، نوشتن دسته‌ای. وقتی کاربر در طول یک سشن چندین اسکرول می‌کند، به‌جای نوشتن هر اسکرول به‌عنوان یک ردیف، مقدار نهایی را در یک ردیف ثبت کنید. این تصمیم، تعداد ردیف‌های Interaction را می‌تواند چند برابر کاهش دهد.

راهبرد سوم، جداسازی دیتابیس. جداکردن جدول‌های آماری در یک دیتابیس اختصاصی، فشار نوشتن را از دیتابیس اصلی جدا می‌کند. این کار با database router در جنگو انجام می‌شود. اگر روی معماری آماری بزرگ کار می‌کنید، این جداسازی یک ضرورت است، نه یک انتخاب. برای آمار زنده هم بهتر است از یک API جدا استفاده کنید، مثل الگویی که در ساخت API JSON برای آمار زنده آمده است.

مسیر خواندن؛ الگوهای تحلیل رفتاری

سه کوئری تحلیلی بسیار پرتکرار در این سیستم وجود دارد که هر کدام به یک الگوی متفاوت نیاز دارند.

الگوی اول، قیف تبدیل. مثلاً: «چند درصد کاربران از دیدن صفحه‌ی محصول به کلیک خرید و از آن به تسویه رسیدند؟» این کوئری را با values و annotate می‌نویسید:

from django.db.models import Count, Q

funnel = (
    PageView.objects
    .filter(entered_at__gte=start, entered_at__lt=end)
    .aggregate(
        product_views=Count("id", filter=Q(path__startswith="/product/")),
        cart_views=Count("id", filter=Q(path="/cart/")),
        checkout_views=Count("id", filter=Q(path="/checkout/")),
    )
)

الگوی دوم، تحلیل engagement. مثلاً: «میانگین عمق اسکرول در صفحه‌ی مقاله چقدر است؟» برای این کوئری، از فیلد scroll_depth در PageView استفاده کنید:

from django.db.models import Avg, Max

engagement = (
    PageView.objects
    .filter(path__startswith="/blog/", entered_at__gte=start)
    .aggregate(
        avg_scroll=Avg("scroll_depth"),
        max_scroll=Max("scroll_depth"),
    )
)

الگوی سوم، تحلیل تعاملات خاص. مثلاً: «تعداد کلیک روی دکمه‌ی خرید در ماه گذشته چقدر بود؟» این کوئری روی Interaction اجرا می‌شود:

clicks = Interaction.objects.filter(
    event_type="click",
    selector__startswith="[data-action="add-to-cart"]",
    occurred_at__gte=start,
).count()

نکته‌ی مهم در این کوئری‌ها، استفاده از filter در aggregate و annotate است. این الگو، به شما اجازه می‌دهد در یک کوئری، چند متریک را حساب کنید. اگر با ساختار انتقال منطق از services.py به templatetags آشنا هستید، می‌دانید که این کوئری‌ها باید در یک لایه‌ی سرویس قرار بگیرند، نه مستقیم در قالب.

نگهداشت داده و پاک‌سازی دوره‌ای

جدول Interaction سریع‌ترین رشد را در کل سیستم دارد. اگر روزانه ۱۰۰ هزار بازدید صفحه داشته باشید، احتمالاً روزانه ۵۰۰ هزار تا ۱ میلیون Interaction تولید می‌شود. این یعنی در سال، حدود ۳۰۰ میلیون ردیف فقط در این جدول.

سه راهبرد نگهداشت را در نظر بگیرید.

راهبرد اول، نگهداشت لایه‌ای. داده‌های Interaction را برای مدت محدودی (مثلاً ۳۰ روز) نگه دارید. بعد از آن، فقط تجمیع‌های روزانه را حفظ کنید. مثلاً به‌جای نگه‌داشتن ۱۰ هزار کلیک در یک روز، فقط یک ردیف با شمارش کلیکی روز ذخیره کنید.

راهبرد دوم، آرشیو کردن. داده‌های قدیمی‌تر از ۹۰ روز را به یک فایل پارکت (Parquet) یا به S3 منتقل کنید. این کار، هم هزینه‌ی ذخیره‌سازی را پایین می‌آورد، هم کوئری‌های دیتابیس را سریع‌تر می‌کند.

راهبرد سوم، پاک‌سازی دوره‌ای. یک task روزانه که Interactionهای قدیمی‌تر از N روز را در بچ‌های کوچک حذف کند. الگوی دقیق این کار در حذف رکوردهای تکراری و یتیم با batch delete و کامند مدیریتی پاک‌سازی داده‌های قدیمی به‌طور کامل توضیح داده شده است.

نکته‌ی مهم: هرگز داده‌ها را بدون بکاپ حذف نکنید. قبل از حذف، به یک فایل یا storage خارجی منتقل کنید. تجربه‌ی من این است که در ۸۰٪ موارد، چند ماه بعد کسی از تیم، به یک کوئری تاریخی نیاز پیدا می‌کند.

فشرده‌سازی و آرشیو کردن تعامل‌ها

وقتی داده‌ها زیاد شدند، فشرده‌سازی نقش حیاتی پیدا می‌کند. سه رویکرد مؤثر را بشناسید.

رویکرد اول، فشرده‌سازی در سطح دیتابیس. PostgreSQL از TOAST (The Oversized-Attribute Storage Technique) برای فشرده‌سازی مقادیر بزرگ استفاده می‌کند. MySQL از InnoDB Compression که برای جدول‌های آماری بسیار مفید است. با فعال‌سازی این گزینه، حجم جدول تا ۵۰٪ کاهش پیدا می‌کند، به قیمت مصرف CPU بیشتر.

رویکرد دوم، تجمیع هوشمند. به‌جای نگه‌داشتن هر اسکرول به‌عنوان یک Interaction، فقط بیشترین عمق اسکرول در هر PageView را نگه دارید. این تصمیم، حجم Interaction را می‌تواند تا ۷۰٪ کاهش دهد.

رویکرد سوم، آرشیو ستونی. برای داده‌های تاریخی، از فرمت‌های ستونی مثل Parquet یا ORC استفاده کنید. این فرمت‌ها، حجم داده را چند برابر کوچک‌تر می‌کنند و کوئری‌های تحلیلی روی آن‌ها سریع‌تر است. ابزارهایی مثل DuckDB یا ClickHouse می‌توانند مستقیماً روی این فایل‌ها کوئری بزنند.

در پروژه‌های با ترافیک بالا، فشرده‌سازی و آرشیو، تفاوت بین یک دیتابیس مدیریت‌پذیر و یک دیتابیس در حال انفجار است.

حریم خصوصی در ذخیره‌ی تعامل‌ها

ذخیره‌ی تعامل‌های کاربر، حساسیت‌های خاص خودش را دارد. سه نکته را در نظر بگیرید.

نکته‌ی اول، عدم ذخیره‌ی محتوای حساس. هرگز محتوای فیلدهای فرم (مثل رمز عبور، شماره‌ی کارت بانکی، اطلاعات هویتی) را در metadata ذخیره نکنید. فقط رویداد را ثبت کنید، نه محتوا را. اگر کاربر در حال پر کردن فرم پرداخت است، شما باید بدانید که «فرم پر شد»، نه اینکه «چه چیزی وارد شد».

نکته‌ی دوم، ناشناس‌سازی داده‌های قدیمی. بعد از مدتی، می‌توانید IP و User-Agent را از Visitor حذف کنید و فقط fingerprint را نگه دارید. این کار، هم حریم خصوصی را حفظ می‌کند، هم امکان تحلیل تاریخی را از بین نمی‌برد.

نکته‌ی سوم، رعایت رضایت کاربر. در حوزه‌های قضایی که قانون GDPR اروپا اعمال می‌شود، ذخیره‌ی داده‌های رفتاری نیاز به رضایت صریح کاربر دارد. حتی اگر سایت شما ایرانی باشد، اگر کاربران اروپایی دارد، بهتر است cookie consent را جدی بگیرید. برای اطلاعات بیشتر درباره‌ی مفهوم Web Beacon و کاربردهای آن، می‌توانید به منابع معتبر مراجعه کنید.

اگر روی سیاست حریم خصوصی کار می‌کنید، بهتر است یک فایل جداگانه بنویسید که دقیقاً چه داده‌ای ذخیره می‌شود، برای چه مدت، و چگونه کاربر می‌تواند آن را حذف کند. شفافیت در این زمینه، اعتماد کاربر و اعتبار برند را می‌سازد.

anti-patternهای رایج در طراحی این دو مدل

در بازبینی پروژه‌های مختلف، این اشتباهات تکرارشونده را دیده‌ام:

۱. ذخیره‌ی Interaction بدون page_view. بعضی تیم‌ها فقط visit و visitor را ذخیره می‌کنند. بدون page_view، نمی‌توانید تحلیل کنید که این کلیک در کدام صفحه رخ داده. این خطا در گزارش‌های عمیق رفتاری فاجعه می‌سازد.

۲. نام‌گذاری event_typeهای بی‌قاعده. مثلاً click_button_red، click_buy_now_v2. این نام‌گذاری در طول شش ماه به یک آشفتگی غیرقابل مدیریت تبدیل می‌شود. نام‌گذاری باید بر اساس معنای کاربری باشد، نه بر اساس ظاهر صفحه.

۳. ذخیره‌ی محتوای کل صفحه در Interaction. بعضی تیم‌ها برای دیباگ، کل HTML صفحه را در metadata ذخیره می‌کنند. این کار، حجم دیتابیس را ۱۰۰ برابر می‌کند و ارزش تحلیلی پایینی دارد.

۴. عدم ایندکس روی occurred_at. این فیلد در اکثر کوئری‌های تحلیلی به‌عنوان فیلتر بازه‌ی زمانی استفاده می‌شود. بدون ایندکس، هر کوئری یک full scan می‌شود.

۵. عدم نگهداشت‌پذیری در JSON. با نبود validation، ساختار JSON در طول زمان متفاوت می‌شود و کوئری‌های قدیمی می‌شکنند. راه‌حل: یک validator بنویسید که قبل از ذخیره، ساختار را چک کند.

۶. محاسبه‌ی metrics در زمان خواندن. به‌جای ذخیره‌ی denormalized metrics در Visit، بعضی تیم‌ها آن‌ها را در هر کوئری محاسبه می‌کنند. این کار در حجم بالا به یک گلوگاه تبدیل می‌شود. الگوی درستش در طراحی مدل Visitor و Visit توضیح داده شده است.

۷. عدم تفکیک تاریخ و زمان. در بعضی پروژه‌ها، فیلد DateField برای occurred_at استفاده می‌شود. این خطا، تحلیل‌های درون‌روزی را غیرممکن می‌کند.

۸. نادیده‌گرفتن region و timezone. اگر سایت شما چند منطقه‌ی زمانی دارد، ذخیره‌ی زمان بدون timezone باعث آشفتگی در گزارش‌ها می‌شود.

۹. ذخیره‌ی هر حرکت موس. بعضی ابزارها هر حرکت موس را ذخیره می‌کنند. این کار حجم دیتابیس را چند برابر می‌کند و ارزش تحلیلی‌اش معمولاً پایین است. مگر اینکه روی UX تحقیق جدی داشته باشید.

۱۰. عدم هماهنگی با middleware. اگر Interactionها از middleware متفاوت با PageView ثبت شوند، ممکن است کلید خارجی نامعتبر بسازند. راه‌حل: تمام نوشتن‌ها از یک مسیر واحد. الگوی درستش در نوشتن Middleware سفارشی در جنگو آمده است.

پرسش‌های پرتکرار درباره‌ی PageView و Interaction

آیا باید هر اسکرول را به‌عنوان یک Interaction ثبت کنم؟ نه. ثبت هر اسکرول، حجم دیتابیس را چند برابر می‌کند. توصیه می‌کنم فقط بیشترین عمق اسکرول در هر PageView را در فیلد scroll_depth ذخیره کنید. اگر به تحلیل دقیق‌تری نیاز دارید، می‌توانید حداکثر ۵ نقطه از اسکرول (مثلاً ۲۰٪، ۴۰٪، ۶۰٪، ۸۰٪، ۱۰۰٪) را به‌عنوان Interaction جدا ثبت کنید.

آیا باید visitor را در Interaction ذخیره کنم؟ بله، اگر کوئری‌های مستقیم روی Visitor دارید. با این کار، می‌توانید بدون join از طریق visit و page_view، همه‌ی تعامل‌های یک کاربر را ببینید. بهای آن، حدود ۸ بایت اضافی در هر ردیف است که در حجم بالا چند گیگابایت می‌شود، ولی سرعت کوئری‌ها را چند برابر می‌کند.

چطور از Interactionهای آلوده پاک‌سازی کنم؟ از یک management command استفاده کنید که Interactionهای بات‌ها را حذف کند. برای تشخیص، از ترکیب visitor.device_type == "bot" و الگوهای User-Agent استفاده کنید. الگوی کاملش در تشخیص بات از کاربر انسانی آمده است.

آیا Interaction باید به Visitor وصل باشد یا به Visit؟ به هر دو. page_view + visit + visitor، سه کلید خارجی را نگه دارید. این denormalization، هزینه‌ی ذخیره‌سازی دارد ولی سرعت کوئری را چند برابر می‌کند.

چطور می‌توانم تحلیل کنم که کاربر روی چه چیزی کلیک کرده؟ با فیلد target برای توصیف انسانی و selector برای شناسه‌ی فنی. توصیه می‌کنم در کد فرانت‌اند، به هر عنصر قابل کلیک یک data-track مشخص بدهید و همان را در selector ذخیره کنید. این کار، تحلیل‌های آینده را بسیار ساده‌تر می‌کند.

آیا ذخیره‌ی load_time_ms ارزش دارد؟ بله، اگر Core Web Vitals برایتان مهم است. این فیلد، به شما اجازه می‌دهد تحلیل کنید که آیا کندی صفحه، روی تعامل کاربر تأثیر دارد یا نه. با این تحلیل، می‌توانید ROI بهینه‌سازی performance را محاسبه کنید.

چطور از دو بار شمارش جلوگیری کنم؟ از یک event_id یکتا استفاده کنید. اگر beacon دوبار ارسال شد (که در شبکه‌های موبایل کم‌کیفیت شایع است)، یک unique index روی (visit, event_id) بگذارید و از get_or_create استفاده کنید.

آیا باید Interactionها را در Redis کش کنم؟ برای نوشتن، بله. برای خواندن، نه. کوئری‌های تحلیلی روی Interaction، معمولاً کل بازه‌ی زمانی را حساب می‌کنند و کش کردن آن‌ها هزینه‌ی زیادی دارد. بهتر است به‌جای کش، از جدول‌های تجمیعی استفاده کنید.

چطور بین کلیک واقعی و کلیک تصادفی تفکیک کنم؟ با فیلد value می‌توانید مدت زمان hover قبل از کلیک را ذخیره کنید. اگر کاربر کمتر از ۲۰۰ میلی‌ثانیه روی دکمه بوده، احتمالاً کلیک تصادفی است. این نوع تحلیل، در بهینه‌سازی UX بسیار مفید است.

آیا باید برای Interaction رابط گرافیکی بسازم؟ برای پروژه‌های کوچک، پنل ادمین جنگو کافی است. برای پروژه‌های بزرگ، یک رابط گرافیکی سفارشی با فیلترهای پیچیده لازم است. اگر می‌خواهید الگویی برای این کار ببینید، ساخت پنل ادمین با کارت‌های quick view نقطه‌ی شروع خوبی است.

آیا می‌توانم Interactionها را روی یک دیتابیس جدا ذخیره کنم؟ بله، و در پروژه‌های بزرگ توصیه می‌شود. با database router در جنگو، می‌توانید Interactionها را به یک دیتابیس اختصاصی بفرستید. این جداسازی، فشار نوشتن را از دیتابیس اصلی جدا می‌کند.

چطور از Interaction برای A/B testing استفاده کنم؟ در فیلد metadata، یک کلید variant ذخیره کنید. سپس می‌توانید میانگین تعامل‌ها در گروه‌های مختلف را با هم مقایسه کنید. الگوهای کامل تحلیل A/B، خودش یک موضوع جداگانه است که ارزش یک مقاله‌ی اختصاصی دارد.

نگاهی از منظر مهندسی داده در مقیاس میلیونی

وقتی دیتای شما به مقیاس میلیون‌ها ردیف در روز می‌رسد، دو مفهوم بنیادین را باید بازتعریف کنید: مرز بین OLTP و OLAP، و مفهوم exactly-once write.

مرز OLTP و OLAP. دیتابیس اصلی سایت شما OLTP است (Online Transaction Processing) و برای تراکنش‌های کوچک بهینه شده. جدول Interaction ذاتاً OLAP است (Online Analytical Processing) و برای کوئری‌های تحلیلی بزرگ. اگر هر دو در یک دیتابیس باشند، به‌ناچار یکی فدای دیگری می‌شود. راه‌حل بلندمدت، استفاده از یک دیتابیس ستونی مثل ClickHouse یا Druid برای Interaction است، و نگه‌داشتن دیتابیس رابطه‌ای فقط برای داده‌های خلاصه.

exactly-once write. در شبکه‌های ناپایدار، امکان اینکه یک رویداد دوبار به سرور برسد وجود دارد. اگر از beacon استفاده می‌کنید، این احتمال بالاتر است. برای تضمین exactly-once، به یک شناسه‌ی یکتای رویداد و یک unique index نیاز دارید. این الگو در ساختارهایی که با sendBeacon کار می‌کنند، اجتناب‌ناپذیر است.

مهاجرت به معماری ستونی. اگر می‌خواهید به ClickHouse یا TimescaleDB مهاجرت کنید، سه مرحله را در نظر بگیرید. اول، یک لایه‌ی انتزاعی برای نوشتن بنویسید که هم به دیتابیس فعلی، هم به دیتابیس جدید داده بفرستد (dual write). دوم، کوئری‌های تحلیلی را تدریجاً به دیتابیس جدید منتقل کنید. سوم، بعد از اطمینان از صحت، نوشتن به دیتابیس قدیمی را قطع کنید. این مهاجرت، معمولاً چند ماه زمان می‌برد و نیاز به تست‌های دقیق دارد.

نکته‌ی آخر که در پروژه‌های بزرگ به آن رسیده‌ام: در مقیاس میلیونی، جدول Interaction به‌عنوان یک جدول تراکنشی، به یک جدول تحلیلی تبدیل می‌شود. این تغییر ماهیت، نیازمند تغییر ابزار است. تلاش برای نگه‌داشتن میلیون‌ها ردیف در MySQL یا PostgreSQL، در نهایت به شکست می‌انجامد، نه به‌خاطر ضعف این دیتابیس‌ها، بلکه به‌خاطر اینکه برای این نوع بار طراحی نشده‌اند.

پرسشی که قبل از شروع باید پاسخ بدهید

قبل از اینکه جدول Interaction را بسازید، از خودتان بپرسید: «آیا واقعاً به این سطح از جزئیات نیاز دارم؟» اگر هدف شما فقط فهمیدن رفتار کلی است، ممکن است یک جدول ساده‌تر کافی باشد. اگر هدف شما تحلیل عمیق UX و بهینه‌سازی قیف تبدیل است، این ساختار ارزش خود را دارد. اگر با ساختار ساخت اپ جنگو برای ردیابی بازدیدکننده شروع کرده باشید، این تصمیم را در مرحله‌ی طراحی مدل‌ها می‌گیرید، نه بعد از تولید داده.

در پایان، یک نکته‌ی عملی که در چند پروژه دیده‌ام: تیم‌هایی که داده‌های تعامل را بدون تحلیل ذخیره می‌کنند، در نهایت با یک دیتابیس بزرگ و بی‌استفاده روبرو می‌شوند. تیم‌هایی که با یک سؤال مشخص شروع می‌کنند (مثلاً «چرا کاربران در مرحله‌ی تسویه رها می‌کنند؟»)، سریع‌تر به بینش می‌رسند و معماری سبک‌تری دارند. اگر این ساختار را در پروژه‌ی خودتان پیاده کردید و به نکته‌ای رسیدید که ارزش تجربه‌کردن دارد، خوشحال می‌شوم تجربه‌تان را بخوانم. مخصوصاً اگر راه‌حل متفاوتی برای کاهش حجم Interaction پیدا کرده‌اید، چون همان راه‌حل‌ها می‌توانند به خواننده‌ی بعدی کمک کنند.