راهنمای جامع لاگ‌نویسی در بک‌اند: چه چیزی، کجا و با چه سطحی

راهنمای جامع لاگ‌نویسی در بک‌اند: چه چیزی، کجا و با چه سطحی
در این مقاله می‌خوانید
  1. لاگ برای چه کسی نوشته می‌شود؟
  2. چه چیزی را لاگ کنیم؟
  3. لاگ ساخت‌یافته: پیام برای آدم، فیلد برای ماشین
  4. سطح لاگ: قراردادی که باید در تیم یکی باشد
  5. زمینه: هر خط لاگ باید بگوید مال کدام درخواست است
  6. خطاها را یک بار، کامل و در جای درست لاگ کنید
  7. لاگ کن و دوباره پرتاب کن
  8. پیام بدون exception
  9. بلعیدن بی‌صدا
  10. چه چیزی را هرگز نباید لاگ کرد
  11. لاگ‌ها کجا بروند؟
  12. خروجی استاندارد (stdout)
  13. فایل
  14. ارسال مستقیم به یک انبار متمرکز
  15. هزینه، حجم و کارایی
  16. چک‌لیست: همین هفته
  17. منابع و مطالعهٔ بیشتر

تقریباً هر تیمی که با آن کار کرده‌ام لاگ داشت. مشکل این نبود که لاگ نداشتند؛ مشکل این بود که روز حادثه، لاگ‌ها جواب سؤال را نمی‌دادند. هزاران خط «Request started» و «Request finished» بود، چند stack trace بی‌صاحب، و هیچ راهی نبود بفهمیم آن خطای ساعت ۱۴:۰۵ مال کدام کاربر و کدام سفارش بوده است. لاگ زیاد بود، اطلاعات کم.

این راهنما حاصل همان روزهاست. دربارهٔ ابزار خاصی نیست؛ دربارهٔ تصمیم‌هایی است که پیش از انتخاب هر ابزاری باید گرفت: چه چیزی ارزش لاگ شدن دارد، هر خط لاگ چه شکلی باید داشته باشد، با چه سطحی نوشته شود، چه چیزی هرگز نباید در آن بیاید، و آخر سر این خط‌ها کجا بروند. مثال‌ها بیشتر از ⁦C#⁩ و Node و Python است، ولی اصول برای هر زبانی یکی است.

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

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

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

یک تمایز هم از همین اول لازم است. لاگ، متریک و trace سه چیز متفاوت‌اند:

  • متریک عدد تجمیعی است: تعداد درخواست در دقیقه، درصد خطا، زمان پاسخ صدک ۹۵. ارزان است و برای «آیا مشکلی هست؟» عالی.
  • لاگ رخداد تکی با جزئیات است: «پرداخت سفارش ۱۲۳۴ با کد ۵۱ از درگاه رد شد». گران‌تر است و برای «دقیقاً چه شد؟» لازم.
  • trace مسیر یک درخواست در چند سرویس است، با زمان هر مرحله (هر مرحله یک span).

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

چه چیزی را لاگ کنیم؟

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

  • مرز درخواست: یک خط در پایان هر درخواست با مسیر، کد وضعیت، مدت و شناسهٔ کاربر. یک خط، نه دو خط شروع و پایان. بیشتر فریم‌ورک‌ها این را خودشان می‌دهند؛ کافی است درست تنظیمش کنید.
  • فراخوانی سیستم‌های بیرونی: درگاه پرداخت، سرویس پیامک، API شریک تجاری. نشانی مقصد، مدت، نتیجه و — اگر خطاست — بدنهٔ خطا. بیشتر حادثه‌هایی که دیده‌ام از همین مرزها شروع شده‌اند و بدون لاگ، اثبات اینکه «مشکل از طرف آن‌هاست» تقریباً ناممکن است.
  • تغییر وضعیت مهم: سفارش پرداخت شد، حساب قفل شد، فایل خروجی ساخته شد، جاب شبانه تمام شد. این‌ها رخدادهای کسب‌وکاری‌اند که ماه‌ها بعد هم کسی دنبالشان می‌گردد.
  • تصمیم‌های غیرمنتظره: وقتی کد مسیر پیش‌فرض را نمی‌رود. «کش منقضی بود، از دیتابیس خواندیم»، «تلاش دوم برای اتصال»، «کاربر به نسخهٔ قدیمی API هدایت شد».
  • خطاها، با زمینهٔ کافی برای بازسازی موقعیت — جدا در ادامه دربارهٔ آن حرف می‌زنم.
  • رخدادهای امنیتی: ورود ناموفق، تغییر رمز، تغییر دسترسی، استفاده از کلید باطل‌شده. این‌ها را بعداً ممیز امنیتی یا خود شما برای بررسی نفوذ لازم دارید.

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

لاگ ساخت‌یافته: پیام برای آدم، فیلد برای ماشین

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

