Skip to content

Commit 70eb607

Browse files
committed
feat: Report handled errors to an external provider.
- Add the ErrorReporter seam and the ReportedError context every reporter reads, so a handled error reaches error tracking without the middleware knowing the provider. - Add the SentryReporter adapter, which either boots the SDK from the deployment values or captures onto a hub the application already holds. - Add ReportingFilter, so the consumer decides which handled errors reach the provider and a client error never spends quota. - Add ReportingPriority and PrioritizedError, the four level urgency scale stated once in neutral terms and translated by each adapter. - Add ReportingContext and ReportingTags, the exception side and request side supplements a report carries, which is how a value resolved below the middleware crosses the PSR-7 immutability gap. - Add RouteMiddleware, so a report carries the route the request matched instead of the raw path that names one request rather than one endpoint. - Add withMessage, which settles the message the response answers with before the body is built, so no layer above parses and re-encodes it. - Validate the headers a mapping rule declares, so a malformed field fails while the table is declared rather than on the request that would return it.
1 parent 08ffce6 commit 70eb607

63 files changed

Lines changed: 3581 additions & 296 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 352 additions & 7 deletions
Large diffs are not rendered by default.

‎composer.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"psr/http-server-handler": "^1.0",
2929
"psr/http-server-middleware": "^1.0",
3030
"psr/log": "^3.0",
31-
"tiny-blocks/http": "^7.0",
31+
"tiny-blocks/http": "^7.1",
3232
"tiny-blocks/http-correlation-id": "^2.1"
3333
},
3434
"require-dev": {
@@ -37,10 +37,14 @@
3737
"infection/infection": "^0.34",
3838
"phpstan/phpstan": "^2.2",
3939
"phpunit/phpunit": "^13.2",
40+
"sentry/sentry": "^4.0",
4041
"slevomat/coding-standard": "^8.31",
4142
"slim/slim": "^4.15",
4243
"squizlabs/php_codesniffer": "^4.0"
4344
},
45+
"suggest": {
46+
"sentry/sentry": "Required to use SentryReporter (^4.0)."
47+
},
4448
"minimum-stability": "stable",
4549
"prefer-stable": true,
4650
"autoload": {

‎phpstan.neon.dist‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,48 @@ parameters:
88
# Constructor parameter holds the registered entries; PHPDoc is prohibited on constructors.
99
- identifier: missingType.iterableValue
1010
path: src/ExceptionMappingTable.php
11+
# Constructor parameter holds the response headers; PHPDoc is prohibited on constructors.
12+
- identifier: missingType.iterableValue
13+
path: src/MappedError.php
14+
# Constructor parameter holds the accumulated tags; PHPDoc is prohibited on constructors.
15+
- identifier: missingType.iterableValue
16+
path: src/ReportedError.php
17+
# Tag values reach the provider untyped, because ReportedError cannot annotate the array they come from.
18+
- identifier: argument.type
19+
path: src/Internal/Reporting/SentryTags.php
20+
# Header values reach the response untyped; root cause is the same as the MappedError entry above.
21+
- identifier: argument.type
22+
path: src/Internal/Response/MappedResponse.php
1123
# Iteration over the constructor-typed array of entries; root cause is the same as above.
1224
- identifier: method.nonObject
1325
path: src/ExceptionMappingTable.php
1426
# The mapTo return type cannot be inferred without the constructor-level array shape.
1527
- identifier: return.type
1628
path: src/ExceptionMappingTable.php
29+
# Constructor parameter holds the composed reporters; PHPDoc is prohibited inside src/Internal/.
30+
- identifier: missingType.iterableValue
31+
path: src/Internal/Reporting/CompositeErrorReporter.php
32+
# Iteration over the constructor-typed array of reporters; root cause is the same as above.
33+
- identifier: method.nonObject
34+
path: src/Internal/Reporting/CompositeErrorReporter.php
35+
# Trace lines and the assembled detail map; PHPDoc is prohibited inside src/Internal/.
36+
- identifier: missingType.iterableValue
37+
path: src/Internal/ExceptionDetails.php
38+
# Header map arrives from the MappedError constructor; PHPDoc is prohibited inside src/Internal/.
39+
- identifier: missingType.iterableValue
40+
path: src/Internal/Response/ResponseHeaders.php
1741
# Internal matcher accepts a list of class-strings; PHPDoc is prohibited inside src/Internal/.
1842
- identifier: missingType.iterableValue
1943
path: src/Internal/Mapping/AnyExactClassMatcher.php
2044
# Closure invocation is opaque to PHPStan; PHPDoc is prohibited inside src/Internal/.
2145
- identifier: return.type
2246
path: src/Internal/Mapping/DynamicMappedErrorResolver.php
47+
# Tag map the fake exception replays; PHPDoc is prohibited on constructors and inside tests/.
48+
- identifier: missingType.iterableValue
49+
path: tests/Unit/ContextualException.php
50+
# The replayed map cannot be narrowed without the constructor tag; root cause is the same as above.
51+
- identifier: return.type
52+
path: tests/Unit/ContextualException.php
2353
# json_decode in test assertions yields mixed; PHPDoc is prohibited inside tests/.
2454
- identifier: offsetAccess.nonOffsetAccessible
2555
path: tests/Unit/ErrorMiddlewareTest.php

‎src/ErrorHandlingSettings.php‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,6 @@ private function __construct(
1717
) {
1818
}
1919

20-
/**
21-
* Creates an ErrorHandlingSettings with all flags disabled.
22-
*
23-
* @return ErrorHandlingSettings The default settings instance.
24-
*/
25-
public static function default(): ErrorHandlingSettings
26-
{
27-
return ErrorHandlingSettings::from(
28-
logErrors: false,
29-
logErrorDetails: false,
30-
displayErrorDetails: false
31-
);
32-
}
33-
3420
/**
3521
* Creates an ErrorHandlingSettings from the given flags.
3622
*
@@ -50,4 +36,18 @@ public static function from(
5036
displayErrorDetails: $displayErrorDetails
5137
);
5238
}
39+
40+
/**
41+
* Creates an ErrorHandlingSettings with all flags disabled.
42+
*
43+
* @return ErrorHandlingSettings The default settings instance.
44+
*/
45+
public static function default(): ErrorHandlingSettings
46+
{
47+
return ErrorHandlingSettings::from(
48+
logErrors: false,
49+
logErrorDetails: false,
50+
displayErrorDetails: false
51+
);
52+
}
5353
}

