window.onerror و unhandledrejection: تور ایمنی سراسری سایت

window.onerror و unhandledrejection: تور ایمنی سراسری سایت
در این مقاله می‌خوانید
  1. دو رویداد، نه یکی
  2. یک پیاده‌سازی کامل
  3. نکاتی که در پیاده‌سازی ساده جا می‌مانند
  4. خطای Script error.
  5. خطای بارگذاری منابع
  6. حلقهٔ بی‌نهایت
  7. سیل رخداد
  8. خطاهایی که مال شما نیستند
  9. چه چیزی را همراه خطا بفرستیم
  10. محدودسازی نرخ، جدی‌تر از آنچه فکر می‌کنید
  11. چطور تستش کنیم
  12. خطا را کجا بفرستیم؟
  13. مکمل ضروری: صدای کاربر
  14. جمع‌بندی
  15. منابع و مطالعهٔ بیشتر

هر چقدر هم دقیق باشید، خطاهایی هستند که پیش‌بینی نکرده‌اید. تور ایمنی سراسری برای همان‌هاست: مکانیزمی که هر خطایی را که از تمام try/catchها رد شده و به بالاترین سطح رسیده، می‌گیرد و ثبت می‌کند.

این مقاله راهنمای ساختن آن است — و توضیح اینکه چرا نسخهٔ ساده‌اش معمولاً کافی نیست.

دو رویداد، نه یکی

مهم‌ترین نکته‌ای که بیشتر پیاده‌سازی‌های دست‌ساز از قلم می‌اندازند: خطاهای همگام و Promiseهای رد شده دو رویداد جدا دارند.

// خطاهای معمولی
window.addEventListener('error', function (e) { /* ... */ });

// Promise هایی که rejected شدند و catch نداشتند
window.addEventListener('unhandledrejection', function (e) { /* ... */ });

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

یک پیاده‌سازی کامل

(function () {
  var sent = {};                       // جلوگیری از ارسال تکراری

  function report(payload) {
    var key = payload.message + '|' + (payload.line || '');
    if (sent[key]) return;             // همان خطا را بارها نفرست
    sent[key] = true;

    var body = JSON.stringify(Object.assign(payload, {
      url: location.href,
      userAgent: navigator.userAgent,
      time: Date.now()
    }));

    // sendBeacon هنگام بسته شدن صفحه هم کار می‌کند
    if (navigator.sendBeacon) navigator.sendBeacon('/api/errors', body);
    else fetch('/api/errors', { method: 'POST', body: body, keepalive: true });
  }

  window.addEventListener('error', function (e) {
    // خطای بارگذاری منابع، شکل متفاوتی دارد
    if (e.target && e.target !== window && e.target.tagName) {
      report({ type: 'resource', message: 'failed to load', src: e.target.src || e.target.href });
      return;
    }
    report({
      type: 'error',
      message: e.message,
      file: e.filename,
      line: e.lineno,
      column: e.colno,
      stack: e.error && e.error.stack
    });
  }, true);                            // true = فاز capture، برای گرفتن خطای منابع

  window.addEventListener('unhandledrejection', function (e) {
    var r = e.reason;
    report({
      type: 'rejection',
      message: (r && r.message) || String(r),
      stack: r && r.stack
    });
  });
})();

نکاتی که در پیاده‌سازی ساده جا می‌مانند

خطای Script error.

اگر خطایی در اسکریپتی از دامنهٔ دیگر رخ دهد، مرورگر به‌دلایل امنیتی جزئیاتش را پنهان می‌کند و فقط Script error. می‌دهد — بدون فایل، بدون خط، بدون stack.

رفعش دو بخش دارد: روی تگ اسکریپت ویژگی crossorigin="anonymous" بگذارید، و سرویس‌دهندهٔ آن فایل باید هدر Access-Control-Allow-Origin بدهد. اگر فایل‌ها را روی CDN می‌گذارید، این تنظیم را حتماً بررسی کنید. سازوکار پشتش همان CORS است.

خطای بارگذاری منابع

وقتی تصویری یا اسکریپتی بارگذاری نمی‌شود، رویداد error روی خود آن عنصر منتشر می‌شود و به window صعود نمی‌کند. برای همین در کد بالا شنونده با آرگومان سوم true ثبت شده تا در فاز capture اجرا شود.

حلقهٔ بی‌نهایت