2026-09-10 14:05:12 ERROR Payment failed for order 1234, user 88, gateway=saman code=51
{"timestamp":"2026-09-10T10:35:12.481Z","level":"error","message":"Payment failed for order 1234","order_id":1234,"user_id":88,"gateway":"saman","gateway_code":"51","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}

خط اول برای آدم کاملاً خواناست. ولی وقتی بخواهید «همهٔ خطاهای درگاه سامان با کد ۵۱ در دیروز» را پیدا کنید، باید با regex دنبال gateway=saman بگردید و دعا کنید کسی در جای دیگری نوشته باشد gateway: Saman. خط دوم همان اطلاعات را دارد، ولی هر بخشش یک فیلد با نام ثابت است و هر ابزاری می‌تواند رویش فیلتر و شمارش کند.

نکتهٔ ظریف این است که لازم نیست JSON را با دست بسازید. کتابخانه‌های لاگ امروزی همه از قالب پیام (message template) یا شیء زمینه پشتیبانی می‌کنند. در ⁦C#⁩:

logger.LogError(ex, "Payment failed for order {OrderId} via {Gateway}", order.Id, gateway);

اینجا {OrderId} و {Gateway} هم در متن پیام جایگذاری می‌شوند و هم به‌صورت فیلد جدا ذخیره می‌شوند. در Node با pino، شیء زمینه اول می‌آید و پیام بعد:

const logger = require('pino')();

logger.error({ orderId: order.id, gateway, gatewayCode: res.code }, 'payment failed');

و در Python، کتابخانهٔ استاندارد logging با پارامتر extra فیلد اضافه می‌پذیرد؛ برای اینکه این فیلدها در خروجی بیایند، یک formatter یا کتابخانهٔ JSON (مثل structlog یا python-json-logger) لازم است:

import logging

logger = logging.getLogger("payments")
logger.error("payment failed", extra={"order_id": order.id, "gateway": gateway})

یک قاعدهٔ ساده که بعداً بسیار کمک می‌کند: متن پیام را ثابت نگه دارید و متغیرها را در فیلد بگذارید. یعنی "Payment failed for order {OrderId}" و نه $"Payment failed for order {order.Id}" با درون‌یابی رشته. وقتی قالب ثابت است، می‌شود همهٔ رخدادهای یک نوع را با هم شمرد؛ وقتی هر پیام متن یکتایی دارد، هر خط لاگ برای ابزار یک چیز تازه است. جزئیات بیشتر، ازجمله نام‌گذاری فیلدها، را در مقالهٔ لاگ ساخت‌یافته و دلیل کنار گذاشتن متن ساده آورده‌ام.

سطح لاگ: قراردادی که باید در تیم یکی باشد

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

سطح معنا در پروداکشن
Trace جزئی‌ترین جزئیات؛ مقادیر میانی، هر گام الگوریتم خاموش
Debug اطلاعاتی که فقط موقع دیباگ لازم است معمولاً خاموش؛ موقتاً برای یک بخش روشن
Information رخداد عادی ولی معنادار: سفارش ثبت شد، جاب تمام شد روشن
Warning چیزی غیرعادی که سیستم از پسش برآمد: تلاش دوباره موفق شد، کش در دسترس نبود روشن
Error یک عملیات شکست خورد و کاربر یا فرایند نتیجه نگرفت روشن، و باید کسی نگاهش کند
Critical / Fatal کل برنامه یا یک بخش اساسی از کار افتاد روشن، و باید کسی همین الان بیدار شود

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

نکتهٔ دوم: ورودی نامعتبر کاربر خطای شما نیست. درخواستی که با ۴۰۰ رد می‌شود، در بهترین حالت Information یا Warning است. خطای ۵۰۰ اما تقریباً همیشه Error است، چون یعنی کد شما کاری را که باید، نکرد. بحث کامل‌تر با مثال‌های مرزی را در سطح‌های لاگ: کِی Information، کِی Warning و کِی Error نوشته‌ام.

زمینه: هر خط لاگ باید بگوید مال کدام درخواست است

خطی که می‌گوید «Database timeout» بی‌ارزش است اگر نتوانید بفهمید کدام درخواست، کدام کاربر و کدام عملیات به آن رسیده. در یک سرور با صد درخواست هم‌زمان، خط‌های لاگ درهم‌اند و ترتیب زمانی به‌تنهایی رابطه‌ای را نشان نمی‌دهد.

راه‌حل این است که چند فیلد به‌طور خودکار روی همهٔ خطوط یک درخواست بنشینند، بی آنکه برنامه‌نویس هر بار یادش باشد:

  • trace_id: شناسهٔ یکتای کل درخواست، حتی وقتی از چند سرویس می‌گذرد. استاندارد W3C Trace Context این شناسه را در هدر traceparent بین سرویس‌ها جابه‌جا می‌کند و OpenTelemetry و بیشتر فریم‌ورک‌های امروزی آن را خودکار می‌سازند.
  • service، environment و host: کدام برنامه، در کدام محیط (production، staging) و روی کدام ماشین.
  • شناسه‌های کسب‌وکاری که در همان درخواست معلوم‌اند: user_id، tenant_id، order_id.

تقریباً همهٔ کتابخانه‌ها سازوکاری برای این دارند: scope در Microsoft.Extensions.Logging، LogContext در Serilog، logger فرزند در pino (logger.child({ orderId }))، و contextvars در Python. ارزش واقعی وقتی معلوم می‌شود که بتوانید با یک trace_id، همهٔ خطوط یک درخواست را — از gateway تا سرویس پرداخت و صف پیام — یک‌جا ببینید. این بحث را در مقالهٔ trace_id و correlation id با جزئیات پیاده‌سازی باز کرده‌ام.

خطاها را یک بار، کامل و در جای درست لاگ کنید

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

لاگ کن و دوباره پرتاب کن

try
{
    await _payments.ChargeAsync(order);
}
catch (Exception ex)
{
    _logger.LogError(ex, "Charge failed");
    throw;
}

این بلوک خودش به‌تنهایی بد نیست؛ مشکل اینجاست که لایهٔ بالاتر هم همین کار را می‌کند، و لایهٔ بالاتر از آن هم، و آخر سر middleware خطای سراسری هم. نتیجه چهار خط Error برای یک خطاست، که شمارش را خراب می‌کند و خواندن را سخت. قاعده: خطا را در جایی لاگ کنید که تصمیم دربارهٔ آن گرفته می‌شود — یا جایی که مدیریتش می‌کنید و ادامه می‌دهید، یا در لبهٔ بیرونی برنامه (middleware، حلقهٔ اصلی worker). اگر در لایهٔ میانی فقط می‌خواهید زمینه اضافه کنید، exception را با پیام بهتر بپیچید (throw new PaymentException("...", ex)) یا زمینه را در scope بگذارید، ولی لاگ نکنید.

پیام بدون exception

_logger.LogError("Charge failed: " + ex.Message) نوع exception و stack trace را دور می‌ریزد — دو چیزی که بیشتر از همه لازم دارید. همیشه خود شیء exception را به logger بدهید تا کتابخانه آن را کامل و جدا ثبت کند.

بلعیدن بی‌صدا

catch { } یا except: pass بدترین حالت است: سیستم رفتار عجیبی دارد و هیچ ردی در هیچ لاگی نیست. اگر واقعاً می‌خواهید خطایی را نادیده بگیرید، دست‌کم یک Warning یا Debug با دلیل بنویسید.

و یک نکتهٔ عملی: پیام خطا را برای خطاهای هم‌ریشه ثابت نگه دارید. اگر پیام شامل شناسهٔ متغیر باشد («Order 1234 not found»، «Order 1235 not found») و ابزار شما خطاها را با پیام گروه کند، یک باگ را هزار باگ می‌بیند. شناسه را در فیلد بگذارید.

چه چیزی را هرگز نباید لاگ کرد

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

فهرست حداقلی چیزهایی که نباید در لاگ بیایند:

  • رمز عبور، حتی رمز اشتباهی که کاربر وارد کرده (معمولاً با رمز درستش یک حرف فرق دارد).
  • توکن‌ها: هدر Authorization، کوکی نشست، کلید API، توکن بازیابی رمز، کد یک‌بارمصرف.
  • شمارهٔ کارت بانکی، CVV2، تاریخ انقضا.
  • دادهٔ شخصی بیش از نیاز: کد ملی، نشانی، شمارهٔ موبایل، ایمیل — مگر اینکه واقعاً برای عیب‌یابی لازم باشد و آگاهانه تصمیم گرفته باشید.
  • بدنهٔ کامل درخواست و پاسخ، که معمولاً همهٔ موارد بالا را یک‌جا دارد.

خطرناک‌ترین الگو، لاگ کردن کل یک شیء است: logger.LogInformation("Request {@Request}", request). امروز آن شیء چیز حساسی ندارد؛ شش ماه بعد کسی فیلد NationalCode را به آن اضافه می‌کند و هیچ‌کس یادش نیست که این شیء جایی کامل لاگ می‌شود. فیلدهای لازم را صریحاً انتخاب کنید.

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

لاگ‌ها کجا بروند؟

سه گزینهٔ اصلی وجود دارد و هر سیستم بالغی دیر یا زود به ترکیبی از آن‌ها می‌رسد.

خروجی استاندارد (stdout)

برای برنامه‌هایی که در کانتینر یا زیر یک مدیر سرویس (systemd، Kubernetes) اجرا می‌شوند، ساده‌ترین و درست‌ترین کار این است که برنامه فقط روی stdout بنویسد و جمع‌آوری را به محیط بسپارد. این همان توصیهٔ قدیمی Twelve-Factor App است: برنامه نباید نگران چرخش فایل و مسیر دیسک باشد. شرطش این است که خروجی JSON باشد، نه متن رنگی خوش‌ظاهر.

فایل

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

ارسال مستقیم به یک انبار متمرکز

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

در هر سه حالت، یک اصل ثابت است: نوشتن لاگ نباید درخواست کاربر را کند یا خراب کند. اگر مقصد شبکه‌ای کند شد یا در دسترس نبود، برنامه باید به کارش ادامه دهد. کتابخانه‌های جدی (sinkهای Serilog، exporterهای OpenTelemetry، transportهای pino) لاگ را در حافظه دسته می‌کنند و در پس‌زمینه می‌فرستند. پیش از استفاده، سقف بافر و رفتارشان هنگام قطعی را بدانید.

هزینه، حجم و کارایی

لاگ مجانی نیست. هر خط CPU برای قالب‌بندی، حافظه برای بافر، شبکه برای ارسال و دیسک برای نگه‌داری می‌خورد. در بیشتر سیستم‌ها هزینهٔ اصلی در سمت نگه‌داری و جستجوست، نه در خود برنامه — ولی در مسیرهای داغ، خود برنامه هم آسیب می‌بیند.

چند عادت که در عمل بیشترین اثر را داشته‌اند:

  • سطح پیش‌فرض پروداکشن را Information بگذارید و برای کتابخانه‌های پرحرف (چارچوب وب، ORM، کلاینت HTTP) Warning. معمولاً همین یک تنظیم، حجم را چند برابر کم می‌کند.
  • در مسیرهای داغ، پیش از ساختن پیام گران، فعال بودن سطح را بپرسید یا از ابزارهای بدون تخصیص حافظه مثل source generator در ⁦.NET⁩ استفاده کنید.
  • یک خط به‌جای چند خط. «شروع پردازش»، «مرحلهٔ ۱»، «مرحلهٔ ۲»، «پایان» را به یک خط در پایان با فیلدهای مدت و نتیجه تبدیل کنید.
  • بدنه‌های بزرگ را لاگ نکنید. یک پاسخ ۵۰۰ کیلوبایتی در هر درخواست، به‌تنهایی می‌تواند بیشتر حجم لاگ را بسازد.
  • مدت نگه‌داری را آگاهانه انتخاب کنید. لاگ دیباگ دیروز ارزشش با لاگ ممیزی شش ماه پیش فرق دارد.

دربارهٔ نمونه‌برداری (sampling) لاگ محتاط باشم: برای لاگ‌های پرتکرار و کم‌ارزش منطقی است، ولی هرگز خطاها را نمونه‌برداری نکنید. خطای نادری که دور ریخته شود، دقیقاً همانی است که بعداً دنبالش می‌گردید.

چک‌لیست: همین هفته

  1. یک خطای واقعی هفتهٔ گذشته را بردارید و ببینید فقط با لاگ‌ها، بی‌کمک کد و حافظه، می‌توانید بفهمید چه شد یا نه. هر جا گیر کردید، همان‌جا لاگ کم دارید.
  2. خروجی لاگ را ساخت‌یافته کنید (JSON یا قالب پیام با فیلد).
  3. مطمئن شوید trace_id، سرویس، محیط و میزبان روی همهٔ خطوط می‌نشینند، خودکار.
  4. تعریف سطح‌ها را یک صفحه بنویسید و با تیم یکی کنید. Errorهای کاذب را پایین بیاورید.
  5. کد را برای لاگ کردن کل اشیا، هدرها و بدنهٔ درخواست جستجو کنید.
  6. الگوی «لاگ کن و دوباره پرتاب کن» را در لایه‌های میانی پیدا و حذف کنید.
  7. اگر بیش از یک سرور دارید، لاگ‌ها را یک‌جا جمع کنید.

اگر با ⁦.NET⁩ کار می‌کنید، گام بعدی منطقی راهنمای جامع لاگ در ⁦ASP.NET Core⁩ است که همین اصول را با کد و پیکربندی واقعی پیاده می‌کند. ما LogMug را دقیقاً برای بند آخر این چک‌لیست ساختیم — جمع کردن لاگ‌های همهٔ سرویس‌ها و جستجو بر اساس سطح، سرویس و trace_id — ولی همهٔ بندهای پیش از آن، با هر ابزاری که دارید، ارزش انجام دادن دارند.

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

لاگ همهٔ سرویس‌هایتان را در یک جا جستجو کنید

C#، Java یا هر زبان دیگر — با چند خط پیکربندی وصل می‌شود. پلن رایگان کارت بانکی نمی‌خواهد.

شروع رایگان