اگر تا به امروز برای ثبت نقطه‌ی خروج کاربر از window.onbeforeunload و یک درخواست AJAX ساده استفاده کرده‌اید، احتمالاً بخش قابل توجهی از خروج‌های کاربران موبایل را از دست داده‌اید؛ چون مرورگرهای موبایل به‌طور سیستماتیک این نوع درخواست‌ها را در لحظه‌ی بستن صفحه لغو می‌کنند.

چرا ثبت نقطه‌ی خروج کاربر اهمیت استراتژیک دارد؟

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

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

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

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

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

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

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

چرا روش‌های سنتی در موبایل شکست می‌خورند؟

روش سنتی ثبت خروج، در سال‌های اول وب شکل گرفت و بر اساس رویداد window.onbeforeunload و یک درخواست AJAX (Asynchronous JavaScript and XML) ساده بود.

window.onbeforeunload = function() {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/api/track-exit/", false);  // synchronous!
  xhr.setRequestHeader("Content-Type", "application/json");
  xhr.send(JSON.stringify({ event: "exit", url: location.href }));
};

این کد، در ظاهر کار می‌کند، ولی در عمل سه مشکل جدی دارد.

مشکل اول، درخواست synchronous. تنها راه تضمین ارسال درخواست در beforeunload، استفاده از synchronous XHR است. ولی این نوع درخواست، از سال ۲۰۱۵ به‌عنوان anti-pattern شناخته شده و در مرورگرهای مدرن، روی thread اصلی اجرا می‌شود و می‌تواند تجربه‌ی کاربر را به‌طور جدی کند کند.

مشکل دوم، لغو شدن درخواست‌های async. اگر از درخواست async استفاده کنید، مرورگر ممکن است آن را قبل از رسیدن به سرور لغو کند. این اتفاق در مرورگرهای موبایل بیشتر رخ می‌دهد، چون مدیریت منابع سخت‌گیرانه‌تر است. مفهومی به نام XMLHttpRequest در ویکی‌پدیا توضیح داده شده است، ولی رفتار واقعی آن در موبایل، بسیار پیچیده‌تر از مستندات است.

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

در یکی از پروژه‌ها، بعد از بررسی دقیق، متوجه شدیم که حدود ۶۰٪ خروج‌های کاربران موبایل اصلاً ثبت نمی‌شد. یعنی مدت حضور این کاربران، همیشه کمتر از واقعیت محاسبه می‌شد.

این محدودیت‌ها، در نهایت به معرفی یک API جدید انجامید: sendBeacon. اگر با الگوی تشخیص دستگاه کاربر در جنگو کار کرده باشید، می‌دانید که این نوع تفکیک رفتار موبایل و دسکتاپ، در تمام لایه‌های پروژه اهمیت دارد.

sendBeacon دقیقاً چیست؟

sendBeacon یک API جدید در مرورگرهای مدرن است که برای ارسال داده‌های کوچک به سرور، در لحظه‌ی خروج کاربر یا بستن صفحه طراحی شده است. این API، در سال ۲۰۱۷ در Chrome معرفی شد و امروز در تمام مرورگرهای مدرن پشتیبانی می‌شود.

مشخصه‌ی کلیدی sendBeacon، غیرمسدودکننده بودن آن است. یعنی وقتی شما یک درخواست beacon می‌فرستید، مرورگر آن را در صف قرار می‌دهد و به‌صورت مستقل از thread اصلی JavaScript، آن را به سرور می‌فرستد. این ویژگی، آن را برای ثبت خروج کاربر ایده‌آل می‌کند.

window.addEventListener("beforeunload", function() {
  var payload = {
    event: "exit",
    url: location.href,
    timestamp: Date.now(),
  };

  var blob = new Blob(
    [JSON.stringify(payload)],
    { type: "application/json" }
  );

  navigator.sendBeacon("/api/track-exit/", blob);
});

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

نکته‌ی اول، استفاده از Blob. sendBeacon هم Blob و هم رشته‌ی ساده و هم FormData را قبول می‌کند. ولی استفاده از Blob، اجازه می‌دهد نوع محتوا (Content-Type) را دقیقاً مشخص کنید.

نکته‌ی دوم، non-blocking. برخلاف XHR synchronous، این کد اصلاً thread اصلی را مسدود نمی‌کند. یعنی مرورگر می‌تواند در همان لحظه، صفحه را ببندد.

