Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,13 +34,15 @@ class RequestLoggingMiddleware
readonly Func<HttpContext, string, double, int, IEnumerable<LogEventProperty>> _getMessageTemplateProperties;
readonly ILogger? _logger;
readonly bool _includeQueryInRequestPath;
readonly RequestLoggingOptions _options;
static readonly LogEventProperty[] NoProperties = [];

public RequestLoggingMiddleware(RequestDelegate next, DiagnosticContext diagnosticContext, RequestLoggingOptions options)
{
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string> 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<string> 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
}
27 changes: 27 additions & 0 deletions src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ static IEnumerable<LogEventProperty> DefaultGetMessageTemplateProperties(HttpCon
/// </summary>
public bool IncludeQueryInRequestPath { get; set; }

/// <summary>
/// Include the request body in the log event. The default is <c>false</c>.
/// </summary>
/// <remarks>
/// If set to <c>true</c>, 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.
/// </remarks>
public bool IncludeRequestBody { get; set; }

/// <summary>
/// The content types for which the request body should be included in the log event.
/// The default is <c>application/json</c>.
/// </summary>
/// <remarks>
/// Only used if <see cref="IncludeRequestBody"/> is set to <c>true</c>.
/// </remarks>
public List<string> RequestBodyContentTypes { get; set; } = ["application/json"];

/// <summary>
/// The maximum length of the request body content to include in the log event.
/// The default is 8 KB. Set to <c>null</c> for no limit.
/// </summary>
/// <remarks>
/// Only used if <see cref="IncludeRequestBody"/> is set to <c>true</c>.
/// </remarks>
public long? RequestBodyContentMaxLength { get; set; } = 8 * 1024; // 8 KB

/// <summary>
/// A function to specify the values of the MessageTemplateProperties.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<RequestLoggingMiddleware>()(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"));
}
}