اگر امروز بدون هیچ محدودیتی هر اسکرول و هر کلیک کاربران را در دیتابیس ذخیره کنید، احتمالاً تا چند هفته دیگر جدول Interaction شما چند ده میلیون ردیف خواهد داشت و کوئری‌های داشبورد شما به‌جای میلی‌ثانیه، چند ثانیه طول می‌کشند؛ چون داده‌ی رفتاری ذاتاً منفجر می‌شود.

چرا ثبت اسکرول و کلیک، ارزش استراتژیک دارد؟

در یکی از پروژه‌های فروشگاهی که چند سال پیش روی آن کار می‌کردم، تیم محصول در تحلیل قیف تبدیل به یک سؤال بی‌پاسخ رسیده بود: «چرا کاربران صفحه‌ی محصول را می‌بینند، ولی روی دکمه‌ی خرید کلیک نمی‌کنند؟» با اضافه کردن ثبت اسکرول و کلیک، پاسخ در چند روز مشخص شد: حدود ۷۰٪ کاربران تا زیر دکمه‌ی خرید اسکرول نمی‌کردند، چون دکمه در یک موقعیت پایین‌تر از fold قرار داشت. تغییر مکان دکمه، نرخ کلیک را ۴۰٪ افزایش داد.

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

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

سطح دوم، تحلیل قیف تبدیل. با ثبت کلیک روی عناصر مهم، می‌توانید نقاط ریزش را دقیقاً شناسایی کنید.

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

ثبت رفتار کاربر، یک شمشیر دولبه است: اگر درست پیاده شود، به کشف‌های ارزشمند منجر می‌شود؛ اگر بی‌محابا باشد، زیر حجم داده غرق می‌شوید.

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

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

بیایید یک محاسبه‌ی ساده انجام دهیم. فرض کنید یک سایت با ۱۰۰ هزار بازدید روزانه داریم و هر بازدید شامل:

  • ۵ سیگنال اسکرول (۲۰٪، ۴۰٪، ۶۰٪، ۸۰٪، ۱۰۰٪)
  • ۱۵ کلیک (روی لینک‌ها، دکمه‌ها، تصاویر)

یعنی هر بازدید، حدود ۲۰ Interaction تولید می‌کند. با ۱۰۰ هزار بازدید روزانه، شما روزانه ۲ میلیون Interaction خواهید داشت. سالانه حدود ۷۳۰ میلیون ردیف. اگر هر ردیف حدود ۵۰۰ بایت باشد، شما با حجمی در حدود ۳۶۵ گیگابایت در سال مواجه هستید.

بازدید روزانهInteraction روزانهInteraction سالانهحجم تخمینی
۱٬۰۰۰۲۰٬۰۰۰۷.۳ میلیون۳.۶ گیگابایت
۱۰٬۰۰۰۲۰۰٬۰۰۰۷۳ میلیون۳۶ گیگابایت
۱۰۰٬۰۰۰۲ میلیون۷۳۰ میلیون۳۶۵ گیگابایت
۱٬۰۰۰٬۰۰۰۲۰ میلیون۷.۳ میلیارد۳.۶ ترابایت

در یکی از پروژه‌ها، بعد از ۶ ماه بدون محدودیت، جدول Interaction به ۲.۱ میلیارد ردیف رسید. یک کوئری ساده مثل SELECT COUNT(*) FROM analytics_interaction بیش از ۴۵ ثانیه طول می‌کشید. حتی با ایندکس، کوئری‌های تحلیلی به‌دلیل حجم، غیرقابل استفاده شده بودند.

این انفجار داده، نشان می‌دهد که باید از روز اول، استراتژی روشنی برای محدود کردن داده داشته باشید. اگر با الگوی تشخیص بات از کاربر انسانی کار کرده باشید، می‌دانید که این نوع محدودسازی، بخشی از انضباط مهندسی داده است.

throttle و debounce؛ دو تکنیک پایه

قبل از اینکه به پیاده‌سازی برویم، باید دو تکنیک پایه را بشناسید: throttle و debounce.

throttle. اجرای تابع در فواصل زمانی مشخص. مثلاً اجرای تابع حداکثر هر ۲۰۰ میلی‌ثانیه.

function throttle(fn, wait) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= wait) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

debounce. اجرای تابع بعد از یک دوره‌ی سکوت. مثلاً اجرای تابع فقط اگر ۳۰۰ میلی‌ثانیه از آخرین رویداد گذشته باشد.

function debounce(fn, wait) {
  let timeout = null;
  return function(...args) {
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => {
      fn.apply(this, args);
    }, wait);
  };
}

تفاوت این دو، در نوع استفاده است.