نکته‌ی سوم، تضمین ارسال. مرورگر تضمین می‌کند که داده‌ی beacon، حتی اگر صفحه بسته شود، ارسال می‌شود. این تضمین، در سطح مرورگر است، نه در سطح JavaScript.

تفاوت sendBeacon با fetch و XMLHttpRequest

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

ویژگیsendBeaconfetchXMLHttpRequest
غیرمسدودکنندهبلهبلهبسته به تنظیمات
تضمین ارسال در خروجبلهخیرفقط در حالت sync
پشتیبانی از متدهای غیر POSTخیربلهبله
دسترسی به responseخیربلهبله
تنظیم هدر سفارشیخیربلهبله
مناسب برای خروجبلهخیرخیر
مناسب برای API عادیخیربلهبله

این جدول، سه نکته‌ی کلیدی را روشن می‌کند.

نکته‌ی اول، sendBeacon فقط برای POST است. این محدودیت، به‌عمدی است، چون sendBeacon برای ارسال داده به سرور طراحی شده، نه برای دریافت داده از سرور.

نکته‌ی دوم، sendBeacon پاسخ نمی‌دهد. شما نمی‌توانید پاسخ سرور را ببینید. این محدودیت، در ثبت خروج مشکلی ایجاد نمی‌کند، چون در آن لحظه نیازی به پاسخ نیست.

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

نکته‌ی مهم: sendBeacon برای همه‌ی درخواست‌ها مناسب نیست. برای درخواست‌های معمول، از fetch استفاده کنید. sendBeacon فقط برای رویدادهای لحظه‌ی خروج طراحی شده است. اگر با الگوی ساخت API Endpoint برای دریافت Beacon کار کرده باشید، می‌دانید که این تفکیک، در معماری صحیح ضروری است.

ترکیب beforeunload با sendBeacon

حالا بیایید یک پیاده‌سازی کامل از ترکیب beforeunload با sendBeacon ببینیم.

// analytics/static/analytics/exit-tracker.js

(function() {
  const ENDPOINT = "/panel/stat/api/track/";
  const MAX_PAYLOAD_SIZE = 60000; // محدودیت 64KB مرورگر

  function getExitPayload() {
    return {
      event: "exit",
      exit_url: location.href,
      exit_path: location.pathname,
      timestamp: Date.now(),
      scroll_y: window.scrollY || 0,
      viewport_w: window.innerWidth,
      viewport_h: window.innerHeight,
    };
  }

  function sendExit() {
    const payload = getExitPayload();
    const json = JSON.stringify(payload);

    if (json.length > MAX_PAYLOAD_SIZE) {
      console.warn("Exit payload too large, skipping");
      return;
    }

    const blob = new Blob([json], { type: "application/json" });

    if (navigator.sendBeacon) {
      const success = navigator.sendBeacon(ENDPOINT, blob);
      if (!success) {
        // fallback به fetch با keepalive
        fetch(ENDPOINT, {
          method: "POST",
          body: json,
          headers: { "Content-Type": "application/json" },
          keepalive: true,
        }).catch(() => {});
      }
    } else {
      // fallback برای مرورگرهای قدیمی
      fetch(ENDPOINT, {
        method: "POST",
        body: json,
        headers: { "Content-Type": "application/json" },
        keepalive: true,
      }).catch(() => {});
    }
  }

  window.addEventListener("beforeunload", sendExit);
  window.addEventListener("pagehide", sendExit);
})();

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

نکته‌ی اول، محدودیت اندازه. sendBeacon محدودیت اندازه دارد. محدودیت دقیق در مرورگرهای مختلف متفاوت است، ولی توصیه می‌شود از ۶۴ کیلوبایت عبور نکنید. اگر payload بزرگ باشد، مرورگر آن را بدون اطلاع شما رد می‌کند.

نکته‌ی دوم، fallback به fetch با keepalive. اگر sendBeacon در دسترس نبود یا شکست خورد، از fetch با گزینه‌ی keepalive استفاده می‌کنیم. این گزینه، از سال ۲۰۱۸ در مرورگرهای مدرن پشتیبانی می‌شود و به fetch اجازه می‌دهد در لحظه‌ی خروج هم کار کند.

