Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
3 changes: 2 additions & 1 deletion src/TeaPie.DotnetTool/ExploreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ protected override ApplicationBuilder ConfigureApplication(Settings settings)
var pathToLogFile = settings.LogFile ?? string.Empty;
var logLevel = Helper.ResolveLogLevel(settings);
var path = PathResolver.Resolve(settings.Path, Directory.GetCurrentDirectory());
var pathToRequestsLogFile = settings.RequestsLogFile;

var appBuilder = ApplicationBuilder.Create(path.IsCollectionPath());

appBuilder
.WithPath(path)
.WithLogging(logLevel, pathToLogFile, settings.LogFileLogLevel)
.WithLogging(logLevel, pathToLogFile, settings.LogFileLogLevel, pathToRequestsLogFile)
.WithEnvironmentFile(PathResolver.Resolve(settings.EnvironmentFile, string.Empty))
.WithInitializationScript(PathResolver.Resolve(settings.InitializationScriptPath, string.Empty))
.WithStructureExplorationPipeline();
Expand Down
4 changes: 4 additions & 0 deletions src/TeaPie.DotnetTool/LoggingSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ internal class LoggingSettings : CommandSettings
"Supported levels: Trace, Debug, Information, Warning, Error, Critical, None.")]
public LogLevel LogFileLogLevel { get; init; } = LogLevel.Information;

[CommandOption("--requests-log-file")]
[Description("Path to the file where structured JSON file with information about HTTP requests will be saved.")]
Comment thread
mchlkntrv marked this conversation as resolved.
Outdated
public string? RequestsLogFile { get; init; }

[CommandOption("-l|--log-level")]
[Description("Log level for console output. " +
"Supported levels: Trace, Debug, Information, Warning, Error, Critical, None.")]
Expand Down
3 changes: 2 additions & 1 deletion src/TeaPie.DotnetTool/TestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ protected override ApplicationBuilder ConfigureApplication(Settings settings)
var pathToLogFile = settings.LogFile ?? string.Empty;
var logLevel = Helper.ResolveLogLevel(settings);
var path = PathResolver.Resolve(settings.Path, Directory.GetCurrentDirectory());
var pathToRequestsLogFile = settings.RequestsLogFile;

var appBuilder = ApplicationBuilder.Create(path.IsCollectionPath());

appBuilder
.WithPath(path)
.WithTemporaryPath(settings.TemporaryPath ?? string.Empty)
.WithLogging(logLevel, pathToLogFile, settings.LogFileLogLevel)
.WithLogging(logLevel, pathToLogFile, settings.LogFileLogLevel, pathToRequestsLogFile)
.WithEnvironment(settings.Environment ?? string.Empty)
.WithEnvironmentFile(PathResolver.Resolve(settings.EnvironmentFilePath, string.Empty))
.WithReportFile(PathResolver.Resolve(settings.ReportFilePath, string.Empty))
Expand Down
7 changes: 5 additions & 2 deletions src/TeaPie/ApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public sealed class ApplicationBuilder
private LogLevel _minimumLogLevel = LogLevel.None;
private string _pathToLogFile = string.Empty;
private LogLevel _minimumLevelForLogFile = LogLevel.None;
private string? _pathToRequestsLogFile;

private bool _variablesCaching = true;

Expand Down Expand Up @@ -59,11 +60,13 @@ public ApplicationBuilder WithTemporaryPath(string temporaryPath)
public ApplicationBuilder WithLogging(
LogLevel minimumLevel,
string pathToLogFile = "",
LogLevel minimumLevelForLogFile = LogLevel.None)
LogLevel minimumLevelForLogFile = LogLevel.None,
string? pathToRequestsLogFile = null)
{
_minimumLogLevel = minimumLevel;
_pathToLogFile = pathToLogFile;
_minimumLevelForLogFile = minimumLevelForLogFile;
_pathToRequestsLogFile = pathToRequestsLogFile;
return this;
}

Expand Down Expand Up @@ -153,7 +156,7 @@ private ApplicationContext GetApplicationContext(IServiceProvider provider)
private void ConfigureServices()
=> _services.AddTeaPie(
_isCollectionRun,
() => _services.ConfigureLogging(_minimumLogLevel, _pathToLogFile, _minimumLevelForLogFile));
() => _services.ConfigureLogging(_minimumLogLevel, _pathToLogFile, _minimumLevelForLogFile, _pathToRequestsLogFile));

