فهرست مطالب
کد سمت سرور روی ماشینی اجرا میشود که مال شماست و لاگش دست شماست. کد جاوااسکریپت روی هزاران مرورگر مختلف اجرا میشود، با افزونههای ناشناخته، اینترنت ناپایدار و نسخههایی از Safari که هیچوقت رویشان آزمایش نکردهاید. وقتی آنجا چیزی خراب میشود، کنسول مرورگر کاربر تنها شاهد است و شما به آن دسترسی ندارید. بیشتر کاربرها هم گزارش نمیدهند؛ فقط صفحه را میبندند.
رصد خطای جاوااسکریپت یعنی خطاهای مرورگر کاربران را با زمینهٔ کافی به جایی بفرستید که تیم ببیند. در این راهنما سازوکارهای خود مرورگر را مرور میکنیم، میگوییم کجاها غافلگیر میشوید، و در آخر میگوییم ساختن این سیستم چه مقدار کار است.
window.onerror و رویداد error
قدیمیترین راه، window.onerror است که برای هر استثنای گرفتهنشده صدا زده میشود. شکل امروزیتر همان رویداد error روی window است که شیء خطا را هم در اختیار میگذارد:
window.addEventListener('error', function (event) {
// خطای اجرای اسکریپت
if (event.error) {
send({
type: 'js',
message: event.message,
stack: event.error.stack,
file: event.filename,
line: event.lineno,
col: event.colno,
url: location.href
});
return;
}
// خطای بارگذاری منبع (img، script، link)
var t = event.target;
if (t && t !== window) {
send({ type: 'resource', tag: t.tagName, src: t.src || t.href });
}
}, true); // capture: برای دیدن خطای منابع لازم است
دو نکته که بیشتر تیمها دیر میفهمند:
- خطای بارگذاری منابع حباب نمیزند. اگر تصویری یا فایل اسکریپتی بار نشود، رویداد error فقط در فاز capture به window میرسد؛ برای همین آرگومان سوم
trueاست. - «Script error.» بدون هیچ جزئیات. وقتی اسکریپت از دامنهٔ دیگری مثل CDN بار شده باشد، مرورگر برای حفظ امنیت پیام و stack را پنهان میکند. چاره این است که تگ اسکریپت ویژگی
crossorigin="anonymous"داشته باشد و سرور CDN سرآیندAccess-Control-Allow-Originرا برگرداند.
Promiseهای رد شده: unhandledrejection
در کد امروزی بیشتر خطاها داخل async/await و Promise رخ میدهند و onerror آنها را نمیبیند. برای Promiseی که رد شده و کسی catchش نکرده، رویداد جداگانهای وجود دارد:
window.addEventListener('unhandledrejection', function (event) {
var r = event.reason;
send({
type: 'promise',
message: r && r.message ? r.message : String(r),
stack: r && r.stack ? r.stack : null
});
});
دقت کنید که reason لزوماً یک Error نیست. کتابخانهها گاهی رشته، عدد یا یک شیء پاسخ HTTP را reject میکنند و در آن صورت stack ندارید. یکی از عادتهای خوب تیمی این است که همیشه new Error(...) را reject کنید.
خطاهای شبکه: fetch و XHR
بخش بزرگی از «سایت کار نمیکند»ها اصلاً خطای جاوااسکریپت نیست؛ درخواستی است که با 500 برگشته یا هیچوقت نرسیده. نکتهٔ مهم: fetch برای پاسخ 4xx و 5xx خطا نمیدهد و فقط وقتی reject میشود که اصلاً پاسخی نرسد. پس باید خودتان وضعیت را بررسی کنید. یک روش این است که fetch را بپوشانید:
var originalFetch = window.fetch;
window.fetch = function (input, init) {
var started = Date.now();
var url = typeof input === 'string' ? input : input.url;
var method = (init && init.method) || 'GET';
return originalFetch.apply(this, arguments).then(function (res) {
if (res.status >= 500) {
send({ type: 'http', method: method, url: url,
status: res.status, ms: Date.now() - started });
}
return res;
}, function (err) {
send({ type: 'network', method: method, url: url,
message: err.message, ms: Date.now() - started });
throw err;
});
};
برای XMLHttpRequest هم همین کار با پوشاندن open و send و گوش دادن به loadend انجام میشود. مدت درخواست را حتماً ثبت کنید؛ تفاوت «بعد از ۲۰۰ میلیثانیه 500 داد» با «بعد از ۳۰ ثانیه timeout شد» دو مسیر عیبیابی کاملاً جداست. اگر سرور در پاسخ یک شناسهٔ درخواست برمیگرداند، آن را هم ذخیره کنید تا بعداً به لاگ بکاند برسید؛ نوشتهٔ trace_id همین را توضیح میدهد.
Source map: stack trace خوانا از کد فشرده
stack traceای که از کد minifyشده میرسد چیزی شبیه at e (app.3f9a.js:1:48213) است که کمکی نمیکند. source map نگاشت کد فشرده به فایل و خط اصلی است. چند قاعدهٔ عملی:
- در build تولید، source map بسازید ولی لزوماً عمومی منتشرش نکنید. در webpack گزینهٔ
hidden-source-mapفایل map را میسازد بدون اینکه در فایل JS به آن ارجاع بدهد. - هر build یک شناسهٔ نسخه (release) داشته باشد و همین شناسه همراه هر خطا ارسال شود؛ وگرنه نمیدانید کدام map را باید به کار ببرید.
- stack را خام ذخیره کنید و هنگام نمایش با map درستش ترجمه کنید، نه در مرورگر کاربر.
Breadcrumbs: چه چیزی پیش از خطا اتفاق افتاد
stack trace میگوید کجا شکست، ولی نمیگوید چرا کاربر به آنجا رسید. breadcrumbs یک صف کوتاه از رخدادهای قبل از خطاست: کلیکها، تغییر مسیر در SPA، درخواستهای شبکه و پیامهای کنسول. معمولاً سی تا پنجاه رخداد آخر بس است. برای کلیک، بهجای متن کامل دکمه، یک انتخابگر کوتاه مثل button#submit-order ذخیره کنید تا متنهای حاوی دادهٔ کاربر ثبت نشوند. همین صف کوتاه اغلب فرق بین «نمیتوانیم بازتولیدش کنیم» و «آهان، بعد از برگشتن از صفحهٔ پرداخت رخ میدهد» است.
گروهبندی و کنترل نویز
یک باگ در صفحهٔ پرترافیک میتواند روزی ده هزار رخداد بسازد. اگر هر رخداد یک ردیف جدا باشد، داشبورد بیفایده میشود. رخدادها باید با یک fingerprint گروه شوند؛ معمولاً از نوع خطا و چند فریم بالای stack که در کد خود شماست، نه پیام خطا که ممکن است شناسه یا عدد متغیر داشته باشد. اولویت هم بهتر است با «تعداد کاربران متأثر» سنجیده شود، نه تعداد رخداد؛ یک کاربر در یک حلقه میتواند هزار رخداد بسازد.
بخشی از خطاها هم ربطی به شما ندارند: خطاهای افزونههای مرورگر (stackهایی که با chrome-extension:// شروع میشوند)، رباتها، و هشدارهای شناختهشدهای مثل ResizeObserver loop limit exceeded. فهرستی برای نادیده گرفتن اینها داشته باشید، ولی محتاطانه؛ هر قانون نادیده گرفتن ممکن است روزی یک خطای واقعی را هم پنهان کند.
حریم خصوصی و دادههای شخصی
سیستم رصد خطا بهراحتی به انباری از دادههای شخصی تبدیل میشود: نشانی صفحهای که شمارهٔ موبایل در query string دارد، بدنهٔ درخواستی که رمز عبور در آن است، یا پیام خطایی که ایمیل کاربر را چاپ میکند. حداقلها:
- بدنهٔ درخواست و پاسخ را بهطور پیشفرض ثبت نکنید.
- پارامترهایی مثل token، password و code را از URLها پیش از ارسال حذف کنید.
- برای شناسایی کاربر یک شناسهٔ داخلی بفرستید، نه نام و موبایل.
- یک لایهٔ پاکسازی سمت سرور هم داشته باشید، چون پاکسازی سمت مرورگر همیشه چیزی را جا میاندازد.
خودتان بسازید یا از سرویس استفاده کنید؟
کدهای بالا بخش سادهٔ ماجراست. بخش سخت، سمت سرور است: endpointی که زیر سیل رخداد نخوابد، محدودیت نرخ، ترجمهٔ source map، گروهبندی، داشبورد، اعلان و نگهداری داده. اگر تیمتان وقتش را دارد ساختن آن تمرین خوبی است؛ اگر نه، استفاده از یک سرویس منطقیتر است.
باگماگ همین کارها را با یک تگ اسکریپت انجام میدهد: خطاهای جاوااسکریپت، Promiseهای رد شده و لاگهای کنسول را با stack trace ثبت میکند، درخواستهای ناموفق fetch و XHR را با وضعیت و مدت ضبط میکند، breadcrumbs را کنار هر رخداد نگه میدارد و خطاهای مشابه را با fingerprint زیر یک Issue جمع میکند و بر اساس تعداد کاربران متأثر اولویت میدهد. دادههای شخصی سمت سرور پاکسازی میشوند و اعلان از راه اسلک، وبهوک و ایمیل میرسد. مستقل از فریمورک است و روی React، Vue، وردپرس یا PHP فرقی نمیکند:
<script src="https://app.bugmug.ir/bugmug.js"></script>
<script>
BugMug.init({ publicKey: 'pk_xxx', release: 'v1.0.0' });
</script>
پلن رایگانش برای یک سایت با سقف قطعی است و جزئیات بیشتر در سایت باگماگ آمده است. باگماگ کنار رصد خودکار، دکمهٔ گزارش باگ با اسکرینشات هم دارد که در نوشتهٔ گزارش باگ کاربر به آن پرداختهایم.
پرسشهای پرتکرار
try/catch در کد کافی نیست؟
try/catch فقط جاهایی را میپوشاند که از قبل حدس زدهاید خطا رخ میدهد. رصد خطا برای جاهایی است که حدس نزدهاید. هر دو لازم است و خطای catchشدهای را که مهم است میتوانید دستی هم گزارش کنید.
اسکریپت رصد سرعت سایت را کم نمیکند؟
اگر غیرهمزمان بار شود و ارسال رخدادها را دستهای و با navigator.sendBeacon یا درخواستهای کماولویت انجام دهد، اثرش ناچیز است. چیزی که باید مراقبش بود، ثبت بیرویهٔ رخداد در حلقههاست، نه خود اسکریپت.
آیا باید لاگهای console.log را هم فرستاد؟
نه بهعنوان رخداد جدا. ولی چند پیام آخر کنسول بهعنوان breadcrumb کنار هر خطا بسیار مفید است، به شرط اینکه دادهٔ حساس در آنها چاپ نکنید.
جمعبندی
رصد خطای جاوااسکریپت با دو شنوندهٔ error و unhandledrejection شروع میشود، ولی بدون رصد شبکه، source map، breadcrumbs و گروهبندی، فقط فهرست بلندی از پیامهای نامفهوم تحویل میدهد. حریم خصوصی را از روز اول جدی بگیرید و خطای مرورگر را اولین حلقهٔ یک زنجیرهٔ بلندتر ببینید؛ ادامهٔ این زنجیره را در چرخهٔ عمر یک خطا دنبال کردهایم.