‎src/ErrorMiddleware.php‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44

55
namespace TinyBlocks\Http\ErrorHandler;
66

7+
use Closure;
78
use Psr\Http\Message\ResponseInterface;
89
use Psr\Http\Message\ServerRequestInterface;
910
use Psr\Http\Server\MiddlewareInterface;
1011
use Psr\Http\Server\RequestHandlerInterface;
1112
use Psr\Log\LoggerInterface;
1213
use Throwable;
14+
use TinyBlocks\Http\Code;
1315
use TinyBlocks\Http\ErrorHandler\Internal\DefaultErrorMiddlewareBuilder;
1416
use TinyBlocks\Http\ErrorHandler\Internal\ErrorLogger;
1517
use TinyBlocks\Http\ErrorHandler\Internal\ErrorOutcome;
18+
use TinyBlocks\Http\ErrorHandler\Internal\Reporting\CompositeErrorReporter;
19+
use TinyBlocks\Http\ErrorHandler\Internal\ResolvedRoute;
20+
use TinyBlocks\Http\ErrorHandler\Reporters\SilentErrorReporter;
1621

1722
/**
1823
* PSR-15 middleware that captures exceptions thrown downstream, delegates to a consumer-provided
@@ -29,21 +34,33 @@ private function __construct(private ErrorOutcome $errorOutcome)
2934
* Builds an ErrorMiddleware from its configuration components.
3035
*
3136
* @param LoggerInterface|null $logger The logger to use for error logging, or <code>null</code> to disable logging.
37+
* @param Closure(ErrorPayload, ServerRequestInterface): string $message The rule settling the message the
38+
* response answers with.
3239
* @param ExceptionMappingTable $mappings The composed table that maps exceptions to error responses.
40+
* @param Closure(?MappedError, Code): ReportingPriority $priority The rule deriving a priority from the rule
41+
* that matched and the resolved status.
3342
* @param ErrorHandlingSettings $settings The settings controlling error display and logging behavior.
3443
* @param bool $fallbackOnUnmapped Whether to return a fallback response when no mapping matches.
44+
* @param ErrorReporter $reporter The reporter notified after the response is produced and logged. Defaults to
45+
* {@see SilentErrorReporter}, which forwards every report to no provider.
3546
* @return ErrorMiddleware The configured middleware instance.
3647
*/
3748
public static function build(
3849
?LoggerInterface $logger,
50+
Closure $message,
3951
ExceptionMappingTable $mappings,
52+
Closure $priority,
4053
ErrorHandlingSettings $settings,
41-
bool $fallbackOnUnmapped
54+
bool $fallbackOnUnmapped,
55+
ErrorReporter $reporter = new SilentErrorReporter()
4256
): ErrorMiddleware {
4357
$errorOutcome = new ErrorOutcome(
58+
message: $message,
4459
mappings: $mappings,
60+
priority: $priority,
4561
settings: $settings,
4662
errorLogger: ErrorLogger::from(logger: $logger, settings: $settings),
63+
errorReporter: CompositeErrorReporter::create()->with(reporter: $reporter),
4764
fallbackOnUnmapped: $fallbackOnUnmapped
4865
);
4966

@@ -62,10 +79,21 @@ public static function create(): ErrorMiddlewareBuilder
6279

6380
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
6481
{
82+
$tags = ReportingTags::pending();
83+
$route = ResolvedRoute::pending();
84+
$routed = $request
85+
->withAttribute(ResolvedRoute::ATTRIBUTE_NAME, $route)
86+
->withAttribute(ReportingTags::ATTRIBUTE_NAME, $tags);
87+
6588
try {
66-
return $handler->handle($request);
89+
return $handler->handle($routed);
6790
} catch (Throwable $exception) {
68-
return $this->errorOutcome->resolve(request: $request, exception: $exception);
91+
return $this->errorOutcome->resolve(
92+
tags: $tags,
93+
route: $route,
94+
request: $routed,
95+
exception: $exception
96+
);
6997
}
7098
}
7199
}