نکته‌ی سوم، ثبت هر دو رویداد. beforeunload و pagehide هر دو ثبت می‌شوند. چرا؟ چون در برخی مرورگرها، یکی از این دو ممکن است اجرا نشود. ثبت هر دو، احتمال از دست دادن داده را کاهش می‌دهد.

نکته‌ی ظریف: در مرورگرهایی که beforeunload را برای تأیید بستن صفحه استفاده می‌کنند (مثلاً وقتی فرم نیمه‌کامل دارید)، ممکن است کاربر تصمیم بگیرد نرود. در این حالت، sendBeacon اشتباهاً ارسال می‌شود. برای مقابله، می‌توانید یک تأخیر کوچک بگذارید یا از event visibilitychange استفاده کنید.

visibilitychange؛ سیگنال مهمی که نادیده گرفته می‌شود

رویداد visibilitychange، از سال ۲۰۱۴ در مرورگرهای مدرن پشتیبانی می‌شود و یک سیگنال مهم را فراهم می‌کند: وقتی کاربر تب را تغییر می‌دهد، اپلیکیشن را ترک می‌کند یا صفحه را به حالت background می‌برد.

این رویداد، در چند سناریو بسیار مفید است.

سناریوی اول، تغییر تب. وقتی کاربر تب سایت شما را ترک می‌کند و به تب دیگری می‌رود، visibilitychange اجرا می‌شود. این سیگنال، نشان می‌دهد که کاربر توجهش به سایت شما کم شده، ولی هنوز از سایت خارج نشده.

سناریوی دوم، بستن اپلیکیشن موبایل. وقتی کاربر اپلیکیشن مرورگر را در موبایل به حالت background می‌برد یا آن را می‌بندد، visibilitychange اجرا می‌شود، ولی beforeunload ممکن است اصلاً اجرا نشود.

سناریوی سوم، رفتن به اپلیکیشن دیگر. وقتی کاربر از یک لینک در سایت شما به یک اپلیکیشن دیگر (مثل واتس‌اپ یا اینستاگرام) می‌رود، visibilitychange اجرا می‌شود.

let lastVisibleTime = Date.now();

document.addEventListener("visibilitychange", function() {
  if (document.visibilityState === "hidden") {
    // کاربر از صفحه خارج شده
    const hiddenAt = Date.now();
    const visibleFor = hiddenAt - lastVisibleTime;

    const payload = {
      event: "page_hidden",
      hidden_at: hiddenAt,
      visible_for_ms: visibleFor,
      url: location.href,
    };

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

    if (navigator.sendBeacon) {
      navigator.sendBeacon("/panel/stat/api/track/", blob);
    }
  } else if (document.visibilityState === "visible") {
    // کاربر برگشته
    lastVisibleTime = Date.now();

    const payload = {
      event: "page_visible",
      visible_at: lastVisibleTime,
      url: location.href,
    };

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

    if (navigator.sendBeacon) {
      navigator.sendBeacon("/panel/stat/api/track/", blob);
    }
  }
});

این کد، یک تصویر دقیق‌تر از رفتار کاربر می‌سازد.

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

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

مزیت سوم، ثبت خروج‌هایی که beforeunload از دست می‌دهد. در موبایل، خیلی از خروج‌ها از طریق visibilitychange قابل تشخیص هستند، نه از طریق beforeunload.

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

pagehide و unload؛ چه زمانی از کدام استفاده کنیم؟

چند رویداد مرتبط با خروج صفحه وجود دارد که هرکدام رفتار متفاوتی دارند. درک تفاوت این رویدادها، برای ثبت دقیق خروج ضروری است.

رویداد اول، beforeunload. قبل از بسته شدن صفحه اجرا می‌شود. در دسکتاپ قابل اعتماد است، ولی در موبایل ممکن است اجرا نشود. همچنین، اگر صفحه در حافظه‌ی cache مرورگر باشد (back-forward cache)، ممکن است اجرا نشود.

رویداد دوم، pagehide. قبل از beforeunload یا به‌جای آن اجرا می‌شود. این رویداد در back-forward cache هم قابل اعتماد است. پیشنهاد می‌کنم از این رویداد به‌عنوان سیگنال اصلی استفاده کنید.

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

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

// ترکیب رویدادها به ترتیب اولویت

// قبل از هر رویداد، وضعیت فعلی را ذخیره کن
let exitSent = false;

