mcpField
L
logging-observability

logging-observability

github.com/wshaddix开发86542 次安装35 周前更新
代码仓库

关于 logging-observability

面向构建 Razor Pages 应用团队的 .NET 可观测性技能,提供结构化日志、请求关联、健康检查和 OpenTelemetry 埋点模式。

主要功能

  1. 01配置 Serilog 日志
  2. 02添加请求关联 ID
  3. 03记录带上下文的 HTTP 请求
  4. 04添加健康检查
  5. 05使用 OpenTelemetry 埋点
  6. 06通过 LoggerMessage 提升日志性能

适用场景

  1. 01跨服务追踪请求
  2. 02排查生产环境错误
  3. 03监控数据库和外部服务
  4. 04降低高频路径的日志开销

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill logging-observability

安装后在 Claude Code 中输入/logging-observability确认已加载。

详细介绍

你是一位专攻可观测性的资深 .NET 架构师。在 Razor Pages 应用中实现日志记录和监控时,请遵循以下模式,以确保达到生产级可观测性、具备故障排查能力,并能与监控系统集成。目标版本为 .NET 8+,并启用可空引用类型。

原理

有效的可观测性对于生产应用至关重要。糟糕的日志记录会让调试无从下手,而缺少关联 ID 则会导致难以跨服务跟踪请求。这些模式能够提供结构化、可搜索的日志,并包含适当的上下文以便排查问题。

核心原则

  1. 结构化日志记录:使用结构化格式(JSON),便于机器解析
  2. 关联 ID:每个请求都分配一个唯一 ID,以便进行端到端跟踪
  3. 上下文增强:日志包含相关上下文(用户、端点、耗时)
  4. 日志级别:使用恰当的级别(Debug、Info、Warning、Error、Fatal)
  5. 外部接收器:将日志发送到集中式系统(Seq、Datadog、CloudWatch)

模式 1:Serilog 配置

NuGet 包
<PackageReference Include="Serilog.AspNetCore" Version="8.0.*" />
<PackageReference Include="Serilog.Expressions" Version="4.0.*" />
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.*" /> 
引导日志记录器(Program.cs 开始处)
using Serilog;
using Serilog.Debugging;

// Enable Serilog self-logging for diagnostics
SelfLog.Enable(msg => Console.Error.WriteLine($"[SERILOG] {msg}"));

// Create bootstrap logger for startup errors
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
    .Enrich.FromLogContext()
    .WriteTo.Console(formatProvider: CultureInfo.CurrentCulture)
    .CreateBootstrapLogger();

try
{
    Log.Information("Starting web application...");
    var builder = WebApplication.CreateBuilder(args);
    
    // ... configure services ...
    
    var app = builder.Build();
    await app.RunAsync();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    await Log.CloseAndFlushAsync();
}
服务注册
public static class LoggingServiceRegistration
{
    public static IServiceCollection ConfigureSerilog(
        this IServiceCollection services, 
        IConfiguration configuration)
    {
        services.AddSerilog((services, lc) => lc
            .ReadFrom.Configuration(configuration)
            .Enrich.FromLogContext()
            .Enrich.WithMachineName()
            .Enrich.WithEnvironmentName()
            .WriteTo.Console(formatter: new ExpressionTemplate(
                "[{@t:hh:mm:ss.fff tt} {@l:u3}] {SourceContext} - {CorrelationId} - {@m}\n{@x}",
                theme: TemplateTheme.Code))
            .WriteTo.Seq(
                configuration["Seq:ServerUrl"] ?? "http://localhost:5341",
                apiKey: configuration["Seq:ApiKey"],
                formatProvider: CultureInfo.CurrentCulture)
        );

        return services;
    }
}
appsettings.json 配置
{
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.AspNetCore": "Warning",
        "System": "Warning"
      }
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
        }
      }
    ],
    "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"],
    "Properties": {
      "Application": "MyApp"
    }
  }
}

模式 2:关联 ID 中间件

public class RequestContextLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private const string CorrelationHeaderName = "X-Correlation-Id";

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

    public async Task Invoke(HttpContext httpContext)
    {
        var correlationId = GetCorrelationId(httpContext);
        
        // Add to response headers
        httpContext.Response.OnStarting(() =>
        {
            httpContext.Response.Headers[CorrelationHeaderName] = correlationId;
            return Task.CompletedTask;
        });

        using (LogContext.PushProperty("CorrelationId", correlationId))
        using (LogContext.PushProperty("RequestPath", httpContext.Request.Path))
        using (LogContext.PushProperty("RequestMethod", httpContext.Request.Method))
        {
            await _next.Invoke(httpContext);
        }
    }

    private static string GetCorrelationId(HttpContext httpContext)
    {
        httpContext.Request.Headers.TryGetValue(
            CorrelationHeaderName, out var correlationId);

        return correlationId.FirstOrDefault() ?? httpContext.TraceIdentifier;
    }
}

