<?php
declare(strict_types=1);
namespace App\EventsSubscriber\Exception;
use Psr\Log\LoggerInterface;
use Sentry\Severity;
use Sentry\State\HubInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Ensures 5xx failures are logged to stderr (CloudWatch). Sentry exception capture is handled by
* register_error_listener; this subscriber also reports HTTP 500 responses that never threw.
*/
final class HttpErrorLogSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly HubInterface $sentryHub,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 0],
KernelEvents::RESPONSE => ['onKernelResponse', -256],
];
}
public function onKernelException(ExceptionEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$exception = $event->getThrowable();
$statusCode = $exception instanceof HttpExceptionInterface
? $exception->getStatusCode()
: Response::HTTP_INTERNAL_SERVER_ERROR;
if ($statusCode < Response::HTTP_INTERNAL_SERVER_ERROR) {
return;
}
$request = $event->getRequest();
$context = $this->buildRequestContext($request, $statusCode);
$this->logger->error(
sprintf('Unhandled %d: %s', $statusCode, $exception->getMessage()),
array_merge($context, ['exception' => $exception]),
);
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
if ($response->getStatusCode() < Response::HTTP_INTERNAL_SERVER_ERROR) {
return;
}
$request = $event->getRequest();
$context = $this->buildRequestContext($request, $response->getStatusCode());
$this->logger->error(
sprintf('HTTP %d response without logged exception', $response->getStatusCode()),
$context,
);
$this->sentryHub->captureMessage(
sprintf(
'HTTP %d on %s %s',
$response->getStatusCode(),
$request->getMethod(),
$request->getPathInfo(),
),
Severity::error(),
);
}
/**
* @return array<string, mixed>
*/
private function buildRequestContext(\Symfony\Component\HttpFoundation\Request $request, int $statusCode): array
{
return [
'status_code' => $statusCode,
'method' => $request->getMethod(),
'path' => $request->getPathInfo(),
'query' => $request->query->all(),
'route' => $request->attributes->get('_route'),
'request_id' => $request->headers->get('X-Amzn-Trace-Id')
?? $request->headers->get('X-Request-Id'),
];
}
}