function sendExitOnce(reason) {
  if (exitSent) return;
  exitSent = true;

  const payload = {
    event: "exit",
    reason: reason,
    url: location.href,
    timestamp: Date.now(),
  };

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

  if (navigator.sendBeacon) {
    navigator.sendBeacon("/panel/stat/api/track/", blob);
  }
}

// رویداد اصلی
window.addEventListener("pagehide", function() {
  sendExitOnce("pagehide");
});

// رویداد تکمیلی
window.addEventListener("beforeunload", function() {
  sendExitOnce("beforeunload");
});

// سیگنال توجه
document.addEventListener("visibilitychange", function() {
  if (document.visibilityState === "hidden") {
    sendExitOnce("visibility_hidden");
  }
});

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

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

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

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

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

چالش‌های موبایل؛ جایی که داده گم می‌شود

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

چالش اول، بستن اپلیکیشن از Task Manager. وقتی کاربر اپلیکیشن مرورگر را از Task Manager سیستمعامل می‌بندد، مرورگر فرصت اجرای beforeunload یا pagehide را ندارد. در این حالت، هیچ beacon‌ای ارسال نمی‌شود.

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

چالش سوم، back-forward cache. وقتی کاربر از یک صفحه به صفحه‌ی دیگری می‌رود و بعد برمی‌گردد، مرورگر ممکن است صفحه‌ی قبلی را از cache لود کند. در این حالت، رویدادهای خروج اجرا نمی‌شوند و داده‌ی شما ناقص می‌ماند.

چالش چهارم، اتصال ضعیف. اگر کاربر در لحظه‌ی خروج، اتصال اینترنت ضعیفی داشته باشد، beacon ممکن است هرگز به سرور نرسد. sendBeacon تضمین می‌کند که درخواست در صف مرورگر بماند و در اولین فرصت ارسال شود، ولی اگر کاربر اپلیکیشن را ببندد، این صف پاک می‌شود.

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

برای مقابله با این چالش‌ها، چند راهبرد عملی:

راهبرد اول، ارسال دوره‌ای. علاوه بر ثبت خروج، هر ۳۰ ثانیه یک beacon «heartbeat» بفرستید. اگر beacon خروج نیامد، از آخرین heartbeat برای محاسبه‌ی مدت حضور استفاده کنید.

راهبرد دوم، زمان‌بندی محلی. زمان خروج را در سمت کلاینت به یک صف محلی (localStorage یا IndexedDB) اضافه کنید. در بازدید بعدی، این صف را به سرور بفرستید. این رویکرد، در پروژه‌های موبایل‌محور بسیار مؤثر است.

راهبرد سوم، تشخیص back-forward cache. با رویداد pageshow، می‌توانید تشخیص دهید که صفحه از cache بارگذاری شده است یا نه. در این حالت، می‌توانید منطق خروج را دوباره فعال کنید.

window.addEventListener("pageshow", function(event) {
  if (event.persisted) {
    // صفحه از back-forward cache بارگذاری شده
    // منطق خروج را دوباره فعال کن
    exitSent = false;
  }
});

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

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

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

# analytics/api.py

import json
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from analytics.models import Visit, PageView


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

    event = payload.get("event", "")

    if event == "exit":
        return _handle_exit(request, payload)

    if event == "page_hidden":
        return _handle_hidden(request, payload)

    if event == "page_visible":
        return _handle_visible(request, payload)

    return JsonResponse({"ok": False, "error": "unknown_event"})


def _handle_exit(request, payload):
    visit_id = request.session.get("_analytics_visit_id")
    if not visit_id:
        return JsonResponse({"ok": False, "error": "no_visit"})

    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit:
        return JsonResponse({"ok": False, "error": "visit_not_found"})

    now = timezone.now()
    duration = int((now - visit.entry_time).total_seconds())

    Visit.objects.filter(pk=visit.pk).update(
        exit_time=now,
        exit_url=(payload.get("exit_url") or "")[:2000],
        exit_path=(payload.get("exit_path") or "")[:500],
        exit_reason=(payload.get("reason") or "")[:30],
        duration_seconds=duration,
        is_active=False,
        last_activity=now,
    )

    # بستن PageView جاری
    pv_id = request.session.get("_analytics_page_view_id")
    if pv_id:
        PageView.objects.filter(
            pk=pv_id, left_at__isnull=True,
        ).update(
            left_at=now,
            duration_seconds=0,
            is_exit=True,
        )

    return JsonResponse({"ok": True})