رویدادتکنیک مناسبدلیل
اسکرولthrottleمی‌خواهیم در طول اسکرول، سیگنال‌های پیوسته ثبت کنیم
resizedebounceمی‌خواهیم فقط بعد از پایان تغییر اندازه، یک‌بار ثبت کنیم
keyup در searchdebounceمی‌خواهیم فقط بعد از توقف تایپ، جستجو را اجرا کنیم
mousemovethrottleمی‌خواهیم مکان موس را در فواصل منظم ثبت کنیم

در ثبت اسکرول، از throttle استفاده می‌کنیم، چون می‌خواهیم تغییرات را در طول زمان ببینیم. در ثبت کلیک، نیازی به throttle نیست، چون کلیک یک رویداد گسسته است. اگر با الگوی ثبت اسکرول و کلیک کاربر کار کرده باشید، این تفکیک برایتان آشناست.

ثبت اسکرول؛ از هر پیکسل تا سیگنال معنا‌دار

ثبت هر پیکسل از اسکرول، بی‌معنا و پرهزینه است. باید از هر پیکسل، به سیگنال‌های معنادار برسیم.

پیشنهاد من: به‌جای ثبت هر پیکسل، فقط عبور از آستانه‌ها را ثبت کنید. مثلاً ۲۵٪، ۵۰٪، ۷۵٪، ۱۰۰٪.

// analytics/static/analytics/scroll-tracker.js

(function() {
  const THRESHOLDS = [25, 50, 75, 100];
  const reportedThresholds = new Set();
  let maxScroll = 0;

  function getScrollPercent() {
    const docHeight = Math.max(
      document.body.scrollHeight,
      document.documentElement.scrollHeight
    ) - window.innerHeight;
    if (docHeight <= 0) return 100;
    return Math.min(100, Math.round((window.scrollY / docHeight) * 100));
  }

  function checkThresholds() {
    const percent = getScrollPercent();
    if (percent > maxScroll) {
      maxScroll = percent;
    }

    THRESHOLDS.forEach(threshold => {
      if (percent >= threshold && !reportedThresholds.has(threshold)) {
        reportedThresholds.add(threshold);
        sendScrollEvent(threshold, percent);
      }
    });
  }

  function sendScrollEvent(threshold, currentPercent) {
    const payload = {
      event: "scroll",
      threshold: threshold,
      depth: currentPercent,
      url: location.href,
      timestamp: Date.now(),
    };

    queueBeacon(payload);
  }

  const throttledCheck = throttle(checkThresholds, 500);
  window.addEventListener("scroll", throttledCheck, { passive: true });
})();

این کد، چند نکته‌ی مهم را رعایت می‌کند.

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

نکته‌ی دوم، throttling با ۵۰۰ میلی‌ثانیه. حتی اگر کاربر سریع اسکرول کند، بیشتر از هر ۵۰۰ میلی‌ثانیه یک check انجام نمی‌شود.

نکته‌ی سوم، passive listener. گزینه‌ی passive: true به مرورگر می‌گوید که این listener قصد جلوگیری از رویداد را ندارد، پس مرورگر می‌تواند بهینه‌تر عمل کند.

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

نکته‌ی ظریف: اگر کاربر در جهت مخالف اسکرول کند (مثلاً از ۸۰٪ به ۵۰٪ برگردد)، آستانه دوباره ثبت نمی‌شود. این رفتار برای تحلیل عمق اسکرول مطلوب است. اگر می‌خواهید رفتار رفت و برگشت را ثبت کنید، می‌توانید آستانه‌ها را در هر جهت جداگانه ثبت کنید.

ثبت کلیک؛ از هر رویداد تا اطلاعات ارزشمند

ثبت هر کلیک، شامل حجم بالایی از داده‌ی بی‌ارزش است. برای استخراج اطلاعات ارزشمند، باید سه فیلتر را اعمال کنید.

فیلتر اول، فقط روی عناصر معنادار. کلیک روی هر متن یا تصویر معنادار نیست. باید فقط کلیک روی عناصر تعاملی (لینک، دکمه، input) و عناصر با data-track را ثبت کنید.

فیلتر دوم، اطلاعات کافی اما نه بیش‌ازحد. برای هر کلیک، به اطلاعات زیر نیاز دارید: نوع عنصر (tag)، انتخابگر (selector)، متن (label)، مسیر (href اگر لینک است). نباید محتوای کامل عنصر را ذخیره کنید.

فیلتر سوم، ترتیب منطقی. برای تحلیل قیف، باید بدانید کلیک در کدام مرحله‌ی سشن اتفاق افتاده. این کار با ذخیره‌ی page_view_id و position انجام می‌شود.

