Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ teapie init [path-to-teapie-folder] [--no-logo]
**Full Syntax:**

```sh
teapie test [path] [--temp-path <path>] [-e|--env <envName>] [--env-file <file>] [-r|--report-file <file>] [-i|--init-script <script>] [--no-cache-vars] [--log-file <file>] [--log-file-log-level <level>] [-l|--log-level <level>] [-d|--debug] [-v|--verbose] [-q|--quiet] [--no-logo]
teapie test [path] [--temp-path <path>] [-e|--env <envName>] [--env-file <file>] [-r|--report-file <file>] [-i|--init-script <script>] [--no-cache-vars] [--log-file <file>] [--log-file-log-level <level>] [--requests-log-file <file>] [-l|--log-level <level>] [-d|--debug] [-v|--verbose] [-q|--quiet] [--no-logo]
```

| **Argument** | **Meaning** | **Mandatory** |
Expand All @@ -151,6 +151,7 @@ teapie test [path] [--temp-path <path>] [-e|--env <envName>] [--env-file <file>]
| `--no-cache-vars` | Disables loading and caching variables from/to file. | `false` |
| `--log-file` | Specifies the path to the file where all logs will be saved. | `null` |
| `--log-file-log-level` | Log level for the log file (only applicable if `--log-file` is set). Supported levels: `Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`. | `Information` |
| `--requests-log-file` | Specifies path to the file where structured JSON data about HTTP requests will be saved. | `null` |
| `-l`, `--log-level` | Log level for console output. Supported levels: `Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`. | `Information` |
| `-d`, `--debug` | Displays debug information. | `false` |
| `-v`, `--verbose` | Displays all available information, including debug details. | `false` |
Expand Down
86 changes: 86 additions & 0 deletions docs/docs/requests-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# HTTP Requests Logging

TeaPie can log detailed HTTP request and response information to a separate JSON file for better analysis of requests and debugging.

## Usage

Use the `--requests-log-file` parameter to capture structured logs:

```bash
teapie test my-requests.http --requests-log-file requests.json
```

This creates a `requests.json` file with one JSON object per line (JSONL format).

## What's Included

Each log entry contains:

- **Request details**: Name, method, URL, headers, body, file path
- **Response details**: Status code, reason phrase, headers, body, content type
- **Timing**: Start time, end time, duration in milliseconds
- **Authentication**: Provider type, whether it's default, authentication timestamp
- **Errors**: Array of any errors that occurred
- **Metadata**: Request ID, type tags, source context

## Example

```json
[
{
"Timestamp": "2025-12-03T14:06:09.3239022+01:00",
"Level": "Information",
"MessageTemplate": "{@RequestLogFileEntry}",
"Properties": {
"RequestLogFileEntry": {
"RequestId": "28ccf092-0240-4d91-8acc-108ef45c8acb",
"StartTime": "2025-12-03T13:06:09.1820864Z",
"EndTime": "2025-12-03T13:06:09.3237782Z",
"DurationMs": 141.6918,
"Request": {
"Name": "GetEditedCarRequest",
"Method": "GET",
"Uri": "http://localhost:3001/cars/6",
"Headers": {
"Authorization": "Bearer authToken"
},
"Body": "",
"ContentType": "text/plain",
"FilePath": "002-Cars\\002-Edit-Car-req.http",
"_typeTag": "RequestInfo"
},
"Response": {
"StatusCode": 200,
"ReasonPhrase": "OK",
"Headers": {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With",
"Date": "Wed, 03 Dec 2025 13:06:09 GMT",
"Connection": "keep-alive",
"Keep-Alive": "timeout=5"
},
"Body": "{\"Id\":6,\"Brand\":\"Toyota\",\"Model\":\"RAV4\",\"EngineType\":\"3.0 TDI\",\"TransmissionType\":\"Manual\",\"PeopleCapacity\":5,\"Color\":\"silver\",\"Year\":2022,\"DrivenKilometres\":21000,\"Description\":\"\"}",
"ContentType": "application/json",
"ReceivedAt": "2025-12-03T13:06:09.3238287Z",
"_typeTag": "ResponseInfo"
},
"Authentication": {
"ProviderType": "OAuth2Provider",
"IsDefault": true,
"AuthenticatedAt": "2025-12-03T13:06:09.3238312Z",
"_typeTag": "AuthInfo"
},
"Errors": [],
"_typeTag": "RequestLogFileEntry"
},
"SourceContext": "HttpRequests",
"HttpMethod": "GET",
"Uri": "http://localhost:3001/cars/6",
"Scope": [
"HTTP GET http://localhost:3001/cars/6"
]
}
}
]
```
2 changes: 1 addition & 1 deletion docs/docs/running-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Both single test case and collection runs follow these two main steps:
For more advanced usage, here’s the full command specification:

