< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Middleware.CommonAccessLogMiddleware
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Middleware/CommonAccessLogMiddleware.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
76%
Covered lines: 112
Uncovered lines: 35
Coverable lines: 147
Total lines: 324
Line coverage: 76.1%
Branch coverage
50%
Covered branches: 60
Total branches: 118
Branch coverage: 50.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 76.1% (112/147) Branch coverage: 50.8% (60/118) Total lines: 324 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 76.1% (112/147) Branch coverage: 50.8% (60/118) Total lines: 324 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%44100%
InvokeAsync()100%11100%
WriteAccessLog(...)66.66%9655%
ResolveLogger(...)50%22100%
CreateScopedLogger(...)100%11100%
BuildLogLine(...)100%22100%
ResolveTimestamp(...)50%6681.81%
RenderTimestamp(...)100%66100%
ResolveClientAddress(...)33.33%351245.45%
ExtractFirstHeaderValue(...)0%2040%
ResolveRemoteUser(...)60%1010100%
BuildRequestLine(...)64.28%161477.77%
ResolveContentLength(...)50%66100%
GetHeaderValue(...)10%151062.5%
SanitizeToken(...)21.42%971425%
SanitizeQuoted(...)72.72%232288.88%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Middleware/CommonAccessLogMiddleware.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Globalization;
 3using System.Net;
 4using System.Text;
 5using Microsoft.Extensions.Options;
 6using Microsoft.Extensions.Primitives;
 7using Microsoft.Net.Http.Headers;
 8
 9namespace Kestrun.Middleware;
 10
 11/// <summary>
 12/// ASP.NET Core middleware that emits Apache style common access log entries using Serilog.
 13/// </summary>
 14public sealed class CommonAccessLogMiddleware
 15{
 16    private readonly RequestDelegate _next;
 17    private readonly IOptionsMonitor<CommonAccessLogOptions> _optionsMonitor;
 18    private readonly Serilog.ILogger _defaultLogger;
 19
 20    /// <summary>
 21    /// Initializes a new instance of the <see cref="CommonAccessLogMiddleware"/> class.
 22    /// </summary>
 23    /// <param name="next">The next middleware in the pipeline.</param>
 24    /// <param name="optionsMonitor">The options monitor for <see cref="CommonAccessLogOptions"/>.</param>
 25    /// <param name="logger">The Serilog logger instance.</param>
 326    public CommonAccessLogMiddleware(
 327        RequestDelegate next,
 328        IOptionsMonitor<CommonAccessLogOptions> optionsMonitor,
 329        Serilog.ILogger logger)
 30    {
 331        _next = next ?? throw new ArgumentNullException(nameof(next));
 332        _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
 333        ArgumentNullException.ThrowIfNull(logger, nameof(logger));
 34
 335        _defaultLogger = CreateScopedLogger(logger);
 336    }
 37
 38    /// <summary>
 39    /// Invokes the middleware for the specified HTTP context.
 40    /// </summary>
 41    /// <param name="context">The HTTP context.</param>
 42    public async Task InvokeAsync(HttpContext context)
 43    {
 344        ArgumentNullException.ThrowIfNull(context);
 45
 346        var stopwatch = Stopwatch.StartNew();
 47        try
 48        {
 349            await _next(context);
 350        }
 51        finally
 52        {
 353            stopwatch.Stop();
 354            WriteAccessLog(context, stopwatch.Elapsed);
 55        }
 356    }
 57
 58    private void WriteAccessLog(HttpContext context, TimeSpan elapsed)
 59    {
 60        CommonAccessLogOptions options;
 61        try
 62        {
 363            options = _optionsMonitor.CurrentValue ?? new CommonAccessLogOptions();
 364        }
 065        catch
 66        {
 067            options = new CommonAccessLogOptions();
 068        }
 69
 370        var logger = ResolveLogger(options);
 371        if (!logger.IsEnabled(options.Level))
 72        {
 173            return;
 74        }
 75
 276        var (timestamp, usedFallbackFormat) = ResolveTimestamp(options);
 277        if (usedFallbackFormat)
 78        {
 079            _defaultLogger.Debug(
 080                "Invalid common access log timestamp format '{TimestampFormat}' supplied – falling back to default.",
 081                options.TimestampFormat);
 82        }
 83
 84        try
 85        {
 286            var logLine = BuildLogLine(context, options, elapsed, timestamp);
 287            logger.Write(options.Level, "{CommonAccessLogLine}", logLine);
 288        }
 089        catch (Exception ex)
 90        {
 91            // Access logging should never take down the pipeline – swallow and trace.
 092            _defaultLogger.Debug(ex, "Failed to emit common access log entry.");
 093        }
 294    }
 95
 96    private Serilog.ILogger ResolveLogger(CommonAccessLogOptions options)
 97    {
 398        ArgumentNullException.ThrowIfNull(options);
 99
 3100        return options.Logger is { } customLogger ? CreateScopedLogger(customLogger) : _defaultLogger;
 101    }
 102
 103    private static Serilog.ILogger CreateScopedLogger(Serilog.ILogger logger)
 104    {
 3105        ArgumentNullException.ThrowIfNull(logger);
 106
 3107        return logger.ForContext("LogFormat", "CommonAccessLog")
 3108                     .ForContext<CommonAccessLogMiddleware>();
 109    }
 110
 111    private static string BuildLogLine(HttpContext context, CommonAccessLogOptions options, TimeSpan elapsed, string tim
 112    {
 2113        var request = context.Request;
 2114        var response = context.Response;
 115
 2116        var remoteHost = SanitizeToken(ResolveClientAddress(context, options));
 2117        var remoteIdent = "-"; // identd is rarely used – emit dash per the spec.
 2118        var remoteUser = SanitizeToken(ResolveRemoteUser(context));
 119
 2120        var requestLine = SanitizeQuoted(BuildRequestLine(request, options));
 2121        var statusCode = context.Response.StatusCode;
 2122        var responseBytes = ResolveContentLength(response);
 2123        var referer = SanitizeQuoted(GetHeaderValue(request.Headers, HeaderNames.Referer));
 2124        var userAgent = SanitizeQuoted(GetHeaderValue(request.Headers, HeaderNames.UserAgent));
 125
 126        // Pre-size the StringBuilder to avoid unnecessary allocations.
 2127        var builder = new StringBuilder(remoteHost.Length
 2128                                        + remoteUser.Length
 2129                                        + requestLine.Length
 2130                                        + referer.Length
 2131                                        + userAgent.Length
 2132                                        + 96);
 133
 2134        _ = builder.Append(remoteHost).Append(' ')
 2135                   .Append(remoteIdent).Append(' ')
 2136                   .Append(remoteUser).Append(" [")
 2137                   .Append(timestamp).Append("] \"")
 2138                   .Append(requestLine).Append("\" ")
 2139                   .Append(statusCode.ToString(CultureInfo.InvariantCulture)).Append(' ')
 2140                   .Append(responseBytes);
 141
 2142        _ = builder.Append(' ')
 2143                   .Append('"').Append(referer).Append('"')
 2144                   .Append(' ')
 2145                   .Append('"').Append(userAgent).Append('"');
 146
 2147        if (options.IncludeElapsedMilliseconds)
 148        {
 1149            var elapsedMs = elapsed.TotalMilliseconds.ToString("0.####", CultureInfo.InvariantCulture);
 1150            _ = builder.Append(' ').Append(elapsedMs);
 151        }
 152
 2153        return builder.ToString();
 154    }
 155
 156    private static (string Timestamp, bool UsedFallbackFormat) ResolveTimestamp(CommonAccessLogOptions options)
 157    {
 2158        var provider = options.TimeProvider ?? TimeProvider.System;
 2159        var timestamp = options.UseUtcTimestamp
 2160            ? provider.GetUtcNow()
 2161            : provider.GetLocalNow();
 162
 2163        var format = string.IsNullOrWhiteSpace(options.TimestampFormat)
 2164            ? CommonAccessLogOptions.DefaultTimestampFormat
 2165            : options.TimestampFormat;
 166
 167        try
 168        {
 2169            return (RenderTimestamp(timestamp, format), false);
 170        }
 0171        catch (FormatException)
 172        {
 0173            return (RenderTimestamp(timestamp, CommonAccessLogOptions.DefaultTimestampFormat), true);
 174        }
 2175    }
 176
 177    private static string RenderTimestamp(DateTimeOffset timestamp, string format)
 178    {
 2179        var rendered = timestamp.ToString(format, CultureInfo.InvariantCulture);
 180
 2181        if (string.Equals(format, CommonAccessLogOptions.DefaultTimestampFormat, StringComparison.Ordinal))
 182        {
 2183            var lastColon = rendered.LastIndexOf(':');
 2184            if (lastColon >= 0 && lastColon >= rendered.Length - 5)
 185            {
 2186                rendered = rendered.Remove(lastColon, 1);
 187            }
 188        }
 189
 2190        return rendered;
 191    }
 192
 193    private static string ResolveClientAddress(HttpContext context, CommonAccessLogOptions options)
 194    {
 2195        if (!string.IsNullOrWhiteSpace(options.ClientAddressHeader)
 2196            && context.Request.Headers.TryGetValue(options.ClientAddressHeader, out var forwarded))
 197        {
 0198            var first = ExtractFirstHeaderValue(forwarded);
 0199            if (!string.IsNullOrWhiteSpace(first))
 200            {
 0201                return first!;
 202            }
 203        }
 204
 2205        var address = context.Connection.RemoteIpAddress;
 2206        if (address is null)
 207        {
 2208            return "-";
 209        }
 210
 211        // Format IPv6 addresses without scope ID to match Apache behaviour.
 0212        return address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
 0213            ? address.ScopeId == 0 ? address.ToString() : new IPAddress(address.GetAddressBytes()).ToString()
 0214            : address.ToString();
 215    }
 216
 217    private static string ExtractFirstHeaderValue(StringValues values)
 218    {
 0219        if (StringValues.IsNullOrEmpty(values))
 220        {
 0221            return "";
 222        }
 223
 0224        var span = values.ToString().AsSpan();
 0225        var commaIndex = span.IndexOf(',');
 0226        var first = commaIndex >= 0 ? span[..commaIndex] : span;
 0227        return first.Trim().ToString();
 228    }
 229
 230    private static string ResolveRemoteUser(HttpContext context)
 231    {
 2232        var identity = context.User?.Identity;
 2233        return identity is { IsAuthenticated: true, Name.Length: > 0 } ? identity.Name! : "-";
 234    }
 235
 236    private static string BuildRequestLine(HttpRequest request, CommonAccessLogOptions options)
 237    {
 2238        var method = string.IsNullOrWhiteSpace(request.Method) ? "-" : request.Method;
 2239        var path = request.Path.HasValue ? request.Path.Value : "/";
 2240        if (string.IsNullOrEmpty(path))
 241        {
 0242            path = "/";
 243        }
 244
 2245        if (options.IncludeQueryString && request.QueryString.HasValue)
 246        {
 0247            path += request.QueryString.Value;
 248        }
 249
 2250        return options.IncludeProtocol && !string.IsNullOrWhiteSpace(request.Protocol)
 2251            ? string.Concat(method, " ", path, " ", request.Protocol)
 2252            : string.Concat(method, " ", path);
 253    }
 254
 255    private static string ResolveContentLength(HttpResponse? response)
 256    {
 2257        return response is null
 2258            ? "-"
 2259            : response.ContentLength.HasValue
 2260            ? response.ContentLength.Value.ToString(CultureInfo.InvariantCulture)
 2261            : response.Headers.ContentLength.HasValue ? response.Headers.ContentLength.Value.ToString(CultureInfo.Invari
 262    }
 263
 264    private static string GetHeaderValue(IHeaderDictionary headers, string headerName)
 265    {
 4266        return !headers.TryGetValue(headerName, out var value)
 4267            ? "-"
 4268            : value.Count switch
 4269            {
 0270                0 => "-",
 0271                1 => value[0] ?? "-",
 0272                _ => value[0] ?? "-",
 4273            };
 274    }
 275
 276    private static string SanitizeToken(string? value)
 277    {
 4278        if (string.IsNullOrWhiteSpace(value) || value == "-")
 279        {
 4280            return "-";
 281        }
 282
 0283        var span = value.AsSpan();
 0284        var builder = new StringBuilder(span.Length);
 0285        foreach (var ch in span)
 286        {
 0287            if (char.IsControl(ch))
 288            {
 289                continue;
 290            }
 291
 0292            _ = char.IsWhiteSpace(ch) ? builder.Append('_') : ch == '"' ? builder.Append('_') : builder.Append(ch);
 293        }
 294
 0295        return builder.Length == 0 ? "-" : builder.ToString();
 296    }
 297
 298    private static string SanitizeQuoted(string? value)
 299    {
 6300        if (string.IsNullOrEmpty(value) || value == "-")
 301        {
 4302            return "-";
 303        }
 304
 2305        var builder = new StringBuilder(value.Length + 8);
 46306        foreach (var ch in value)
 307        {
 21308            if (ch is '\\' or '"')
 309            {
 0310                _ = builder.Append('\\').Append(ch);
 311            }
 21312            else if (ch is '\r' or '\n' || char.IsControl(ch))
 313            {
 314                continue;
 315            }
 316            else
 317            {
 21318                _ = builder.Append(ch);
 319            }
 320        }
 321
 2322        return builder.Length == 0 ? "-" : builder.ToString();
 323    }
 324}