Skip to content

Commit 9bac267

Browse files
committed
Add request body logging to middleware
Enhanced `RequestLoggingMiddleware` to log request bodies based on new `RequestLoggingOptions`. Introduced `CollectRequestBody` method to handle body collection, ensuring logging only for specified content types and within a maximum length. Updated `RequestLoggingOptions` with `IncludeRequestBody`, `RequestBodyContentTypes`, and `RequestBodyContentMaxLength` properties for configuration. Added tests in `SerilogWebHostBuilderExtensionsTests` to verify request body logging functionality and ensure backward compatibility with .NET versions.
1 parent 142e8da commit 9bac267

File tree

3 files changed

+161
-0
lines changed

3 files changed

+161
-0
lines changed

src/Serilog.AspNetCore/AspNetCore/RequestLoggingMiddleware.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
using Serilog.Events;
1818
using Serilog.Extensions.Hosting;
1919
using Serilog.Parsing;
20+
using System.Buffers;
2021
using System.Diagnostics;
22+
using System.Text;
2123

2224
namespace Serilog.AspNetCore;
2325

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

3740
public RequestLoggingMiddleware(RequestDelegate next, DiagnosticContext diagnosticContext, RequestLoggingOptions options)
3841
{
3942
if (options == null) throw new ArgumentNullException(nameof(options));
4043
_next = next ?? throw new ArgumentNullException(nameof(next));
4144
_diagnosticContext = diagnosticContext ?? throw new ArgumentNullException(nameof(diagnosticContext));
45+
_options = options;
4246

4347
_getLevel = options.GetLevel;
4448
_enrichDiagnosticContext = options.EnrichDiagnosticContext;
@@ -58,6 +62,7 @@ public async Task Invoke(HttpContext httpContext)
5862
var collector = _diagnosticContext.BeginCollection();
5963
try
6064
{
65+
await CollectRequestBody(httpContext, collector, _options);
6166
await _next(httpContext);
6267
var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp());
6368
var statusCode = httpContext.Response.StatusCode;
@@ -130,4 +135,104 @@ static string GetPath(HttpContext httpContext, bool includeQueryInRequestPath)
130135

131136
return requestPath!;
132137
}
138+
139+
private async static Task CollectRequestBody(HttpContext httpContext, DiagnosticContextCollector collector, RequestLoggingOptions options)
140+
{
141+
// Check if we should include the request body
142+
if (!options.IncludeRequestBody)
143+
return;
144+
145+
HttpRequest request = httpContext.Request;
146+
147+
// Check if the Content-Type matches the specified types
148+
if (!IsContentTypeMatch(request.ContentType, options.RequestBodyContentTypes))
149+
return;
150+
151+
// Check if the Content-Length exceeds the maximum allowed length
152+
if (options.RequestBodyContentMaxLength.HasValue &&
153+
request.ContentLength.HasValue &&
154+
request.ContentLength.Value > options.RequestBodyContentMaxLength.Value)
155+
{
156+
return;
157+
}
158+
159+
string bodyAsText;
160+
161+
#if NET5_0_OR_GREATER
162+
// Enable buffering to allow multiple reads of the request body
163+
request.EnableBuffering();
164+
165+
// read the body as text
166+
var body = await request.BodyReader.ReadAsync();
167+
bodyAsText = Encoding.UTF8.GetString(body.Buffer.ToArray());
168+
#else
169+
// backward compatibility for .NET Standard 2.0 and .NET Framework
170+
bodyAsText = await ReadBodyAsString(request);
171+
#endif
172+
173+
// Reset the request body stream position for further processing
174+
request.Body.Position = 0;
175+
176+
var property = new LogEventProperty("RequestBody", new ScalarValue(bodyAsText));
177+
collector.AddOrUpdate(property);
178+
}
179+
180+
private static bool IsContentTypeMatch(string? currentContentType, List<string> contentTypesToMatch)
181+
{
182+
// Extract the base MIME type from the current ContentType (ignore parameters like charset, boundary, etc.)
183+
var currentMimeType = ExtractBaseMimeType(currentContentType!);
184+
if (string.IsNullOrWhiteSpace(currentMimeType) || contentTypesToMatch == null || contentTypesToMatch.Count == 0)
185+
return false;
186+
187+
// Check if the base MIME type matches any in the list
188+
foreach (var contentTypeToMatch in contentTypesToMatch)
189+
{
190+
var matchMimeType = ExtractBaseMimeType(contentTypeToMatch);
191+
if (string.Equals(currentMimeType, matchMimeType, StringComparison.OrdinalIgnoreCase))
192+
return true;
193+
}
194+
195+
return false;
196+
}
197+
198+
private static string? ExtractBaseMimeType(string? contentType)
199+
{
200+
if (contentType == null || string.IsNullOrWhiteSpace(contentType))
201+
return contentType;
202+
203+
// Split on semicolon to remove parameters (e.g., "text/html; charset=utf-8" -> "text/html")
204+
int semicolonIndex = contentType.IndexOf(';');
205+
string baseMimeType = semicolonIndex >= 0
206+
? contentType.Substring(0, semicolonIndex)
207+
: contentType;
208+
209+
return baseMimeType.Trim();
210+
}
211+
212+
#if !NET5_0_OR_GREATER
213+
private static async Task<string> ReadBodyAsString(HttpRequest request)
214+
{
215+
if (request == null)
216+
throw new ArgumentNullException(nameof(request));
217+
218+
// Read the body as bytes first
219+
byte[] bodyBytes;
220+
using (var memoryStream = new MemoryStream())
221+
{
222+
await request.Body.CopyToAsync(memoryStream);
223+
bodyBytes = memoryStream.ToArray();
224+
}
225+
226+
// Convert bytes to string
227+
string bodyAsText = Encoding.UTF8.GetString(bodyBytes);
228+
229+
// Only replace the request body stream if it doesn't support seeking
230+
if (!request.Body.CanSeek)
231+
request.Body = new MemoryStream(bodyBytes);
232+
233+
request.Body.Position = 0;
234+
235+
return bodyAsText;
236+
}
237+
#endif
133238
}