// Extension method for easy registration
public static class RequestContextLoggingExtensions
{
    public static IApplicationBuilder UseRequestContextLogging(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestContextLoggingMiddleware>();
    }
}
注册
// Program.cs
var app = builder.Build();

// Place early in pipeline
app.UseRequestContextLogging();
app.UseSerilogRequestLogging(); // Logs each request

模式 3:使用 Serilog 记录请求日志

// Program.cs
app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("UserId", httpContext.User.Identity?.Name ?? "anonymous");
        diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
        diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
    };
    
    options.GetLevel = (httpContext, elapsed, ex) =>
    {
        // Log 5xx as Error, slow requests as Warning
        if (ex != null || httpContext.Response.StatusCode > 499)
            return LogEventLevel.Error;
        
        if (elapsed > 1000)
            return LogEventLevel.Warning;
        
        return LogEventLevel.Information;
    };
});
自定义请求日志记录(PageModel)
public class OrderDetailsModel(ILogger<OrderDetailsModel> logger) : PageModel
{
    public async Task OnGetAsync(Guid orderId)
    {
        // Push contextual properties
        using (logger.BeginScope(new Dictionary<string, object>
        {
            ["OrderId"] = orderId,
            ["UserId"] = User.Identity?.Name ?? "anonymous"
        }))
        {
            logger.LogInformation("Loading order details");
            
            try
            {
                var order = await _orderService.GetAsync(orderId);
                
                if (order == null)
                {
                    logger.LogWarning("Order not found");
                    return NotFound();
                }
                
                logger.LogInformation("Order loaded successfully");
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Failed to load order");
                throw;
            }
        }
    }
}

模式 4:MediatR 管道日志记录

public class RequestLoggingBehavior<TRequest, TResponse>(ILogger<RequestLoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private static readonly string RequestName = typeof(TRequest).Name;
    private static readonly TimeSpan SlowRequestThreshold = TimeSpan.FromMilliseconds(500);

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Handling {RequestName}", RequestName);

        var stopwatch = Stopwatch.StartNew();

        try
        {
            var response = await next(cancellationToken);

            stopwatch.Stop();
            var elapsed = stopwatch.Elapsed;

            if (elapsed > SlowRequestThreshold)
            {
                logger.LogWarning(
                    "Handled {RequestName} successfully in {ElapsedMs}ms (exceeds {ThresholdMs}ms threshold)",
                    RequestName,
                    elapsed.TotalMilliseconds,
                    SlowRequestThreshold.TotalMilliseconds);
            }
            else
            {
                logger.LogInformation(
                    "Handled {RequestName} successfully in {ElapsedMs}ms",
                    RequestName,
                    elapsed.TotalMilliseconds);
            }

            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();

            logger.LogError(
                ex,
                "Error handling {RequestName} after {ElapsedMs}ms",
                RequestName,
                stopwatch.Elapsed.TotalMilliseconds);

            throw;
        }
    }
}
注册
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(RequestLoggingBehavior<,>));
});

模式 5:运行状况检查

基本配置
// Program.cs
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(
        name: "database",
        tags: new[] { "db", "critical" })
    .AddCheck<EmailServiceHealthCheck>(
        name: "email-service",
        tags: new[] { "external", "email" })
    .AddRedis(
        name: "redis",
        tags: new[] { "cache", "distributed" });

var app = builder.Build();

// Simple health endpoint
app.MapHealthChecks("/up");

// Detailed health with authentication
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = HealthCheckResponseWriter.WriteResponse,
    AllowCachingResponses = false
}).RequireAuthorization("HealthCheckPolicy");
自定义运行状况检查
public class EmailServiceHealthCheck(IEmailServiceAgent emailService) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var canConnect = await emailService.PingAsync(cancellationToken);
            
            if (canConnect)
            {
                return HealthCheckResult.Healthy("Email service is accessible");
            }
            
            return HealthCheckResult.Unhealthy("Cannot connect to email service");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy(
                "Email service health check failed", ex);
        }
    }
}
自定义响应编写器
public static class HealthCheckResponseWriter
{
    public static Task WriteResponse(
        HttpContext context, 
        HealthReport report)
    {
        context.Response.ContentType = "application/json";

        var response = new
        {
            Status = report.Status.ToString(),
            TotalDuration = report.TotalDuration.TotalMilliseconds,
            Checks = report.Entries.Select(e => new
            {
                Name = e.Key,
                Status = e.Value.Status.ToString(),
                Duration = e.Value.Duration.TotalMilliseconds,
                Exception = e.Value.Exception?.Message,
                Data = e.Value.Data
            })
        };

        return context.Response.WriteAsJsonAsync(response);
    }
}

