Skip to content
Go back

Serilog Enrichers:给每条日志自动注入上下文

Serilog Enrichers 解决的是日志里最烦人的一件事:保证每条日志都带着排查问题需要的上下文。没有 Enricher 的时候,你总是在事后问自己”这条日志是哪个服务器打的?""当时是哪个请求?""哪个用户触发了这段代码?“——然后发现写日志的时候根本没记这些信息。

Enricher 在 Serilog 的管线里自动给每条日志事件附加属性,在所有 sink 收到日志之前就处理完了。配置一次,之后就不用再管。不管你需要多服务器调试用的机器名、分布式追踪用的关联 ID,还是自定义的应用元数据,Enricher 都能透明地搞定。

什么是 Serilog Enricher

Enricher 是 Serilog 管线中的一个组件,在日志事件到达 sink 之前添加、删除或修改属性。Enricher 实现 ILogEventEnricher 接口,对每条经过管线的事件执行。

数据源 → [Enrichers] → Filters → Sinks

Enricher 本质上是装饰器模式的思路:它在源代码不知情的情况下给日志事件追加属性。代码里写的是 _logger.LogInformation("订单 {OrderId} 已创建", orderId),Enricher 静默地给同一条事件加上 MachineName=prod-01ThreadId=15CorrelationId=abc-123

标准 Serilog Enricher

环境 Enricher

dotnet add package Serilog.Enrichers.Environment
.Enrich.WithMachineName()       // 添加 MachineName 属性
.Enrich.WithEnvironmentName()   // 添加 EnvironmentName
.Enrich.WithEnvironmentUserName() // 添加 EnvironmentUserName

WithMachineName() 在任何多服务器部署里都很值钱——你的服务跑了 10 个 pod,错误一出来第一反应就是”哪个 pod?“。每条日志自带 MachineName,当场回答这个问题。

线程 Enricher

dotnet add package Serilog.Enrichers.Thread
.Enrich.WithThreadId()   // 添加 ThreadId(int)
.Enrich.WithThreadName() // 添加 ThreadName(如果命名了)

线程 ID 在并行处理的 Worker Service 里特别有用——你可以追踪某个工作项在线程生命周期里的所有日志。

进程 Enricher

dotnet add package Serilog.Enrichers.Process
.Enrich.WithProcessId()
.Enrich.WithProcessName()

关联 ID Enricher

dotnet add package Serilog.Enrichers.CorrelationId
.Enrich.WithCorrelationId() // 读取 X-Correlation-ID 头并添加 CorrelationId

加上中间件确保 header 被传播:

builder.Services.AddHttpContextAccessor();

app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Correlation-ID"))
        context.Request.Headers["X-Correlation-ID"] =
            Guid.NewGuid().ToString();
    await next();
});

关联 ID 在分布式系统里是关键——前端发一个请求扇出到五个微服务,同一个关联 ID 能把所有服务上的日志串联起来。

通过 appsettings.json 配置 Enricher

标准 Enricher 可以完全通过 appsettings.json 配置:

{
  "Serilog": {
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      "WithProcessId",
      "WithEnvironmentName"
    ]
  }
}

Enrich 数组里的字符串对应 LoggerEnrichmentConfiguration 的方法名。带参数的 Enricher(如 WithProperty)需要用代码配置。

用静态属性做 Enrich

给每条日志加一个固定值——适合应用名称、版本号、部署环境:

.Enrich.WithProperty("Application", "OrderApi")
.Enrich.WithProperty("Version",
    Assembly.GetExecutingAssembly()
        .GetName().Version?.ToString() ?? "unknown")
.Enrich.WithProperty("DeploymentEnvironment",
    builder.Environment.EnvironmentName)

appsettings.json 写法:

{
  "Serilog": {
    "Enrich": [
      {
        "Name": "WithProperty",
        "Args": {
          "name": "Application",
          "value": "OrderApi"
        }
      }
    ]
  }
}

当多个应用把日志发到同一个 Seq 实例或 Elasticsearch 集群时,这些静态属性让你可以马上过滤到 Application = 'OrderApi'

LogContext:动态的按作用域 Enrich

Serilog.Context.LogContext 允许你在当前逻辑线程上下文上压入属性,在一个作用域块内有效。块内发出的所有日志事件都带着这些属性:

using Serilog.Context;

public async Task<IActionResult> ProcessPayment(
    PaymentRequest request)
{
    using (LogContext.PushProperty("PaymentId", request.PaymentId))
    using (LogContext.PushProperty("CustomerId", request.CustomerId))
    using (LogContext.PushProperty("Amount", request.Amount))
    {
        _logger.LogInformation("开始处理支付");

        var result = await _paymentGateway.ChargeAsync(request);

        if (result.Succeeded)
        {
            _logger.LogInformation(
                "支付成功,交易号 {TransactionId}",
                result.TransactionId);
        }
        else
        {
            _logger.LogWarning(
                "支付被拒绝: {DeclineReason}",
                result.DeclineReason);
        }
    }
    // 属性在此处从上下文中移除

    return Ok();
}