private static TeaPie CreateUserContext(IServiceProvider provider, ApplicationContext applicationContext)
=> TeaPie.Create(
Expand Down
1 change: 1 addition & 0 deletions src/TeaPie/Http/Auth/Setup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static IServiceCollection AddAuthentication(this IServiceCollection servi

services.AddHttpClient<ExecuteRequestStep>()
.AddHttpMessageHandler<AuthHttpMessageHandler>()
.AddHttpMessageHandler<RequestsLoggingHandler>()
Comment on lines 14 to +15

Copilot AI Nov 12, 2025

Copy link

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. RequestsLoggingHandler is added after AuthHttpMessageHandler but before LoggingInterceptorHandler. 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.

Suggested change
.AddHttpMessageHandler<AuthHttpMessageHandler>()
.AddHttpMessageHandler<RequestsLoggingHandler>()
.AddHttpMessageHandler<RequestsLoggingHandler>()
.AddHttpMessageHandler<AuthHttpMessageHandler>()

Copilot uses AI. Check for mistakes.
.AddHttpMessageHandler<LoggingInterceptorHandler>();

services.AddSingleton<IAuthProviderRegistry, AuthProviderRegistry>();
Expand Down
9 changes: 9 additions & 0 deletions src/TeaPie/Http/ExecuteRequestStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Polly;
using TeaPie.Http.Auth;
using TeaPie.Http.Headers;
using TeaPie.Logging;
using TeaPie.Pipelines;
using TeaPie.Testing;

Expand All @@ -22,6 +23,7 @@ internal class ExecuteRequestStep(
private readonly IAuthProviderAccessor _authProviderAccessor = defaultAuthProviderAccessor;
private readonly IPipeline _pipeline = pipeline;
private readonly ITestScheduler _testScheduler = testScheduler;
private static readonly HttpRequestOptionsKey<RequestExecutionContext> _contextKey = new("__TeaPie_Context__");

public async Task Execute(ApplicationContext context, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -100,10 +102,13 @@ private async Task<HttpResponseMessage> ExecuteRequest(
var messageUsed = false;
var retryAttemptNumber = -1;

originalMessage.Options.Set(_contextKey, requestExecutionContext);

return await resiliencePipeline.ExecuteAsync(async token =>
{
retryAttemptNumber = UpdateRetryAttemptNumber(logger, retryAttemptNumber);
var request = GetMessage(requestExecutionContext, originalMessage, content, ref messageUsed);
request.Options.Set(_contextKey, requestExecutionContext);
return await client.SendAsync(request, token);
}, cancellationToken);
}
Expand Down Expand Up @@ -149,6 +154,10 @@ private HttpRequestMessage CloneMessage(
};

_headersHandler.SetHeaders(originalMessage, request);
if (originalMessage.Options.TryGetValue(RequestsLoggingHandler.LogEntryKey, out var logEntry))
{
request.Options.Set(RequestsLoggingHandler.LogEntryKey, logEntry);
}
return request;
}

Expand Down
75 changes: 75 additions & 0 deletions src/TeaPie/Logging/RequestLogFileEntry.cs
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
Comment thread
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; }

Copilot AI Nov 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception object itself is being serialized directly into the JSON log. This could cause serialization issues or expose sensitive stack trace information. Consider logging only essential exception details like exception.GetType().Name and exception.Message, or use a custom exception serializer.

Suggested change
[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;

Copilot uses AI. Check for mistakes.
public bool IsSuccessful { get; set; }
public double DurationMs { get; set; }
}

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; }
}
175 changes: 175 additions & 0 deletions src/TeaPie/Logging/RequestsLoggingHandler.cs
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)
Comment thread
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;
Comment thread
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)
Comment thread
Burgyn marked this conversation as resolved.
Outdated
{
return existingEntry;
}

var logEntry = new RequestLogFileEntry
{
RequestId = Guid.NewGuid().ToString(),
Comment thread
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
Comment thread
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)
Comment thread
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),

Copilot AI Nov 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the HTTP request content body will consume the stream and prevent it from being read again later in the pipeline. This will cause the actual HTTP request to fail when it tries to send the body. Consider either:

  1. Removing body logging for requests, or
  2. Using a buffered approach that preserves the content stream for later use

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to read the content body only once for now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm nie som si istý. My ho teraz už čítame 2x. Lebo raz ho čítaš tu pre účely logovania a druhýkrát sa číta na mieste kde sa spracováva výsledok toho responsu.
Takže by sme to mali zvážiť. Čitanie obsahu je tricky vec.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nad týmto si sa zamyslela?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

obsah je čítaný viackrát ale skrz CloneMessage v ExecuteRequestStep, kt. implementoval ešte Maťo sa stream ukladá do StringContentu a mal by byť readable viac ako raz,
aj v .json je body requestu uložené správne:

{"Name":"AddCarRequest","Method":"POST","Uri":"http://localhost:3001/cars","Headers":{"Authorization":"Bearer authToken"},"Body":"{\"Id\":10,\"Brand\":\"Ford\",\"Model\":\"Focus\",\"EngineType\":\"Petrol\",\"TransmissionType\":\"Automatic\",\"PeopleCapacity\":2,\"Color\":\"indigo\",\"Year\":1994,\"DrivenKilometres\":124679.6246257195,\"Description\":\"Vero similique ut sed.\"}",

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),

Copilot AI Nov 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the HTTP response content body will consume the stream and prevent it from being read again by subsequent code that expects to process the response. This will cause failures when the response needs to be read elsewhere. Consider buffering the response content before reading it, or removing body logging for responses.

Copilot uses AI. Check for mistakes.
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]";
}
Comment thread
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
Comment thread
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));
}
}
Loading
Loading