src/EventsSubscriber/System/RequestLogSubscriber.php line 50

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\EventsSubscriber\System;
  4. use Psr\Log\LoggerInterface;
  5. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\HttpKernel\Event\RequestEvent;
  8. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  9. use Symfony\Component\HttpKernel\KernelEvents;
  10. /**
  11.  * Structured access log for every API request, shipped to CloudWatch via the "request" channel.
  12.  *
  13.  * Metadata (method, path, status, creator, client IP, latency) is logged for ALL requests. The
  14.  * request BODY is logged only for an explicit allow-list of endpoints whose payload carries no
  15.  * credentials (e.g. geolocation coordinates). Auth/payment bodies are never logged, and a key
  16.  * redactor is applied to anything that is logged as a backstop.
  17.  */
  18. final class RequestLogSubscriber implements EventSubscriberInterface
  19. {
  20.     private const START_ATTR '_request_log_start';
  21.     /** Endpoints whose JSON body is safe to log in full (no credentials). */
  22.     private const BODY_ALLOWLIST = [
  23.         '#^/api/v\d+/creators/\d+/geolocation#',
  24.     ];
  25.     /** Body keys whose value is replaced with "[redacted]" (compared case/format-insensitively). */
  26.     private const REDACT_KEYS = [
  27.         'password''token''accesstoken''refreshtoken''secret''authorization',
  28.         'apikey''card''cardnumber''cvv''cvc''pan''pin',
  29.     ];
  30.     public function __construct(
  31.         private readonly LoggerInterface $requestLogger,
  32.     ) {
  33.     }
  34.     public static function getSubscribedEvents(): array
  35.     {
  36.         return [
  37.             KernelEvents::REQUEST => ['onRequest'1024],
  38.             KernelEvents::RESPONSE => ['onResponse', -300],
  39.         ];
  40.     }
  41.     public function onRequest(RequestEvent $event): void
  42.     {
  43.         if (!$event->isMainRequest()) {
  44.             return;
  45.         }
  46.         $event->getRequest()->attributes->set(self::START_ATTRmicrotime(true));
  47.     }
  48.     public function onResponse(ResponseEvent $event): void
  49.     {
  50.         if (!$event->isMainRequest()) {
  51.             return;
  52.         }
  53.         $request $event->getRequest();
  54.         $start $request->attributes->get(self::START_ATTR);
  55.         $context = [
  56.             'method' => $request->getMethod(),
  57.             'path' => $request->getPathInfo(),
  58.             'route' => $request->attributes->get('_route'),
  59.             'status' => $event->getResponse()->getStatusCode(),
  60.             'creator_id' => $this->resolveUserId($request),
  61.             'client_ip' => $request->getClientIp(),
  62.             'duration_ms' => is_float($start) ? (int) round((microtime(true) - $start) * 1000) : null,
  63.             'request_id' => $request->headers->get('X-Amzn-Trace-Id') ?? $request->headers->get('X-Request-Id'),
  64.             'user_agent' => $request->headers->get('User-Agent'),
  65.         ];
  66.         $body $this->loggableBody($request);
  67.         if ($body !== null) {
  68.             $context['body'] = $body;
  69.         }
  70.         $this->requestLogger->info('api_request'$context);
  71.     }
  72.     private function resolveUserId(Request $request): ?int
  73.     {
  74.         if (preg_match('#/(?:creators|sponsors)/(\d+)#'$request->getPathInfo(), $matches) === 1) {
  75.             return (int) $matches[1];
  76.         }
  77.         return null;
  78.     }
  79.     /**
  80.      * @return array<array-key, mixed>|null
  81.      */
  82.     private function loggableBody(Request $request): ?array
  83.     {
  84.         if (!in_array($request->getMethod(), [Request::METHOD_POSTRequest::METHOD_PUTRequest::METHOD_PATCH], true)) {
  85.             return null;
  86.         }
  87.         $path $request->getPathInfo();
  88.         $allowed false;
  89.         foreach (self::BODY_ALLOWLIST as $pattern) {
  90.             if (preg_match($pattern$path) === 1) {
  91.                 $allowed true;
  92.                 break;
  93.             }
  94.         }
  95.         if (!$allowed) {
  96.             return null;
  97.         }
  98.         $raw $request->getContent();
  99.         if ($raw === '') {
  100.             return null;
  101.         }
  102.         try {
  103.             $decoded json_decode($rawtrue512JSON_THROW_ON_ERROR);
  104.         } catch (\JsonException) {
  105.             return null;
  106.         }
  107.         return is_array($decoded) ? $this->redact($decoded) : null;
  108.     }
  109.     /**
  110.      * @param array<array-key, mixed> $data
  111.      *
  112.      * @return array<array-key, mixed>
  113.      */
  114.     private function redact(array $data): array
  115.     {
  116.         foreach ($data as $key => $value) {
  117.             if (is_array($value)) {
  118.                 $data[$key] = $this->redact($value);
  119.                 continue;
  120.             }
  121.             if (is_string($key) && in_array($this->normalizeKey($key), self::REDACT_KEYStrue)) {
  122.                 $data[$key] = '[redacted]';
  123.             }
  124.         }
  125.         return $data;
  126.     }
  127.     private function normalizeKey(string $key): string
  128.     {
  129.         return preg_replace('/[^a-z0-9]/'''strtolower($key)) ?? '';
  130.     }
  131. }