<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\EventSubscriber;
use App\Shared\Infrastructure\Observability\ObservabilityLogger;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMInvalidArgumentException;
use Doctrine\ORM\UnitOfWork;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
/**
* FIX-260601-008 — Captura kernel.exception específica para
* ORMInvalidArgumentException "is not managed". Vuelca estado del UoW al
* momento del catch (identityMap class counts + scheduled inserts/updates/
* deletes + clases de entidades visibles) + ruta + stack trace formateado
* para identificar el listener / método que detacha la entity.
*
* NO modifica el flujo (read-only sobre UoW). NO suprime la exception
* (sigue propagando al handler estándar). Prioridad alta (1024) para
* correr antes que cualquier exception handler que envuelva la traza.
*/
final class EntityNotManagedDebugSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $em,
private ObservabilityLogger $observabilityLogger
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 1024],
];
}
public function onKernelException(ExceptionEvent $event): void
{
try {
$e = $event->getThrowable();
if (!$e instanceof ORMInvalidArgumentException) {
return;
}
$msg = $e->getMessage();
if (stripos($msg, 'is not managed') === false) {
return;
}
$uow = $this->em->getUnitOfWork();
$context = [
'route' => $event->getRequest()->attributes->get('_route'),
'path' => $event->getRequest()->getPathInfo(),
'method' => $event->getRequest()->getMethod(),
'message_head' => mb_substr($msg, 0, 240),
'identity_map' => $this->summarizeIdentityMap($uow),
'scheduled' => $this->summarizeScheduled($uow),
'stack_top' => $this->topStackFrames($e, 25),
];
$this->observabilityLogger->safeLog(
'critical',
'[OBS-DETACHED-CATCH] orm_entity_not_managed',
$context
);
} catch (Throwable $inner) {
// swallow.
}
}
/**
* Cuenta entities por clase + estado al momento del exception.
*/
private function summarizeIdentityMap(UnitOfWork $uow): array
{
$counts = [];
foreach ($uow->getIdentityMap() as $className => $entities) {
$byState = ['managed' => 0, 'new' => 0, 'detached' => 0, 'removed' => 0, 'unknown' => 0];
foreach ($entities as $entity) {
try {
$state = $uow->getEntityState($entity);
switch ($state) {
case UnitOfWork::STATE_MANAGED: $byState['managed']++; break;
case UnitOfWork::STATE_NEW: $byState['new']++; break;
case UnitOfWork::STATE_DETACHED: $byState['detached']++; break;
case UnitOfWork::STATE_REMOVED: $byState['removed']++; break;
default: $byState['unknown']++;
}
} catch (Throwable $t) {
$byState['unknown']++;
}
}
$counts[$className] = $byState;
}
return $counts;
}
private function summarizeScheduled(UnitOfWork $uow): array
{
$sum = static fn(array $a) => array_count_values(array_map(
static fn($e) => is_object($e) ? get_class($e) : gettype($e),
$a
));
return [
'insertions' => $sum($uow->getScheduledEntityInsertions()),
'updates' => $sum($uow->getScheduledEntityUpdates()),
'deletions' => $sum($uow->getScheduledEntityDeletions()),
];
}
/**
* Top frames del stack trace, filtradas: app + listeners + sonata.
*/
private function topStackFrames(Throwable $e, int $max): array
{
$out = [];
foreach ($e->getTrace() as $i => $frame) {
if (count($out) >= $max) break;
$file = $frame['file'] ?? '';
$line = $frame['line'] ?? '?';
$cls = $frame['class'] ?? '';
$fn = $frame['function'] ?? '';
// Filtramos los irrelevantes (kernel symfony interno reiterativo).
if (str_contains($file, '/vendor/symfony/http-kernel/HttpKernel.php')) continue;
$short = preg_replace('#^/var/www/[^/]+/[^/]+/httpdocs/#', '', $file);
$out[] = sprintf('%s:%s %s%s%s', $short, $line, $cls, $cls ? '::' : '', $fn);
}
return $out;
}
}