ASP.NET Core 日志体系
大约 10 分钟约 3034 字
ASP.NET Core 日志体系
简介
日志不是简单的“打印字符串”,而是服务端系统最基础的观测手段之一。对于 ASP.NET Core 项目来说,日志体系的核心价值在于:帮助你快速定位请求链路问题、理解系统运行状态、支撑审计和告警,并为性能分析与故障复盘提供可靠依据。
特点
实现
内置日志基础使用
public class UserService
{
private readonly ILogger<UserService> _logger;
public UserService(ILogger<UserService> logger)
{
_logger = logger;
}
public async Task<UserDto?> GetUserAsync(int id, CancellationToken cancellationToken)
{
_logger.LogInformation("Start querying user {UserId}", id);
try
{
await Task.Delay(30, cancellationToken);
_logger.LogDebug("User {UserId} query completed", id);
return new UserDto(id, "SunnyFan", "sunnyfan@example.com");
}
catch (OperationCanceledException ex)
{
_logger.LogWarning(ex, "User query cancelled. UserId={UserId}", id);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error while querying user {UserId}", id);
throw;
}
}
}
public record UserDto(int Id, string Name, string Email);// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"MyApp.Services": "Debug"
}
}
}日志级别建议:
- Trace:极细粒度调试,通常只在开发排障中开启
- Debug:流程和变量信息,适合开发和测试环境
- Information:关键业务流程正常记录
- Warning:可恢复异常、降级、重试、超时边缘情况
- Error:明确失败,需要关注
- Critical:系统级故障,需要立即处理结构化日志与 Scope
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task<Guid> CreateOrderAsync(CreateOrderCommand command, CancellationToken cancellationToken)
{
var orderId = Guid.NewGuid();
using var scope = _logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = orderId,
["UserId"] = command.UserId,
["TraceType"] = "OrderCreate"
});
_logger.LogInformation(
"Creating order. ItemCount={ItemCount}, TotalAmount={TotalAmount}",
command.Items.Count,
command.Items.Sum(x => x.UnitPrice * x.Quantity));
await Task.Delay(50, cancellationToken);
_logger.LogInformation("Order created successfully");
return orderId;
}
}
public record CreateOrderCommand(long UserId, List<OrderItem> Items);
public record OrderItem(long ProductId, int Quantity, decimal UnitPrice);// 正确:结构化日志
_logger.LogInformation("User {UserId} login success at {LoginTime}", userId, DateTimeOffset.UtcNow);
// 不推荐:字符串插值会失去结构化字段优势
_logger.LogInformation($"User {userId} login success at {DateTimeOffset.UtcNow}");结构化日志的价值:
- 可以按字段搜索
- 可以聚合统计
- 可以在 Seq / Kibana 中按 UserId、OrderId、TenantId 检索Serilog 集成
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Threadusing Serilog;
using Serilog.Events;
var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.Enrich.WithProperty("Application", "SunnyFan API")
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 10 * 1024 * 1024)
.CreateLogger();
builder.Host.UseSerilog();var app = builder.Build();
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} => {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (ctx, http) =>
{
ctx.Set("RequestHost", http.Request.Host.Value);
ctx.Set("RequestScheme", http.Request.Scheme);
ctx.Set("RemoteIp", http.Connection.RemoteIpAddress?.ToString());
ctx.Set("UserAgent", http.Request.Headers.UserAgent.ToString());
};
});高性能日志与源生成
public static partial class AppLog
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Order {OrderId} created successfully for user {UserId}")]
public static partial void OrderCreated(ILogger logger, Guid orderId, long userId);
[LoggerMessage(EventId = 2001, Level = LogLevel.Error, Message = "Payment failed. OrderId={OrderId}, Reason={Reason}")]
public static partial void PaymentFailed(ILogger logger, Guid orderId, string reason, Exception exception);
}// 使用 Source Generator 生成的日志方法
AppLog.OrderCreated(_logger, orderId, userId);
AppLog.PaymentFailed(_logger, orderId, "timeout", ex);高频热点路径下:
- 优先使用结构化日志
- 对高频、固定模板日志优先考虑 LoggerMessage / Source Generator
- 避免在日志参数里做重 JSON 序列化和大对象 ToString()集中式日志与生产治理
// Serilog 输出到 Elasticsearch / Seq / OpenTelemetry 的思路类似
// 关键不在 sink,而在日志字段设计
var importantFields = new
{
TraceId = "trace-001",
UserId = 1001,
TenantId = "tenant-a",
RequestPath = "/api/orders",
OrderId = Guid.NewGuid(),
Environment = "Production"
};生产环境建议至少统一这些字段:
- TraceId / CorrelationId
- UserId / TenantId
- RequestPath / HttpMethod / StatusCode
- ServiceName / Environment / Version
- OrderId / BusinessId / AggregateId(按业务需要)日志级别动态调整
// 运行时动态调整日志级别(不重启服务)
// 方式1:通过 IConfiguration 动态刷新
builder.Services.Configure<LoggerFilterOptions>(
builder.Configuration.GetSection("Logging"));
// 方式2:通过代码动态设置
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
loggerFactory.AddProvider(new CustomLogLevelProvider());
// 方式3:通过环境变量覆盖
// ASPNETCORE_LOGGING__LOGLEVEL__DEFAULT=Debug
// ASPNETCORE_LOGGING__LOGLEVEL__MICROSOFT=Information// 自定义日志级别过滤器
public class EnvironmentAwareLoggerFilter : ILoggerFilter
{
private readonly string _environment;
public EnvironmentAwareLoggerFilter(IHostEnvironment env)
{
_environment = env.EnvironmentName;
}
public bool IsEnabled(LogLevel level, string category)
{
// 开发环境允许 Trace 级别
if (_environment == "Development")
return true;
// 生产环境只允许 Information 及以上
return level >= LogLevel.Information;
}
}日志分类与过滤策略
// 按命名空间精细控制日志级别
builder.Services.AddLogging(logging =>
{
logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
logging.AddFilter("Microsoft.AspNetCore.Hosting", LogLevel.Information);
logging.AddFilter("Microsoft.AspNetCore.Routing", LogLevel.Warning);
logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
logging.AddFilter("MyApp.Services.PaymentService", LogLevel.Debug);
logging.AddFilter("MyApp.Services.OrderService", LogLevel.Information);
});
// 按自定义类别过滤
builder.Services.AddLogging(logging =>
{
logging.AddFilter((provider, category, logLevel) =>
{
// 数据库访问日志只记录 Warning 以上
if (category.StartsWith("Microsoft.EntityFrameworkCore"))
return logLevel >= LogLevel.Warning;
// 第三方 HTTP 客户端只记录 Error
if (category.Contains("HttpClient"))
return logLevel >= LogLevel.Error;
return true;
});
});日志性能优化
// 1. 避免在日志参数中进行复杂计算
// ❌ 无论日志是否输出,字符串拼接都会执行
_logger.LogInformation($"Order details: {SerializeOrder(order)}");
// ✅ 使用结构化占位符,只在日志实际输出时才格式化
_logger.LogInformation("Order {OrderId} created with {ItemCount} items", order.Id, order.Items.Count);
// 2. 条件检查避免不必要的日志构造
if (_logger.IsEnabled(LogLevel.Debug))
{
// 仅在 Debug 级别启用时才执行昂贵操作
var debugInfo = await BuildDebugInfoAsync();
_logger.LogDebug("Debug info: {@DebugInfo}", debugInfo);
}
// 3. 大对象日志使用 @ 结构化序列化
_logger.LogInformation("Received order: {@Order}", order); // 完整对象序列化
_logger.LogInformation("Order key: {OrderId}", order.Id); // 只记录标量值
// 4. 日志消息模板缓存
// LoggerMessage Source Generator 会自动缓存模板
// 手动写时避免每次构造新模板字符串
private static readonly Action<ILogger, int, string, Exception?> _orderCreatedLog =
LoggerMessage.Define<int, string>(LogLevel.Information, new EventId(1001, "OrderCreated"),
"Order {OrderId} created by {UserId}");
// 5. 批量日志场景
public class BatchLogHelper
{
private readonly ILogger _logger;
private readonly StringBuilder _buffer = new();
private int _count;
public BatchLogHelper(ILogger logger) => _logger = logger;
public void Add(string message)
{
_buffer.AppendLine(message);
_count++;
}
public void Flush()
{
if (_count > 0)
{
_logger.LogInformation("Batch log ({Count} entries): {Log}", _count, _buffer.ToString());
_buffer.Clear();
_count = 0;
}
}
}敏感信息脱敏
// 自定义脱敏 Enricher
public class SensitiveDataEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
// 对包含手机号的字段脱敏
if (logEvent.Properties.TryGetValue("PhoneNumber", out var phoneValue))
{
var phone = phoneValue.ToString().Trim('"');
if (phone.Length == 11)
{
var masked = phone.Substring(0, 3) + "****" + phone.Substring(7);
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("PhoneNumber", masked));
}
}
// 对身份证号脱敏
if (logEvent.Properties.TryGetValue("IdCard", out var idValue))
{
var idCard = idValue.ToString().Trim('"');
if (idCard.Length == 18)
{
var masked = idCard.Substring(0, 4) + "**********" + idCard.Substring(14);
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("IdCard", masked));
}
}
}
}
// Serilog 注册脱敏 Enricher
Log.Logger = new LoggerConfiguration()
.Enrich.With<SensitiveDataEnricher>()
.WriteTo.Console()
.CreateLogger();
// 日志中的敏感信息检查规则
// 绝不能出现的字段:password, token, secret, apikey, creditcard
// 需要脱敏的字段:phone, email, idcard, address, ip
// 可以明文记录的字段:orderid, userid, traceid, status日志与 OpenTelemetry 集成
// Serilog 输出到 OpenTelemetry
// dotnet add package Serilog.Sinks.OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("MyApp");
tracing.AddAspNetCoreInstrumentation();
tracing.AddHttpClientInstrumentation();
tracing.AddEntityFrameworkCoreInstrumentation();
});
Log.Logger = new LoggerConfiguration()
.WriteTo.OpenTelemetry(options =>
{
options.Endpoint = "http://otel-collector:4317";
options.ResourceAttributes = new Dictionary<string, object>
{
["service.name"] = "MyApp",
["service.version"] = "1.0.0",
["deployment.environment"] = builder.Environment.EnvironmentName
};
})
.CreateLogger();
// 日志中携带 TraceId 和 SpanId
builder.Host.UseSerilog((context, services, loggerConfig) =>
{
loggerConfig
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("ServiceName", "MyApp");
});
// 在日志中自动注入 TraceId
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("TraceId", Activity.Current?.Id ?? httpContext.TraceIdentifier);
diagnosticContext.Set("SpanId", Activity.Current?.SpanId.ToString());
diagnosticContext.Set("ParentId", Activity.Current?.ParentId.ToString());
};
});多环境日志策略
// Program.cs — 按环境差异化配置
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {SourceContext}{NewLine} {Message:lj}{NewLine}{Exception}")
.CreateLogger();
}
else if (builder.Environment.IsStaging())
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.File("logs/staging-.log", rollingInterval: RollingInterval.Day)
.WriteTo.Seq("http://seq-server:5341")
.CreateLogger();
}
else
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.MinimumLevel.Override("MyApp", Serilog.Events.LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.WriteTo.Console()
.WriteTo.File("logs/production-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 50 * 1024 * 1024)
.WriteTo.OpenTelemetry(options =>
{
options.Endpoint = builder.Configuration["Otlp:Endpoint"]!;
})
.CreateLogger();
}
builder.Host.UseSerilog();审计日志实现
// 审计日志接口
public interface IAuditLogService
{
Task LogAsync(AuditLogEntry entry);
}
public record AuditLogEntry(
string Action,
string EntityType,
string EntityId,
long? UserId,
string? UserName,
Dictionary<string, object?> OldValues,
Dictionary<string, object?> NewValues,
string IpAddress,
DateTime Timestamp);
// 审计日志实现(写入独立表或消息队列)
public class EfCoreAuditLogService : IAuditLogService
{
private readonly AuditLogDbContext _db;
private readonly ILogger<EfCoreAuditLogService> _logger;
public EfCoreAuditLogService(AuditLogDbContext db, ILogger<EfCoreAuditLogService> logger)
{
_db = db;
_logger = logger;
}
public async Task LogAsync(AuditLogEntry entry)
{
try
{
_db.AuditLogs.Add(new AuditLog
{
Action = entry.Action,
EntityType = entry.EntityType,
EntityId = entry.EntityId,
UserId = entry.UserId,
UserName = entry.UserName,
OldValues = JsonSerializer.Serialize(entry.OldValues),
NewValues = JsonSerializer.Serialize(entry.NewValues),
IpAddress = entry.IpAddress,
Timestamp = entry.Timestamp
});
await _db.SaveChangesAsync();
}
catch (Exception ex)
{
// 审计日志写入失败不应影响业务流程
_logger.LogError(ex, "审计日志写入失败: {Action} {EntityType} {EntityId}",
entry.Action, entry.EntityType, entry.EntityId);
}
}
}
// 审计日志中间件
public class AuditLogMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AuditLogMiddleware> _logger;
public AuditLogMiddleware(RequestDelegate next, ILogger<AuditLogMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IAuditLogService auditService)
{
// 记录需要审计的操作(POST / PUT / DELETE)
if (context.Request.Method is "POST" or "PUT" or "DELETE"
&& context.Request.Path.StartsWithSegments("/api"))
{
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
context.Response.Body = originalBodyStream;
responseBody.Seek(0, SeekOrigin.Begin);
if (context.Response.StatusCode is 200 or 201 or 204)
{
_logger.LogInformation(
"Audit: {Method} {Path} => {StatusCode} by {UserId}",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
context.User.FindFirst("sub")?.Value);
}
await responseBody.CopyToAsync(originalBodyStream);
}
else
{
await _next(context);
}
}
}优点
缺点
总结
ASP.NET Core 日志体系的重点不是“有没有日志”,而是“日志是否可定位、可检索、可复盘、可治理”。真正成熟的日志实践,应该把结构化字段、请求链路、业务主键、错误分类和敏感信息边界一起纳入设计,而不是等线上出问题后再临时补日志。
关键知识点
ILogger是统一抽象,Serilog / NLog / ELK 是不同实现与输出链路。- 结构化日志比字符串日志更适合检索和聚合分析。
- Scope 适合把同一请求 / 订单 / 操作链路关联起来。
- 日志既要够用,也不能无节制滥打。
项目落地视角
- API 服务至少记录请求、错误、关键业务动作和外部依赖异常。
- 后台任务要补 traceId、任务 ID、批次号和执行耗时。
- 微服务系统应把 TraceId 与日志平台、链路追踪系统统一起来。
- 高风险操作(退款、审批、删除、权限变更)建议单独记录审计日志。
常见误区
- 全部都打 Information,结果重要信号被淹没。
- 只打中文描述,不打结构化字段,后期无法搜索和聚合。
- 发生异常时只输出 ex.Message,不保留完整上下文。
- 把密码、Token、身份证号等敏感信息直接写进日志。
进阶路线
- 学习 Serilog、Seq、ELK、OpenTelemetry 的组合实践。
- 将日志与 Trace / Metrics 打通形成完整可观测体系。
- 建立日志字段规范、保留策略和审计分层方案。
- 研究采样、异步日志、冷热分层和成本治理策略。
适用场景
- 所有 ASP.NET Core Web API / MVC / Worker Service 项目。
- 微服务、后台任务、批处理、集成服务。
- 需要审计、告警、运营分析的业务系统。
- 需要接入 ELK / Seq / 云日志平台的生产项目。
落地建议
- 从一开始就定义关键日志字段和级别策略。
- 关键业务操作建立统一日志模板,不要各写各的。
- 对异常和外部依赖失败补充 traceId 和业务主键。
- 在日志平台中建立高频查询视图和故障排查面板。
排错清单
- 线上查不到关键日志时,先检查级别过滤和输出目标配置。
- 请求链路断开时,先检查 traceId / CorrelationId 是否贯通。
- 日志太多看不清时,先检查级别设计和结构化字段是否合理。
- ELK / Seq 中无法检索时,先检查字段名是否统一、结构是否正确。
复盘问题
- 当前日志是帮助你定位问题,还是制造更多噪音?
- 哪些业务主键应该成为默认日志字段?
- 你的系统是否已经区分了“调试日志”“业务日志”“审计日志”?
- 如果今天发生生产事故,现有日志足够支撑完整复盘吗?