using 块内的每一条日志都带着 PaymentIdCustomerIdAmount——但这些值不需要出现在 LogInformation 的消息模板里。代码干净,上下文透明注入。

在中间件中用 LogContext

一个常见模式是在 ASP.NET Core 中间件里压入关联 ID 或用户 ID,让每个请求的日志自动带注释:

public class LogContextMiddleware
{
    private readonly RequestDelegate _next;

    public LogContextMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"]
            .FirstOrDefault() ?? context.TraceIdentifier;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        using (LogContext.PushProperty("RequestPath",
            context.Request.Path))
        using (LogContext.PushProperty("UserId",
            context.User.FindFirst("sub")?.Value ?? "anonymous"))
        {
            await _next(context);
        }
    }
}

Program.cs 中注册:

app.UseMiddleware<LogContextMiddleware>();
app.UseSerilogRequestLogging(); // 放在 LogContext 中间件之后

请求生命周期内的每条日志现在都带着 CorrelationIdRequestPathUserId——不管日志从哪个类、哪一层发出。

用 ILogger 的日志作用域

内置 ILogger.BeginScope() API 等价于 Serilog 的 LogContext.PushProperty()——配上 FromLogContext(),通过 BeginScope() 压入的作用域会被 Serilog 捕获并输出:

public class OrderProcessor
{
    private readonly ILogger<OrderProcessor> _logger;

    public OrderProcessor(ILogger<OrderProcessor> logger)
    {
        _logger = logger;
    }

    public async Task ProcessBatchAsync(IEnumerable<Order> orders)
    {
        using var batchScope = _logger.BeginScope(
            new Dictionary<string, object>
        {
            ["BatchId"] = Guid.NewGuid().ToString("N"),
            ["BatchSize"] = orders.Count()
        });

        _logger.LogInformation("开始批次处理");

        foreach (var order in orders)
        {
            using var orderScope = _logger.BeginScope(
                new Dictionary<string, object>
            {
                ["OrderId"] = order.Id,
                ["CustomerId"] = order.CustomerId
            });

            _logger.LogInformation("处理订单");
            await ProcessSingleOrderAsync(order);
            _logger.LogInformation("订单处理完毕");
        }

        _logger.LogInformation("批次处理完成");
    }
}

要让 BeginScope() 的属性出现在 Serilog 输出中,需要满足两个条件:

  1. Serilog 配置里必须有 Enrich.FromLogContext()
  2. 如果用 Console sink 配了文本模板,模板里必须包含 {Properties}(或者直接用 JSON 格式化器)

Console sink 可以用这个模板显示所有作用域属性:

"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"

自定义 Enricher

标准 Enricher 不够用时,实现 ILogEventEnricher

using Serilog.Core;
using Serilog.Events;

public class TenantEnricher : ILogEventEnricher
{
    private readonly ITenantAccessor _tenantAccessor;

    public TenantEnricher(ITenantAccessor tenantAccessor)
    {
        _tenantAccessor = tenantAccessor;
    }

    public void Enrich(
        LogEvent logEvent,
        ILogEventPropertyFactory propertyFactory)
    {
        var tenantId = _tenantAccessor.GetCurrentTenantId();

        if (!string.IsNullOrEmpty(tenantId))
        {
            var property = propertyFactory
                .CreateProperty("TenantId", tenantId);
            logEvent.AddPropertyIfAbsent(property);
        }
    }
}

ReadFrom.Services() 注册,利用 DI 注入依赖:

// 注册到 DI
builder.Services.AddSingleton<TenantEnricher>();
builder.Services.AddHttpContextAccessor();

// 通过 ReadFrom.Services() 注册到 Serilog
builder.Host.UseSerilog((ctx, services, config) =>
    config
        .ReadFrom.Configuration(ctx.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

ReadFrom.Services(services) 会扫描 DI 容器中所有 ILogEventEnricher 注册并自动加入——不需要手动 Enrich.With<TenantEnricher>()

Enricher 性能注意事项

Enricher 在每条日志事件上都执行,所以 Enrich() 方法里不要做昂贵的操作(数据库查询、HTTP 调用)。替代方案:

public class CachedMachineEnricher : ILogEventEnricher
{
    private static readonly LogEventProperty MachineNameProperty =
        new("MachineName",
            new ScalarValue(Environment.MachineName));

    private static readonly LogEventProperty AppVersionProperty =
        new("AppVersion", new ScalarValue(
            Assembly.GetEntryAssembly()
                ?.GetName().Version?.ToString() ?? "unknown"));

    public void Enrich(
        LogEvent logEvent,
        ILogEventPropertyFactory propertyFactory)
    {
        logEvent.AddPropertyIfAbsent(MachineNameProperty);
        logEvent.AddPropertyIfAbsent(AppVersionProperty);
    }
}

参考


Tags


Next

分布式单体:每个 C# 开发者都应该识别的反模式