// analytics/static/analytics/click-tracker.js

(function() {
  function getElementSelector(el) {
    if (el.dataset.track) {
      return `[data-track="${el.dataset.track}"]`;
    }
    if (el.id) {
      return `#${el.id}`;
    }
    const classes = Array.from(el.classList || [])
      .slice(0, 2)
      .join(".");
    return `${el.tagName.toLowerCase()}${classes ? "." + classes : ""}`;
  }

  function getElementLabel(el) {
    return (el.innerText || el.value || el.getAttribute("aria-label") || "")
      .trim()
      .slice(0, 200);
  }

  function handleClick(event) {
    const el = event.target.closest("a, button, [data-track], input[type=submit]");
    if (!el) return;

    // فیلتر: نادیده گرفتن کلیک روی لینک‌های خارج از سایت اصلی
    if (el.tagName === "A" && el.target === "_blank") {
      // ثبت کلیک‌های خارجی با نوع خاص
    }

    const payload = {
      event: "click",
      tag: el.tagName.toLowerCase(),
      selector: getElementSelector(el),
      label: getElementLabel(el),
      href: el.href ? el.href.slice(0, 500) : "",
      url: location.href,
      timestamp: Date.now(),
    };

    queueBeacon(payload);
  }

  document.addEventListener("click", handleClick, true);
})();

این کد، چند نکته‌ی حرفه‌ای را رعایت می‌کند.

نکته‌ی اول، استفاده از closest. با این متد، به‌جای ثبت کلیک روی هر عنصر داخلی، کلیک روی نزدیک‌ترین عنصر معنادار ثبت می‌شود.

نکته‌ی دوم، انتخابگر کوتاه. به‌جای ذخیره‌ی کل DOM، یک انتخابگر کوتاه و منحصر به فرد ذخیره می‌شود.

نکته‌ی سوم، محدود کردن متن. متن عنصر به ۲۰۰ کاراکتر محدود می‌شود تا دیتابیس از ورودی‌های بزرگ محافظت شود.

نکته‌ی چهارم، استفاده از capture. با true به‌عنوان پارامتر سوم، listener در فاز capture اجرا می‌شود، که اجازه می‌دهد حتی اگر رویداد در ادامه متوقف شود، کلیک ثبت شود.

اگر با الگوی استفاده از sendBeacon برای رویداد خروج کار کرده باشید، می‌دانید که ثبت کلیک‌های خارجی (با target="_blank") باید در beacon خروج ترکیب شود.

event delegation؛ راهی برای سبک‌تر کردن کد

در سایت‌های با DOM پیچیده، اضافه کردن listener به هر عنصر به‌طور جداگانه، هزینه‌ی حافظه‌ی بالایی دارد. راه بهتر، event delegation است: یک listener روی document که همه‌ی کلیک‌ها را مدیریت می‌کند.

این الگو، سه مزیت دارد.

مزیت اول، حافظه‌ی کمتر. یک listener به‌جای صدها listener.

مزیت دوم، پشتیبانی از عناصر پویا. اگر عناصر جدیدی به صفحه اضافه شوند (مثلاً در یک SPA (Single Page Application))، listener فعلی همچنان کار می‌کند.

مزیت سوم، سادگی. کد کوتاه‌تر و قابل نگهداری‌تر است.

ولی event delegation یک نکته‌ی ظریف هم دارد: باید مطمئن شوید که رویداد از کدام عنصر آمده. این کار با event.target و closest انجام می‌شود.

event delegation، انتخاب پیش‌فرض در سایت‌های مدرن است. فقط در موارد خاص که نیاز به کنترل دقیق دارید، از listener‌های مستقیم استفاده کنید.

ارسال دسته‌ای beacon؛ جایی که صرفه‌جویی اتفاق می‌افتد

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

// analytics/static/analytics/beacon-queue.js

const beaconQueue = (function() {
  const queue = [];
  const MAX_BATCH_SIZE = 20;
  const MAX_BATCH_AGE = 5000; // 5 ثانیه
  let flushTimer = null;

  function enqueue(payload) {
    queue.push(payload);

    if (queue.length >= MAX_BATCH_SIZE) {
      flush();
    } else if (!flushTimer) {
      flushTimer = setTimeout(flush, MAX_BATCH_AGE);
    }
  }

  function flush() {
    if (queue.length === 0) return;

    const batch = queue.splice(0, queue.length);
    if (flushTimer) {
      clearTimeout(flushTimer);
      flushTimer = null;
    }

    const body = JSON.stringify({ events: batch });
    const blob = new Blob([body], { type: "application/json" });

    if (navigator.sendBeacon) {
      navigator.sendBeacon("/panel/stat/api/track-batch/", blob);
    } else {
      fetch("/panel/stat/api/track-batch/", {
        method: "POST",
        body: body,
        headers: { "Content-Type": "application/json" },
        keepalive: true,
      }).catch(() => {});
    }
  }

  // اطمینان از ارسال در لحظه‌ی خروج
  window.addEventListener("pagehide", flush);
  window.addEventListener("beforeunload", flush);

  return { enqueue };
})();

