-
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 14 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 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ public static IServiceCollection AddAuthentication(this IServiceCollection servi | |||||||||
|
|
||||||||||
| services.AddHttpClient<ExecuteRequestStep>() | ||||||||||
| .AddHttpMessageHandler<AuthHttpMessageHandler>() | ||||||||||
| .AddHttpMessageHandler<RequestsLoggingHandler>() | ||||||||||
|
Comment on lines
14
to
+15
|
||||||||||
| .AddHttpMessageHandler<AuthHttpMessageHandler>() | |
| .AddHttpMessageHandler<RequestsLoggingHandler>() | |
| .AddHttpMessageHandler<RequestsLoggingHandler>() | |
| .AddHttpMessageHandler<AuthHttpMessageHandler>() |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,75 @@ | ||||||||||||||||||||||
| using System.Text.Json.Serialization; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| namespace TeaPie.Logging; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| internal class RequestLogFileEntry | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| public string RequestId { get; set; } = Guid.NewGuid().ToString(); | ||||||||||||||||||||||
| public DateTime StartTime { get; set; } = DateTime.UtcNow; | ||||||||||||||||||||||
| 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; } | ||||||||||||||||||||||
| public RetryInfo Retries { get; set; } = new(); | ||||||||||||||||||||||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||||||||||||||||||||
| public AuthInfo? Authentication { get; set; } | ||||||||||||||||||||||
| public List<string> Errors { get; set; } = []; | ||||||||||||||||||||||
| public Dictionary<string, object> Metadata { 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 RetryInfo | ||||||||||||||||||||||
|
Burgyn marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||
| { | ||||||||||||||||||||||
| public int AttemptCount { get; set; } = 1; | ||||||||||||||||||||||
| public List<RetryAttempt> Attempts { get; set; } = []; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| internal class RetryAttempt | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| public int AttemptNumber { get; set; } | ||||||||||||||||||||||
| public DateTime Timestamp { get; set; } = DateTime.UtcNow; | ||||||||||||||||||||||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||||||||||||||||||||
| public string? Reason { get; set; } | ||||||||||||||||||||||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||||||||||||||||||||
| public ResponseInfo? Response { get; set; } | ||||||||||||||||||||||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||||||||||||||||||||
| public Exception? Exception { get; set; } | ||||||||||||||||||||||
|
||||||||||||||||||||||
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | |
| public Exception? Exception { get; set; } | |
| [JsonIgnore] | |
| public Exception? Exception { get; set; } | |
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | |
| public string? ExceptionType => Exception?.GetType().Name; | |
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | |
| public string? ExceptionMessage => Exception?.Message; | |
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | |
| public string? ExceptionStackTrace => Exception?.StackTrace; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| 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__"); | ||
| public static readonly HttpRequestOptionsKey<RequestLogFileEntry> LogEntryKey = new("__TeaPie_LogEntry__"); | ||
|
|
||
| protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||
| { | ||
| if (!request.Options.TryGetValue(_contextKey, out var requestContext) || requestContext is null) | ||
|
Burgyn marked this conversation as resolved.
Outdated
|
||
| { | ||
| return await base.SendAsync(request, cancellationToken); | ||
| } | ||
|
|
||
| var logEntry = await GetOrCreateLogEntryAsync(request, requestContext); | ||
| var attemptStartTime = DateTime.UtcNow; | ||
| HttpResponseMessage? response = null; | ||
| Exception? exception = null; | ||
|
|
||
| try | ||
| { | ||
| response = await base.SendAsync(request, cancellationToken); | ||
| return response; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| exception = ex; | ||
|
mchlkntrv marked this conversation as resolved.
Outdated
|
||
| throw; | ||
| } | ||
| finally | ||
| { | ||
| await RecordAttemptAsync(logEntry, response, exception, attemptStartTime); | ||
| await LogCompletedRequestAsync(request, response); | ||
| } | ||
| } | ||
|
|
||
| private async Task<RequestLogFileEntry> GetOrCreateLogEntryAsync(HttpRequestMessage request, RequestExecutionContext requestContext) | ||
| { | ||
| if (request.Options.TryGetValue(LogEntryKey, out var existingEntry) && existingEntry != null) | ||
|
Burgyn marked this conversation as resolved.
Outdated
|
||
| { | ||
| return existingEntry; | ||
| } | ||
|
|
||
| var logEntry = new RequestLogFileEntry | ||
| { | ||
| RequestId = Guid.NewGuid().ToString(), | ||
|
mchlkntrv marked this conversation as resolved.
Outdated
|
||
| StartTime = DateTime.UtcNow, | ||
| Request = await CreateRequestInfoAsync(requestContext, request), | ||
| Authentication = CreateAuthInfo(), | ||
| Metadata = CreateMetadata(requestContext), | ||
| Retries = new RetryInfo { AttemptCount = 0, Attempts = [] }, | ||
| Errors = [] | ||
| }; | ||
|
|
||
| request.Options.Set(LogEntryKey, logEntry); | ||
| return logEntry; | ||
| } | ||
|
|
||
| private static async Task RecordAttemptAsync(RequestLogFileEntry logEntry, HttpResponseMessage? response, Exception? exception, DateTime attemptStartTime) | ||
| { | ||
| var attemptNumber = logEntry.Retries.AttemptCount + 1; | ||
| var attempt = new RetryAttempt | ||
|
Burgyn marked this conversation as resolved.
Outdated
|
||
| { | ||
| AttemptNumber = attemptNumber, | ||
| Timestamp = attemptStartTime, | ||
| Reason = attemptNumber == 1 ? "Initial attempt" : "Resilience policy triggered retry", | ||
| IsSuccessful = response?.IsSuccessStatusCode ?? false, | ||
| DurationMs = (DateTime.UtcNow - attemptStartTime).TotalMilliseconds, | ||
| Exception = exception | ||
| }; | ||
|
|
||
| if (response != null) | ||
| { | ||
| attempt.Response = await CreateResponseInfoAsync(response); | ||
| } | ||
|
|
||
| logEntry.Retries.Attempts.Add(attempt); | ||
| logEntry.Retries.AttemptCount = attemptNumber; | ||
| logEntry.EndTime = DateTime.UtcNow; | ||
|
|
||
| if (exception != null) | ||
| { | ||
| logEntry.Errors.Add(exception.Message); | ||
| } | ||
| } | ||
|
|
||
| public async Task LogCompletedRequestAsync(HttpRequestMessage request, HttpResponseMessage? finalResponse) | ||
|
mchlkntrv marked this conversation as resolved.
Outdated
|
||
| { | ||
| if (request.Options.TryGetValue(LogEntryKey, out var logEntry) && logEntry != null) | ||
| { | ||
| if (finalResponse != null && logEntry.Response == null) | ||
| { | ||
| logEntry.Response = await CreateResponseInfoAsync(finalResponse); | ||
| } | ||
|
|
||
| _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 | ||
| { | ||
| return "[Content reading failed]"; | ||
| } | ||
|
mchlkntrv marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| 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, object> CreateMetadata(RequestExecutionContext requestContext) | ||
| { | ||
| return new Dictionary<string, object> | ||
| { | ||
| ["testCaseId"] = requestContext.TestCaseExecutionContext?.Id.ToString() ?? "none", | ||
| ["hasResiliencePipeline"] = requestContext.ResiliencePipeline != null | ||
|
mchlkntrv marked this conversation as resolved.
Outdated
|
||
| }; | ||
| } | ||
|
|
||
| private static Dictionary<string, string> ProcessHeaders(HttpHeaders headers) | ||
| { | ||
| return headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.