def _handle_hidden(request, payload):
    visit_id = request.session.get("_analytics_visit_id")
    if not visit_id:
        return JsonResponse({"ok": False})

    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit:
        return JsonResponse({"ok": False})

    # این رویداد، فقط برای به‌روزرسانی last_activity است
    Visit.objects.filter(pk=visit.pk).update(
        last_activity=timezone.now(),
    )

    return JsonResponse({"ok": True})


def _handle_visible(request, payload):
    visit_id = request.session.get("_analytics_visit_id")
    if not visit_id:
        return JsonResponse({"ok": False})

    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit:
        return JsonResponse({"ok": False})

    # ثبت اینکه کاربر برگشته
    Visit.objects.filter(pk=visit.pk).update(
        last_activity=timezone.now(),
        is_active=True,
    )

    return JsonResponse({"ok": True})

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

نکته‌ی اول، مدیریت خطای JSON. اگر بدنه‌ی درخواست JSON معتبر نبود، یک پاسخ خطای ساختاریافته برمی‌گردانیم، نه یک exception که باعث 500 شود.

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

نکته‌ی سوم، عدم وابستگی به session. در beacon، ممکن است session از دست رفته باشد (مثلاً اگر کاربر کوکی‌ها را پاک کرده باشد). در این حالت، باید یک fallback داشته باشید.

نکته‌ی مهم: در beacon خروج، ممکن است session در دسترس نباشد، چون کوکی‌های session ممکن است در لحظه‌ی خروج حذف شوند. برای این حالت، می‌توانید یک شناسه‌ی اختصاصی در URL یا در payload بفرستید و از آن برای شناسایی Visit استفاده کنید.

معماری نهایی؛ سرویس، API و middleware

حالا بیایید همه‌ی بخش‌ها را در یک معماری نهایی ترکیب کنیم.

# analytics/services/exit.py

from django.utils import timezone
from django.core.cache import cache

from analytics.models import Visit, PageView


def close_visit(visit_id: int, exit_data: dict) -> bool:
    """
    بستن یک Visit بر اساس داده‌ی خروج.
    """
    visit = Visit.objects.filter(pk=visit_id).first()
    if not visit or not visit.is_active:
        return False

    now = timezone.now()
    duration = int((now - visit.entry_time).total_seconds())

    visit.exit_time = now
    visit.exit_url = (exit_data.get("exit_url") or "")[:2000]
    visit.exit_path = (exit_data.get("exit_path") or "")[:500]
    visit.exit_reason = (exit_data.get("reason") or "")[:30]
    visit.duration_seconds = duration
    visit.is_active = False
    visit.last_activity = now
    visit.save(update_fields=[
        "exit_time", "exit_url", "exit_path", "exit_reason",
        "duration_seconds", "is_active", "last_activity",
    ])

    PageView.objects.filter(
        visit=visit, left_at__isnull=True,
    ).update(
        left_at=now,
        duration_seconds=0,
        is_exit=True,
    )

    return True


def touch_visit(visit_id: int) -> bool:
    """
    به‌روزرسانی last_activity بدون بستن Visit.
    """
    updated = Visit.objects.filter(pk=visit_id).update(
        last_activity=timezone.now(),
    )
    return bool(updated)


def mark_visit_active(visit_id: int) -> bool:
    """
    علامت‌گذاری Visit به‌عنوان فعال (کاربر برگشته).
    """
    updated = Visit.objects.filter(pk=visit_id).update(
        last_activity=timezone.now(),
        is_active=True,
    )
    return bool(updated)

و در API:

# analytics/api.py

from analytics.services.exit import (
    close_visit,
    touch_visit,
    mark_visit_active,
)


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

    event = payload.get("event", "")
    visit_id = request.session.get("_analytics_visit_id")

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

    if event == "exit":
        success = close_visit(visit_id, payload)
        return JsonResponse({"ok": success})

    if event == "page_hidden":
        touch_visit(visit_id)
        return JsonResponse({"ok": True})

    if event == "page_visible":
        mark_visit_active(visit_id)
        return JsonResponse({"ok": True})

    return JsonResponse({"ok": False})

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

