From a776840552dee61f55d50253e89d7397c989525c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 13:44:39 -0300 Subject: [PATCH] docs: demonstrate structured event logging with existing adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changelog/structured-event-logging-recipe.md | 9 ++ docs/api.md | 10 ++ docs/examples.md | 57 ++++++++- docs/index.html | 2 +- examples/README.md | 7 ++ examples/event-dispatcher.php | 45 ++++++- examples/verify.md | 3 + examples/verify.php | 5 + .../coisa-error-handler/examples/README.md | 27 +++++ .../examples/event-logging.php | 86 +++++++++++++ .../references/reporters.md | 15 +++ .../Functional/ConsumerSkillExamplesTest.php | 4 + .../ErrorEventLoggingCompositionTest.php | 113 ++++++++++++++++++ 13 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 .changelog/structured-event-logging-recipe.md create mode 100644 resources/skills/coisa-error-handler/examples/event-logging.php create mode 100644 tests/Unit/EventDispatcher/Listener/ErrorEventLoggingCompositionTest.php diff --git a/.changelog/structured-event-logging-recipe.md b/.changelog/structured-event-logging-recipe.md new file mode 100644 index 0000000..2fee700 --- /dev/null +++ b/.changelog/structured-event-logging-recipe.md @@ -0,0 +1,9 @@ +--- +category: added +type: patch +--- + +Document structured PSR-3 logging from ErrorEvent using the existing callable +listener and throwable reporter. Include executable checkout and installed-package +recipes and verification of original-object context, one delivery and unchanged +logger-failure propagation through the bridge, preserving the legacy event listener's policy. diff --git a/docs/api.md b/docs/api.md index c51805a..251333f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -117,6 +117,16 @@ Construct it explicitly or bind `ThrowableHandlerInterface` using the See [the complete logging example](https://github.com/coisa/error-handler/blob/master/examples/logging.php). Unlike this direct adapter, `LogErrorEventListener` retains its event-string message policy. +For structured logging through an `ErrorEvent`, compose `ErrorEventCallableListener` +with `LogThrowableHandler` and pass `$event->getThrowable()` to the reporter. +This explicit bridge keeps the exact throwable message and original exception +context without changing the legacy event listener. Run the +[event recipe](examples.md#events) in `structured` mode or use the +[bundled application program](../resources/skills/coisa-error-handler/examples/event-logging.php). +The bridge forwards logger failures unchanged, with no retries or discovery. +The application dispatcher still owns its subsequent failure behavior; retain the +[documented dispatcher integration limits](testing.md#fastforward-event-dispatcher-integration-limits). + ## Conditional reporting `Handler\FilteringThrowableHandler($reporter, $predicate)` wraps an explicit diff --git a/docs/examples.md b/docs/examples.md index 5e0c826..bc46cc6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -150,8 +150,16 @@ echo implode(PHP_EOL, $deliveries) . PHP_EOL; ## PSR-14 dispatch and PSR-3 event logging Exercise original Throwable and ErrorEvent notification paths with only an -application dispatcher. Expect one raw delivery, one wrapped delivery and -`Private log records: 1`; exit 0 alone does not prove reporting occurred. +application dispatcher. The default mode keeps the full event-string logger. +Run `php examples/event-dispatcher.php structured` for an explicit callable-listener +bridge to `LogThrowableHandler`, retaining the original Throwable under PSR-3 +`context['exception']` with the exact message, configured level and application +metadata. Both modes produce one raw delivery, one wrapped delivery and +`Private log records: 1`; structured mode also checks and announces original-object +context. The bridge forwards logger failures without retry or implicit discovery; +the application dispatcher's [failure boundaries](testing.md#fastforward-event-dispatcher-integration-limits) still apply. +The [installed-package program](../resources/skills/coisa-error-handler/examples/event-logging.php) +shows a complete structured route with only the wrapped event delivery. See [optional discovery and fallback](fast-forward.md) before relying on the default reporter. [Source](https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php). @@ -168,6 +176,7 @@ use CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener; use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; +use CoiSA\ErrorHandler\Handler\LogThrowableHandler; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use FastForward\Config\ArrayConfig; use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; @@ -179,11 +188,17 @@ use function FastForward\Container\container; require __DIR__ . '/bootstrap.php'; +$mode = $argv[1] ?? 'legacy'; +if (!in_array($mode, ['legacy', 'structured'], true)) { + throw new InvalidArgumentException('Choose legacy or structured event logging.'); +} + $deliveries = []; +$reported = []; $logger = new class extends AbstractLogger { /** - * @var list synthetic private log records; replace with an application logger + * @var list}> synthetic private records */ public array $records = []; @@ -198,21 +213,34 @@ $logger = new class extends AbstractLogger { */ public function log($level, $message, array $context = []): void { - $this->records[] = $level . ': ' . (string) $message; + $this->records[] = [$level, (string) $message, $context]; } }; $throwables = new ThrowableListenerProvider( - new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { + // @param Throwable $error the unchanged diagnostic also retained for the context assertion + new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries, &$reported): void { $deliveries[] = 'throwable: ' . $error->getMessage(); + $reported[] = $error; }), ); $events = new ErrorEventListenerProvider( + // @param ErrorEventInterface $event the wrapper carrying the same original throwable new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void { $deliveries[] = 'event: ' . $event->getThrowable()->getMessage(); }), ); -$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); +if ('structured' === $mode) { + $reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, ['channel' => 'errors']); + $events->attach(new ErrorEventCallableListener( + /** @param ErrorEventInterface $event the wrapper retaining the original diagnostic */ + static function (ErrorEventInterface $event) use ($reporter): void { + $reporter->handleThrowable($event->getThrowable()); + }, + )); +} else { + $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); +} $configuration = new ArrayConfig([ ListenerProviderInterface::class => [$throwables, $events], @@ -230,8 +258,25 @@ try { $errors->unregister(); } +if ('structured' === $mode) { + $original = $reported[0] ?? null; + $record = $logger->records[0] ?? null; + if ( + null === $original || 1 !== count($reported) || null === $record || 1 !== count($logger->records) + || LogLevel::CRITICAL !== $record[0] || 'Synthetic warning' !== $record[1] + || 2 !== count($record[2]) || 'errors' !== ($record[2]['channel'] ?? null) + || $original !== ($record[2]['exception'] ?? null) + ) { + throw new RuntimeException('Expected one structured log retaining the original throwable.'); + } +} + echo implode(PHP_EOL, $deliveries) . PHP_EOL; printf("Private log records: %d\n", count($logger->records)); + +if ('structured' === $mode) { + echo 'Structured exception context: original throwable' . PHP_EOL; +} ``` diff --git a/docs/index.html b/docs/index.html index 779e9ab..8fc7001 100644 --- a/docs/index.html +++ b/docs/index.html @@ -202,7 +202,7 @@

Compose adapters deliberately

-

The event example includes providers, callable listeners and PSR-3 logging. Listener providers select listeners; the application dispatcher executes them. Raw and wrapped routes are separate deliveries, and the recipe records one private log entry without configuring a callable reporter. Verify delivery after invocation; see optional discovery and native fallback and API contracts for failure boundaries.

+

The event example includes providers, callable listeners and PSR-3 logging. Listener providers select listeners; the application dispatcher executes them. Raw and wrapped routes are separate deliveries. The default mode keeps full event-string logging; php examples/event-dispatcher.php structured instead composes the existing callable listener and throwable reporter, keeping the original exception in PSR-3 context and one private log entry. An installed-package program supplies the complete structured route. These explicit choices change no default listener or discovery policy. See optional discovery and native fallback and API contracts for failure boundaries.

diff --git a/examples/README.md b/examples/README.md index 4208858..1e02192 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,6 +35,13 @@ the checkout's own graph, follow its [dependency setup](https://github.com/coisa/error-handler/blob/master/CONTRIBUTING.md) and run commands from that checkout root. +For event logging, the default `php examples/event-dispatcher.php` keeps the full +event-string policy. Run `php examples/event-dispatcher.php structured` to use the +existing callable listener and throwable reporter, retaining the original object +in PSR-3 `context['exception']`. Both modes produce one private log record. The +structured mode also verifies its exact message, level and application context. +It does not modify the default listener or attach another reporter automatically. + The source checkout supplies application implementations through development dependencies. In a disposable application, add `nyholm/psr7:^1.8.2` and `fast-forward/event-dispatcher:^1.0` for HTTP/event recipes. diff --git a/examples/event-dispatcher.php b/examples/event-dispatcher.php index df8495f..7e4eebc 100644 --- a/examples/event-dispatcher.php +++ b/examples/event-dispatcher.php @@ -21,6 +21,7 @@ use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; +use CoiSA\ErrorHandler\Handler\LogThrowableHandler; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use FastForward\Config\ArrayConfig; use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; @@ -32,11 +33,17 @@ require __DIR__ . '/bootstrap.php'; +$mode = $argv[1] ?? 'legacy'; +if (!in_array($mode, ['legacy', 'structured'], true)) { + throw new InvalidArgumentException('Choose legacy or structured event logging.'); +} + $deliveries = []; +$reported = []; $logger = new class extends AbstractLogger { /** - * @var list synthetic private log records; replace with an application logger + * @var list}> synthetic private records */ public array $records = []; @@ -51,21 +58,34 @@ */ public function log($level, $message, array $context = []): void { - $this->records[] = $level . ': ' . (string) $message; + $this->records[] = [$level, (string) $message, $context]; } }; $throwables = new ThrowableListenerProvider( - new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { + // @param Throwable $error the unchanged diagnostic also retained for the context assertion + new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries, &$reported): void { $deliveries[] = 'throwable: ' . $error->getMessage(); + $reported[] = $error; }), ); $events = new ErrorEventListenerProvider( + // @param ErrorEventInterface $event the wrapper carrying the same original throwable new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void { $deliveries[] = 'event: ' . $event->getThrowable()->getMessage(); }), ); -$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); +if ('structured' === $mode) { + $reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, ['channel' => 'errors']); + $events->attach(new ErrorEventCallableListener( + /** @param ErrorEventInterface $event the wrapper retaining the original diagnostic */ + static function (ErrorEventInterface $event) use ($reporter): void { + $reporter->handleThrowable($event->getThrowable()); + }, + )); +} else { + $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); +} $configuration = new ArrayConfig([ ListenerProviderInterface::class => [$throwables, $events], @@ -83,5 +103,22 @@ public function log($level, $message, array $context = []): void $errors->unregister(); } +if ('structured' === $mode) { + $original = $reported[0] ?? null; + $record = $logger->records[0] ?? null; + if ( + null === $original || 1 !== count($reported) || null === $record || 1 !== count($logger->records) + || LogLevel::CRITICAL !== $record[0] || 'Synthetic warning' !== $record[1] + || 2 !== count($record[2]) || 'errors' !== ($record[2]['channel'] ?? null) + || $original !== ($record[2]['exception'] ?? null) + ) { + throw new RuntimeException('Expected one structured log retaining the original throwable.'); + } +} + echo implode(PHP_EOL, $deliveries) . PHP_EOL; printf("Private log records: %d\n", count($logger->records)); + +if ('structured' === $mode) { + echo 'Structured exception context: original throwable' . PHP_EOL; +} diff --git a/examples/verify.md b/examples/verify.md index 408cdbb..00c5566 100644 --- a/examples/verify.md +++ b/examples/verify.md @@ -24,6 +24,9 @@ The verifier covers every recipe and documented mode. JSON public/debug, Symfony Symfony CLI direct/provider/Console ownership, manual HTML public/development and HTML provider public/Whoops-default/configured are included without an extra FastForward flag: the container is a runtime dependency of this major. The event recipe uses FastForward's published provider/configuration API. The +default event mode preserves full-string logging; its `structured` mode uses the +existing callable listener and throwable reporter and verifies the original +exception context, selected level, exact message and one private record. The documented development graph supplies Console and optional Whoops for those tests. Middleware recipes report the original once before pure rendering; the direct HTML pipeline reports explicitly once and supplies its current request. A diff --git a/examples/verify.php b/examples/verify.php index 64339a1..1e787b2 100644 --- a/examples/verify.php +++ b/examples/verify.php @@ -30,6 +30,11 @@ 'event-dispatcher.php', [], 0, "throwable: Synthetic warning\nevent: Synthetic warning\nPrivate log records: 1\n", '', ], + [ + 'event-dispatcher.php', ['structured'], 0, + "throwable: Synthetic warning\nevent: Synthetic warning\nPrivate log records: 1\n" + . "Structured exception context: original throwable\n", '', + ], ['http-middleware.php', [], 0, "HTTP 503 | Internal Server Error\nReported: 2\n", ''], [ 'http-rendering.php', [], 0, diff --git a/resources/skills/coisa-error-handler/examples/README.md b/resources/skills/coisa-error-handler/examples/README.md index 7787fcc..f9c1cd5 100644 --- a/resources/skills/coisa-error-handler/examples/README.md +++ b/resources/skills/coisa-error-handler/examples/README.md @@ -67,6 +67,33 @@ The program checks cached core identity and invokes the reusable Resolution installs no callbacks. See [container composition](../references/container.md) for ordinary optional discovery and actual failure/fallback boundaries. +## Structured event logging + +Use the application's existing PSR-14 dispatcher; this complete program selects +FastForward Event Dispatcher. If the application has not installed it already, +add that optional application implementation: + +```sh +composer require 'fast-forward/event-dispatcher:^1.0' --no-plugins --no-scripts +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/event-logging.php ./vendor/autoload.php +``` + +Expect exit 0, empty stderr and +`Event logging: one structured record with the original throwable passed`. +The synthetic logger contacts no service. FastForward ArrayConfig selects one +ErrorEvent listener, and `DispatchErrorEventThrowableHandler` wraps the original +failure before the application dispatcher delivers it. The callable listener +passes `getThrowable()` to `LogThrowableHandler`, retaining the original object +in `context['exception']` along with the selected level and application metadata. + +This is explicit application composition using existing adapters. It does not +change `LogErrorEventListener`, discover loggers automatically, register native +callbacks or add retries. Choose this listener instead of the full-string logger +when a structured processor needs the original exception; attaching both would +deliberately log twice. The bridge forwards logger failures unchanged without +retry; the chosen dispatcher's outward failure behavior still applies. See +[reporter and dispatcher boundaries](../references/reporters.md#psr-14). + ## Explicit HTTP policy Supply Nyholm as the application's PSR-17 implementation if it has not selected diff --git a/resources/skills/coisa-error-handler/examples/event-logging.php b/resources/skills/coisa-error-handler/examples/event-logging.php new file mode 100644 index 0000000..c44f11c --- /dev/null +++ b/resources/skills/coisa-error-handler/examples/event-logging.php @@ -0,0 +1,86 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; +use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; +use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; +use CoiSA\ErrorHandler\Handler\LogThrowableHandler; +use FastForward\Config\ArrayConfig; +use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\ListenerProviderInterface; +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; + +use function FastForward\Container\container; + +require __DIR__ . '/bootstrap.php'; + +// The application owns this private sink and its real transport configuration. +$logger = new class extends AbstractLogger { + /** @var list}> synthetic observations */ + public array $records = []; + + /** + * Retains a record without emitting diagnostics or contacting a real transport. + * + * @param mixed $level the unchanged PSR-3 level + * @param mixed $message the exact diagnostic message + * @param array $context the structured logging context + */ + public function log($level, $message, array $context = []): void + { + $this->records[] = [$level, (string) $message, $context]; + } +}; + +$reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, ['channel' => 'errors']); +$listener = new ErrorEventCallableListener( + /** @param ErrorEventInterface $event the wrapper retaining the original diagnostic */ + static function (ErrorEventInterface $event) use ($reporter): void { + $reporter->handleThrowable($event->getThrowable()); + }, +); + +$configuration = new ArrayConfig([ + ListenerProviderInterface::class => [new ErrorEventListenerProvider($listener)], +]); + +$services = container($configuration, EventDispatcherServiceProvider::class); +$dispatcher = $services->get(EventDispatcherInterface::class); +if (!$dispatcher instanceof EventDispatcherInterface) { + throw new RuntimeException('Expected the application PSR-14 dispatcher.'); +} + +$errors = new ErrorHandler(new DispatchErrorEventThrowableHandler($dispatcher)); +$original = new RuntimeException('Synthetic private event failure'); + +// Direct reporting does not install process callbacks or choose an exit status. +$errors->handleThrowable($original); + +$record = $logger->records[0] ?? null; +if ( + 1 !== count($logger->records) || null === $record + || LogLevel::CRITICAL !== $record[0] || $original->getMessage() !== $record[1] + || 2 !== count($record[2]) || 'errors' !== ($record[2]['channel'] ?? null) + || $original !== ($record[2]['exception'] ?? null) +) { + throw new RuntimeException('Expected one structured log with the identical original throwable.'); +} + +echo 'Event logging: one structured record with the original throwable passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/references/reporters.md b/resources/skills/coisa-error-handler/references/reporters.md index 4b61316..18e6223 100644 --- a/resources/skills/coisa-error-handler/references/reporters.md +++ b/resources/skills/coisa-error-handler/references/reporters.md @@ -139,6 +139,21 @@ Error Handler listener-provider objects. Compose that configuration and provider before ErrorHandlerServiceProvider. The dispatcher remains an application choice; Error Handler only consumes the PSR-14 interface. +For a structured PSR-3 record, construct `LogThrowableHandler` with the application's +logger, level and context, then invoke it from an `ErrorEventCallableListener` +callback using `$event->getThrowable()`. The reporter records the exact message +and retains the identical Throwable under `context['exception']`; it does not +stringify the entire wrapper. `LogErrorEventListener` keeps its existing full-string +policy. Choose one logging listener, rather than attaching both and logging twice. + +The [complete installed-package example](../examples/event-logging.php) supplies +the application dispatcher/configuration and private synthetic logger. See its +[command and dependencies](../examples/README.md#structured-event-logging). +The bridge forwards logger failures unchanged and adds no retry. The selected +dispatcher still owns its outward failure behavior; see the +[FastForward integration limits](https://github.com/coisa/error-handler/blob/master/docs/testing.md#fastforward-event-dispatcher-integration-limits). +Registering native PHP callbacks is a separate application decision. + Use the consumer's existing PHPUnit doubles or synthetic recording collaborators. Verify original-object identity, order/nonmatch behavior, empty aggregates, partial reporting failures and exact logger context. No example proves delivery diff --git a/tests/Functional/ConsumerSkillExamplesTest.php b/tests/Functional/ConsumerSkillExamplesTest.php index 502a64b..ea791e5 100644 --- a/tests/Functional/ConsumerSkillExamplesTest.php +++ b/tests/Functional/ConsumerSkillExamplesTest.php @@ -247,6 +247,10 @@ public function testShippedProgramsUseTheExplicitConsumerAutoloader( public static function provideShippedProgramsUseTheExplicitConsumerAutoloaderCases(): iterable { return [ + 'structured event logging' => [ + 'event-logging.php', [], + 'Event logging: one structured record with the original throwable passed', + ], 'configuration' => ['configured.php', [], 'configured reporting: original delivered once'], 'container' => [ 'container.php', [], diff --git a/tests/Unit/EventDispatcher/Listener/ErrorEventLoggingCompositionTest.php b/tests/Unit/EventDispatcher/Listener/ErrorEventLoggingCompositionTest.php new file mode 100644 index 0000000..d38be8c --- /dev/null +++ b/tests/Unit/EventDispatcher/Listener/ErrorEventLoggingCompositionTest.php @@ -0,0 +1,113 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\EventDispatcher\Listener; + +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; +use CoiSA\ErrorHandler\Handler\LogThrowableHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +/** + * Verifies the explicit event-to-structured-log composition using existing adapters. + * + * @internal + */ +#[CoversClass(ErrorEventCallableListener::class)] +#[UsesClass(ErrorEvent::class)] +#[UsesClass(LogThrowableHandler::class)] +final class ErrorEventLoggingCompositionTest extends TestCase +{ + /** Original objects and caller context survive exactly one private log delivery. */ + public function testLogsTheOriginalThrowableWithStructuredContextOnce(): void + { + $original = new \RuntimeException('Synthetic event failure'); + $event = new ErrorEvent($original); + $marker = new \stdClass(); + $replaced = new \LogicException('Caller-supplied exception entry'); + $context = ['channel' => 'audit', 'marker' => $marker, 'exception' => $replaced]; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('log')->with( + LogLevel::CRITICAL, + $original->getMessage(), + self::callback( + /** @param array $values logger context; key order is not a contract */ + static fn(array $values): bool => 3 === \count($values) + && 'audit' === ($values['channel'] ?? null) + && $marker === ($values['marker'] ?? null) + && $original === ($values['exception'] ?? null), + ), + ); + $listener = $this->listener($logger, $context); + + $this->expectOutputString(''); + self::assertSame($event, $listener($event)); + self::assertSame($original, $event->getThrowable()); + self::assertSame($replaced, $context['exception']); + } + + /** Logger transport failure propagates unchanged after one attempt, without a retry. */ + public function testPropagatesTheIdenticalLoggerFailureWithoutRetry(): void + { + $original = new \RuntimeException('Synthetic event failure'); + $event = new ErrorEvent($original); + $failure = new \LogicException('Synthetic private logger failure'); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('log')->with( + LogLevel::CRITICAL, + $original->getMessage(), + self::identicalTo(['exception' => $original]), + )->willThrowException($failure); + $listener = $this->listener($logger); + $observed = null; + + $this->expectOutputString(''); + + try { + $listener($event); + } catch (\Throwable $caught) { + $observed = $caught; + } + + self::assertSame($failure, $observed); + self::assertSame($original, $event->getThrowable()); + } + + /** + * Builds the same explicit bridge shown in the executable recipe. + * + * @param LoggerInterface $logger the application-controlled diagnostic sink + * @param array $context application metadata forwarded to that sink + * + * @return ErrorEventCallableListener the listener extracting the original diagnostic once + */ + private function listener(LoggerInterface $logger, array $context = []): ErrorEventCallableListener + { + $reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, $context); + + return new ErrorEventCallableListener( + /** @param ErrorEventInterface $event the current diagnostic wrapper */ + static function (ErrorEventInterface $event) use ($reporter): void { + $reporter->handleThrowable($event->getThrowable()); + }, + ); + } +}