‎src/ErrorMiddlewareBuilder.php‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
namespace TinyBlocks\Http\ErrorHandler;
66

7+
use Closure;
8+
use Psr\Http\Message\ServerRequestInterface;
79
use Psr\Log\LoggerInterface;
10+
use TinyBlocks\Http\Code;
811
use TinyBlocks\Http\ErrorHandler\Exceptions\MappingNotConfigured;
912

1013
/**
11-
* Fluent builder that assembles an {@see ErrorMiddleware} from a logger, exception mappings, and settings.
14+
* Fluent builder that assembles an {@see ErrorMiddleware} from a logger, exception mappings, error reporters,
15+
* settings, and the rules deciding the message a response answers with and the priority an error carries.
1216
*/
1317
interface ErrorMiddlewareBuilder
1418
{
@@ -36,6 +40,19 @@ public function withLogger(?LoggerInterface $logger): ErrorMiddlewareBuilder;
3640
*/
3741
public function withMapping(ExceptionMapping $mapping): ErrorMiddlewareBuilder;
3842

43+
/**
44+
* Registers the rule that settles the message the response answers with.
45+
*
46+
* <p>Without this rule the response answers what the mapping or the fallback already said. An
47+
* application that speaks to people replaces it, and decides from the resolved code and the request,
48+
* so the message is settled once and never parsed back out of a rendered body.</p>
49+
*
50+
* @param Closure(ErrorPayload, ServerRequestInterface): string $resolver The rule naming the message to answer
51+
* with.
52+
* @return ErrorMiddlewareBuilder The configured builder.
53+
*/
54+
public function withMessage(Closure $resolver): ErrorMiddlewareBuilder;
55+
3956
/**
4057
* Returns a builder with the given exception mappings registered.
4158
*
@@ -44,6 +61,32 @@ public function withMapping(ExceptionMapping $mapping): ErrorMiddlewareBuilder;
4461
*/
4562
public function withMappings(ExceptionMapping ...$mappings): ErrorMiddlewareBuilder;
4663

64+
/**
65+
* Registers the rule that derives a priority from the status the middleware resolved.
66+
*
67+
* <p>The default is {@see ReportingPriority::from}, which every consumer is free to replace. What an
68+
* exception declares through {@see PrioritizedError} still wins over whatever this rule answers.</p>
69+
*
70+
* @param Closure(?MappedError, Code): ReportingPriority $resolver The rule naming the priority an error
71+
* justifies.
72+
* @return ErrorMiddlewareBuilder The configured builder.
73+
*/
74+
public function withPriority(Closure $resolver): ErrorMiddlewareBuilder;
75+
76+
/**
77+
* Returns a builder with the given error reporter registered.
78+
*
79+
* <p>Reporters are optional, and the method may be called more than once to register several of
80+
* them, so one application can report to an error tracking service, a metrics backend, and an
81+
* audit trail at once. Each one is notified after the response has been produced and the log
82+
* entry emitted, and a failure thrown by one of them is discarded, so it can never affect the
83+
* response.</p>
84+
*
85+
* @param ErrorReporter $reporter The reporter notified when an error is handled.
86+
* @return ErrorMiddlewareBuilder The configured builder.
87+
*/
88+
public function withReporter(ErrorReporter $reporter): ErrorMiddlewareBuilder;
89+
4790
/**
4891
* Returns a builder configured with the given error handling settings.
4992
*

‎src/ErrorPayload.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\ErrorHandler;
6+
7+
use TinyBlocks\Http\Code;
8+
9+
/**
10+
* Body the middleware is about to answer with, before the message is final.
11+
*
12+
* <p>An application that speaks to people rather than to machines rewrites the message, and the
13+
* rest of the answer stays as the middleware resolved it. Handing it this, rather than the rendered
14+
* response, is what keeps the rewrite from being a parse and a re-encode of what was just built.</p>
15+
*/
16+
final readonly class ErrorPayload
17+
{
18+
public function __construct(
19+
public string $code,
20+
public Code $status,
21+
public string $message,
22+
public bool $wasMapped
23+
) {
24+
}
25+
}

‎src/ErrorReporter.php‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\ErrorHandler;
6+
7+
/**
8+
* Consumer-provided sink that forwards a handled error to an external observability provider.
9+
*
10+
* <p>One implementation adapts one provider, and the middleware accepts any number of them, so a
11+
* single application can report to an error tracking service, a metrics backend, and an audit trail
12+
* without any of them knowing about the others.</p>
13+
*
14+
* <p>Reporting is the best effort. Reporters run synchronously, after the response has been produced
15+
* and after the log entry has been emitted, and any failure thrown by an implementation is
16+
* discarded, so a provider that fails never fails the response. A slow one still adds its own
17+
* duration to it.</p>
18+
*/
19+
interface ErrorReporter
20+
{
21+
/**
22+
* Reports the error to the provider this reporter adapts.
23+
*
24+
* <p>Implementations are free to throw. The middleware discards whatever comes out, so an
25+
* implementation never needs to defend itself for the sake of the request.</p>
26+
*
27+
* @param ReportedError $error The resolved error context to forward.
28+
*/
29+
public function report(ReportedError $error): void;
30+
}

‎src/ExceptionMappingRule.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public function __construct(private ExceptionMappingTable $table, private Except
2525
* Closes the rule with a fixed MappedError produced from the given fields.
2626
*
2727
* @param string $code Machine-readable error code.
28-
* @param int $status HTTP response status code (400-599).
28+
* @param int $status HTTP response status code, one of the known HTTP error statuses.
2929
* @param string $message Human-readable error description.
3030
* @param array<string, string|string[]> $headers Optional HTTP response headers.
3131
* @return ExceptionMappingTable The table with the new rule appended.

‎src/ExceptionMappingTable.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public function when(string $exceptionClass): ExceptionMappingRule
5353
public function mapTo(Throwable $exception): ?MappedError
5454
{
5555
foreach ($this->entries as $entry) {
56-
$mappedError = $entry->resolve(exception: $exception);
56+
$mappedError = $entry->resolve($exception);
5757

5858
if (!is_null($mappedError)) {
5959
return $mappedError;

0 commit comments

Comments
 (0)