اگر خودِ کد گزارش‌دهی خطا بدهد، خطای جدیدی می‌سازد که دوباره گزارش می‌شود. مکانیزم جلوگیری از تکرار در کد بالا این را می‌گیرد، ولی حواستان باشد که تابع report خودش هرگز نباید استثنا پرتاب کند.

سیل رخداد

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

خطاهایی که مال شما نیستند

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

چه چیزی را همراه خطا بفرستیم

خودِ پیام خطا معمولاً برای حل کردن کافی نیست. چیزهایی که تفاوت بین «خطایی هست» و «می‌دانم چطور بازتولیدش کنم» را می‌سازند:

  • آدرس کامل صفحه، با پارامترها. بسیاری از باگ‌ها فقط با ترکیب خاصی از پارامترها رخ می‌دهند.
  • نسخهٔ انتشار. بدون این نمی‌دانید خطا از کد فعلی است یا از نسخه‌ای که در کش کاربر مانده.
  • اطلاعات مرورگر و اندازهٔ صفحه. الگو معمولاً همین‌جا پیدا می‌شود.
  • شناسهٔ نشست. تا بفهمید ده خطا از یک کاربر است یا از ده نفر.
  • مسیر کاربر تا این لحظه — کلیک‌ها، جابه‌جایی صفحات، درخواست‌های شبکه. مفیدترین بخش، و همان چیزی که به آن breadcrumb می‌گویند.

و چیزی که نباید بفرستید: محتوای فیلدهای فرم، توکن‌ها، و هر چیزی که در آدرس صفحه شبیه شناسهٔ شخصی است. پیش از ارسال پاک‌سازی کنید، نه بعد از ذخیره.

محدودسازی نرخ، جدی‌تر از آنچه فکر می‌کنید

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

الگویی که در عمل جواب می‌دهد، عقب‌نشینی نمایی است: رخداد اول، دوم، چهارم، هشتم و… از یک اثر انگشت را بفرستید و بقیه را بشمارید. یک خطا که ۵۰۰ بار تکرار شده، ۹ درخواست تولید می‌کند و شماره‌اش را هم همراه دارد.

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

چطور تستش کنیم

تور ایمنی که تست نشده باشد، معمولاً کار نمی‌کند. سه آزمون ساده:

// ۱. خطای همگام
setTimeout(function () { throw new Error('تست همگام'); }, 0);

// ۲. Promise رد شده بدون catch
Promise.reject(new Error('تست ناهمگام'));

// ۳. خطای بارگذاری منبع
var img = new Image(); img.src = '/این-فایل-وجود-ندارد.png';
document.body.appendChild(img);

هر سه باید در مقصد شما ظاهر شوند. اگر دومی نیامد، شنوندهٔ unhandledrejection را ندارید. اگر سومی نیامد، فاز capture را فعال نکرده‌اید. این آزمون را بعد از هر تغییر در ساختار اسکریپت‌ها تکرار کنید.

خطا را کجا بفرستیم؟

ساختن endpoint دریافت خطا ساده است؛ ساختن چیزی که واقعاً قابل استفاده بماند نه. جدولی که هر رخداد را جداگانه ذخیره کند، ظرف یک هفته میلیون‌ها ردیف دارد و هیچ‌کس نمی‌تواند در آن چیزی پیدا کند.

چیزهایی که لازم می‌شوند و معمولاً دیرتر کشف می‌شوند:

  • گروه‌بندی با اثر انگشت، تا هزاران رخداد یکسان یک ردیف شوند.
  • ترجمهٔ stack trace با source map، وگرنه چیزی جز t:1:24817 نمی‌بینید.
  • شمارش کاربران متأثر، نه فقط تعداد رخداد.
  • پاک‌سازی داده‌های شخصی پیش از ذخیره.
  • حذف خودکار داده‌های قدیمی.

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

مکمل ضروری: صدای کاربر

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

برای این‌ها هیچ رویداد جاوااسکریپتی وجود ندارد. تنها راه فهمیدنشان این است که کاربر بتواند به‌سادگی بگوید — و این چیزی است که در کنار تور ایمنی، نه به‌جای آن، باید داشته باشید.

جمع‌بندی

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

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

منابع و مطالعهٔ بیشتر

باگ‌ماگ را رایگان امتحان کنید

خطاهای سایتتان را خودکار ثبت کنید و بگذارید کاربران با یک کلیک باگ گزارش دهند.

شروع رایگان