-
Notifications
You must be signed in to change notification settings - Fork 4
Structured HTTP Request Logging via Serilog JSON Sink #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c208e5d
c035a9a
699a606
c366e4c
6c586c1
05568d1
9c6b9f7
d186c63
7e9f36c
ff2666a
9561245
c7c9f31
7bb84ef
c6af778
d93fa3a
f957013
a1333b0
6c05f51
4d3766c
10bd12e
79588ae
fc9a439
55de903
fad56fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace TeaPie.Logging; | ||
|
|
||
| internal class RequestLogFileEntry | ||
| { | ||
| public string RequestId { get; init; } = Guid.NewGuid().ToString(); | ||
| public DateTime StartTime { get; set; } | ||
| public DateTime? EndTime { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public double? DurationMs => EndTime?.Subtract(StartTime).TotalMilliseconds; | ||
| public RequestInfo Request { get; set; } = new(); | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public ResponseInfo? Response { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public AuthInfo? Authentication { get; set; } | ||
| public List<string> Errors { get; set; } = []; | ||
| } | ||
|
|
||
| internal class RequestInfo | ||
| { | ||
| public string Name { get; set; } = string.Empty; | ||
| public string Method { get; set; } = string.Empty; | ||
| public string Uri { get; set; } = string.Empty; | ||
| public Dictionary<string, string> Headers { get; set; } = []; | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? Body { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? ContentType { get; set; } | ||
| public string FilePath { get; set; } = string.Empty; | ||
| } | ||
|
|
||
| internal class ResponseInfo | ||
| { | ||
| public int StatusCode { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? ReasonPhrase { get; set; } | ||
| public Dictionary<string, string> Headers { get; set; } = []; | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? Body { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? ContentType { get; set; } | ||
| public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; | ||
| } | ||
|
|
||
| internal class AuthInfo | ||
| { | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public string? ProviderType { get; set; } | ||
| public bool IsDefault { get; set; } | ||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public DateTime? AuthenticatedAt { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| using System.Net.Http.Headers; | ||
| using TeaPie.Http; | ||
| using TeaPie.Http.Auth; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace TeaPie.Logging; | ||
|
|
||
| internal class RequestsLoggingHandler(IAuthProviderAccessor authProviderAccessor, ILoggerFactory loggerFactory) : DelegatingHandler | ||
| { | ||
| private readonly IAuthProviderAccessor _authProviderAccessor = authProviderAccessor; | ||
| private readonly ILogger _logger = loggerFactory.CreateLogger("HttpRequests"); | ||
| private static readonly HttpRequestOptionsKey<RequestExecutionContext> _contextKey = new("__TeaPie_Context__"); | ||
|
|
||
| protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||
| { | ||
| request.Options.TryGetValue(_contextKey, out var requestContext); | ||
|
|
||
| var attemptStartTime = DateTime.UtcNow; | ||
| try | ||
| { | ||
| var response = await base.SendAsync(request, cancellationToken); | ||
| if (requestContext is not null) | ||
| { | ||
| await LogRequestAsync(requestContext, request, response, null, attemptStartTime); | ||
| } | ||
| return response; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| if (requestContext is not null) | ||
| { | ||
| await LogRequestAsync(requestContext, request, null, ex, attemptStartTime); | ||
| } | ||
| throw; | ||
| } | ||
| } | ||
|
|
||
| private async Task LogRequestAsync( | ||
| RequestExecutionContext requestContext, | ||
| HttpRequestMessage request, | ||
| HttpResponseMessage? response, | ||
| Exception? exception, | ||
| DateTime attemptStartTime) | ||
| { | ||
| var logEntry = new RequestLogFileEntry | ||
| { | ||
| StartTime = attemptStartTime, | ||
| EndTime = DateTime.UtcNow, | ||
| Request = await CreateRequestInfoAsync(requestContext, request), | ||
| Response = response != null ? await CreateResponseInfoAsync(response) : null, | ||
| Authentication = CreateAuthInfo(), | ||
| Errors = exception != null ? [exception.Message] : [] | ||
| }; | ||
|
|
||
| _logger.LogInformation("{@RequestLogFileEntry}", logEntry); | ||
| } | ||
|
|
||
| private static async Task<RequestInfo> CreateRequestInfoAsync(RequestExecutionContext requestContext, HttpRequestMessage request) | ||
| { | ||
| return new RequestInfo | ||
| { | ||
| Name = requestContext.Name, | ||
| Method = request.Method.ToString(), | ||
| Uri = request.RequestUri?.ToString() ?? string.Empty, | ||
| Headers = ProcessHeaders(request.Headers), | ||
| Body = await GetContentBodyAsync(request.Content), | ||
|
||
| ContentType = request.Content?.Headers.ContentType?.MediaType, | ||
| FilePath = requestContext.RequestFile.RelativePath | ||
| }; | ||
| } | ||
|
|
||
| private static async Task<ResponseInfo> CreateResponseInfoAsync(HttpResponseMessage response) | ||
| { | ||
| return new ResponseInfo | ||
| { | ||
| StatusCode = (int)response.StatusCode, | ||
| ReasonPhrase = response.ReasonPhrase, | ||
| Headers = ProcessHeaders(response.Headers), | ||
| Body = await GetContentBodyAsync(response.Content), | ||
|
||
| ContentType = response.Content?.Headers.ContentType?.MediaType, | ||
| ReceivedAt = DateTime.UtcNow | ||
| }; | ||
| } | ||
|
|
||
| private static async Task<string?> GetContentBodyAsync(HttpContent? content) | ||
| { | ||
| if (content == null) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| return await content.ReadAsStringAsync(); | ||
| } | ||
| catch (OperationCanceledException ex) | ||
| { | ||
| return ($"Content reading failed: {ex.Message}"); | ||
| } | ||
| } | ||
|
|
||
| private AuthInfo? CreateAuthInfo() | ||
| { | ||
| var currentProvider = _authProviderAccessor.CurrentProvider; | ||
| return currentProvider == null ? null : new AuthInfo | ||
| { | ||
| ProviderType = currentProvider.GetType().Name, | ||
| IsDefault = currentProvider == _authProviderAccessor.DefaultProvider, | ||
| AuthenticatedAt = DateTime.UtcNow | ||
| }; | ||
| } | ||
|
|
||
| private static Dictionary<string, string> ProcessHeaders(HttpHeaders headers) | ||
| { | ||
| return headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |||||||||||||||||||||||||||
| using Microsoft.Extensions.Logging; | ||||||||||||||||||||||||||||
| using Serilog; | ||||||||||||||||||||||||||||
| using Serilog.Events; | ||||||||||||||||||||||||||||
| using Serilog.Filters; | ||||||||||||||||||||||||||||
| using Serilog.Formatting.Json; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| namespace TeaPie.Logging; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -12,6 +14,7 @@ public static IServiceCollection AddLogging(this IServiceCollection services, Ac | |||||||||||||||||||||||||||
| configure(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| services.AddTransient<LoggingInterceptorHandler>(); | ||||||||||||||||||||||||||||
| services.AddTransient<RequestsLoggingHandler>(); | ||||||||||||||||||||||||||||
| services.AddSingleton<NuGet.Common.ILogger, NuGetLoggerAdapter>(); | ||||||||||||||||||||||||||||
| services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog(dispose: true)); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -22,7 +25,8 @@ public static IServiceCollection ConfigureLogging( | |||||||||||||||||||||||||||
| this IServiceCollection services, | ||||||||||||||||||||||||||||
| LogLevel minimumLevel, | ||||||||||||||||||||||||||||
| string pathToLogFile = "", | ||||||||||||||||||||||||||||
| LogLevel minimumLevelForLogFile = LogLevel.Debug) | ||||||||||||||||||||||||||||
| LogLevel minimumLevelForLogFile = LogLevel.Debug, | ||||||||||||||||||||||||||||
| string? pathToRequestsLogFile = null) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| if (minimumLevel == LogLevel.None) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
|
|
@@ -33,12 +37,18 @@ public static IServiceCollection ConfigureLogging( | |||||||||||||||||||||||||||
| var config = new LoggerConfiguration() | ||||||||||||||||||||||||||||
| .MinimumLevel.Is(GetMaximumFromMinimalLevels(minimumLevel, minimumLevelForLogFile)) | ||||||||||||||||||||||||||||
| .MinimumLevel.Override("System.Net.Http", ApplyRestrictiveLogLevelRule(minimumLevel)) | ||||||||||||||||||||||||||||
| .MinimumLevel.Override("TeaPie.Logging.NuGetLoggerAdapter", ApplyRestrictiveLogLevelRule(minimumLevel)) | ||||||||||||||||||||||||||||
| .WriteTo.Console(restrictedToMinimumLevel: minimumLevel.ToSerilogLogLevel()); | ||||||||||||||||||||||||||||
| .MinimumLevel.Override("TeaPie.Logging.NuGetLoggerAdapter", ApplyRestrictiveLogLevelRule(minimumLevel)); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| AddConsoleSink(config, minimumLevel); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!pathToLogFile.Equals(string.Empty) && minimumLevelForLogFile < LogLevel.None) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| config.WriteTo.File(pathToLogFile, restrictedToMinimumLevel: minimumLevelForLogFile.ToSerilogLogLevel()); | ||||||||||||||||||||||||||||
| AddLogFileSink(config, pathToLogFile, minimumLevelForLogFile); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!string.IsNullOrEmpty(pathToRequestsLogFile)) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| AddRequestsFileSink(config, pathToRequestsLogFile, minimumLevelForLogFile); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Log.Logger = config.CreateLogger(); | ||||||||||||||||||||||||||||
|
|
@@ -54,4 +64,33 @@ private static LogEventLevel GetMaximumFromMinimalLevels(LogLevel minimumLevel1, | |||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private static LogEventLevel ApplyRestrictiveLogLevelRule(LogLevel minimumLevel) | ||||||||||||||||||||||||||||
| => minimumLevel >= LogLevel.Information ? LogEventLevel.Warning : LogEventLevel.Debug; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private static void AddConsoleSink(LoggerConfiguration config, LogLevel minimumLevel) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| config.WriteTo.Logger(lc => lc | ||||||||||||||||||||||||||||
| .Filter.ByExcluding(Matching.FromSource("HttpRequests")) | ||||||||||||||||||||||||||||
| .WriteTo.Console(restrictedToMinimumLevel: minimumLevel.ToSerilogLogLevel())); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private static void AddLogFileSink(LoggerConfiguration config, string pathToLogFile, LogLevel minimumLevelForLogFile) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| config.WriteTo.Logger(lc => lc | ||||||||||||||||||||||||||||
| .Filter.ByExcluding(Matching.FromSource("HttpRequests")) | ||||||||||||||||||||||||||||
| .WriteTo.File(pathToLogFile, restrictedToMinimumLevel: minimumLevelForLogFile.ToSerilogLogLevel())); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private static void AddRequestsFileSink(LoggerConfiguration config, string pathToRequestsLogFile, LogLevel minimumLevelForLogFile) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| if (File.Exists(pathToRequestsLogFile)) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| File.Delete(pathToRequestsLogFile); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| File.Delete(pathToRequestsLogFile); | |
| try | |
| { | |
| File.Delete(pathToRequestsLogFile); | |
| } | |
| catch (IOException) | |
| { | |
| // The file is in use or cannot be deleted. Optionally log this event. | |
| } | |
| catch (UnauthorizedAccessException) | |
| { | |
| // The file cannot be deleted due to permission issues. Optionally log this event. | |
| } |
Copilot
AI
Oct 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deleting the file without synchronization could cause issues if multiple processes or threads attempt to write to the same log file simultaneously. Consider using a lock mechanism or checking if the file is in use before deletion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handler order matters for DelegatingHandlers.
RequestsLoggingHandleris added afterAuthHttpMessageHandlerbut beforeLoggingInterceptorHandler. This means authentication headers will be captured in the logs, which could be a security concern if tokens or credentials are logged. Consider the security implications of this ordering or add sanitization for sensitive headers.