```sh
teapie test [path-to-collection-or-test-case] [--temp-path <path-to-temporary-folder>] [-d|--debug] [-v|--verbose] [-q|--quiet] [--log-level <minimal-log-level>] [--log-file <path-to-log-file>] [--log-file-log-level <minimal-log-level-for-log-file>] [-e|--env|--environment <environment-name>] [--env-file|--environment-file <path-to-environment-file>] [-r|--report-file <path-to-report-file>] [-i|--init-script|--initialization-script <path-to-initialization-script>] [--no-cache-vars|--no-cache-variables]
teapie test [path-to-collection-or-test-case] [--temp-path <path-to-temporary-folder>] [-d|--debug] [-v|--verbose] [-q|--quiet] [--log-level <minimal-log-level>] [--log-file <path-to-log-file>] [--log-file-log-level <minimal-log-level-for-log-file>] [--requests-log-file <path-to-log-file>] [-e|--env|--environment <environment-name>] [--env-file|--environment-file <path-to-environment-file>] [-r|--report-file <path-to-report-file>] [-i|--init-script|--initialization-script <path-to-initialization-script>] [--no-cache-vars|--no-cache-variables]
```

> 💁‍♂️ You can use alias `t` or **completely omit command name**, since `test` command is considered as **default command** when launching `teapie`.
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
href: reporting.md
- name: Logging
href: logging.md
name: HTTP Requests Logging
href: requests-logging.md
- name: JSON Handling
href: json.md
- name: Debugging
Expand Down
2 changes: 1 addition & 1 deletion src/TeaPie.DotnetTool/ExploreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 pathToRequestsLogFile = settings.RequestsLogFile ?? string.Empty;

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

Expand Down
2 changes: 1 addition & 1 deletion src/TeaPie.DotnetTool/TestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 pathToRequestsLogFile = settings.RequestsLogFile ?? string.Empty;

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

Expand Down
4 changes: 2 additions & 2 deletions src/TeaPie/ApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +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 string _pathToRequestsLogFile = string.Empty;

private bool _variablesCaching = true;