این کد، چند نکته‌ی کلیدی را رعایت می‌کند.

نکته‌ی اول، حداکثر اندازه‌ی بچ. اگر صف به ۲۰ آیتم رسید، بلافاصله ارسال می‌شود. این کار، از بزرگ شدن بی‌رویه‌ی payload جلوگیری می‌کند.

نکته‌ی دوم، حداکثر زمان انتظار. اگر رویدادها کم بیایند، حداکثر بعد از ۵ ثانیه ارسال می‌شوند.

نکته‌ی سوم، ارسال در لحظه‌ی خروج. با listener روی pagehide و beforeunload، تضمین می‌شود که صف خالی می‌شود.

این رویکرد، تعداد درخواست‌ها را ۱۰ تا ۲۰ برابر کاهش می‌دهد. اگر با الگوی ساخت API Endpoint برای دریافت Beacon کار کرده باشید، می‌دانید که این کاهش، مستقیماً روی هزینه‌ی سرور اثر می‌گذارد.

نمونه‌برداری هوشمند؛ فدا کردن کمی دقت برای بقا

در سایت‌های بسیار پربازدید، حتی با throttle و batching، حجم داده می‌تواند غیرقابل مدیریت شود. راه‌حل، نمونه‌برداری هوشمند است.

سه سطح نمونه‌برداری را در نظر بگیرید.

سطح اول، نمونه‌برداری کاربر. فقط برای درصدی از کاربران (مثلاً ۱۰٪)، داده‌ی کامل ثبت کنید. این رویکرد، حجم داده را خطی کاهش می‌دهد.

سطح دوم، نمونه‌برداری رویداد. برای هر کاربر، فقط درصدی از رویدادها را ثبت کنید. مثلاً ۵۰٪ کلیک‌ها. این رویکرد، حجم داده را کاهش می‌دهد، ولی تحلیل قیف را مختل می‌کند.

سطح سوم، نمونه‌برداری زمانی. فقط در ساعات خاصی از روز، داده‌ی کامل ثبت کنید. این رویکرد، برای تحلیل‌های خاص مفید است، ولی برای تحلیل روندها مناسب نیست.

function shouldSample(userSeed, sampleRate) {
  // userSeed یک عدد یکتا برای هر کاربر
  return (userSeed % 100) < (sampleRate * 100);
}

const SAMPLE_RATE = 0.1; // 10٪
const userSeed = getOrCreateUserSeed();

function queueBeacon(payload) {
  if (!shouldSample(userSeed, SAMPLE_RATE)) {
    return;
  }
  beaconQueue.enqueue(payload);
}

نکته‌ی مهم: نمونه‌برداری باید به‌صورت پایدار برای هر کاربر باشد، نه برای هر رویداد. یعنی اگر یک کاربر انتخاب شد، تمام رویدادهای او ثبت شوند. این رویکرد، تحلیل قیف را حفظ می‌کند. اگر با الگوی بهینه‌سازی جنگو برای ترافیک بالا کار کرده باشید، می‌دانید که این نوع تصمیم‌های trade-off، بخشی از انضباط مهندسی است.

سمت سرور؛ پردازش سریع beacon در جنگو

حالا بیایید ببینیم که چگونه beacon‌های دسته‌ای را در سمت سرور پردازش کنیم.

# analytics/api.py

import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.db import transaction

from analytics.services.interaction import save_interactions_batch


@csrf_exempt
@require_POST
def track_batch(request):
    try:
        payload = json.loads(request.body or b"{}")
    except Exception:
        return JsonResponse({"ok": False, "error": "invalid_json"})

    events = payload.get("events", [])
    if not isinstance(events, list) or not events:
        return JsonResponse({"ok": False, "error": "no_events"})

    visit_id = request.session.get("_analytics_visit_id")
    page_view_id = request.session.get("_analytics_page_view_id")

    if not visit_id:
        return JsonResponse({"ok": False, "error": "no_visit"})

    try:
        count = save_interactions_batch(
            visit_id=visit_id,
            page_view_id=page_view_id,
            events=events,
        )
    except Exception as exc:
        return JsonResponse({"ok": False, "error": str(exc)})

    return JsonResponse({"ok": True, "count": count})

و در سرویس:

# analytics/services/interaction.py

