<?php
namespace App\EventSubscriber;
use App\Entity\ApiClient;
use App\Entity\ApiRequestLog;
use App\Entity\User;
use App\Security\ApiTokenAuthenticator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiRequestLoggingSubscriber implements EventSubscriberInterface
{
private const START_ATTR = '_api_start_time';
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 1024],
KernelEvents::RESPONSE => ['onResponse', -1024],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/v1')) {
return;
}
$request->attributes->set(self::START_ATTR, microtime(true));
}
public function onResponse(ResponseEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/v1')) {
return;
}
$response = $event->getResponse();
$start = $request->attributes->get(self::START_ATTR);
$duration = $start ? (int) ((microtime(true) - $start) * 1000) : null;
$this->attachRateLimitHeaders($request, $response);
$log = new ApiRequestLog();
$log->setMethod($request->getMethod());
$log->setPath($request->getPathInfo());
$log->setStatusCode($response->getStatusCode());
$log->setIp($request->getClientIp());
$log->setUserAgent(substr((string) $request->headers->get('User-Agent', ''), 0, 255));
$log->setDurationMs($duration);
$client = $request->attributes->get(ApiTokenAuthenticator::ATTR_CLIENT);
if ($client instanceof ApiClient) {
$log->setApiClient($client);
}
$impersonated = $request->attributes->get(ApiTokenAuthenticator::ATTR_IMPERSONATED_BY);
if ($impersonated instanceof User) {
$log->setImpersonatedByUser($impersonated);
}
$errorCode = $this->extractErrorCode($response);
if ($errorCode !== null) {
$log->setErrorCode($errorCode);
}
try {
$this->em->persist($log);
$this->em->flush();
} catch (\Throwable $e) {
// logging must never break the response
}
}
private function attachRateLimitHeaders($request, $response): void
{
$minute = $request->attributes->get(ApiTokenAuthenticator::ATTR_RATE_MINUTE);
$hour = $request->attributes->get(ApiTokenAuthenticator::ATTR_RATE_HOUR);
if (is_array($minute)) {
$response->headers->set('X-RateLimit-Limit', (string) $minute['limit']);
$response->headers->set('X-RateLimit-Remaining', (string) $minute['remaining']);
$response->headers->set('X-RateLimit-Reset', (string) $minute['reset']);
}
if (is_array($hour)) {
$response->headers->set('X-RateLimit-Hour-Limit', (string) $hour['limit']);
$response->headers->set('X-RateLimit-Hour-Remaining', (string) $hour['remaining']);
}
}
private function extractErrorCode($response): ?string
{
if ($response->getStatusCode() < 400) {
return null;
}
$content = $response->getContent();
if (!is_string($content) || $content === '') {
return null;
}
$data = json_decode($content, true);
return is_array($data) && isset($data['error']['code']) ? (string) $data['error']['code'] : null;
}
}