Expand Down Expand Up @@ -61,7 +61,7 @@ public ApplicationBuilder WithLogging(
LogLevel minimumLevel,
string pathToLogFile = "",
LogLevel minimumLevelForLogFile = LogLevel.None,
string? pathToRequestsLogFile = null)
string pathToRequestsLogFile = "")
{
_minimumLogLevel = minimumLevel;
_pathToLogFile = pathToLogFile;
Expand Down
4 changes: 2 additions & 2 deletions src/TeaPie/Logging/RequestLogFileEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ internal class RequestLogFileEntry
{
public string RequestId { get; init; } = Guid.NewGuid().ToString();
public DateTime StartTime { get; set; }
public DateTime? EndTime { get; set; }
public DateTime EndTime { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? DurationMs => EndTime?.Subtract(StartTime).TotalMilliseconds;
public double DurationMs => EndTime.Subtract(StartTime).TotalMilliseconds;
public RequestInfo Request { get; set; } = new();
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ResponseInfo? Response { get; set; }
Expand Down
4 changes: 2 additions & 2 deletions src/TeaPie/Logging/Setup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public static IServiceCollection ConfigureLogging(
LogLevel minimumLevel,
string pathToLogFile = "",
LogLevel minimumLevelForLogFile = LogLevel.Debug,
string? pathToRequestsLogFile = null)
string pathToRequestsLogFile = "")
{
if (minimumLevel == LogLevel.None)
{
Expand All @@ -46,7 +46,7 @@ public static IServiceCollection ConfigureLogging(
AddLogFileSink(config, pathToLogFile, minimumLevelForLogFile);
}

if (!string.IsNullOrEmpty(pathToRequestsLogFile))
if (!pathToRequestsLogFile.Equals(string.Empty))
{
AddRequestsFileSink(config, pathToRequestsLogFile, minimumLevelForLogFile);
}
Expand Down
189 changes: 189 additions & 0 deletions tests/TeaPie.Tests/Http/RequestsLoggingHandlerShould.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging;
using NSubstitute;
using System.Net;
using TeaPie.Http;
using TeaPie.Http.Auth;
using TeaPie.Logging;
using TeaPie.StructureExploration;
using static Xunit.Assert;

namespace TeaPie.Tests.Http;

public class RequestsLoggingHandlerShould
{
private static readonly HttpRequestOptionsKey<RequestExecutionContext> _contextKey = new("__TeaPie_Context__");

[Theory]
[InlineData(HttpStatusCode.OK)]
[InlineData(HttpStatusCode.Created)]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.BadRequest)]
[InlineData(HttpStatusCode.InternalServerError)]
public async Task LogRequestWithStatusCode(HttpStatusCode statusCode)
{
var (handler, logger, innerHandler) = CreateHandler();
innerHandler.Response = new HttpResponseMessage(statusCode);
var request = CreateRequest();
var invoker = new HttpMessageInvoker(handler);

await invoker.SendAsync(request, CancellationToken.None);

logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(state => ValidateLogEntry(state, statusCode)),
null,
Arg.Any<Func<object, Exception?, string>>());
}

[Fact]
public async Task LogRequestWhenException()
{
var (handler, logger, innerHandler) = CreateHandler();
var request = CreateRequest();
var invoker = new HttpMessageInvoker(handler);

innerHandler.Exception = new HttpRequestException("Network error");
await ThrowsAsync<HttpRequestException>(
async () => await invoker.SendAsync(request, CancellationToken.None));

logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(state => ValidateLogEntry(state, null)),
null,
Arg.Any<Func<object, Exception?, string>>());
}

[Fact]
public async Task NotLogWhenContextIsMissing()
{
var (handler, logger, _) = CreateHandler();
var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
var invoker = new HttpMessageInvoker(handler);
await invoker.SendAsync(request, CancellationToken.None);

logger.DidNotReceive().Log(
Arg.Any<LogLevel>(),
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}

[Fact]
public async Task LogMultipleConsecutiveRequests()
{
var (handler, logger, innerHandler) = CreateHandler();
var invoker = new HttpMessageInvoker(handler);
await invoker.SendAsync(CreateRequest(), CancellationToken.None);

innerHandler.Exception = new HttpRequestException("Network error");
await ThrowsAsync<HttpRequestException>(
async () => await invoker.SendAsync(CreateRequest(), CancellationToken.None));

innerHandler.Exception = null;
await invoker.SendAsync(CreateRequest(), CancellationToken.None);

logger.Received(3).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Any<object>(),
null,
Arg.Any<Func<object, Exception?, string>>());

logger.Received(2).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(state => ValidateLogEntry(state, HttpStatusCode.OK)),
null,
Arg.Any<Func<object, Exception?, string>>());

logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(state => ValidateLogEntry(state, null)),
null,
Arg.Any<Func<object, Exception?, string>>());
}

private static bool ValidateLogEntry(object? state, HttpStatusCode? expectedStatusCode)
{
var entry = ExtractEntry(state);

if (expectedStatusCode == null)
{
return entry?.Response == null && entry?.Errors.Count > 0;
}
else
{
return entry != null
&& !string.IsNullOrEmpty(entry.RequestId)
&& entry.StartTime != default
&& entry.EndTime != default
&& entry.DurationMs >= 0
&& entry.Request.Method == "GET"
&& entry.Request.Uri == "https://example.com/"
&& entry.Request.FilePath == "test.http"
&& entry.Response != null
&& entry.Response.StatusCode == (int)expectedStatusCode
&& entry.Errors.Count == 0;
}
}

private static RequestLogFileEntry? ExtractEntry(object? state)
{
if (state is RequestLogFileEntry entry)
{
return entry;
}

if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
{
return pairs.Select(kvp => kvp.Value).OfType<RequestLogFileEntry>().FirstOrDefault();
}

return null;
}

private static (RequestsLoggingHandler handler, ILogger logger, TestHttpMessageHandler innerHandler) CreateHandler()
{
var authAccessor = Substitute.For<IAuthProviderAccessor>();
var loggerFactory = Substitute.For<ILoggerFactory>();
var logger = Substitute.For<ILogger>();
loggerFactory.CreateLogger(Arg.Any<string>()).Returns(logger);

var handler = new RequestsLoggingHandler(authAccessor, loggerFactory);
var innerHandler = new TestHttpMessageHandler();
handler.InnerHandler = innerHandler;

return (handler, logger, innerHandler);
}

private static HttpRequestMessage CreateRequest()
{
var file = new InternalFile("test.http", "test.http", new Folder("", "", "", null));
var context = new RequestExecutionContext(file);
var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
request.Options.Set(_contextKey, context);

return request;
}

private class TestHttpMessageHandler : HttpMessageHandler
{
public HttpResponseMessage? Response { get; set; }
public Exception? Exception { get; set; }

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (Exception != null)
{
throw Exception;
}
return Task.FromResult(Response ?? new HttpResponseMessage(HttpStatusCode.OK));
}
}
}