from django.utils import timezone
from django.db import transaction

from analytics.models import Interaction, Visit


ALLOWED_EVENTS = {
    "click", "scroll", "form_submit", "form_abandon",
    "download", "outbound", "video_play", "video_complete",
    "custom",
}


@transaction.atomic
def save_interactions_batch(visit_id, page_view_id, events):
    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit:
        return 0

    now = timezone.now()
    to_create = []

    for event in events[:100]:  # محدودیت اندازه
        event_type = (event.get("event") or "").lower()
        if event_type not in ALLOWED_EVENTS:
            continue

        to_create.append(Interaction(
            visit=visit,
            page_view_id=page_view_id,
            visitor=visit.visitor,
            event_type=event_type,
            target=(event.get("selector") or "")[:500],
            metadata=_extract_metadata(event),
            occurred_at=now,
        ))

    if to_create:
        Interaction.objects.bulk_create(to_create, batch_size=500)

    return len(to_create)


def _extract_metadata(event: dict) -> dict:
    allowed_keys = {
        "label", "href", "tag", "threshold", "depth",
    }
    return {
        k: v for k, v in event.items()
        if k in allowed_keys
    }

این endpoint، چند نکته‌ی کلیدی را رعایت می‌کند.

نکته‌ی اول، محدودیت اندازه. حداکثر ۱۰۰ رویداد در هر بچ پردازش می‌شود. این کار، از حملات DoS (Denial of Service) جلوگیری می‌کند.

نکته‌ی دوم، whitelist رویدادها. فقط رویدادهای مجاز پردازش می‌شوند. این کار، از دیتابیس در برابر داده‌های آلوده محافظت می‌کند.

نکته‌ی سوم، bulk_create. به‌جای درج تکی، از bulk_create استفاده می‌شود. این کار، سرعت درج را چند برابر می‌کند.

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

نکته‌ی مهم: در beacon دسته‌ای، ممکن است session در دسترس نباشد. باید یک fallback داشته باشید، مثلاً یک شناسه‌ی Visit در payload بفرستید.

معماری نهایی؛ سرویس، صف و ذخیره‌سازی

در پروژه‌های بزرگ، حتی با batching، نوشتن مستقیم در دیتابیس می‌تواند گلوگاه شود. راه‌حل، استفاده از یک صف است.

# analytics/api.py

import json
import redis
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


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


@csrf_exempt
@require_POST
def track_batch(request):
    try:
        payload = json.loads(request.body or b"{}")
    except Exception:
        return JsonResponse({"ok": False})

    events = payload.get("events", [])
    if not events:
        return JsonResponse({"ok": False})

    visit_id = request.session.get("_analytics_visit_id")
    page_view_id = request.session.get("_analytics_page_view_id")

    if not visit_id:
        return JsonResponse({"ok": False})

    batch = {
        "visit_id": visit_id,
        "page_view_id": page_view_id,
        "events": events,
        "received_at": timezone.now().isoformat(),
    }

    redis_client.lpush(
        "analytics:interactions",
        json.dumps(batch, ensure_ascii=False),
    )

    return JsonResponse({"ok": True})

و در یک management command یا Celery task:

# analytics/management/commands/process_interactions.py

import json
import time
import redis
from django.conf import settings
from django.core.management.base import BaseCommand

from analytics.services.interaction import save_interactions_batch


class Command(BaseCommand):
    help = "پردازش صف Interactionها"

    def handle(self, *args, **options):
        r = redis.Redis.from_url(settings.REDIS_URL)

        while True:
            batch = []
            for _ in range(100):
                item = r.rpop("analytics:interactions")
                if item is None:
                    break
                batch.append(json.loads(item))

            if not batch:
                time.sleep(1)
                continue

            for item in batch:
                try:
                    save_interactions_batch(
                        visit_id=item["visit_id"],
                        page_view_id=item["page_view_id"],
                        events=item["events"],
                    )
                except Exception as exc:
                    self.stderr.write(f"Error: {exc}")

این معماری، سه مزیت کلیدی دارد.

مزیت اول، پاسخ فوری. endpoint بلافاصله پاسخ می‌دهد، بدون انتظار برای دیتابیس. این کار، تأخیر پاسخ را از چند صد میلی‌ثانیه به زیر ۵ میلی‌ثانیه کاهش می‌دهد.

مزیت دوم، مقاومت در برابر اسپایک. اگر ترافیک ناگهان چند برابر شود، صف Redis می‌تواند حجم بالایی را تحمل کند، در حالی که دیتابیس نمی‌تواند.

مزیت سوم، انعطاف‌پذیری. اگر منطق پردازش تغییر کند، فقط worker را تغییر می‌دهیم، نه endpoint را.