src/Serilog.AspNetCore/AspNetCore/RequestLoggingOptions.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,33 @@ static IEnumerable<LogEventProperty> DefaultGetMessageTemplateProperties(HttpCon
8181
/// </summary>
8282
public bool IncludeQueryInRequestPath { get; set; }
8383

84+
/// <summary>
85+
/// Include the request body in the log event. The default is <c>false</c>.
86+
/// </summary>
87+
/// <remarks>
88+
/// If set to <c>true</c>, the request body will be buffered and read in order to include it
89+
/// in the log event. This may have performance implications and may not be suitable for large request bodies.
90+
/// </remarks>
91+
public bool IncludeRequestBody { get; set; }
92+
93+
/// <summary>
94+
/// The content types for which the request body should be included in the log event.
95+
/// The default is <c>application/json</c>.
96+
/// </summary>
97+
/// <remarks>
98+
/// Only used if <see cref="IncludeRequestBody"/> is set to <c>true</c>.
99+
/// </remarks>
100+
public List<string> RequestBodyContentTypes { get; set; } = ["application/json"];
101+
102+
/// <summary>
103+
/// The maximum length of the request body content to include in the log event.
104+
/// The default is 8 KB. Set to <c>null</c> for no limit.
105+
/// </summary>
106+
/// <remarks>
107+
/// Only used if <see cref="IncludeRequestBody"/> is set to <c>true</c>.
108+
/// </remarks>
109+
public long? RequestBodyContentMaxLength { get; set; } = 8 * 1024; // 8 KB
110+
84111
/// <summary>
85112
/// A function to specify the values of the MessageTemplateProperties.
86113
/// </summary>

test/Serilog.AspNetCore.Tests/SerilogWebHostBuilderExtensionsTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Serilog.Filters;
1111
using Serilog.AspNetCore.Tests.Support;
1212
using Serilog.Events;
13+
using System.Net.Http.Json;
1314

1415
// Newer frameworks provide IHostBuilder
1516
#pragma warning disable CS0618
@@ -62,6 +63,7 @@ public async Task RequestLoggingMiddlewareShouldEnrich()
6263
Assert.Equal(200, completionEvent.Properties["StatusCode"].LiteralValue());
6364
Assert.Equal("GET", completionEvent.Properties["RequestMethod"].LiteralValue());
6465
Assert.True(completionEvent.Properties.ContainsKey("Elapsed"));
66+
Assert.False(completionEvent.Properties.ContainsKey("RequestBody"));
6567
}
6668

6769
[Fact]
@@ -191,4 +193,31 @@ public async Task RequestLoggingMiddlewareShouldAddTraceAndSpanIds()
191193

192194
return (sink, web);
193195
}
196+
197+
[Fact]
198+
public async Task RequestLoggingMiddlewareShouldIncludeBody()
199+
{
200+
var (sink, web) = Setup(options =>
201+
{
202+
options.EnrichDiagnosticContext += (diagnosticContext, _) =>
203+
{
204+
diagnosticContext.Set("SomeInteger", 42);
205+
};
206+
options.IncludeRequestBody = true;
207+
});
208+
209+
await web.CreateClient().PostAsJsonAsync("/post", new { Name = "Test" });
210+
211+
Assert.NotEmpty(sink.Writes);
212+
213+
var completionEvent = sink.Writes.First(logEvent => Matching.FromSource<RequestLoggingMiddleware>()(logEvent));
214+
215+
Assert.Equal(42, completionEvent.Properties["SomeInteger"].LiteralValue());
216+
Assert.Equal("string", completionEvent.Properties["SomeString"].LiteralValue());
217+
Assert.Equal("/post", completionEvent.Properties["RequestPath"].LiteralValue());
218+
Assert.Equal(200, completionEvent.Properties["StatusCode"].LiteralValue());
219+
Assert.Equal("POST", completionEvent.Properties["RequestMethod"].LiteralValue());
220+
Assert.Equal("{\"name\":\"Test\"}", completionEvent.Properties["RequestBody"].LiteralValue());
221+
Assert.True(completionEvent.Properties.ContainsKey("Elapsed"));
222+
}
194223
}

0 commit comments

Comments
 (0)