diff --git a/README.md b/README.md
index e2ae3a8..822af28 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@
+ [Wiring the middleware](#wiring-the-middleware)
+ [Correlated logs](#correlated-logs)
+ [Customizing the clock](#customizing-the-clock)
+ + [Ignoring paths](#ignoring-paths)
+ + [Logging paths only on failure](#logging-paths-only-on-failure)
* [License](#license)
* [Contributing](#contributing)
@@ -16,7 +18,9 @@
Provides a PSR-15 middleware that records every inbound HTTP exchange. Before invoking the next handler, the
middleware logs the request method, URI, query parameters, and parsed body. After the handler runs, it logs the
response status code, decoded JSON body when present, and the elapsed duration in milliseconds. Successful
-responses are logged at `info` level. Responses in the 4xx/5xx range are logged at `error` level.
+responses are logged at `info` level. Responses in the 4xx/5xx range are logged at `error` level. Chosen paths can be
+left out of the log entirely, or logged only when their response is an error, so health checks and routes called on
+a schedule do not bury the entries that matter.
Duration is measured through a `TinyBlocks\Time\MonotonicClock` and a `Stopwatch`, so the reading is unaffected
by wall-clock adjustments. When the request carries a correlation identifier (under the `correlationId`
@@ -115,6 +119,53 @@ $middleware = LogMiddleware::create()
->build();
```
+### Ignoring paths
+
+Leaves the given paths out of the log entirely. Neither the `request` entry nor the `response` entry is written for
+them, whatever the response status, which suits health checks that a load balancer or an orchestrator polls every few
+seconds. Each path is compared by exact equality with the request URI path, so `/health` does not match
+`/health/readiness`, and the query string plays no part in the comparison.
+
+```php
+withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/liveness', '/health/readiness')
+ ->build();
+```
+
+### Logging paths only on failure
+
+Logs the given paths only when their response is an error, meaning a status in the 4xx/5xx range. Any other response
+produces no entry. An error response produces both the `request` entry and the `response` entry, with the same context
+as on any other path, so the failure stays fully visible. This suits internal routes called on a schedule that almost
+always succeed with nothing to report. Each path is compared by exact equality with the request URI path.
+
+The outcome is known only once the handler returns, so on these paths the `request` entry is written after the handler
+runs instead of before it. A handler that throws instead of returning a response leaves no entry on these paths. A
+path that is also ignored is never logged.
+
+```php
+withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/readiness')
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+```
+
## License
Http Logging is licensed under [MIT](LICENSE).
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index cfb3a61..5b14f49 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -13,6 +13,10 @@ parameters:
# log payload (decoded body) cannot be expressed there.
- identifier: missingType.iterableValue
path: src/Internal/LogResponse.php
+ # PHPDoc is prohibited inside src/Internal/; the path lookup keyed by path cannot
+ # carry its value type there.
+ - identifier: missingType.iterableValue
+ path: src/Internal/Paths.php
# The request context returned by LogRequest::toContext() is typed as `array`, so
# PHPStan cannot infer the shape consumed by the LogResponse factory.
- identifier: argument.type
@@ -21,4 +25,10 @@ parameters:
# value type, so PHPStan flags offset access on the assertions.
- identifier: offsetAccess.nonOffsetAccessible
path: tests/Unit/LogMiddlewareTest.php
+ # PHPDoc is prohibited inside tests/; the captured log entries and the data sets
+ # cannot carry a value type.
+ - identifier: missingType.iterableValue
+ paths:
+ - tests/Unit/CapturingLogger.php
+ - tests/Unit/LogMiddlewareTest.php
reportUnmatchedIgnoredErrors: true
diff --git a/src/Internal/FailureOnlyLogExchange.php b/src/Internal/FailureOnlyLogExchange.php
new file mode 100644
index 0000000..fe8accf
--- /dev/null
+++ b/src/Internal/FailureOnlyLogExchange.php
@@ -0,0 +1,51 @@
+stopwatch->elapsed(),
+ request: $this->request,
+ response: $response
+ );
+
+ if (!$responseLog->isError()) {
+ return $response;
+ }
+
+ $this->request->writeTo(logger: $this->logger);
+ $responseLog->writeTo(logger: $this->logger);
+
+ return $response;
+ }
+}
diff --git a/src/Internal/LogExchange.php b/src/Internal/LogExchange.php
index ed84d94..4e49ff9 100644
--- a/src/Internal/LogExchange.php
+++ b/src/Internal/LogExchange.php
@@ -25,7 +25,7 @@ public static function start(
ServerRequestInterface $request
): LogExchange {
$requestLog = LogRequest::from(request: $request);
- $logger->info('request', $requestLog->toContext());
+ $requestLog->writeTo(logger: $logger);
return new LogExchange(
logger: $logger,
@@ -36,16 +36,11 @@ public static function start(
public function complete(ResponseInterface $response): ResponseInterface
{
- $responseLog = LogResponse::from(
+ LogResponse::from(
elapsed: $this->stopwatch->elapsed(),
request: $this->request,
response: $response
- );
-
- match ($responseLog->isError()) {
- true => $this->logger->error('response', $responseLog->toContext()),
- false => $this->logger->info('response', $responseLog->toContext())
- };
+ )->writeTo(logger: $this->logger);
return $response;
}
diff --git a/src/Internal/LogRequest.php b/src/Internal/LogRequest.php
index 4fe0d34..5c4d4f7 100644
--- a/src/Internal/LogRequest.php
+++ b/src/Internal/LogRequest.php
@@ -5,6 +5,7 @@
namespace TinyBlocks\Http\Logging\Internal;
use Psr\Http\Message\ServerRequestInterface;
+use Psr\Log\LoggerInterface;
use TinyBlocks\Http\Server\Request;
final readonly class LogRequest
@@ -31,6 +32,11 @@ public static function from(ServerRequestInterface $request): LogRequest
);
}
+ public function writeTo(LoggerInterface $logger): void
+ {
+ $logger->info('request', $this->toContext());
+ }
+
public function toContext(): array
{
$context = [
diff --git a/src/Internal/LogResponse.php b/src/Internal/LogResponse.php
index ad010f9..0d6f38b 100644
--- a/src/Internal/LogResponse.php
+++ b/src/Internal/LogResponse.php
@@ -5,6 +5,7 @@
namespace TinyBlocks\Http\Logging\Internal;
use Psr\Http\Message\ResponseInterface;
+use Psr\Log\LoggerInterface;
use TinyBlocks\Http\Code;
use TinyBlocks\Time\Elapsed;
@@ -38,7 +39,15 @@ public function isError(): bool
return Code::isErrorCode(code: $this->statusCode);
}
- public function toContext(): array
+ public function writeTo(LoggerInterface $logger): void
+ {
+ match ($this->isError()) {
+ true => $logger->error('response', $this->toContext()),
+ false => $logger->info('response', $this->toContext())
+ };
+ }
+
+ private function toContext(): array
{
$context = [
'method' => $this->method,
diff --git a/src/Internal/Paths.php b/src/Internal/Paths.php
new file mode 100644
index 0000000..96523b1
--- /dev/null
+++ b/src/Internal/Paths.php
@@ -0,0 +1,22 @@
+paths[$path]);
+ }
+}
diff --git a/src/LogMiddleware.php b/src/LogMiddleware.php
index 73e397a..09565be 100644
--- a/src/LogMiddleware.php
+++ b/src/LogMiddleware.php
@@ -12,7 +12,9 @@
use TinyBlocks\Http\CorrelationId\CorrelatedLogger;
use TinyBlocks\Http\CorrelationId\CorrelationId;
use TinyBlocks\Http\CorrelationId\CorrelationIdMiddleware;
+use TinyBlocks\Http\Logging\Internal\FailureOnlyLogExchange;
use TinyBlocks\Http\Logging\Internal\LogExchange;
+use TinyBlocks\Http\Logging\Internal\Paths;
use TinyBlocks\Time\MonotonicClock;
/**
@@ -21,20 +23,38 @@
*/
final readonly class LogMiddleware implements MiddlewareInterface
{
- private function __construct(private MonotonicClock $clock, private LoggerInterface $logger)
- {
+ private function __construct(
+ private MonotonicClock $clock,
+ private LoggerInterface $logger,
+ private Paths $ignoredPaths,
+ private Paths $failureOnlyPaths
+ ) {
}
/**
- * Builds a LogMiddleware from a monotonic clock and a logger.
+ * Builds a LogMiddleware from a monotonic clock, a logger, and optional path filters.
+ *
+ *
Paths are compared by exact string equality with the request URI path. A path present in both lists is
+ * never logged.
*
* @param MonotonicClock $clock The monotonic clock used to measure request duration.
* @param LoggerInterface $logger The logger to use for request and response logging.
+ * @param array $ignoredPaths The request paths that are never logged.
+ * @param array $failureOnlyPaths The request paths that are logged only when the response is an error.
* @return LogMiddleware The configured middleware instance.
*/
- public static function build(MonotonicClock $clock, LoggerInterface $logger): LogMiddleware
- {
- return new LogMiddleware(clock: $clock, logger: $logger);
+ public static function build(
+ MonotonicClock $clock,
+ LoggerInterface $logger,
+ array $ignoredPaths = [],
+ array $failureOnlyPaths = []
+ ): LogMiddleware {
+ return new LogMiddleware(
+ clock: $clock,
+ logger: $logger,
+ ignoredPaths: Paths::from(...$ignoredPaths),
+ failureOnlyPaths: Paths::from(...$failureOnlyPaths)
+ );
}
/**
@@ -49,12 +69,20 @@ public static function create(): LogMiddlewareBuilder
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
+ $path = $request->getUri()->getPath();
+
+ if ($this->ignoredPaths->contains(path: $path)) {
+ return $handler->handle($request);
+ }
+
$correlationId = $request->getAttribute(CorrelationIdMiddleware::ATTRIBUTE_NAME);
$logger = $correlationId instanceof CorrelationId
? CorrelatedLogger::from(logger: $this->logger, correlationId: $correlationId)
: $this->logger;
- $exchange = LogExchange::start(clock: $this->clock, logger: $logger, request: $request);
+ $exchange = $this->failureOnlyPaths->contains(path: $path)
+ ? FailureOnlyLogExchange::start(clock: $this->clock, logger: $logger, request: $request)
+ : LogExchange::start(clock: $this->clock, logger: $logger, request: $request);
$response = $handler->handle($request);
diff --git a/src/LogMiddlewareBuilder.php b/src/LogMiddlewareBuilder.php
index ab1824b..0b4d69f 100644
--- a/src/LogMiddlewareBuilder.php
+++ b/src/LogMiddlewareBuilder.php
@@ -10,20 +10,26 @@
use TinyBlocks\Time\SystemMonotonicClock;
/**
- * Fluent builder that assembles a LogMiddleware with an optional custom monotonic clock.
+ * Fluent builder that assembles a LogMiddleware with an optional custom monotonic clock and optional path filters.
*/
final class LogMiddlewareBuilder
{
private MonotonicClock $clock;
private ?LoggerInterface $logger = null;
+ /** @var array */
+ private array $ignoredPaths = [];
+
+ /** @var array */
+ private array $failureOnlyPaths = [];
+
public function __construct()
{
$this->clock = new SystemMonotonicClock();
}
/**
- * Builds a LogMiddleware from the configured logger and monotonic clock.
+ * Builds a LogMiddleware from the configured logger, monotonic clock, and path filters.
*
* @return LogMiddleware The configured middleware instance.
* @throws LoggerNotConfigured If no logger was configured.
@@ -34,7 +40,12 @@ public function build(): LogMiddleware
throw new LoggerNotConfigured(message: 'A Logger must be provided to build the LogMiddleware.');
}
- return LogMiddleware::build(clock: $this->clock, logger: $this->logger);
+ return LogMiddleware::build(
+ clock: $this->clock,
+ logger: $this->logger,
+ ignoredPaths: $this->ignoredPaths,
+ failureOnlyPaths: $this->failureOnlyPaths
+ );
}
/**
@@ -60,4 +71,39 @@ public function withLogger(LoggerInterface $logger): LogMiddlewareBuilder
$this->logger = $logger;
return $this;
}
+
+ /**
+ * Sets the request paths that are never logged and returns the builder.
+ *
+ * A request whose URI path equals one of these paths produces neither the request entry nor
+ * the response entry, whatever the response status. The comparison is an exact string equality
+ * with the URI path, so /health does not match /health/readiness. Suited to health
+ * checks and other probes that carry nothing worth reading.
+ *
+ * @param string ...$paths The exact request paths that are never logged.
+ * @return LogMiddlewareBuilder The builder instance for fluent configuration.
+ */
+ public function withIgnoredPaths(string ...$paths): LogMiddlewareBuilder
+ {
+ $this->ignoredPaths = $paths;
+ return $this;
+ }
+
+ /**
+ * Sets the request paths that are logged only when the response is an error and returns the builder.
+ *
+ * A request whose URI path equals one of these paths produces no entry while its response is not an
+ * error. When the response status is in the 4xx or 5xx range, the request entry and the
+ * response entry are both written after the handler returns, with the same context as on any
+ * other path. The comparison is an exact string equality with the URI path. A path also set through
+ * {@see LogMiddlewareBuilder::withIgnoredPaths()} is never logged.
+ *
+ * @param string ...$paths The exact request paths that are logged only when the response is an error.
+ * @return LogMiddlewareBuilder The builder instance for fluent configuration.
+ */
+ public function withFailureOnlyPaths(string ...$paths): LogMiddlewareBuilder
+ {
+ $this->failureOnlyPaths = $paths;
+ return $this;
+ }
}
diff --git a/tests/Unit/CapturingLogger.php b/tests/Unit/CapturingLogger.php
new file mode 100644
index 0000000..28761af
--- /dev/null
+++ b/tests/Unit/CapturingLogger.php
@@ -0,0 +1,23 @@
+entries[] = ['level' => $level, 'message' => $message, 'context' => $context];
+ }
+
+ public function entries(): array
+ {
+ return $this->entries;
+ }
+}
diff --git a/tests/Unit/LogMiddlewareTest.php b/tests/Unit/LogMiddlewareTest.php
index 1311b4a..17f8498 100644
--- a/tests/Unit/LogMiddlewareTest.php
+++ b/tests/Unit/LogMiddlewareTest.php
@@ -6,6 +6,7 @@
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use TinyBlocks\Http\CorrelationId\CorrelationId;
@@ -482,6 +483,37 @@ public function testBuilderThrowsWhenLoggerNotConfigured(): void
$builder->build();
}
+ #[DataProvider('anyStatusDataProvider')]
+ public function testIgnoredPathWhenProcessedThenNothingIsLogged(int $statusCode): void
+ {
+ /** @Given a health check request */
+ $request = new ServerRequest('GET', '/health/readiness');
+
+ /** @And a response with the status code of the data set */
+ $response = new Response($statusCode);
+
+ /** @And a handler that answers with that response */
+ $handler = new CapturingHandler(response: $response);
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware that never logs the health check paths */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/liveness', '/health/readiness')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $actual = $middleware->process($request, $handler);
+
+ /** @Then no entry is written */
+ self::assertSame([], $logger->entries());
+
+ /** @And the response of the handler is returned unchanged */
+ self::assertSame($response, $actual);
+ }
+
public function testLogsWithBaseLoggerWhenCorrelationIdIsAbsent(): void
{
/** @Given a request without a correlation ID attribute */
@@ -598,6 +630,311 @@ public function testLogsResponseBodyStreamRemainsReadableAfterLogging(): void
self::assertSame($bodyContent, $actual->getBody()->__toString());
}
+ public function testBuilderWhenNoPathsAreGivenThenEveryExchangeIsLogged(): void
+ {
+ /** @Given a health check request */
+ $request = new ServerRequest('GET', '/health/readiness');
+
+ /** @And a handler that returns a successful response */
+ $handler = new CapturingHandler(response: new Response(200));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware whose path filters were given no paths */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withIgnoredPaths()
+ ->withFailureOnlyPaths()
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry are both written */
+ self::assertSame(['request', 'response'], array_column($logger->entries(), 'message'));
+ }
+
+ public function testPathInBothListsWhenResponseFailsThenNothingIsLogged(): void
+ {
+ /** @Given a health check request */
+ $request = new ServerRequest('GET', '/health/readiness');
+
+ /** @And a handler that returns a 503 response */
+ $handler = new CapturingHandler(response: new Response(503));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware that lists the same path as ignored and as failure-only */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/readiness')
+ ->withFailureOnlyPaths('/health/readiness')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then no entry is written, since ignoring the path takes precedence */
+ self::assertSame([], $logger->entries());
+ }
+
+ #[DataProvider('nonErrorStatusDataProvider')]
+ public function testFailureOnlyPathWhenResponseSucceedsThenNothingIsLogged(int $statusCode): void
+ {
+ /** @Given a request to a periodic sweep route */
+ $request = new ServerRequest('POST', '/v1/outbox/dispatches');
+
+ /** @And a handler that answers with the status code of the data set, outside the error range */
+ $handler = new CapturingHandler(response: new Response($statusCode));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware that logs the sweep route only when it fails */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then no entry is written */
+ self::assertSame([], $logger->entries());
+ }
+
+ public function testFailureOnlyPathWhenCorrelationIdIsPresentThenEntriesCarryIt(): void
+ {
+ /** @Given a correlation ID */
+ $correlationId = $this->createStub(CorrelationId::class);
+ $correlationId->method('toString')->willReturn('req-abc-123');
+
+ /** @And a request to a periodic sweep route carrying the correlation ID */
+ $request = new ServerRequest('POST', '/v1/outbox/dispatches')
+ ->withAttribute('correlationId', $correlationId);
+
+ /** @And a handler that returns a 500 response */
+ $handler = new CapturingHandler(response: new Response(500));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware with a deterministic clock that logs the sweep route only when it fails */
+ $middleware = LogMiddleware::create()
+ ->withClock(clock: new ClockFake(initial: 0, increment: 2_000_000))
+ ->withLogger(logger: $logger)
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry both carry the correlation ID */
+ self::assertSame(
+ [
+ [
+ 'level' => 'info',
+ 'message' => 'request',
+ 'context' => [
+ 'method' => 'POST',
+ 'uri' => '/v1/outbox/dispatches',
+ 'correlation_id' => 'req-abc-123'
+ ]
+ ],
+ [
+ 'level' => 'error',
+ 'message' => 'response',
+ 'context' => [
+ 'method' => 'POST',
+ 'uri' => '/v1/outbox/dispatches',
+ 'status_code' => 500,
+ 'duration_ms' => 2.0,
+ 'correlation_id' => 'req-abc-123'
+ ]
+ ]
+ ],
+ $logger->entries()
+ );
+ }
+
+ public function testBuildWhenOnlyClockAndLoggerAreGivenThenEveryExchangeIsLogged(): void
+ {
+ /** @Given a health check request */
+ $request = new ServerRequest('GET', '/health/readiness');
+
+ /** @And a handler that returns a successful response */
+ $handler = new CapturingHandler(response: new Response(200));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware built from a clock and a logger only, as before path filters existed */
+ $middleware = LogMiddleware::build(clock: new ClockFake(initial: 0, increment: 2_000_000), logger: $logger);
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry are both written */
+ self::assertSame(['request', 'response'], array_column($logger->entries(), 'message'));
+ }
+
+ #[DataProvider('partialMatchDataProvider')]
+ public function testIgnoredPathsWhenPathOnlyPartiallyMatchesThenExchangeIsLogged(string $path): void
+ {
+ /** @Given a request whose path only partially matches the ignored path */
+ $request = new ServerRequest('GET', $path);
+
+ /** @And a handler that returns a successful response */
+ $handler = new CapturingHandler(response: new Response(200));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware that never logs the readiness path */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/readiness')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry are both written */
+ self::assertSame(['request', 'response'], array_column($logger->entries(), 'message'));
+ }
+
+ #[DataProvider('statusAndLevelDataProvider')]
+ public function testUnlistedPathWhenPathsAreConfiguredThenExchangeIsLoggedAsBefore(
+ int $statusCode,
+ string $level
+ ): void {
+ /** @Given a request to a route that no path filter lists */
+ $request = new ServerRequest('GET', '/v1/orders');
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a handler that writes its own entry and answers with the status code of the data set */
+ $handler = new LoggingHandler(logger: $logger, response: new Response($statusCode));
+
+ /** @And a middleware with a deterministic clock, an ignored path, and a failure-only path */
+ $middleware = LogMiddleware::create()
+ ->withClock(clock: new ClockFake(initial: 0, increment: 2_000_000))
+ ->withLogger(logger: $logger)
+ ->withIgnoredPaths('/health/readiness')
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry precedes the handler and the response entry follows it at the expected level */
+ self::assertSame(
+ [
+ [
+ 'level' => 'info',
+ 'message' => 'request',
+ 'context' => ['method' => 'GET', 'uri' => '/v1/orders']
+ ],
+ [
+ 'level' => 'info',
+ 'message' => 'handled',
+ 'context' => []
+ ],
+ [
+ 'level' => $level,
+ 'message' => 'response',
+ 'context' => [
+ 'method' => 'GET',
+ 'uri' => '/v1/orders',
+ 'status_code' => $statusCode,
+ 'duration_ms' => 2.0
+ ]
+ ]
+ ],
+ $logger->entries()
+ );
+ }
+
+ #[DataProvider('errorStatusDataProvider')]
+ public function testFailureOnlyPathWhenResponseFailsThenRequestAndResponseAreLogged(int $statusCode): void
+ {
+ /** @Given a request to a periodic sweep route carrying query parameters and a body */
+ $request = new ServerRequest('POST', '/v1/outbox/dispatches?batch=10')
+ ->withQueryParams(['batch' => '10'])
+ ->withParsedBody(['cycle' => 'weekly']);
+
+ /** @And a handler that answers with the error status code of the data set and a JSON body */
+ $handler = new CapturingHandler(response: new Response($statusCode, [], '{"reason":"dispatch failed"}'));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware with a deterministic clock that logs the sweep route only when it fails */
+ $middleware = LogMiddleware::create()
+ ->withClock(clock: new ClockFake(initial: 0, increment: 2_000_000))
+ ->withLogger(logger: $logger)
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry are written with the context any other path gets */
+ self::assertSame(
+ [
+ [
+ 'level' => 'info',
+ 'message' => 'request',
+ 'context' => [
+ 'method' => 'POST',
+ 'uri' => '/v1/outbox/dispatches?batch=10',
+ 'query_parameters' => ['batch' => '10'],
+ 'body' => ['cycle' => 'weekly']
+ ]
+ ],
+ [
+ 'level' => 'error',
+ 'message' => 'response',
+ 'context' => [
+ 'method' => 'POST',
+ 'uri' => '/v1/outbox/dispatches?batch=10',
+ 'status_code' => $statusCode,
+ 'duration_ms' => 2.0,
+ 'body' => ['reason' => 'dispatch failed']
+ ]
+ ]
+ ],
+ $logger->entries()
+ );
+ }
+
+ public function testFailureOnlyPathWhenResponseFailsThenRequestIsLoggedAfterHandler(): void
+ {
+ /** @Given a request to a periodic sweep route */
+ $request = new ServerRequest('POST', '/v1/outbox/dispatches');
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a handler that writes its own entry and returns a 500 response */
+ $handler = new LoggingHandler(logger: $logger, response: new Response(500));
+
+ /** @And a middleware that logs the sweep route only when it fails */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withFailureOnlyPaths('/v1/outbox/dispatches')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry follows the entry of the handler and precedes the response entry */
+ self::assertSame(['handled', 'request', 'response'], array_column($logger->entries(), 'message'));
+ }
+
public function testLogsResponseContextAlwaysContainsMethodUriStatusCodeAndDuration(): void
{
/** @Given a PATCH request */
@@ -630,4 +967,79 @@ public function testLogsResponseContextAlwaysContainsMethodUriStatusCodeAndDurat
self::assertSame(200, $capturedContext['status_code']);
self::assertArrayHasKey('duration_ms', $capturedContext);
}
+
+ #[DataProvider('partialMatchDataProvider')]
+ public function testFailureOnlyPathsWhenPathOnlyPartiallyMatchesThenExchangeIsLogged(string $path): void
+ {
+ /** @Given a request whose path only partially matches the failure-only path */
+ $request = new ServerRequest('GET', $path);
+
+ /** @And a handler that returns a successful response */
+ $handler = new CapturingHandler(response: new Response(200));
+
+ /** @And a logger that captures every entry */
+ $logger = new CapturingLogger();
+
+ /** @And a middleware that logs the readiness path only when it fails */
+ $middleware = LogMiddleware::create()
+ ->withLogger(logger: $logger)
+ ->withFailureOnlyPaths('/health/readiness')
+ ->build();
+
+ /** @When the middleware processes the request */
+ $middleware->process($request, $handler);
+
+ /** @Then the request entry and the response entry are both written */
+ self::assertSame(['request', 'response'], array_column($logger->entries(), 'message'));
+ }
+
+ public static function anyStatusDataProvider(): array
+ {
+ return [
+ 'OK' => [200],
+ 'No content' => [204],
+ 'Not found' => [404],
+ 'Service unavailable' => [503]
+ ];
+ }
+
+ public static function errorStatusDataProvider(): array
+ {
+ return [
+ 'Bad request' => [400],
+ 'Not found' => [404],
+ 'Unprocessable entity' => [422],
+ 'Internal server error' => [500],
+ 'Service unavailable' => [503]
+ ];
+ }
+
+ public static function partialMatchDataProvider(): array
+ {
+ return [
+ 'Prefix of the listed path' => ['/health'],
+ 'Suffix of the listed path' => ['/readiness'],
+ 'Listed path with a trailing slash' => ['/health/readiness/'],
+ 'Listed path followed by a segment' => ['/health/readiness/details'],
+ 'Listed path in another letter case' => ['/HEALTH/READINESS']
+ ];
+ }
+
+ public static function nonErrorStatusDataProvider(): array
+ {
+ return [
+ 'OK' => [200],
+ 'Created' => [201],
+ 'No content' => [204],
+ 'Found' => [302]
+ ];
+ }
+
+ public static function statusAndLevelDataProvider(): array
+ {
+ return [
+ 'Successful response' => [200, 'info'],
+ 'Server error' => [500, 'error']
+ ];
+ }
}
diff --git a/tests/Unit/LoggingHandler.php b/tests/Unit/LoggingHandler.php
new file mode 100644
index 0000000..6439f11
--- /dev/null
+++ b/tests/Unit/LoggingHandler.php
@@ -0,0 +1,23 @@
+logger->info('handled');
+ return $this->response;
+ }
+}