مزیت اول، جداسازی منطق از API. منطق در سرویس است و API فقط نقش هماهنگی دارد. اگر با الگوی تعریف و کاربرد services.py در جنگو کار کرده باشید، این جداسازی برایتان آشناست.

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

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

طراحی داده برای ذخیره‌ی نقطه‌ی خروج

ذخیره‌ی اطلاعات خروج، به‌اندازه‌ی خود ثبت آن اهمیت دارد. چند فیلد کلیدی را در نظر بگیرید.

class Visit(models.Model):
    # ... فیلدهای قبلی

    exit_time = models.DateTimeField(
        null=True, blank=True, db_index=True,
    )
    exit_url = models.CharField(max_length=2000, blank=True)
    exit_path = models.CharField(
        max_length=500, blank=True, db_index=True,
    )
    exit_reason = models.CharField(max_length=30, blank=True)

    # مدت کل حضور
    duration_seconds = models.PositiveIntegerField(default=0)

    # زمان توجه واقعی (بدون احتساب زمان در background)
    engaged_seconds = models.PositiveIntegerField(default=0)

سه نکته در این طراحی:

نکته‌ی اول، ایندکس روی exit_time و exit_path. برای تحلیل صفحات پرخروج، این ایندکس‌ها ضروری هستند.

نکته‌ی دوم، فیلد exit_reason. این فیلد، دلیل خروج را ذخیره می‌کند: pagehide، beforeunload، visibility_hidden. این داده، برای دیباگ بسیار مفید است.

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

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

امنیت و rate limiting در endpoint beacon

endpoint beacon، یک endpoint عمومی است و بنابراین در معرض سوءاستفاده قرار دارد. سه لایه‌ی محافظت را در نظر بگیرید.

لایه‌ی اول، rate limiting. هر IP بیش از N درخواست در دقیقه. این کار با django-ratelimit یا یک middleware سبک قابل انجام است.

from django.core.cache import cache


def check_rate_limit(ip: str, limit: int = 60, window: int = 60) -> bool:
    key = f"ratelimit:beacon:{ip}"
    current = cache.get(key, 0)

    if current >= limit:
        return False

    if current == 0:
        cache.set(key, 1, timeout=window)
    else:
        cache.incr(key)

    return True

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

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

نکته‌ی مهم: در beacon خروج، ممکن است session در دسترس نباشد. این مسئله، در طراحی endpoint باید در نظر گرفته شود. یک راه‌حل، استفاده از یک توکن اختصاصی در payload است که به Visit اشاره می‌کند. اگر با الگوی استفاده از sendBeacon برای رویداد خروج کار کرده باشید، می‌دانید که این چالش، در پروژه‌های واقعی بسیار شایع است.

تست‌نویسی سناریوهای خروج

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

سطح اول، تست واحد سرویس. سرویس‌ها بدون نیاز به request قابل تست هستند:

import pytest
from django.utils import timezone
from datetime import timedelta
from analytics.services.exit import close_visit
from analytics.models import Visit, Visitor


@pytest.mark.django_db
def test_close_visit_sets_duration():
    visitor = Visitor.objects.create(
        fingerprint="test", ip_address="1.2.3.4",
    )
    visit = Visit.objects.create(
        visitor=visitor,
        entry_time=timezone.now() - timedelta(minutes=5),
        entry_url="https://example.com/",
        entry_path="/",
        is_active=True,
    )

    close_visit(visit.pk, {"exit_url": "https://example.com/page"})

    visit.refresh_from_db()
    assert visit.is_active is False
    assert visit.duration_seconds >= 300
    assert visit.exit_url == "https://example.com/page"

سطح دوم، تست API. با django.test.Client، یک درخواست beacon بفرستید:

@pytest.mark.django_db
def test_track_exit_api(client):
    from analytics.models import 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/",
        data=json.dumps({
            "event": "exit",
            "exit_url": "https://example.com/page",
        }),
        content_type="application/json",
    )

    assert response.status_code == 200
    data = response.json()
    assert data["ok"] is True

سطح سوم، تست امنیت. بررسی کنید که rate limiting درست کار می‌کند:

def test_beacon_rate_limited(client):
    for i in range(100):
        response = client.post(
            "/panel/stat/api/track/",
            data=json.dumps({"event": "ping"}),
            content_type="application/json",
        )

    # بعد از حد مجاز، پاسخ باید خطا باشد
    assert response.status_code == 429

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

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

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

