src/EventSubscriber/AuditSubscriber.php line 631

Open in your IDE?
  1. <?php
  2. namespace App\EventSubscriber;
  3. use App\Entity\LogsAction;
  4. use App\Entity\User;
  5. use App\Repository\LogsActionRepository;
  6. use DateTime;
  7. use DateTimeInterface;
  8. use Doctrine\Common\Collections\Collection;
  9. use Doctrine\ORM\Event\LifecycleEventArgs;
  10. use Doctrine\Persistence\ManagerRegistry;
  11. use Doctrine\Persistence\ObjectManager;
  12. use Error;
  13. use Exception;
  14. use ReflectionMethod;
  15. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  16. use Symfony\Component\HttpFoundation\File\UploadedFile;
  17. use Symfony\Component\HttpFoundation\Request;
  18. use Symfony\Component\HttpKernel\Event\ControllerEvent;
  19. use Symfony\Component\HttpKernel\Event\ExceptionEvent;
  20. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  21. use Symfony\Component\HttpKernel\KernelEvents;
  22. use Symfony\Component\Security\Core\Security;
  23. class AuditSubscriber implements EventSubscriberInterface
  24. {
  25.     private array $httpRequestContext = [];
  26.     private array $entityChanges = [];
  27.     private bool $shouldSkipAudit false;
  28.     private ?array $errorData null;
  29.     private ?array $preDeleteEntityData null;
  30.     private ?string $currentRoute null;
  31.     private ?string $currentMethod null;
  32.     // Routes to ignore from audit logging
  33.     private const IGNORED_ROUTES = [
  34.         // Clean route patterns
  35.         '/api/discussion/list',
  36.         '/api/login_check',
  37.         // Original Symfony route patterns  
  38.         '_api_/discussion/list',
  39.         'api/login_check',
  40.         '_api_/discussion/list.{_format}_get',
  41.         'api/login_check_post',
  42.     ];
  43.     public function __construct(
  44.         private readonly \Doctrine\ORM\EntityManagerInterface $entityManager,
  45.         private readonly ManagerRegistry $doctrine,
  46.         private Security $security,
  47.         private readonly LogsActionRepository $logsActionRepository
  48.     ) {
  49.     }
  50.     public static function getSubscribedEvents() :array
  51.     {
  52.         return [
  53.             // HTTP Kernel Events
  54.             KernelEvents::CONTROLLER => 'onKernelController',
  55.             KernelEvents::RESPONSE => 'onKernelResponse',
  56.             KernelEvents::EXCEPTION => 'onKernelException',
  57.         ];
  58.     }
  59.     public function getSubscribedDoctrineEvents(): array
  60.     {
  61.         return [
  62.             'prePersist',
  63.             'preUpdate',
  64.             'preRemove',
  65.         ];
  66.     }
  67.     // Spammed routes like /api/login_check or /api/discussion/list
  68.     private function shouldIgnoreRoute(string $originalRoute nullstring $cleanRoute null): bool
  69.     {
  70.         if (!$originalRoute && !$cleanRoute) {
  71.             return false;
  72.         }
  73.         if ($originalRoute && in_array($originalRouteself::IGNORED_ROUTEStrue)) {
  74.             return true;
  75.         }
  76.         if ($cleanRoute && in_array($cleanRouteself::IGNORED_ROUTEStrue)) {
  77.             return true;
  78.         }
  79.         foreach (self::IGNORED_ROUTES as $ignoredRoute) {
  80.             if ($originalRoute && str_starts_with($originalRoute$ignoredRoute)) {
  81.                 return true;
  82.             }
  83.             if ($originalRoute && $this->matchesRoutePattern($originalRoute$ignoredRoute)) {
  84.                 return true;
  85.             }
  86.         }
  87.         return false;
  88.     }
  89.     private function matchesRoutePattern(string $routestring $pattern): bool
  90.     {
  91.         // Remove format and method suffixes for comparison
  92.         $cleanedRoute preg_replace('/\.\{_format\}(_\w+)?$/'''$route);
  93.         $cleanedRoute preg_replace('/_(?:get|post|put|patch|delete)$/'''$cleanedRoute);
  94.         
  95.         $cleanedPattern preg_replace('/\.\{_format\}(_\w+)?$/'''$pattern);
  96.         $cleanedPattern preg_replace('/_(?:get|post|put|patch|delete)$/'''$cleanedPattern);
  97.         
  98.         return $cleanedRoute === $cleanedPattern;
  99.     }
  100.     private function extractEntityData($entity): array
  101.     {
  102.         try {
  103.             $data = [];
  104.             $reflection = new \ReflectionClass($entity);
  105.             // Always include entity class for clarity
  106.             $data['_entity_class'] = get_class($entity);
  107.             // Try to get entity ID
  108.             if (method_exists($entity'getId')) {
  109.                 try {
  110.                     $id $entity->getId();
  111.                     $data['_entity_id'] = $id ?? 'null';
  112.                 } catch (Error $e) {
  113.                     $data['_entity_id'] = 'uninitialized';
  114.                 }
  115.             }
  116.             foreach ($reflection->getProperties() as $property) {
  117.                 $propertyName $property->getName();
  118.                 try {
  119.                     $value $property->getValue($entity);
  120.                 } catch (Error $e) {
  121.                     // Skip uninitialized properties
  122.                     if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
  123.                         continue;
  124.                     }
  125.                     $data[$propertyName] = 'error:' $e->getMessage();
  126.                     continue;
  127.                 }
  128.                 // Handle different value types
  129.                 if (is_null($value)) {
  130.                     $data[$propertyName] = null;
  131.                 } elseif ($value instanceof DateTimeInterface) {
  132.                     $data[$propertyName] = $value->format('Y-m-d H:i:s');
  133.                 } elseif (is_scalar($value)) {
  134.                     // Truncate long strings for readability
  135.                     if (is_string($value) && strlen($value) > 500) {
  136.                         $data[$propertyName] = substr($value0500) . '... [truncated]';
  137.                     } else {
  138.                         $data[$propertyName] = $value;
  139.                     }
  140.                 } elseif (is_object($value)) {
  141.                     if (method_exists($value'getId')) {
  142.                         // Related entity - store only ID
  143.                         try {
  144.                             $relatedId $value->getId();
  145.                             $data[$propertyName] = [
  146.                                 '_type' => 'relation',
  147.                                 '_class' => get_class($value),
  148.                                 'id' => $relatedId
  149.                             ];
  150.                         } catch (Error $e) {
  151.                             $data[$propertyName] = [
  152.                                 '_type' => 'relation',
  153.                                 '_class' => get_class($value),
  154.                                 'id' => 'uninitialized'
  155.                             ];
  156.                         }
  157.                     } elseif ($value instanceof Collection || $value instanceof \Doctrine\ORM\PersistentCollection) {
  158.                         // Collection - check if initialized
  159.                         try {
  160.                             if (method_exists($value'isInitialized') && !$value->isInitialized()) {
  161.                                 $data[$propertyName] = ['_type' => 'collection''status' => 'uninitialized'];
  162.                             } else {
  163.                                 $data[$propertyName] = ['_type' => 'collection''count' => $value->count()];
  164.                             }
  165.                         } catch (Exception $e) {
  166.                             $data[$propertyName] = ['_type' => 'collection''status' => 'error'];
  167.                         }
  168.                     } elseif (method_exists($value'__toString')) {
  169.                         try {
  170.                             $data[$propertyName] = (string)$value;
  171.                         } catch (Exception $e) {
  172.                             $data[$propertyName] = 'object:' get_class($value);
  173.                         }
  174.                     } else {
  175.                         $data[$propertyName] = 'object:' get_class($value);
  176.                     }
  177.                 } elseif (is_array($value)) {
  178.                     $data[$propertyName] = ['_type' => 'array''count' => count($value)];
  179.                 }
  180.             }
  181.             return $data;
  182.         } catch (Exception $e) {
  183.             return [
  184.                 '_error' => 'extraction_failed',
  185.                 '_entity_class' => get_class($entity),
  186.                 '_message' => $e->getMessage()
  187.             ];
  188.         }
  189.     }
  190.     // This function is useless right now but in case we need to specify which HTTP actions to log later
  191.     private function shouldLogHttpAction(string $methodint $statusCode): bool
  192.     {
  193.         // Log all successful requests for certain methods
  194.         if ($statusCode >= 200 && $statusCode 300) {
  195.             return in_array($method, ['GET''DELETE''POST''PUT''PATCH']);
  196.         }
  197.         // Log all error responses
  198.         return $statusCode >= 400;
  199.     }
  200.     private function getHttpActionType(string $methodstring $route null): string
  201.     {
  202.         if ($route && $this->isDeleteRoute($route)) {
  203.             return 'delete';
  204.         }
  205.         
  206.         return match ($method) {
  207.             'GET' => 'read',
  208.             'POST' => 'create',
  209.             'PUT''PATCH' => 'update'
  210.             'DELETE' => 'delete',
  211.             default => 'request'
  212.         };
  213.     }
  214.     // Some Delete routes are using POST method so u need to handle them separately
  215.     private function isDeleteRoute(string $route): bool
  216.     {
  217.         $deletePatterns = [
  218.             '/delete',
  219.             'delete_',
  220.             '_delete',
  221.             '/remove',
  222.             'remove_',
  223.             '_remove',
  224.             '/destroy',
  225.             'destroy_',
  226.             '_destroy'
  227.         ];
  228.         
  229.         $lowerRoute strtolower($route);
  230.         
  231.         foreach ($deletePatterns as $pattern) {
  232.             if (str_contains($lowerRoute$pattern)) {
  233.                 return true;
  234.             }
  235.         }
  236.         
  237.         return false;
  238.     }
  239.     private function captureEntityBeforeDelete(Request $requeststring $route): void
  240.     {
  241.         try {
  242.             $entityId $this->extractEntityIdFromRequest($request);
  243.             if (!$entityId) {
  244.                 return;
  245.             }
  246.             $entityClass $this->getEntityFromRoute($route);
  247.             if (!$entityClass) {
  248.                 return;
  249.             }
  250.             $repository $this->entityManager->getRepository($entityClass);
  251.             $entity $repository->find($entityId);
  252.             
  253.             if ($entity) {
  254.                 $this->preDeleteEntityData $this->extractEntityData($entity);
  255.             }
  256.         } catch (Exception $e) {
  257.         }
  258.     }
  259.     private function extractEntityIdFromRequest(Request $request): ?int
  260.     {
  261.         $idParams = ['id''entityId''siteId''userId''customerId'];
  262.         
  263.         foreach ($idParams as $param) {
  264.             $value $request->get($param);
  265.             if ($value && is_numeric($value)) {
  266.                 return (int) $value;
  267.             }
  268.         }
  269.         $pathInfo $request->getPathInfo();
  270.         if (preg_match('/\/(\d+)(?:\/|$)/'$pathInfo$matches)) {
  271.             $id = (int) $matches[1];
  272.             return $id;
  273.         }
  274.         return null;
  275.     }
  276.     private function getEntityFromRoute(string $route): ?string
  277.     {
  278.         $routeToEntityMap = [
  279.             'sites' => 'App\Entity\Site',
  280.             'users' => 'App\Entity\User'
  281.             'customers' => 'App\Entity\Customer',
  282.             'evaluations' => 'App\Entity\Evaluation',
  283.             'documents' => 'App\Entity\Document',
  284.             'categories' => 'App\Entity\Category',
  285.             'sections' => 'App\Entity\Section',
  286.             'compliances' => 'App\Entity\Compliance',
  287.             //
  288.             'site' => 'App\Entity\Site',
  289.             'user' => 'App\Entity\User',
  290.             'customer' => 'App\Entity\Customer'
  291.             'evaluation' => 'App\Entity\Evaluation',
  292.             'document' => 'App\Entity\Document',
  293.             'category' => 'App\Entity\Category',
  294.             'section' => 'App\Entity\Section',
  295.             'compliance' => 'App\Entity\Compliance',
  296.         ];
  297.         $lowerRoute strtolower($route);
  298.         
  299.         foreach ($routeToEntityMap as $routePattern => $entityClass) {
  300.             if (str_contains($lowerRoute$routePattern)) {
  301.                 return $entityClass;
  302.             }
  303.         }
  304.         return null;
  305.     }
  306.     private function getAuditEntityManager(): ObjectManager
  307.     {
  308.         try {
  309.             $em $this->doctrine->resetManager();
  310.             return $this->doctrine->getManager();
  311.         } catch (Exception $e) {
  312.             return $this->entityManager;
  313.         }
  314.     }
  315.     private function getCleanRoute($request): string
  316.     {
  317.         // Try to get the actual path first
  318.         $pathInfo $request->getPathInfo();
  319.         if ($pathInfo && $pathInfo !== '/') {
  320.             return $pathInfo;
  321.         }
  322.         
  323.         // Fallback to route name, but clean it up
  324.         $route $request->attributes->get('_route');
  325.         if (!$route) {
  326.             return 'unknown_route';
  327.         }
  328.         
  329.         // Clean up common Symfony route patterns
  330.         $cleanRoute $route;
  331.         
  332.         // Remove format placeholders
  333.         $cleanRoute preg_replace('/\.\{_format\}/'''$cleanRoute);
  334.         
  335.         // Remove method suffixes (like _post, _get, etc.)
  336.         $cleanRoute preg_replace('/_(?:get|post|put|patch|delete)$/'''$cleanRoute);
  337.         
  338.         // Convert underscores to forward slashes for API routes
  339.         if (str_starts_with($cleanRoute'_api_')) {
  340.             $cleanRoute str_replace('_api_''/api/'$cleanRoute);
  341.             $cleanRoute str_replace('_''/'$cleanRoute);
  342.         }
  343.         
  344.         return $cleanRoute;
  345.     }
  346.     public function onKernelController(ControllerEvent $event)
  347.     {
  348.         $request $event->getRequest();
  349.         $originalRoute $request->attributes->get('_route');
  350.         $cleanRoute $this->getCleanRoute($request);
  351.         if ($this->shouldIgnoreRoute($originalRoute$cleanRoute)) {
  352.             $this->shouldSkipAudit true;
  353.             return;
  354.         }
  355.         $this->shouldSkipAudit false;
  356.         $this->currentRoute $cleanRoute;
  357.         $this->currentMethod $request->getMethod();
  358.         // Build structured request data
  359.         $requestPayload = [];
  360.         // Query parameters
  361.         if ($request->query->count() > 0) {
  362.             $requestPayload['query_params'] = $request->query->all();
  363.         }
  364.         // POST/Form data
  365.         if ($request->request->count() > 0) {
  366.             $requestPayload['form_data'] = $request->request->all();
  367.         }
  368.         // JSON body
  369.         if ($request->getContentType() === 'json' || str_contains($request->headers->get('Content-Type'''), 'application/json')) {
  370.             $content $request->getContent();
  371.             if ($content) {
  372.                 $decodedContent json_decode($contenttrue);
  373.                 if (json_last_error() === JSON_ERROR_NONE) {
  374.                     $requestPayload['json_body'] = $decodedContent;
  375.                 } else {
  376.                     $requestPayload['raw_body'] = substr($content01000); // Limit size
  377.                 }
  378.             }
  379.         }
  380.         // Files
  381.         if ($request->files->count() > 0) {
  382.             $requestPayload['files'] = array_map(function($file) {
  383.                 return $file instanceof UploadedFile
  384.                     ? [
  385.                         'name' => $file->getClientOriginalName(),
  386.                         'size' => $file->getSize(),
  387.                         'type' => $file->getMimeType()
  388.                     ]
  389.                     : (string)$file;
  390.             }, $request->files->all());
  391.         }
  392.         // Store context for later use
  393.         $this->httpRequestContext = [
  394.             'route' => $cleanRoute,
  395.             'original_route' => $originalRoute,
  396.             'method' => $request->getMethod(),
  397.             'ip_address' => $this->logsActionRepository->getRealIpAddress($request),
  398.             'user_agent' => $request->headers->get('User-Agent'),
  399.             'request_payload' => $requestPayload,
  400.         ];
  401.         // Capture entity data before potential deletion
  402.         if ($this->isDeleteRoute($cleanRoute)) {
  403.             $this->captureEntityBeforeDelete($request$cleanRoute);
  404.         }
  405.     }
  406.      public function preUpdate(LifecycleEventArgs $args)
  407.      {
  408.          if ($this->shouldSkipAudit) {
  409.              return;
  410.          }
  411.          try {
  412.              $entity $args->getObject();
  413.              $entityClass get_class($entity);
  414.              $entityId $this->getEntityId($entity);
  415.              // Get only the changed fields (before and after values)
  416.              $dataBefore $this->extractChangeSetData($entity);
  417.              $dataAfter $this->extractChangeSetDataAfter($entity);
  418.              $this->entityChanges[] = [
  419.                  'type' => 'entity_change',
  420.                  'action' => 'update',
  421.                  'entity_class' => $entityClass,
  422.                  'entity_id' => $entityId,
  423.                  'data_before' => $dataBefore// Only modified fields - old values
  424.                  'data_after' => $dataAfter,   // Only modified fields - new values
  425.              ];
  426.          } catch (Exception $e) {
  427.              // Silently handle errors to prevent disrupting the update
  428.          }
  429.      }
  430.     private function getEntityId($entity): string
  431.     {
  432.         if (!method_exists($entity'getId')) {
  433.             return 'unknown';
  434.         }
  435.         try {
  436.             $id $entity->getId();
  437.             return $id !== null ? (string)$id 'null';
  438.         } catch (Error $e) {
  439.             if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
  440.                 return 'uninitialized';
  441.             }
  442.             return 'error';
  443.         }
  444.     }
  445.     private function extractChangeSetData($entity): array
  446.     {
  447.         $dataBefore = [];
  448.         try {
  449.             $uow $this->entityManager->getUnitOfWork();
  450.             $changeSet $uow->getEntityChangeSet($entity);
  451.             // Only include fields that have actually changed
  452.             foreach ($changeSet as $field => $values) {
  453.                 $oldValue $values[0]; // Old value before change
  454.                 if ($oldValue instanceof DateTimeInterface) {
  455.                     $dataBefore[$field] = $oldValue->format('Y-m-d H:i:s');
  456.                 } elseif (is_object($oldValue) && method_exists($oldValue'getId')) {
  457.                     try {
  458.                         $dataBefore[$field] = [
  459.                             '_type' => 'relation',
  460.                             '_class' => get_class($oldValue),
  461.                             'id' => $oldValue->getId()
  462.                         ];
  463.                     } catch (Error $e) {
  464.                         $dataBefore[$field] = [
  465.                             '_type' => 'relation',
  466.                             '_class' => get_class($oldValue),
  467.                             'id' => 'uninitialized'
  468.                         ];
  469.                     }
  470.                 } elseif (is_scalar($oldValue) || is_null($oldValue)) {
  471.                     $dataBefore[$field] = $oldValue;
  472.                 } else {
  473.                     try {
  474.                         $dataBefore[$field] = (string)$oldValue;
  475.                     } catch (Exception $e) {
  476.                         $dataBefore[$field] = 'object_conversion_error';
  477.                     }
  478.                 }
  479.             }
  480.         } catch (Exception $e) {
  481.             $dataBefore['_error'] = 'Could not retrieve change set: ' $e->getMessage();
  482.         }
  483.         return $dataBefore;
  484.     }
  485.     private function extractChangeSetDataAfter($entity): array
  486.     {
  487.         $dataAfter = [];
  488.         try {
  489.             $uow $this->entityManager->getUnitOfWork();
  490.             $changeSet $uow->getEntityChangeSet($entity);
  491.             // Only include fields that have actually changed
  492.             foreach ($changeSet as $field => $values) {
  493.                 $newValue $values[1]; // New value after change
  494.                 if ($newValue instanceof DateTimeInterface) {
  495.                     $dataAfter[$field] = $newValue->format('Y-m-d H:i:s');
  496.                 } elseif (is_object($newValue) && method_exists($newValue'getId')) {
  497.                     try {
  498.                         $dataAfter[$field] = [
  499.                             '_type' => 'relation',
  500.                             '_class' => get_class($newValue),
  501.                             'id' => $newValue->getId()
  502.                         ];
  503.                     } catch (Error $e) {
  504.                         $dataAfter[$field] = [
  505.                             '_type' => 'relation',
  506.                             '_class' => get_class($newValue),
  507.                             'id' => 'uninitialized'
  508.                         ];
  509.                     }
  510.                 } elseif (is_scalar($newValue) || is_null($newValue)) {
  511.                     $dataAfter[$field] = $newValue;
  512.                 } else {
  513.                     try {
  514.                         $dataAfter[$field] = (string)$newValue;
  515.                     } catch (Exception $e) {
  516.                         $dataAfter[$field] = 'object_conversion_error';
  517.                     }
  518.                 }
  519.             }
  520.         } catch (Exception $e) {
  521.             $dataAfter['_error'] = 'Could not retrieve change set: ' $e->getMessage();
  522.         }
  523.         return $dataAfter;
  524.     }
  525.     public function prePersist(LifecycleEventArgs $args)
  526.     {
  527.         if ($this->shouldSkipAudit) {
  528.             return;
  529.         }
  530.         try {
  531.             $entity $args->getObject();
  532.             $entityClass get_class($entity);
  533.             $entityId $this->getEntityId($entity);
  534.             $this->entityChanges[] = [
  535.                 'type' => 'entity_change',
  536.                 'action' => 'create',
  537.                 'entity_class' => $entityClass,
  538.                 'entity_id' => $entityId,
  539.                 'data_before' => [],
  540.                 'data_after' => $this->extractEntityData($entity),
  541.             ];
  542.         } catch (Exception $e) {
  543.             // Silently handle errors
  544.         }
  545.     }
  546.     // Capture entity deletions
  547.     public function preRemove(LifecycleEventArgs $args)
  548.     {
  549.         if ($this->shouldSkipAudit) {
  550.             return;
  551.         }
  552.         try {
  553.             $entity $args->getObject();
  554.             $entityClass get_class($entity);
  555.             $entityId $this->getEntityId($entity);
  556.             $entityData $this->extractEntityData($entity);
  557.             $this->entityChanges[] = [
  558.                 'type' => 'entity_change',
  559.                 'action' => 'delete',
  560.                 'entity_class' => $entityClass,
  561.                 'entity_id' => $entityId,
  562.                 'data_before' => $entityData,
  563.                 'data_after' => [], // Empty after deletion
  564.             ];
  565.         } catch (Exception $e) {
  566.             // Silently handle errors
  567.         }
  568.     }
  569.     public function onKernelException(ExceptionEvent $event): void
  570.     {
  571.         if ($this->shouldSkipAudit) {
  572.             return;
  573.         }
  574.         $exception $event->getThrowable();
  575.         $this->errorData = [
  576.             'error_message' => $exception->getMessage(),
  577.             'error_code' => $exception->getCode(),
  578.             'error_file' => $exception->getFile(),
  579.             'error_line' => $exception->getLine(),
  580.             'error_trace' => $exception->getTraceAsString()
  581.         ];
  582.     }
  583.     public function onKernelResponse(ResponseEvent $event): void
  584.     {
  585.         if ($this->shouldSkipAudit) {
  586.             return;
  587.         }
  588.         $response $event->getResponse();
  589.         $user $this->security->getUser();
  590.         $statusCode $response $response->getStatusCode() : 500;
  591.         $method $this->currentMethod ?? 'UNKNOWN';
  592.         $route $this->currentRoute ?? 'unknown_route';
  593.         // Build clean response data
  594.         $responseData $this->buildResponseData($response$statusCode);
  595.         // Handle errors
  596.         if ($statusCode >= 400 && empty($this->entityChanges)) {
  597.             $this->logError($statusCode$responseData);
  598.         }
  599.         // Log HTTP request if no entity changes were captured
  600.         if (empty($this->entityChanges) && $this->shouldLogHttpAction($method$statusCode)) {
  601.             $this->logHttpRequest($method$route$statusCode$responseData);
  602.         }
  603.         // Nothing to log
  604.         if (empty($this->entityChanges)) {
  605.             $this->cleanup();
  606.             return;
  607.         }
  608.         // Persist all logs
  609.         $this->persistLogs($user$statusCode$responseData);
  610.         // Cleanup
  611.         $this->cleanup();
  612.     }
  613.     private function buildResponseData($responseint $statusCode): array
  614.     {
  615.         $responseData = ['status_code' => $statusCode];
  616.         if (!$response) {
  617.             return $responseData;
  618.         }
  619.         // Important headers
  620.         $responseData['headers'] = [];
  621.         $importantHeaders = ['content-type''location''cache-control'];
  622.         foreach ($importantHeaders as $header) {
  623.             if ($response->headers->has($header)) {
  624.                 $responseData['headers'][$header] = $response->headers->get($header);
  625.             }
  626.         }
  627.         // Response content (only for successful responses)
  628.         if ($statusCode >= 200 && $statusCode 300) {
  629.             try {
  630.                 $content $response->getContent();
  631.                 if (!empty($content)) {
  632.                     // Limit response content size
  633.                     if (strlen($content) > 5000) {
  634.                         $responseData['content'] = substr($content05000) . '... [truncated]';
  635.                         $responseData['content_truncated'] = true;
  636.                         $responseData['original_length'] = strlen($content);
  637.                     } else {
  638.                         $decodedContent json_decode($contenttrue);
  639.                         if (json_last_error() === JSON_ERROR_NONE) {
  640.                             $responseData['content'] = $decodedContent;
  641.                         } else {
  642.                             $responseData['content'] = $content;
  643.                         }
  644.                     }
  645.                 }
  646.             } catch (Exception $e) {
  647.                 $responseData['content_error'] = $e->getMessage();
  648.             }
  649.         }
  650.         return $responseData;
  651.     }
  652.     private function logError(int $statusCode, array $responseData): void
  653.     {
  654.         $errorData $this->errorData ?? [];
  655.         $this->entityChanges[] = [
  656.             'type' => 'error',
  657.             'action' => 'error',
  658.             'entity_class' => null,
  659.             'entity_id' => null,
  660.             'data_before' => [],
  661.             'data_after' => array_merge($errorData, ['status_code' => $statusCode]),
  662.             'response_data' => $responseData,
  663.         ];
  664.     }
  665.     private function logHttpRequest(string $methodstring $routeint $statusCode, array $responseData): void
  666.     {
  667.         $action $this->getHttpActionType($method$route);
  668.         $dataBefore = [];
  669.         // For DELETE operations, include pre-delete data
  670.         if ($action === 'delete' && $this->preDeleteEntityData !== null) {
  671.             $dataBefore $this->preDeleteEntityData;
  672.         }
  673.         $this->entityChanges[] = [
  674.             'type' => 'http_request',
  675.             'action' => $action,
  676.             'entity_class' => null,
  677.             'entity_id' => null,
  678.             'data_before' => $dataBefore,
  679.             'data_after' => [],
  680.             'response_data' => $responseData,
  681.             'metadata' => [
  682.                 'detected_as_delete' => $action === 'delete' && $method !== 'DELETE',
  683.                 'has_pre_delete_data' => !empty($dataBefore),
  684.             ],
  685.         ];
  686.     }
  687.     private function persistLogs($userint $statusCode, array $responseData): void
  688.     {
  689.         $auditEM $this->getAuditEntityManager();
  690.         if (!$auditEM) {
  691.             return;
  692.         }
  693.         $auditUser $this->resolveAuditUser($user$auditEM);
  694.         if (!$auditUser) {
  695.             return; // Skip logging if no user can be resolved
  696.         }
  697.         foreach ($this->entityChanges as $change) {
  698.             try {
  699.                 $log = new LogsAction();
  700.                 $log->setUser($auditUser);
  701.                 $log->setAction($change['action']);
  702.                 $log->setRoute($this->currentRoute);
  703.                 $log->setMethod($this->currentMethod);
  704.                 $log->setIpAddress($this->httpRequestContext['ip_address'] ?? null);
  705.                 $log->setUserAgent($this->httpRequestContext['user_agent'] ?? null);
  706.                 $log->setStatusCode($statusCode);
  707.                 // Build clear request data structure - always include request payload
  708.                 $requestData = [
  709.                     'type' => $change['type'] ?? 'entity_change',
  710.                     'entity_class' => $change['entity_class'] ?? null,
  711.                     'entity_id' => $change['entity_id'] ?? null,
  712.                     'payload' => $this->httpRequestContext['request_payload'] ?? [],
  713.                     'metadata' => $change['metadata'] ?? [],
  714.                 ];
  715.                 // Set request data (always persisted, nullable)
  716.                 $log->setRequestData(!empty($requestData['payload']) || !empty($requestData['metadata'])
  717.                     ? json_encode($requestDataJSON_UNESCAPED_UNICODE)
  718.                     : null);
  719.                 // Set data before (only modified fields for updates, full snapshot for deletes)
  720.                 $dataBefore $change['data_before'] ?? [];
  721.                 $log->setDataBefore(!empty($dataBefore) ? json_encode($dataBeforeJSON_UNESCAPED_UNICODE) : null);
  722.                 // Set data after (only modified fields for updates, full snapshot for creates)
  723.                 $dataAfter $change['data_after'] ?? [];
  724.                 $log->setDataAfter(!empty($dataAfter) ? json_encode($dataAfterJSON_UNESCAPED_UNICODE) : null);
  725.                 // Set response data (always persisted if available, nullable)
  726.                 $responseDataToLog $change['response_data'] ?? $responseData;
  727.                 $log->setResponseData(!empty($responseDataToLog) ? json_encode($responseDataToLogJSON_UNESCAPED_UNICODE) : null);
  728.                 $log->setCreatedAt(new DateTime());
  729.                 $log->setUpdatedAt(new DateTime());
  730.                 $auditEM->persist($log);
  731.             } catch (Exception $e) {
  732.                 // Silently continue to next log
  733.                 continue;
  734.             }
  735.         }
  736.         try {
  737.             $auditEM->flush();
  738.         } catch (Exception $e) {
  739.             // Failed to persist logs - silent fail to avoid breaking the request
  740.         }
  741.     }
  742.     private function resolveAuditUser($userObjectManager $auditEM): ?User
  743.     {
  744.         if (!$user) {
  745.             $systemUser $auditEM->find(User::class, 0);
  746.             return $systemUser;
  747.         }
  748.         $auditUser null;
  749.         if ($user instanceof User) {
  750.             $userId $user->getId();
  751.             $auditUser $auditEM->find(User::class, $userId);
  752.         }
  753.         return $auditUser;
  754.     }
  755.     private function cleanup(): void
  756.     {
  757.         $this->entityChanges = [];
  758.         $this->errorData null;
  759.         $this->httpRequestContext = [];
  760.         $this->preDeleteEntityData null;
  761.         $this->currentRoute null;
  762.         $this->currentMethod null;
  763.     }
  764. }