لاگ ساختاریافته (Structured Logging)
لاگ ساختاریافته هر رویداد را با فیلدهای جدا ذخیره میکند، نه فقط به شکل یک جمله. با این کار میشود لاگ را مثل دیتابیس جستجو کرد. با سطح درست لاگ و یک شناسه مشترک مثل Trace Id، مسیر یک درخواست را در چند سرویس پیدا میکنیم.
نویسنده: bezzad
مشکل: لاگ داریم، ولی جواب نداریم
مشتری فروشگاه زنگ میزند: «پول از حسابم کم شد، ولی سفارش ثبت نشد.» ما هزاران خط لاگ داریم. هر خط چیزی شبیه این است:
2026-10-06 10:14:03 Order 1042 paid by user 77, amount 1250000
حالا این سؤالها را جواب بده:
- همه لاگهای سفارش ۱۰۴۲ در چهار سرویس کجاست؟
- در یک ساعت گذشته، کدام پرداختها بیشتر از یک میلیون تومان بودند و شکست خوردند؟
- کاربر ۷۷ امروز چند بار پرداخت ناموفق داشته است؟
با لاگ متنی، برای هر سؤال باید یک الگوی جستجوی متن (Regex) بنویسیم. اگر کسی جمله لاگ را کمی عوض کند، مثلاً «paid by» را به «paid from» تغییر دهد، همه جستجوها و داشبوردها بیصدا خراب میشوند.
ایده: لاگ یک رویداد با فیلد است، نه یک جمله
در لاگ ساختاریافته (Structured Logging) هر لاگ یک رویداد با فیلدهای نامدار است. جمله هنوز وجود دارد تا آدم بتواند آن را بخواند. ولی مقدارها جدا هم ذخیره میشوند تا ماشین بتواند روی آنها جستجو کند.
کد: Message Template، نه رشته ساختهشده
در .NET از رابط ILogger استفاده میکنیم. نکته اصلی این است که جمله لاگ یک الگو (Message Template) است. اسم داخل آکولاد، اسم فیلد میشود:
public sealed class PaymentService(ILogger<PaymentService> logger)
{
public void MarkPaid(Order order)
{
// Good: OrderId, UserId and Amount become separate fields.
logger.LogInformation(
"Order {OrderId} paid by user {UserId}, amount {Amount}",
order.Id, order.UserId, order.Total);
// Bad: one plain string, no fields.
logger.LogInformation($"Order {order.Id} paid by user {order.UserId}");
}
}
خط دوم خیلی شبیه خط اول است، ولی سه مشکل دارد:
- فیلدی ساخته نمیشود. رشته قبل از رسیدن به Logger ساخته شده است. Logger فقط یک متن میبیند.
- هر پیام یک الگوی جدید است. نمیشود پرسید «این نوع پیام چند بار تکرار شد؟». چون هر بار متن فرق دارد.
- هزینه بیدلیل. رشته همیشه ساخته میشود، حتی وقتی این سطح لاگ خاموش است.
سطح لاگ: چه چیزی را کجا بنویسیم؟
هر لاگ یک سطح دارد. سطح نشان میدهد این رویداد چقدر مهم است. در Production معمولاً فقط سطح Information و بالاتر ذخیره میشود.
| سطح | کی؟ | مثال در فروشگاه |
|---|---|---|
| Trace و Debug | جزئیات برای عیبیابی در محیط توسعه. | مقدار هر متغیر در محاسبه تخفیف. |
| Information | یک اتفاق مهم و عادی در کسبوکار. | سفارش ثبت شد. پرداخت انجام شد. |
| Warning | چیزی غیرعادی است، ولی کار ادامه دارد. | بانک دیر جواب داد و تلاش دوم موفق بود. |
| Error | یک کار مشخص شکست خورد. | پرداخت سفارش ۱۰۴۲ بعد از سه تلاش شکست خورد. |
| Critical | کل برنامه یا یک بخش اصلی در خطر است. | اتصال به دیتابیس کاملاً قطع است. |
راهاندازی Serilog
کتابخانه Serilog در .NET خیلی رایج است. پشت همان رابط ILogger کار میکند. پس کد برنامه به Serilog وابسته نمیشود. این تنظیم، لاگها را به شکل JSON در خروجی کنسول مینویسد. در Kubernetes معمولاً یک ابزار جمعآوری همین خروجی را برمیدارد و به ابزار مرکزی میفرستد.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog(config => config
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(new CompactJsonFormatter()));
var app = builder.Build();
// One summary log line per HTTP request, with path, status code and duration.
app.UseSerilogRequestLogging();
سطح لاگ را در فایل تنظیمات میگذاریم تا بدون تغییر کد عوض شود. لاگهای پرحرف خود ASP.NET Core را هم کم میکنیم:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
}
}
فیلدهای مشترک با Scope
بعضی فیلدها باید روی همه لاگهای یک کار باشند. مثلاً شناسه سفارش روی همه لاگهای پرداخت. به جای تکرار آن در هر لاگ، یک Scope باز میکنیم:
using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = order.Id }))
{
logger.LogInformation("Calling bank");
// ...
logger.LogWarning("Bank answered after {ElapsedMs} ms", elapsed);
}
هر دو لاگ داخل این بلوک فیلد OrderId را دارند، بدون اینکه آن را در جمله بنویسیم.
شناسه مشترک بین سرویسها
یک سفارش از چند سرویس رد میشود: سفارش، انبار و پرداخت. برای اینکه همه لاگهای آن را با هم پیدا کنیم، همه لاگها باید یک شناسه مشترک داشته باشند. به آن Correlation Id میگوییم.
امروز بهترین انتخاب، همان Trace Id استاندارد W3C است. چون:
- خود .NET آن را میسازد. ASP.NET Core برای هر درخواست یک Activity میسازد که Trace Id دارد.
- خودکار بین سرویسها منتقل میشود. کلاس HttpClient آن را در هدر traceparent به سرویس بعدی میفرستد.
- لاگ را به Trace وصل میکند. از یک خط لاگ مستقیم به نمودار کامل مسیر درخواست میرسیم.
اگر لاگها را با OpenTelemetry بفرستی، Trace Id خودکار به هر لاگ اضافه میشود. نسخههای جدید Serilog هم Trace Id فعلی را همراه هر رویداد ثبت میکنند.
لاگ در مسیرهای پرتکرار
در کدی که هزاران بار در ثانیه اجرا میشود، هزینه لاگ مهم میشود. .NET یک Source Generator دارد که کد لاگ را در زمان کامپایل میسازد. این روش سریعتر است و اسم فیلدها را هم در یک جا ثابت نگه میدارد:
public static partial class PaymentLog
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} paid, amount {Amount}")]
public static partial void OrderPaid(this ILogger logger, int orderId, decimal amount);
}
// Usage:
logger.OrderPaid(order.Id, order.Total);
قانونهای مهم
- هیچ وقت داده حساس لاگ نکن. رمز عبور، شماره کامل کارت، توکن، کد یکبارمصرف. لاگها را آدمهای زیادی میبینند و مدت زیادی نگه داشته میشوند.
- همیشه الگو، هیچ وقت رشته ساختهشده. نه با علامت دلار، نه با جمع کردن رشتهها.
- سطح درست. خطای کاربر، مثل رمز اشتباه، خطای سیستم نیست. آن را Error ثبت نکن، وگرنه هشدارها بیمعنی میشوند.
- شناسه کسبوکار در لاگ. پشتیبانی Trace Id را نمیداند، ولی شماره سفارش را میداند.
- لاگ را در یک جای مرکزی جمع کن. لاگ روی دیسک یک Pod، با از بین رفتن آن Pod از بین میرود.
- حجم را کنترل کن. لاگ زیاد هزینه ذخیره دارد و پیدا کردن چیز مهم را سخت میکند. برای شمردن رویدادها از Metric استفاده کن، نه از لاگ.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| ساختن رشته با علامت دلار | فیلدی ذخیره نمیشود و جستجو سخت است. | الگو با آکولاد و مقدارها به شکل پارامتر. |
| لاگ کردن یک خطا در همه لایهها | یک خطا چند بار دیده میشود. | فقط جایی که خطا مدیریت میشود. |
| فقط متن خطا، بدون شیء Exception | Stack Trace و خطای داخلی گم میشود. | شیء Exception را به متد لاگ بده. |
| لاگ کردن کل درخواست و جواب | داده حساس در لاگ و هزینه زیاد. | فقط فیلدهای لازم. |
| سطح Debug روشن در Production | حجم زیاد و کندی. | سطح در تنظیمات، روشن کردن موقت هنگام نیاز. |
| بدون شناسه مشترک | لاگهای یک سفارش در چند سرویس به هم وصل نمیشوند. | Trace Id روی همه لاگها. |
خلاصه در شش خط
- لاگ ساختاریافته یعنی هر لاگ یک رویداد با فیلدهای نامدار است.
- همیشه از الگو با آکولاد استفاده کن، نه از رشته ساختهشده.
- سطح لاگ را درست انتخاب کن. در Production معمولاً Information و بالاتر.
- با Scope فیلدهای مشترک یک کار را یک بار اضافه کن.
- همه لاگها را در یک جای مرکزی جمع کن و Trace Id را روی آنها بگذار.
- داده حساس هیچ وقت وارد لاگ نشود.