اگر با الگوی ساخت API JSON برای آمار زنده کار کرده باشید، می‌دانید که این معماری، برای داده‌های نوشتنی پرفرکانس، استاندارد است.

طراحی schema که در برابر انفجار داده مقاوم است

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

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

نکته‌ی دوم، استفاده از کدهای کوتاه. برای event_type، از کدهای کوتاه استفاده کنید (مثلاً c برای click، s برای scroll) یا از یک جدول lookup با ForeignKey.

نکته‌ی سوم، محدود کردن طول فیلدها. فیلدهایی مثل target و metadata را با محدودیت‌های سخت محدود کنید.

نکته‌ی چهارم، ایندکس‌های هدفمند. فقط روی فیلدهایی که در WHERE استفاده می‌شوند، ایندکس بگذارید. هر ایندکس اضافه، سرعت درج را پایین می‌آورد.

class Interaction(models.Model):
    visit = models.ForeignKey(
        "analytics.Visit", on_delete=models.CASCADE,
        related_name="interactions",
    )
    page_view = models.ForeignKey(
        "analytics.PageView", on_delete=models.CASCADE,
        related_name="interactions", null=True, blank=True,
    )
    visitor = models.ForeignKey(
        "analytics.Visitor", on_delete=models.CASCADE,
        related_name="interactions",
    )

    event_type = models.CharField(max_length=15, db_index=True)
    target = models.CharField(max_length=200, blank=True)
    metadata = models.JSONField(default=dict, blank=True)
    occurred_at = models.DateTimeField(db_index=True)

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

توجه کنید که طول فیلد target به ۲۰۰ کاراکتر محدود شده، event_type به ۱۵ کاراکتر، و ایندکس‌ها فقط روی دو ترکیب ضروری هستند.

حریم خصوصی و اخلاق در ثبت رفتار کاربر

ثبت رفتار کاربر، مرز باریکی با نقض حریم خصوصی دارد. سه اصل را رعایت کنید.

اصل اول، عدم ثبت محتوای حساس. هرگز محتوای فیلدهای فرم (مثل رمز عبور، شماره کارت، اطلاعات هویتی) را ثبت نکنید. فقط رویداد را ثبت کنید، نه محتوا را.

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

اصل سوم، opt-out. کاربر باید بتواند از ثبت رفتار خود جلوگیری کند. این کار با یک کوکی یا تنظیم در پروفایل کاربر انجام می‌شود.

ثبت رفتار کاربر، ابزاری برای بهبود تجربه است، نه ابزاری برای جاسوسی. اگر این مرز را گم کنید، اعتماد کاربر را از دست می‌دهید.

از داده خام تا بینش کسب‌وکار

داده‌ی خام Interaction، به‌تنهایی ارزش ندارد. برای تبدیل آن به بینش، سه نوع تحلیل انجام دهید.

تحلیل اول، heatmap اسکرول. درصد کاربرانی که تا هر نقطه از صفحه اسکرول کرده‌اند.

from django.db.models import Count
from analytics.models import Interaction


scroll_depths = (
    Interaction.objects
    .filter(event_type="scroll")
    .values("metadata__threshold")
    .annotate(count=Count("id"))
    .order_by("metadata__threshold")
)

تحلیل دوم، تحلیل قیف تبدیل. کاربران از کدام مرحله به مرحله‌ی بعد می‌روند.

from analytics.models import Interaction


funnel_events = ["view_product", "add_to_cart", "checkout_start", "purchase"]

funnel_counts = {}
for event_name in funnel_events:
    funnel_counts[event_name] = Interaction.objects.filter(
        event_type="custom",
        target=event_name,
    ).count()

تحلیل سوم، کشف نقاط ریزش. کدام صفحات، بالاترین نرخ ریزش را دارند.

نکته‌ی مهم: این تحلیل‌ها باید روی داده‌ی تجمیع‌شده اجرا شوند، نه روی داده‌ی خام. یک task شبانه بنویسید که داده‌ی خام را به خلاصه‌های روزانه تبدیل کند. اگر با الگوی کامند مدیریتی پاک‌سازی داده‌های قدیمی کار کرده باشید، می‌دانید که این نوع task‌های شبانه، بخشی از معماری استاندارد است.

تست‌نویسی رفتارهای کاربر

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

سطح اول، تست JS. با ابزارهایی مثل Jest یا Playwright، می‌توانید رفتار client-side را تست کنید.

// tests/scroll-tracker.test.js

