<?php
declare(strict_types=1);
namespace App\EventsSubscriber\System;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Structured access log for every API request, shipped to CloudWatch via the "request" channel.
*
* Metadata (method, path, status, creator, client IP, latency) is logged for ALL requests. The
* request BODY is logged only for an explicit allow-list of endpoints whose payload carries no
* credentials (e.g. geolocation coordinates). Auth/payment bodies are never logged, and a key
* redactor is applied to anything that is logged as a backstop.
*/
final class RequestLogSubscriber implements EventSubscriberInterface
{
private const START_ATTR = '_request_log_start';
/** Endpoints whose JSON body is safe to log in full (no credentials). */
private const BODY_ALLOWLIST = [
'#^/api/v\d+/creators/\d+/geolocation#',
];
/** Body keys whose value is replaced with "[redacted]" (compared case/format-insensitively). */
private const REDACT_KEYS = [
'password', 'token', 'accesstoken', 'refreshtoken', 'secret', 'authorization',
'apikey', 'card', 'cardnumber', 'cvv', 'cvc', 'pan', 'pin',
];
public function __construct(
private readonly LoggerInterface $requestLogger,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 1024],
KernelEvents::RESPONSE => ['onResponse', -300],
];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$event->getRequest()->attributes->set(self::START_ATTR, microtime(true));
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$start = $request->attributes->get(self::START_ATTR);
$context = [
'method' => $request->getMethod(),
'path' => $request->getPathInfo(),
'route' => $request->attributes->get('_route'),
'status' => $event->getResponse()->getStatusCode(),
'creator_id' => $this->resolveUserId($request),
'client_ip' => $request->getClientIp(),
'duration_ms' => is_float($start) ? (int) round((microtime(true) - $start) * 1000) : null,
'request_id' => $request->headers->get('X-Amzn-Trace-Id') ?? $request->headers->get('X-Request-Id'),
'user_agent' => $request->headers->get('User-Agent'),
];
$body = $this->loggableBody($request);
if ($body !== null) {
$context['body'] = $body;
}
$this->requestLogger->info('api_request', $context);
}
private function resolveUserId(Request $request): ?int
{
if (preg_match('#/(?:creators|sponsors)/(\d+)#', $request->getPathInfo(), $matches) === 1) {
return (int) $matches[1];
}
return null;
}
/**
* @return array<array-key, mixed>|null
*/
private function loggableBody(Request $request): ?array
{
if (!in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH], true)) {
return null;
}
$path = $request->getPathInfo();
$allowed = false;
foreach (self::BODY_ALLOWLIST as $pattern) {
if (preg_match($pattern, $path) === 1) {
$allowed = true;
break;
}
}
if (!$allowed) {
return null;
}
$raw = $request->getContent();
if ($raw === '') {
return null;
}
try {
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($decoded) ? $this->redact($decoded) : null;
}
/**
* @param array<array-key, mixed> $data
*
* @return array<array-key, mixed>
*/
private function redact(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->redact($value);
continue;
}
if (is_string($key) && in_array($this->normalizeKey($key), self::REDACT_KEYS, true)) {
$data[$key] = '[redacted]';
}
}
return $data;
}
private function normalizeKey(string $key): string
{
return preg_replace('/[^a-z0-9]/', '', strtolower($key)) ?? '';
}
}