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
9 changes: 9 additions & 0 deletions .changelog/structured-event-logging-recipe.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 51 additions & 6 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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;
Expand All @@ -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<string> synthetic private log records; replace with an application logger
* @var list<array{mixed, string, array<string, mixed>}> synthetic private records
*/
public array $records = [];

Expand All @@ -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],
Expand All @@ -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;
}
```

<a id="http-middleware"></a>
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ <h2 id="adapters-title">Compose adapters deliberately</h2>
</tbody>
</table>
</div>
<p>The <a href="https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php">event example</a> 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 <a href="https://github.com/coisa/error-handler/blob/master/docs/fast-forward.md">optional discovery and native fallback</a> and <a href="https://github.com/coisa/error-handler/blob/master/docs/api.md">API contracts</a> for failure boundaries.</p>
<p>The <a href="https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php">event example</a> 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; <code>php examples/event-dispatcher.php structured</code> instead composes the existing callable listener and throwable reporter, keeping the original exception in PSR-3 context and one private log entry. An <a href="https://github.com/coisa/error-handler/blob/master/resources/skills/coisa-error-handler/examples/event-logging.php">installed-package program</a> supplies the complete structured route. These explicit choices change no default listener or discovery policy. See <a href="https://github.com/coisa/error-handler/blob/master/docs/fast-forward.md">optional discovery and native fallback</a> and <a href="https://github.com/coisa/error-handler/blob/master/docs/api.md">API contracts</a> for failure boundaries.</p>
</section>

<section id="filtering" aria-labelledby="filtering-title">
Expand Down
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 41 additions & 4 deletions examples/event-dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string> synthetic private log records; replace with an application logger
* @var list<array{mixed, string, array<string, mixed>}> synthetic private records
*/
public array $records = [];

Expand All @@ -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],
Expand All @@ -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;
}
3 changes: 3 additions & 0 deletions examples/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/verify.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions resources/skills/coisa-error-handler/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions resources/skills/coisa-error-handler/examples/event-logging.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/**
* This file is part of coisa/error-handler.
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*
* @link https://github.com/coisa/error-handler
*
* @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com.br>
* @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<array{mixed, string, array<string, mixed>}> 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<string, mixed> $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;
Loading
Loading