src/Shared/Infrastructure/EventSubscriber/EntityNotManagedDebugSubscriber.php line 42

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Shared\Infrastructure\EventSubscriber;
  4. use App\Shared\Infrastructure\Observability\ObservabilityLogger;
  5. use Doctrine\ORM\EntityManagerInterface;
  6. use Doctrine\ORM\ORMInvalidArgumentException;
  7. use Doctrine\ORM\UnitOfWork;
  8. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  9. use Symfony\Component\HttpKernel\Event\ExceptionEvent;
  10. use Symfony\Component\HttpKernel\KernelEvents;
  11. use Throwable;
  12. /**
  13.  * FIX-260601-008 — Captura kernel.exception específica para
  14.  * ORMInvalidArgumentException "is not managed". Vuelca estado del UoW al
  15.  * momento del catch (identityMap class counts + scheduled inserts/updates/
  16.  * deletes + clases de entidades visibles) + ruta + stack trace formateado
  17.  * para identificar el listener / método que detacha la entity.
  18.  *
  19.  * NO modifica el flujo (read-only sobre UoW). NO suprime la exception
  20.  * (sigue propagando al handler estándar). Prioridad alta (1024) para
  21.  * correr antes que cualquier exception handler que envuelva la traza.
  22.  */
  23. final class EntityNotManagedDebugSubscriber implements EventSubscriberInterface
  24. {
  25.     public function __construct(
  26.         private EntityManagerInterface $em,
  27.         private ObservabilityLogger $observabilityLogger
  28.     ) {
  29.     }
  30.     public static function getSubscribedEvents(): array
  31.     {
  32.         return [
  33.             KernelEvents::EXCEPTION => ['onKernelException'1024],
  34.         ];
  35.     }
  36.     public function onKernelException(ExceptionEvent $event): void
  37.     {
  38.         try {
  39.             $e $event->getThrowable();
  40.             if (!$e instanceof ORMInvalidArgumentException) {
  41.                 return;
  42.             }
  43.             $msg $e->getMessage();
  44.             if (stripos($msg'is not managed') === false) {
  45.                 return;
  46.             }
  47.             $uow $this->em->getUnitOfWork();
  48.             $context = [
  49.                 'route'         => $event->getRequest()->attributes->get('_route'),
  50.                 'path'          => $event->getRequest()->getPathInfo(),
  51.                 'method'        => $event->getRequest()->getMethod(),
  52.                 'message_head'  => mb_substr($msg0240),
  53.                 'identity_map'  => $this->summarizeIdentityMap($uow),
  54.                 'scheduled'     => $this->summarizeScheduled($uow),
  55.                 'stack_top'     => $this->topStackFrames($e25),
  56.             ];
  57.             $this->observabilityLogger->safeLog(
  58.                 'critical',
  59.                 '[OBS-DETACHED-CATCH] orm_entity_not_managed',
  60.                 $context
  61.             );
  62.         } catch (Throwable $inner) {
  63.             // swallow.
  64.         }
  65.     }
  66.     /**
  67.      * Cuenta entities por clase + estado al momento del exception.
  68.      */
  69.     private function summarizeIdentityMap(UnitOfWork $uow): array
  70.     {
  71.         $counts = [];
  72.         foreach ($uow->getIdentityMap() as $className => $entities) {
  73.             $byState = ['managed' => 0'new' => 0'detached' => 0'removed' => 0'unknown' => 0];
  74.             foreach ($entities as $entity) {
  75.                 try {
  76.                     $state $uow->getEntityState($entity);
  77.                     switch ($state) {
  78.                         case UnitOfWork::STATE_MANAGED:  $byState['managed']++;  break;
  79.                         case UnitOfWork::STATE_NEW:      $byState['new']++;      break;
  80.                         case UnitOfWork::STATE_DETACHED$byState['detached']++; break;
  81.                         case UnitOfWork::STATE_REMOVED:  $byState['removed']++;  break;
  82.                         default:                         $byState['unknown']++;
  83.                     }
  84.                 } catch (Throwable $t) {
  85.                     $byState['unknown']++;
  86.                 }
  87.             }
  88.             $counts[$className] = $byState;
  89.         }
  90.         return $counts;
  91.     }
  92.     private function summarizeScheduled(UnitOfWork $uow): array
  93.     {
  94.         $sum = static fn(array $a) => array_count_values(array_map(
  95.             static fn($e) => is_object($e) ? get_class($e) : gettype($e),
  96.             $a
  97.         ));
  98.         return [
  99.             'insertions' => $sum($uow->getScheduledEntityInsertions()),
  100.             'updates'    => $sum($uow->getScheduledEntityUpdates()),
  101.             'deletions'  => $sum($uow->getScheduledEntityDeletions()),
  102.         ];
  103.     }
  104.     /**
  105.      * Top frames del stack trace, filtradas: app + listeners + sonata.
  106.      */
  107.     private function topStackFrames(Throwable $eint $max): array
  108.     {
  109.         $out = [];
  110.         foreach ($e->getTrace() as $i => $frame) {
  111.             if (count($out) >= $max) break;
  112.             $file $frame['file'] ?? '';
  113.             $line $frame['line'] ?? '?';
  114.             $cls  $frame['class'] ?? '';
  115.             $fn   $frame['function'] ?? '';
  116.             // Filtramos los irrelevantes (kernel symfony interno reiterativo).
  117.             if (str_contains($file'/vendor/symfony/http-kernel/HttpKernel.php')) continue;
  118.             $short preg_replace('#^/var/www/[^/]+/[^/]+/httpdocs/#'''$file);
  119.             $out[] = sprintf('%s:%s %s%s%s'$short$line$cls$cls '::' ''$fn);
  120.         }
  121.         return $out;
  122.     }
  123. }