diff --git a/src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs b/src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs index a8ec777..0038242 100644 --- a/src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs +++ b/src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs @@ -17,7 +17,9 @@ using Serilog.Events; using Serilog.Extensions.Hosting; using Serilog.Parsing; +using System.Buffers; using System.Diagnostics; +using System.Text; namespace Serilog.AspNetCore; @@ -32,6 +34,7 @@ class RequestLoggingMiddleware readonly Func> _getMessageTemplateProperties; readonly ILogger? _logger; readonly bool _includeQueryInRequestPath; + readonly RequestLoggingOptions _options; static readonly LogEventProperty[] NoProperties = []; public RequestLoggingMiddleware(RequestDelegate next, DiagnosticContext diagnosticContext, RequestLoggingOptions options) @@ -39,6 +42,7 @@ public RequestLoggingMiddleware(RequestDelegate next, DiagnosticContext diagnost if (options == null) throw new ArgumentNullException(nameof(options)); _next = next ?? throw new ArgumentNullException(nameof(next)); _diagnosticContext = diagnosticContext ?? throw new ArgumentNullException(nameof(diagnosticContext)); + _options = options; _getLevel = options.GetLevel; _enrichDiagnosticContext = options.EnrichDiagnosticContext; @@ -58,6 +62,7 @@ public async Task Invoke(HttpContext httpContext) var collector = _diagnosticContext.BeginCollection(); try { + await CollectRequestBody(httpContext, collector, _options); await _next(httpContext); var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); var statusCode = httpContext.Response.StatusCode; @@ -130,4 +135,104 @@ static string GetPath(HttpContext httpContext, bool includeQueryInRequestPath) return requestPath!; } + + private async static Task CollectRequestBody(HttpContext httpContext, DiagnosticContextCollector collector, RequestLoggingOptions options) + { + // Check if we should include the request body + if (!options.IncludeRequestBody) + return; + + HttpRequest request = httpContext.Request; + + // Check if the Content-Type matches the specified types + if (!IsContentTypeMatch(request.ContentType, options.RequestBodyContentTypes)) + return; + + // Check if the Content-Length exceeds the maximum allowed length + if (options.RequestBodyContentMaxLength.HasValue && + request.ContentLength.HasValue && + request.ContentLength.Value > options.RequestBodyContentMaxLength.Value) + { + return; + } + + string bodyAsText; + +#if NET5_0_OR_GREATER + // Enable buffering to allow multiple reads of the request body + request.EnableBuffering(); + + // read the body as text + var body = await request.BodyReader.ReadAsync(); + bodyAsText = Encoding.UTF8.GetString(body.Buffer.ToArray()); +#else + // backward compatibility for .NET Standard 2.0 and .NET Framework + bodyAsText = await ReadBodyAsString(request); +#endif + + // Reset the request body stream position for further processing + request.Body.Position = 0; + + var property = new LogEventProperty("RequestBody", new ScalarValue(bodyAsText)); + collector.AddOrUpdate(property); + } + + private static bool IsContentTypeMatch(string? currentContentType, List contentTypesToMatch) + { + // Extract the base MIME type from the current ContentType (ignore parameters like charset, boundary, etc.) + var currentMimeType = ExtractBaseMimeType(currentContentType!); + if (string.IsNullOrWhiteSpace(currentMimeType) || contentTypesToMatch == null || contentTypesToMatch.Count == 0) + return false; + + // Check if the base MIME type matches any in the list + foreach (var contentTypeToMatch in contentTypesToMatch) + { + var matchMimeType = ExtractBaseMimeType(contentTypeToMatch); + if (string.Equals(currentMimeType, matchMimeType, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static string? ExtractBaseMimeType(string? contentType) + { + if (contentType == null || string.IsNullOrWhiteSpace(contentType)) + return contentType; + + // Split on semicolon to remove parameters (e.g., "text/html; charset=utf-8" -> "text/html") + int semicolonIndex = contentType.IndexOf(';'); + string baseMimeType = semicolonIndex >= 0 + ? contentType.Substring(0, semicolonIndex) + : contentType; + + return baseMimeType.Trim(); + } + +#if !NET5_0_OR_GREATER + private static async Task ReadBodyAsString(HttpRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + // Read the body as bytes first + byte[] bodyBytes; + using (var memoryStream = new MemoryStream()) + { + await request.Body.CopyToAsync(memoryStream); + bodyBytes = memoryStream.ToArray(); + } + + // Convert bytes to string + string bodyAsText = Encoding.UTF8.GetString(bodyBytes); + + // Only replace the request body stream if it doesn't support seeking + if (!request.Body.CanSeek) + request.Body = new MemoryStream(bodyBytes); + + request.Body.Position = 0; + + return bodyAsText; + } +#endif } \ No newline at end of file diff --git a/src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs b/src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs index fe9c1ab..e6facac 100644 --- a/src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs +++ b/src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs @@ -81,6 +81,33 @@ static IEnumerable DefaultGetMessageTemplateProperties(HttpCon /// public bool IncludeQueryInRequestPath { get; set; } + /// + /// Include the request body in the log event. The default is false. + /// + /// + /// If set to true, the request body will be buffered and read in order to include it + /// in the log event. This may have performance implications and may not be suitable for large request bodies. + /// + public bool IncludeRequestBody { get; set; } + + /// + /// The content types for which the request body should be included in the log event. + /// The default is application/json. + /// + /// + /// Only used if is set to true. + /// + public List RequestBodyContentTypes { get; set; } = ["application/json"]; + + /// + /// The maximum length of the request body content to include in the log event. + /// The default is 8 KB. Set to null for no limit. + /// + /// + /// Only used if is set to true. + /// + public long? RequestBodyContentMaxLength { get; set; } = 8 * 1024; // 8 KB + /// /// A function to specify the values of the MessageTemplateProperties. /// diff --git a/test/Serilog.AspNetCore.Tests/SerilogWebHostBuilderExtensionsTests.cs b/test/Serilog.AspNetCore.Tests/SerilogWebHostBuilderExtensionsTests.cs index 61ea07a..33f0784 100644 --- a/test/Serilog.AspNetCore.Tests/SerilogWebHostBuilderExtensionsTests.cs +++ b/test/Serilog.AspNetCore.Tests/SerilogWebHostBuilderExtensionsTests.cs @@ -10,6 +10,7 @@ using Serilog.Filters; using Serilog.AspNetCore.Tests.Support; using Serilog.Events; +using System.Net.Http.Json; // Newer frameworks provide IHostBuilder #pragma warning disable CS0618 @@ -62,6 +63,7 @@ public async Task RequestLoggingMiddlewareShouldEnrich() Assert.Equal(200, completionEvent.Properties["StatusCode"].LiteralValue()); Assert.Equal("GET", completionEvent.Properties["RequestMethod"].LiteralValue()); Assert.True(completionEvent.Properties.ContainsKey("Elapsed")); + Assert.False(completionEvent.Properties.ContainsKey("RequestBody")); } [Fact] @@ -191,4 +193,31 @@ public async Task RequestLoggingMiddlewareShouldAddTraceAndSpanIds() return (sink, web); } + + [Fact] + public async Task RequestLoggingMiddlewareShouldIncludeBody() + { + var (sink, web) = Setup(options => + { + options.EnrichDiagnosticContext += (diagnosticContext, _) => + { + diagnosticContext.Set("SomeInteger", 42); + }; + options.IncludeRequestBody = true; + }); + + await web.CreateClient().PostAsJsonAsync("/post", new { Name = "Test" }); + + Assert.NotEmpty(sink.Writes); + + var completionEvent = sink.Writes.First(logEvent => Matching.FromSource()(logEvent)); + + Assert.Equal(42, completionEvent.Properties["SomeInteger"].LiteralValue()); + Assert.Equal("string", completionEvent.Properties["SomeString"].LiteralValue()); + Assert.Equal("/post", completionEvent.Properties["RequestPath"].LiteralValue()); + Assert.Equal(200, completionEvent.Properties["StatusCode"].LiteralValue()); + Assert.Equal("POST", completionEvent.Properties["RequestMethod"].LiteralValue()); + Assert.Equal("{\"name\":\"Test\"}", completionEvent.Properties["RequestBody"].LiteralValue()); + Assert.True(completionEvent.Properties.ContainsKey("Elapsed")); + } } \ No newline at end of file