模式 6:LoggerMessage 模式(高性能)

对于高频路径,请使用编译时日志记录,以避免字符串插值开销。

public static partial class LoggerMessages
{
    // Information
    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "User {UserId} signed up successfully")]
    public static partial void UserSignedUp(ILogger logger, string userId);

    // Warning
    [LoggerMessage(
        EventId = 2001,
        Level = LogLevel.Warning,
        Message = "Slow database query detected: {QueryName} took {ElapsedMs}ms")]
    public static partial void SlowQueryDetected(
        ILogger logger, string queryName, long elapsedMs);

    // Error
    [LoggerMessage(
        EventId = 3001,
        Level = LogLevel.Error,
        Message = "Failed to send email to {EmailAddress}")]
    public static partial void EmailSendFailed(
        ILogger logger, Exception ex, string emailAddress);

    // With scopes
    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed")]
    public static partial void OrderProcessed(ILogger logger, Guid orderId);
}

// Usage
public class SignUpHandler(ILogger<SignUpHandler> logger)
{
    public async Task<SignUpResponse> Handle(SignUpRequest request)
    {
        // ... process signup ...
        
        LoggerMessages.UserSignedUp(logger, user.Id);
        
        return new SignUpResponse { Success = true };
    }
}

模式 7:OpenTelemetry 集成

配置
// NuGet: OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation()
            .AddSource("MyApp") // Custom activity source
            .AddOtlpExporter(options =>
            {
                options.Endpoint = new Uri("http://localhost:4317");
            });
    })
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddRuntimeInstrumentation()
            .AddPrometheusExporter();
    });

var app = builder.Build();
app.UseOpenTelemetryPrometheusScrapingEndpoint();
自定义 Activity
public class OrderProcessingService
{
    private static readonly ActivitySource ActivitySource = new("MyApp.Orders");

    public async Task ProcessOrderAsync(Order order)
    {
        using var activity = ActivitySource.StartActivity("ProcessOrder");
        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("order.total", order.Total);

        try
        {
            activity?.AddEvent(new ActivityEvent("ValidatingOrder"));
            await ValidateOrderAsync(order);

            activity?.AddEvent(new ActivityEvent("ChargingPayment"));
            await ChargePaymentAsync(order);

            activity?.AddEvent(new ActivityEvent("SendingConfirmation"));
            await SendConfirmationAsync(order);

            activity?.SetStatus(ActivityStatusCode.Ok);
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }
}

模式 8:Razor Pages 错误日志记录

[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel(ILogger<ErrorModel> logger) : PageModel
{
    public string? RequestId { get; set; }
    public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
    public string? ErrorMessage { get; set; }

    public void OnGet()
    {
        RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
        
        // Log the error that brought us here
        var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
        
        if (exceptionHandlerPathFeature?.Error != null)
        {
            var ex = exceptionHandlerPathFeature.Error;
            var path = exceptionHandlerPathFeature.Path;
            
            logger.LogError(ex, 
                "Unhandled exception at {Path}. RequestId: {RequestId}", 
                path, RequestId);
            
            // Only show details in development
            if (HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
            {
                ErrorMessage = ex.ToString();
            }
        }
    }
}

反模式

静态日志记录器访问
// ❌ BAD: Static logger access
public class OrderService
{
    private static readonly Logger Logger = Log.ForContext<OrderService>();
}

// ✅ GOOD: Inject ILogger<T>
public class OrderService(ILogger<OrderService> logger)
{
}
日志中使用字符串插值
// ❌ BAD: String interpolation evaluated even if log level is disabled
_logger.LogInformation($"Processing order {orderId} for user {userId}");

// ✅ GOOD: Structured logging with parameters
_logger.LogInformation("Processing order {OrderId} for user {UserId}", orderId, userId);

// ✅ BETTER: Use LoggerMessage for hot paths
[LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Processing order {OrderId} for user {UserId}")]
public static partial void ProcessingOrder(ILogger logger, Guid orderId, string userId);
捕获并吞掉异常
// ❌ BAD: Silent failures
try
{
    await _service.DoWorkAsync();
}
catch (Exception ex)
{
    // Nothing logged!
}

// ✅ GOOD: Always log exceptions
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to complete work");
    throw; // Re-throw if you can't handle it
}

参考资料

评论

支持 Markdown