راهنمای جامع لاگ در ASP.NET Core: از ILogger تا Serilog و OpenTelemetry

در این مقاله میخوانید
- ILogger و آنچه پشت صحنه میگذرد
- قالب پیام، نه درونیابی رشته
- پیکربندی سطحها در appsettings.json
- scope، trace_id و خروجی JSON
- کارایی: LoggerMessage و source generator
- خطاهای مدیریتنشده را یک بار لاگ کنید
- Serilog: وقتی خروجیها و غنیسازی جدی میشوند
- OpenTelemetry: استاندارد بیطرف
- و برنامههای .NET Framework قدیمی؟
- لاگ در BackgroundService و workerها
- اشتباههایی که بیشتر از همه دیدهام
- چکلیست ASP.NET Core
- منابع و مطالعهٔ بیشتر
در پروژههای ASP.NET Core که بررسی کردهام، لاگ تقریباً همیشه «کار میکرد». یعنی خروجی کنسول داشت و در محیط توسعه همهچیز دیده میشد. مشکل از روزی شروع میشد که برنامه روی سرور میرفت: یا هیچ لاگی جایی ذخیره نمیشد، یا فایلی بود که هر روز چند گیگ بزرگتر میشد و نیمی از آن پیامهای Entity Framework بود، یا لاگها متن سادهای بودند که نمیشد رویشان فیلتر کرد.
خبر خوب این است که ASP.NET Core زیرساخت لاگ بسیار خوبی دارد و بیشتر این مشکلات با پیکربندی درست حل میشوند، نه با کد بیشتر. این راهنما از ILogger خام شروع میکند، به پیکربندی، scope، کارایی و خطاها میرسد، و بعد دو مسیر رایج برای خروجی جدی را مقایسه میکند: Serilog و OpenTelemetry. مثالها برای .NET 8 و 9 نوشته شدهاند. اگر هنوز با اصول کلی لاگنویسی (سطحها، لاگ ساختیافته، دادهٔ حساس) آشنا نیستید، پیش از این مقاله راهنمای جامع لاگنویسی در بکاند را بخوانید.
ILogger و آنچه پشت صحنه میگذرد
هستهٔ لاگ در .NET کتابخانهٔ Microsoft.Extensions.Logging است و سه مفهوم دارد که فهمیدنشان بقیهٔ مقاله را ساده میکند:
- ILogger: رابطی که کد شما با آن لاگ مینویسد. هیچ اطلاعی ندارد لاگ به کجا میرود.
- Provider: مقصدها. Console، Debug، EventLog، و هر چیزی که کتابخانههای بیرونی اضافه کنند (OpenTelemetry، Serilog). هر خط لاگ به همهٔ providerهای ثبتشده میرسد.
- Category: نام logger، که معمولاً نام کامل کلاسی است که لاگ مینویسد. فیلتر سطح بر اساس همین نام کار میکند.
در عمل، ILogger<T> را از DI میگیرید و category خودکار نام کلاس میشود:
public class OrdersController(ILogger<OrdersController> logger, IOrderService orders) : ControllerBase
{
[HttpPost("{id:int}/pay")]
public async Task<IActionResult> Pay(int id, CancellationToken ct)
{
var result = await orders.PayAsync(id, ct);
if (!result.Succeeded)
{
logger.LogWarning("Payment for order {OrderId} declined by {Gateway} with code {GatewayCode}",
id, result.Gateway, result.Code);
return Problem(statusCode: 402, title: "Payment declined");
}
logger.LogInformation("Order {OrderId} paid, amount {AmountRial}", id, result.Amount);
return Ok();
}
}
در Minimal API هم همینطور است؛ ILogger<Program> یا ILoggerFactory را در پارامترهای handler بگیرید.
قالب پیام، نه درونیابی رشته
اگر فقط یک چیز از این مقاله یادتان بماند، همین باشد. این دو خط ظاهراً یک کار میکنند:
logger.LogInformation($"Order {id} paid"); // اشتباه
logger.LogInformation("Order {OrderId} paid", id); // درست
در خط اول، رشته پیش از رسیدن به logger ساخته میشود؛ logger فقط یک متن میبیند و هیچ فیلدی در کار نیست. در خط دوم، logger قالب "Order {OrderId} paid" و مقدار OrderId را جدا دریافت میکند. هر provider ساختیافته (JSON console، Serilog، OpenTelemetry) آن را بهصورت فیلد مستقل ذخیره میکند و بعداً میتوانید بپرسید «همهٔ لاگهای سفارش ۱۲۳۴». بهعلاوه، اگر سطح Information خاموش باشد، در خط دوم هیچ رشتهای ساخته نمیشود.
چند نکتهٔ ریز دربارهٔ قالبها:
- نامگذاریها را PascalCase و در کل پروژه یکدست نگه دارید: همهجا
OrderId، نه یک جاOrderIdو جای دیگرorderID. - مقادیر به ترتیب جایگذاری میشوند، نه بر اساس نام. اگر ترتیب آرگومانها را عوض کنید، فیلدها جابهجا میشوند.
- تحلیلگر
CA2254در .NET، استفاده از قالب غیرثابت را هشدار میدهد. روشنش نگه دارید.
پیکربندی سطحها در appsettings.json
پیشفرض قالب پروژه، سطح Information برای کد شما و Warning برای Microsoft.AspNetCore است. این نقطهٔ شروع خوبی است، ولی در پروداکشن معمولاً باید چند category پرحرف دیگر را هم پایین بیاورید:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
قاعدهٔ فیلتر ساده است: طولانیترین پیشوند منطبق برنده است. پس Microsoft.EntityFrameworkCore.Database.Command روی Microsoft اولویت دارد. میتوانید برای هر provider هم جداگانه سطح بگذارید (مثلاً بخش "Console": { "LogLevel": { ... } } داخل Logging).
دو نکتهٔ عملی که چند بار جلوی اتلاف وقت را گرفتهاند. اول، category خطای EF Core برای هر کوئری (Database.Command) در سطح Information متن SQL را لاگ میکند. در توسعه مفید است؛ در پروداکشن هم حجم را منفجر میکند و هم ممکن است مقادیر پارامترها را بیرون بریزد اگر EnableSensitiveDataLogging روشن باشد — که هرگز نباید در پروداکشن روشن باشد. دوم، چون پیکربندی از سیستم configuration میآید، میتوانید سطح را با متغیر محیطی عوض کنید، بی تغییر فایل: Logging__LogLevel__Default=Debug.
scope، trace_id و خروجی JSON
scope راه افزودن فیلدهای مشترک به همهٔ خطوط یک بخش از کار است:
using (logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = order.Id,
["CustomerId"] = order.CustomerId
}))
{
logger.LogInformation("Reserving stock");
await inventory.ReserveAsync(order, ct);
logger.LogInformation("Charging payment");
await payments.ChargeAsync(order, ct);
}
هر خطی که داخل این بلوک نوشته شود — حتی در سرویسهای inventory و payments — این دو فیلد را دارد، به شرط آنکه provider شما scope را پشتیبانی کند و روشن باشد.
ASP.NET Core خودش هم برای هر درخواست scope میسازد (RequestId و RequestPath). از .NET 5 به بعد، میتوانید شناسههای W3C را هم خودکار روی هر خط بنشانید:
var builder = WebApplication.CreateBuilder(args);
builder.Logging.Configure(o =>
o.ActivityTrackingOptions = ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId);
builder.Logging.AddJsonConsole(o =>
{
o.IncludeScopes = true;
o.UseUtcTimestamp = true;
o.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
});
با همین چند خط، خروجی کنسول برنامه به JSON تبدیل میشود که فیلدهای قالب، scopeها و TraceId/SpanId را دارد. اگر برنامه در کانتینر اجرا میشود و یک agent خروجی کنسول را جمع میکند، این برای خیلی از تیمها کافی است و به هیچ پکیج بیرونی نیازی نیست. trace_id همان شناسهای است که اگر سرویسهای دیگر هم W3C Trace Context را رعایت کنند، کل مسیر درخواست را به هم وصل میکند.
کارایی: LoggerMessage و source generator
متدهای LogInformation و همخانوادهاش راحتاند ولی ارزان نیستند: آرگومانها در یک آرایهٔ object[] بسته میشوند، نوعهای مقداری boxing میشوند و قالب در هر فراخوانی تجزیه میشود. برای ۹۹ درصد کدها این هزینه اهمیتی ندارد. برای مسیرهای داغ — middlewareها، حلقههای پردازش صف، کد کتابخانهای — source generator لاگ در .NET 6 به بعد راه بهتری است:
public static partial class PaymentLog
{
[LoggerMessage(Level = LogLevel.Warning,
Message = "Payment for order {OrderId} declined by {Gateway} with code {GatewayCode}")]
public static partial void PaymentDeclined(this ILogger logger, int orderId, string gateway, string gatewayCode);
}
// استفاده:
logger.PaymentDeclined(id, result.Gateway, result.Code);
کامپایلر کد بهینه را تولید میکند: بدون boxing، بدون تجزیهٔ قالب در زمان اجرا، و با بررسی فعال بودن سطح پیش از هر کاری. یک مزیت جانبی هم دارد که شاید از کارایی مهمتر باشد: همهٔ پیامهای یک بخش در یک فایل جمع میشوند و مرور و یکدست کردنشان ساده میشود. اگر پروژهتان قدیمیتر است، LoggerMessage.Define همین کار را دستی انجام میدهد.
برای جاهایی که ساختن آرگومان خودش گران است (مثلاً سریال کردن یک شیء)، پیش از لاگ بپرسید:
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Cart snapshot {Cart}", JsonSerializer.Serialize(cart));
}
خطاهای مدیریتنشده را یک بار لاگ کنید
وقتی exception از یک endpoint بیرون میرود، middleware مدیریت خطا (UseExceptionHandler در پروداکشن، صفحهٔ خطای توسعهدهنده در محیط توسعه) آن را با سطح Error و کامل، همراه با stack trace، لاگ میکند. یعنی برای خطاهای غیرمنتظره، لازم نیست در هر action بلوک try/catch بگذارید و لاگ کنید. اگر این کار را بکنید و دوباره پرتاب کنید، هر خطا دو بار (یا بیشتر) لاگ میشود.
جای درست try/catch جایی است که واقعاً کاری با خطا میکنید: تلاش دوباره، برگرداندن پاسخ جایگزین، یا تبدیلش به خطای کسبوکاری. در آنجا لاگ کنید — و exception را بهعنوان اولین آرگومان بدهید، نه ex.Message را در متن:
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "SMS gateway unavailable, queuing message {MessageId} for retry", message.Id);
await retryQueue.EnqueueAsync(message, ct);
}
یک نکته دربارهٔ AddHttpLogging که در .NET 6 به بعد هست: middleware خوبی است برای دیدن جزئیات درخواست و پاسخ، ولی اگر بدنه و هدرها را روشن کنید، بهراحتی کوکی و توکن و دادهٔ شخصی لاگ میکند. فیلدهایی که لاگ میشوند را صریحاً و حداقلی انتخاب کنید. برای بیشتر سیستمها، یک خط خلاصه در پایان درخواست (مسیر، وضعیت، مدت) کافی است.
Serilog: وقتی خروجیها و غنیسازی جدی میشوند
Serilog سالها پیش از اینکه لاگ ساختیافته در .NET عادی شود، آن را رواج داد و هنوز پرکاربردترین کتابخانهٔ لاگ در اکوسیستم .NET است. نقطهٔ قوتش اکوسیستم sinkهاست — sink یعنی مقصد خروجی: فایل با چرخش، Seq، Elasticsearch، دیتابیس، و دهها مورد دیگر — بهعلاوهٔ enricherها و پیکربندی کامل از appsettings. با پکیج Serilog.AspNetCore، Serilog جایگزین providerهای پیشفرض میشود ولی کد شما همچنان با ILogger<T> مینویسد.
// dotnet add package Serilog.AspNetCore
// dotnet add package Serilog.Sinks.Seq (فقط اگر به Seq یا سرویس سازگار میفرستید)
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "orders-api")
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter()));
var app = builder.Build();
app.UseSerilogRequestLogging();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
سه نکته در این کد مهماند. CreateBootstrapLogger خطاهای مرحلهٔ راهاندازی (پیش از ساخته شدن host) را هم ثبت میکند؛ بدون آن، برنامهای که هنگام بالا آمدن میمیرد، هیچ ردی در لاگها نمیگذارد. UseSerilogRequestLogging برای هر درخواست یک خط خلاصه با مسیر، وضعیت و مدت مینویسد و جای چند خط پرحرف ASP.NET Core را میگیرد. و Log.CloseAndFlush مطمئن میشود بافر sinkهای شبکهای پیش از خروج خالی شود.
سطحها در Serilog زیر بخش "Serilog" در appsettings پیکربندی میشوند (MinimumLevel.Default و MinimumLevel.Override)، نه زیر "Logging". این رایجترین گیجیای است که در مهاجرت به Serilog دیدهام: کسی Logging:LogLevel را عوض میکند و تعجب میکند که چرا اثری ندارد. پیکربندی کامل، enricherها و sinkها را در مقالهٔ Serilog در ASP.NET Core: پیکربندی درست از صفر قدمبهقدم آوردهام.
OpenTelemetry: استاندارد بیطرف
OpenTelemetry (یا OTel) پروژهٔ متنباز بنیاد CNCF برای استانداردسازی لاگ، متریک و trace است. برخلاف Serilog، جایگزین ILogger نمیشود؛ یک provider دیگر کنار بقیه است که لاگها را به قالب استاندارد OTLP درمیآورد و با یک exporter به هر مقصدی که OTLP بفهمد میفرستد. مزیت اصلیاش بیطرفی است: کد شما به هیچ فروشندهای گره نمیخورد و تغییر مقصد فقط تغییر پیکربندی است. مزیت دوم، trace_id و span_id است که خودکار و درست روی هر لاگ مینشیند.
// dotnet add package OpenTelemetry.Extensions.Hosting
// dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeFormattedMessage = true;
o.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService(serviceName: "orders-api")
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment.name"] = builder.Environment.EnvironmentName
}))
.WithLogging(logging => logging.AddOtlpExporter());
نشانی مقصد و هدر احراز هویت را بهتر است از متغیرهای محیطی استاندارد OTel بدهید تا در کد نباشند:
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=YOUR_KEY
با پروتکل http/protobuf و متغیر OTEL_EXPORTER_OTLP_ENDPOINT، exporter خودش مسیر /v1/logs را به نشانی اضافه میکند. اگر نشانی را در کد با options.Endpoint بدهید، باید مسیر کامل را خودتان بنویسید — این تفاوت ظریف، دلیل بیشتر خطاهای «۴۰۴ از exporter» است که دیدهام.
کدام را انتخاب کنیم؟ پاسخ صادقانهٔ من این است: اگر امروز Serilog دارید و راضی هستید، دلیلی برای کنار گذاشتنش نیست؛ sink مناسب مقصدتان را اضافه کنید. اگر پروژهٔ تازه است یا چند زبان و چند سرویس دارید، OpenTelemetry انتخاب آیندهدارتری است چون همهٔ سرویسها — .NET، Java، Node، Go — به یک زبان مشترک لاگ و trace میفرستند. ترکیب هر دو هم ممکن است (Serilog با sink مربوط به OpenTelemetry)، ولی پیچیدگیاش را فقط وقتی بپذیرید که واقعاً لازم است. یک معیار عملی دیگر هم دارم: اگر تیم شما روزی بخواهد trace و متریک هم جمع کند، OpenTelemetry همان یک پیکربندی را با دو خط بیشتر گسترش میدهد، در حالی که با Serilog باید برای آنها ابزار جدایی بیاورید. مفاهیم پایهٔ trace و span را در OpenTelemetry به زبان ساده توضیح دادهام.
برای اتصال عملی به LogMug هر دو مسیر کار میکنند و هیچ پکیج اختصاصی لازم نیست: اتصال ASP.NET Core با OpenTelemetry و اتصال برنامههایی که Serilog دارند با همان پکیج استاندارد Serilog.Sinks.Seq.
و برنامههای .NET Framework قدیمی؟
بیشتر سازمانهایی که با آنها کار کردهام، کنار سرویسهای تازهٔ ASP.NET Core، یکی دو برنامهٔ ASP.NET MVC یا Web Forms یا سرویس ویندوزی روی .NET Framework 4.x دارند که هیچکس جرئت دست زدن به آنها را ندارد. این برنامهها Microsoft.Extensions.Logging را بهشکل پیشفرض ندارند و معمولاً با log4net یا NLog یا کلاس دستساز در فایل مینویسند.
لازم نیست برای لاگ متمرکز مهاجرتشان دهید. Serilog از netstandard2.0 پشتیبانی میکند و روی .NET Framework 4.6.2 به بالا کار میکند؛ با چند خط پیکربندی در Global.asax یا نقطهٔ شروع سرویس، همان لاگ ساختیافته و همان مقصد سرویسهای جدید را خواهید داشت. جزئیات، ازجمله پل زدن از log4net موجود، در لاگ متمرکز برای برنامههای .NET Framework قدیمی آمده است.
لاگ در BackgroundService و workerها
بیشتر راهنماها فقط دربارهٔ درخواستهای HTTP حرف میزنند، ولی در سیستمهایی که دیدهام، سختترین خطاها در کارهای پسزمینه پنهان بودهاند: پردازش صف، جابهای زمانبندیشده، همگامسازی با سیستمهای بیرونی. این کدها کاربری ندارند که خطا را ببیند و گزارش بدهد؛ اگر لاگ درستی ننویسند، ممکن است روزها بیصدا شکست بخورند.
دو تفاوت مهم با درخواست HTTP دارند. اول، هیچ middlewareای خطای مدیریتنشده را برایشان لاگ نمیکند. exceptionی که از ExecuteAsync بیرون برود، در .NET 6 به بعد بهطور پیشفرض کل host را متوقف میکند (رفتار BackgroundServiceExceptionBehavior.StopHost) — که دستکم دیده میشود — ولی خطای درون حلقه که گرفته و بلعیده شود، هیچ ردی نمیگذارد. دوم، هیچ scope درخواستی خودکار ساخته نمیشود؛ خودتان باید برای هر پیام یا هر اجرای جاب، scope با شناسهٔ آن بسازید:
public class OrderQueueWorker(IOrderQueue queue, ILogger<OrderQueueWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in queue.ReadAllAsync(stoppingToken))
{
using var scope = logger.BeginScope(new Dictionary<string, object>
{
["MessageId"] = message.Id,
["OrderId"] = message.OrderId
});
try
{
await ProcessAsync(message, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Processing queue message failed, attempt {Attempt}", message.Attempt);
await queue.NackAsync(message, stoppingToken);
}
}
}
}
اینجا catch درست است، چون واقعاً کاری با خطا میکنیم (پیام را به صف برمیگردانیم) و حلقه نباید با یک پیام خراب بمیرد. فیلتر when هم مطمئن میشود توقف عادی برنامه بهعنوان خطا لاگ نشود — یکی از منابع رایج Errorهای کاذب. اگر پیام از سرویس دیگری آمده و trace_id آن را همراه دارد، با ساختن یک Activity با همان والد، لاگهای worker هم به همان درخواست اصلی وصل میشوند؛ OpenTelemetry برای بیشتر کتابخانههای صف این کار را خودکار انجام میدهد.
اشتباههایی که بیشتر از همه دیدهام
- دو سیستم لاگ موازی. نیمی از کد با
ILoggerو نیم دیگر باLog.Informationایستای Serilog یا یک کلاسLogHelperدستساز مینویسد. پیکربندی سطحها برای یکی اثر دارد و برای دیگری نه، و scopeها فقط در یکی دیده میشوند. یکی را انتخاب کنید؛ در کد برنامه،ILogger<T>تقریباً همیشه انتخاب بهتری است. - لاگ در سازندههای ایستا و پیش از ساخته شدن host. این لاگها به هیچ provider پیکربندیشدهای نمیرسند. برای مرحلهٔ راهاندازی، bootstrap logger لازم است.
- سطح Debug در پروداکشن، «فقط موقتاً». سه ماه بعد هنوز روشن است. تغییر سطح را با متغیر محیطی و با تاریخ پایان مشخص انجام دهید، یا فقط برای یک category.
- لاگ کردن
HttpContext.Request.Headersبرای دیباگ. کوکی و هدرAuthorizationمستقیم به لاگ میروند. - اعتماد به کنسول در سرویس ویندوزی یا IIS. در این محیطها خروجی کنسول به جایی نمیرود. برنامه «لاگ دارد» ولی هیچکس هرگز آن را نمیبیند.
- نبود نام سرویس و محیط. تا وقتی یک برنامه دارید مشکلی نیست؛ روزی که لاگ دو برنامه و دو محیط در یک جا جمع شود، تشخیص اینکه هر خط از کجا آمده ناممکن میشود.
چکلیست ASP.NET Core
- همهٔ
$"..."ها را در فراخوانیهای لاگ پیدا و به قالب پیام تبدیل کنید. تحلیلگر CA2254 را روشن کنید. - سطح پیشفرض پروداکشن را Information و categoryهای پرحرف (
Microsoft.AspNetCore، EF Core، HttpClient) را Warning بگذارید. - مطمئن شوید
EnableSensitiveDataLoggingفقط در Development روشن است. - TraceId و SpanId را روی همهٔ خطوط بنشانید (ActivityTrackingOptions یا OpenTelemetry).
- بلوکهای «لاگ کن و دوباره پرتاب کن» را حذف کنید؛ خطاهای مدیریتنشده را به middleware بسپارید.
- برای پیامهای مسیرهای داغ از
[LoggerMessage]استفاده کنید. - خروجی را ساختیافته کنید و به یک مقصد متمرکز بفرستید — با Serilog یا OpenTelemetry، هر کدام که با تیم و زیرساختتان جورتر است.
منابع و مطالعهٔ بیشتر
- لاگ در .NET و ASP.NET Core در Microsoft Learn — مرجع رسمی providerها، فیلتر سطح، scope و پیکربندی.
- source generator لاگ در .NET — همهٔ گزینههای ویژگی
LoggerMessageو قیدهای آن. - لاگ در OpenTelemetry .NET — راهنمای رسمی اتصال ILogger به OpenTelemetry و بهترین روشهای آن.
- مستندات Serilog.AspNetCore — راهاندازی دومرحلهای، request logging و پیکربندی از appsettings از زبان نگهدارندگان پروژه.
لاگ همهٔ سرویسهایتان را در یک جا جستجو کنید
C#، Java یا هر زبان دیگر — با چند خط پیکربندی وصل میشود. پلن رایگان کارت بانکی نمیخواهد.
شروع رایگان