describe("Scroll Tracker", () => {
  beforeEach(() => {
    document.body.innerHTML = '
'; window.scrollTo(0, 0); }); test("sends beacon at 50% scroll", async () => { const spy = jest.spyOn(navigator, "sendBeacon").mockImplementation(() => true); window.scrollTo(0, 2500); window.dispatchEvent(new Event("scroll")); await new Promise(r => setTimeout(r, 600)); expect(spy).toHaveBeenCalled(); }); });

سطح دوم، تست API. با django.test.Client، درخواست batch را تست کنید.

@pytest.mark.django_db
def test_track_batch_api(client):
    from analytics.models import Interaction, Visit, Visitor

    visitor = Visitor.objects.create(fingerprint="test", ip_address="1.2.3.4")
    visit = Visit.objects.create(
        visitor=visitor,
        entry_time=timezone.now(),
        entry_url="https://example.com/",
        entry_path="/",
        is_active=True,
    )

    session = client.session
    session["_analytics_visit_id"] = visit.pk
    session.save()

    response = client.post(
        "/panel/stat/api/track-batch/",
        data=json.dumps({
            "events": [
                {"event": "scroll", "threshold": 50, "depth": 50},
                {"event": "click", "selector": "#buy", "label": "خرید"},
            ]
        }),
        content_type="application/json",
    )

    assert response.json()["ok"] is True
    assert Interaction.objects.filter(visit=visit).count() == 2

سطح سوم، تست کارایی. با CaptureQueriesContext، تعداد کوئری‌های هر batch را اندازه بگیرید.

from django.test.utils import CaptureQueriesContext
from django.db import connection


@pytest.mark.django_db
def test_track_batch_query_count(client):
    # setup...

    with CaptureQueriesContext(connection) as ctx:
        client.post(
            "/panel/stat/api/track-batch/",
            data=json.dumps({"events": [...]}),
            content_type="application/json",
        )

    # بیش از ۱۰ کوئری نباید بزند
    assert len(ctx) <= 10

این سه سطح تست، به شما اجازه می‌دهند که در طول زمان، تغییرات را با اطمینان اعمال کنید.

anti-patternهای رایج در ثبت اسکرول و کلیک

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

۱. ثبت هر پیکسل اسکرول. این کار، دیتابیس را به‌سرعت پر می‌کند. راه‌حل: آستانه‌های مشخص.

۲. ثبت هر کلیک بدون فیلتر. کلیک روی هر متن یا تصویر معنادار نیست. راه‌حل: فقط عناصر تعاملی.

۳. عدم throttle در scroll listener. اگر throttle نکنید، در هر فریم یک رویداد ثبت می‌شود. راه‌حل: throttle با ۵۰۰ میلی‌ثانیه.

۴. ارسال هر رویداد به‌صورت جداگانه. این کار، تعداد درخواست‌های HTTP را بالا می‌برد. راه‌حل: batching.

۵. عدم محدودیت اندازه‌ی payload. اگر payload بزرگ باشد، مرورگر آن را رد می‌کند. راه‌حل: محدودیت ۶۴ کیلوبایت.

۶. عدم مدیریت session در beacon. در beacon دسته‌ای، ممکن است session از دست برود. راه‌حل: fallback با visit_id در payload.

۷. ذخیره‌ی محتوای کامل DOM. این کار حجم دیتابیس را ۱۰۰ برابر می‌کند. راه‌حل: فقط selector و label.

۸. عدم نمونه‌برداری در سایت‌های پربازدید. بدون نمونه‌برداری، حجم داده غیرقابل مدیریت می‌شود. راه‌حل: نمونه‌برداری ۱۰٪.

۹. نوشتن مستقیم در دیتابیس. در ترافیک بالا، این کار به گلوگاه تبدیل می‌شود. راه‌حل: صف Redis.

۱۰. عدم rate limiting. endpoint beacon در معرض سوءاستفاده است. راه‌حل: rate limit بر اساس IP.

۱۱. عدم تست کارایی. بدون تست کارایی، رگرسیون‌های پنهان در طول توسعه، در production مشکل‌ساز می‌شوند. راه‌حل: تست با CaptureQueriesContext.

۱۲. عدم پاک‌سازی دوره‌ای. بدون پاک‌سازی، دیتابیس به‌طور نامحدود رشد می‌کند. راه‌حل: کامند پاک‌سازی هفتگی. اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که این کار باید با batch انجام شود.

پرسش‌های پرتکرار درباره‌ی ثبت اسکرول و کلیک

آیا باید هر کلیک را ثبت کنم؟ نه، فقط کلیک روی عناصر تعاملی و عناصر با data-track. کلیک روی متن ساده معمولاً بی‌ارزش است.

آیا باید هر اسکرول را ثبت کنم؟ نه، فقط عبور از آستانه‌های مشخص (۲۵٪، ۵۰٪، ۷۵٪، ۱۰۰٪).

چطور با کاربران موبایل که سریع اسکرول می‌کنند، مقابله کنم؟ با throttle. حتی اگر کاربر سریع اسکرول کند، در فواصل منظم ثبت می‌شود.

آیا باید beacon را در هر رویداد بفرستم؟ نه، با batching هر ۵ ثانیه یا هر ۲۰ رویداد.

چطور مطمئن شوم که رویدادها در لحظه‌ی خروج گم نمی‌شوند؟ با flush در رویداد pagehide یا beforeunload.

آیا باید نمونه‌برداری کنم؟ در سایت‌های با بیش از ۱۰۰ هزار بازدید روزانه، بله. در سایت‌های کوچک‌تر، احتمالاً نه.

چطور نمونه‌برداری را پایدار نگه دارم؟ با یک seed یکتا برای هر کاربر. اگر یک کاربر انتخاب شد، تمام رویدادهای او ثبت شوند.

آیا باید داده‌ی خام را برای همیشه نگه دارم؟ نه، داده‌ی خام را می‌توانید بعد از ۳۰ روز پاک کنید و فقط خلاصه‌های روزانه را نگه دارید.

چطور با رفتارهای اسپم مقابله کنم؟ با rate limiting و تشخیص بات. اگر با الگوی تشخیص بات از کاربر انسانی کار کرده باشید، می‌دانید که این لایه‌ی محافظتی ضروری است.

آیا می‌توانم از WebSocket برای ثبت رویدادها استفاده کنم؟ بله، ولی پیچیدگی عملیاتی زیادی دارد. برای اکثر پروژه‌ها، batching با beacon کافی است.

چطور با GDPR مقابله کنم؟ با شفافیت در سیاست حریم خصوصی، امکان opt-out و عدم ثبت محتوای حساس.

آیا باید metadata را در JSON ذخیره کنم؟ برای داده‌های انعطاف‌پذیر، بله. برای داده‌های پرکوئری، نه. باید فیلدهای پرکوئری را در ستون‌های جداگانه ذخیره کنید.

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

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

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

آیا باید در قالب‌های ادمین، Interaction را نمایش دهم؟ بله، ولی با فیلترهای دقیق. بدون فیلتر، لیست Interaction غیرقابل استفاده است.

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

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

مفهوم اول، جداسازی OLTP و OLAP. دیتابیس اصلی سایت شما OLTP است و برای تراکنش‌های کوچک بهینه شده. جدول Interaction ذاتاً OLAP است و برای کوئری‌های تحلیلی بزرگ. اگر هر دو در یک دیتابیس باشند، به‌ناچار یکی فدای دیگری می‌شود. راه‌حل بلندمدت، استفاده از یک دیتابیس ستونی مثل ClickHouse برای Interaction است.

مفهوم دوم، jIT aggregation. به‌جای ذخیره‌ی تمام Interactionها برای همیشه، می‌توانید آن‌ها را به‌صورت لحظه‌ای (jIT) تجمیع کنید. یک stream processor (مثل Kafka Streams، Flink، یا حتی یک worker ساده) می‌تواند رویدادها را به خلاصه‌های دقیقه‌ای یا ساعتی تبدیل کند. این خلاصه‌ها، حجم بسیار کمتری دارند و برای تحلیل‌های سریع کافی هستند.

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

نکته‌ی آخر: در مقیاس بالا، هیچ استراتژی واحدی جواب نمی‌دهد. باید ترکیبی از نمونه‌برداری، batching، تجمیع و پاک‌سازی را به‌کار بگیرید. بهترین کاری که می‌توانید بکنید این است که این ترکیب را به‌طور مداوم اندازه بگیرید و تنظیم کنید. اگر با الگوی طبقه‌بندی Referrer و تشخیص ورود از گوگل کار کرده باشید، می‌دانید که این نوع اندازه‌گیری مداوم، بخشی از انضباط مهندسی داده است.

پرسشی که در پایان باید پاسخ دهید

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

ثبت رفتار کاربر، در نهایت یک تصمیم مهندسی است که به کیفیت داده‌ی کسب‌وکار شما گره خورده است. اگر این تجربه را در پروژه‌ی خودتان داشته‌اید — مثلاً جایی که حجم داده شما را غافلگیر کرده یا جایی که نمونه‌برداری مشکل‌ساز شده — برایم جالب است بدانید. مخصوصاً اگر راه‌حل خاصی برای یک سناریوی خاص پیدا کرده‌اید، چون همان راه‌حل‌ها می‌توانند به خواننده‌ی بعدی کمک کنند. تجربه‌ی خودتان را در دیدگاه‌ها بنویسید.