۱. استفاده از XHR synchronous. این روش، thread اصلی را مسدود می‌کند و در مرورگرهای مدرن ممنوع شده. همیشه از sendBeacon یا fetch با keepalive استفاده کنید.

۲. عدم پشتیبانی از موبایل. اگر فقط روی beforeunload تکیه کنید، حدود ۶۰٪ خروج‌های موبایل را از دست می‌دهید. باید pagehide و visibilitychange را هم ثبت کنید.

۳. عدم مدیریت back-forward cache. اگر صفحه‌ای از cache بارگذاری شود، منطق خروج شما ممکن است اشتباه عمل کند. باید رویداد pageshow را هم مدیریت کنید.

۴. ارسال مکرر beacon. اگر چند رویداد خروج ثبت کنید و هر کدام یک beacon بفرستند، سرور شما با درخواست‌های تکراری پر می‌شود. باید یک متغیر exitSent برای جلوگیری داشته باشید.

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

۶. اعتماد به session در beacon. در beacon خروج، ممکن است session در دسترس نباشد. باید یک fallback داشته باشید، مثل شناسه‌ی Visit در payload.

۷. عدم rate limiting. endpoint beacon باید در برابر سوءاستفاده محافظت شود. بدون rate limiting، یک مهاجم می‌تواند دیتابیس شما را پر کند.

۸. عدم ثبت دلیل خروج. اگر ندانید beacon از کدام رویداد آمده، دیباگ بسیار سخت می‌شود.

۹. محاسبه‌ی مدت حضور بر اساس entry_time. این روش، زمانی که کاربر در background بوده را هم حساب می‌کند. برای دقت بیشتر، باید visibilitychange را هم در نظر بگیرید.

۱۰. عدم مدیریت اتصال ضعیف. در موبایل با اتصال ضعیف، beacon ممکن است گم شود. باید یک مکانیزم ارسال دوره‌ای (heartbeat) داشته باشید.

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

پرسش‌های پرتکرار درباره‌ی sendBeacon و ردیابی خروج

آیا sendBeacon در همه‌ی مرورگرها پشتیبانی می‌شود؟ sendBeacon در Chrome از ۲۰۱۷، Firefox از ۲۰۱۸، Safari از ۲۰۱۸ و Edge از ۲۰۱۸ پشتیبانی می‌شود. در مرورگرهای بسیار قدیمی، باید fallback داشته باشید.

آیا sendBeacon در داخل اپلیکیشن‌های موبایل کار می‌کند؟ بله، اگر از WebView مدرن استفاده شود. در WebView‌های قدیمی، ممکن است محدودیت داشته باشد.

آیا می‌توانم از sendBeacon برای احراز هویت استفاده کنم؟ نه. sendBeacon هدر سفارشی قبول نمی‌کند، پس نمی‌توانید توکن Authorization بفرستید. برای احراز هویت، از fetch استفاده کنید.

چطور بفهمم beacon به سرور رسیده است؟ sendBeacon پاسخ نمی‌دهد، پس نمی‌توانید مستقیماً بفهمید. راه‌حل: در بازدید بعدی، از سرور بپرسید که beacon قبلی ثبت شده یا نه.

آیا می‌توانم beacon را بعد از خروج صفحه ارسال کنم؟ نه، sendBeacon باید در لحظه‌ی خروج ارسال شود. بعد از بسته شدن صفحه، دیگر امکان اجرای JavaScript ندارید.

چطور با مسدود شدن beacon توسط ad blockers مقابله کنم؟ بعضی ad blockers، درخواست‌های به مسیرهای خاصی مثل /analytics/ یا /track/ را مسدود می‌کنند. توصیه می‌کنم از یک مسیر عمومی مثل /api/events/ استفاده کنید.

آیا می‌توانم چند beacon را به‌طور همزمان بفرستم؟ بله، ولی توصیه می‌کنم این کار را نکنید. sendBeacon منابع مرورگر را محدود می‌کند و ارسال همزمان می‌تواند باعث گم شدن بعضی beacon‌ها شود.

چطور اندازه‌ی payload beacon را کاهش دهم؟ سه راه: اول، فقط داده‌های ضروری را بفرستید. دوم، داده‌ها را فشرده کنید (با JSON ساده، نه base64). سوم، از یک فرمت سبک مثل MessagePack استفاده کنید.

آیا باید beacon را در سطح Visit یا PageView ثبت کنم؟ در هر دو. beacon خروج، هم Visit را می‌بندد و هم PageView جاری را.

چطور مطمئن شوم که beacon در موبایل ارسال می‌شود؟ سه راه: اول، استفاده از pagehide به‌جای beforeunload. دوم، ثبت visibilitychange. سوم، ارسال heartbeat دوره‌ای.

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

چطور با back-forward cache مقابله کنم؟ با رویداد pageshow و بررسی event.persisted. اگر صفحه از cache بارگذاری شد، منطق خروج را دوباره فعال کنید.

آیا می‌توانم از sendBeacon در Service Worker استفاده کنم؟ بله، در Service Worker هم می‌توانید از sendBeacon استفاده کنید. این رویکرد، در PWA‌ها بسیار مفید است.

چطور با کاربرانی که از VPN استفاده می‌کنند مقابله کنم؟ VPN روی beacon تأثیر نمی‌گذارد، چون beacon در سطح HTTP ارسال می‌شود. IP در سمت سرور، IP VPN خواهد بود، ولی beacon به سرور می‌رسد.

آیا باید beacon را در دیتابیس ذخیره کنم یا در فایل؟ برای تحلیل سریع، دیتابیس رابطه‌ای. برای ذخیره‌ی طولانی‌مدت، فایل یا S3. اگر با الگوی ساخت API JSON برای آمار زنده کار کرده باشید، می‌دانید که این تصمیم به حجم و نوع تحلیل بستگی دارد.

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

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

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

مفهوم اول، کاهش حجم با نمونه‌گیری. در سایت‌های بسیار پربازدید، ارسال beacon برای هر کاربر ممکن است گران باشد. یک راه‌حل، نمونه‌گیری هوشمند است: برای ۱۰٪ کاربران، beacon کامل بفرستید و برای بقیه، فقط سیگنال‌های کلیدی. این رویکرد، در تحلیل‌های آماری، دقت را حفظ می‌کند.

مفهوم دوم، ذخیره‌سازی توزیع‌شده. در مقیاس بالا، ذخیره‌ی beacon‌های خام در یک دیتابیس رابطه‌ای ممکن است گران تمام شود. توصیه می‌کنم beacon‌ها را در یک صف (Kafka، Redis Streams، یا مشابه) بفرستید و یک worker مستقل آن‌ها را در دسته‌های بزرگ در دیتابیس وارد کند. این معماری، در سیستم‌های بزرگ استاندارد است.

مفهوم سوم، جریان پیوسته. در معماری‌های مدرن، ثبت خروج کاربر به‌جای یک تصمیم لحظه‌ای، یک جریان پیوسته است. هر beacon، به یک event stream فرستاده می‌شود و مدل، به‌طور مداوم به‌روز می‌شود. اگر با الگوی طبقه‌بندی Referrer و تشخیص ورود از گوگل کار کرده باشید، می‌دانید که این معماری، برای داده‌های تحلیلی مشابه، استاندارد است.

نکته‌ی آخر: در مقیاس بالا، دقت مدل شما در ثبت خروج، در نهایت به کیفیت beacon‌های دریافتی بستگی دارد. اگر بخش بزرگی از beacon‌ها گم شوند، تحلیل شما منحرف می‌شود. سرمایه‌گذاری در مکانیزم‌های پشتیبان مثل heartbeat و localStorage، در مقیاس بزرگ چند برابر جواب می‌دهد.

یک نکته‌ی عملی که در پروژه‌های مختلف دیده‌ام: به‌جای تمرکز بر دقت صد درصد beacon، روی دقت «کافی» تمرکز کنید. برای مثال، اگر ۹۰٪ beacon‌های خروج دریافت شوند، دقت تحلیل شما کافی است. تلاش برای رسیدن به ۱۰۰٪، در نهایت به پیچیدگی اضافی و هزینه‌ی نگهداری بالاتر منجر می‌شود. اگر با الگوی حذف رکوردهای تکراری و یتیم با batch delete کار کرده باشید، می‌دانید که پاک‌سازی beacon‌های ناقص، بخشی از نگهداری استاندارد است.

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

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

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