getContext($exception); // Skip if not auditable if (! $this->isAuditable($context)) { return; } // Convert exception to audit entry $auditEntry = $this->convertToAuditEntry($exception, $context); // Log to audit system $this->auditLogger->log($auditEntry); } catch (Throwable $e) { // Audit logging should never break exception handling // Log error but don't throw error_log("Failed to log exception to audit: {$e->getMessage()}"); } } /** * Check if exception is auditable * * @param ExceptionContextData|null $context Exception context * @return bool True if exception should be audited */ private function isAuditable(?ExceptionContextData $context): bool { if ($context === null) { // Default: log all exceptions if no context provided return true; } // Check auditable flag (defaults to true) return $context->auditable; } /** * Convert exception to audit entry * * @param Throwable $exception Exception to convert * @param ExceptionContextData $context Exception context * @return AuditEntry Audit entry value object */ private function convertToAuditEntry(Throwable $exception, ExceptionContextData $context): AuditEntry { // Determine audit action from exception type and context $action = $this->determineAuditAction($exception, $context); // Determine entity type from exception class or context $entityType = $this->determineEntityType($exception, $context); // Extract entity ID from context data $entityId = $this->extractEntityId($context); // Build metadata from exception and context $metadata = $this->buildMetadata($exception, $context); // Extract IP address (already Value Object or string) $ipAddress = $context->clientIp instanceof IpAddress ? $context->clientIp : ($context->clientIp !== null && IpAddress::isValid($context->clientIp) ? IpAddress::from($context->clientIp) : null); // Extract user agent (already Value Object or string) $userAgent = $context->userAgent instanceof UserAgent ? $context->userAgent : ($context->userAgent !== null ? UserAgent::fromString($context->userAgent) : null); // Create audit entry as failed operation return AuditEntry::failed( clock: $this->clock, action: $action, entityType: $entityType, entityId: $entityId, errorMessage: $exception->getMessage(), userId: $context->userId, ipAddress: $ipAddress, userAgent: $userAgent, metadata: $metadata ); } /** * Determine audit action from exception and context * * @param Throwable $exception Exception * @param ExceptionContextData $context Context * @return AuditableAction Audit action */ private function determineAuditAction(Throwable $exception, ExceptionContextData $context): AuditableAction { // Check if action is specified in metadata if (isset($context->metadata['audit_action']) && is_string($context->metadata['audit_action'])) { $actionValue = $context->metadata['audit_action']; // Try to find matching enum case foreach (AuditableAction::cases() as $case) { if ($case->value === $actionValue) { return $case; } } } // Determine from operation name if ($context->operation !== null) { $operation = $context->operation; if (str_contains($operation, 'create')) { return AuditableAction::CREATE; } if (str_contains($operation, 'update')) { return AuditableAction::UPDATE; } if (str_contains($operation, 'delete')) { return AuditableAction::DELETE; } if (str_contains($operation, 'read') || str_contains($operation, 'get')) { return AuditableAction::READ; } } // Determine from exception type $exceptionClass = $exception::class; if (str_contains($exceptionClass, 'Security') || str_contains($exceptionClass, 'Auth')) { return AuditableAction::SECURITY_VIOLATION; } // Default: custom action return AuditableAction::CUSTOM; } /** * Determine entity type from exception and context * * @param Throwable $exception Exception * @param ExceptionContextData $context Context * @return string Entity type */ private function determineEntityType(Throwable $exception, ExceptionContextData $context): string { // Check if entity type is specified in metadata if (isset($context->metadata['entity_type']) && is_string($context->metadata['entity_type'])) { return $context->metadata['entity_type']; } // Try to extract from component if ($context->component !== null) { // Remove common suffixes $component = $context->component; $component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component); return strtolower($component); } // Extract from exception class name $className = $exception::class; $parts = explode('\\', $className); $shortName = end($parts); // Remove "Exception" suffix $entityType = preg_replace('/Exception$/', '', $shortName); return strtolower($entityType); } /** * Extract entity ID from context data * * @param ExceptionContextData $context Context * @return string|null Entity ID */ private function extractEntityId(ExceptionContextData $context): ?string { // Check common entity ID keys $commonKeys = ['entity_id', 'id', 'user_id', 'order_id', 'account_id', 'resource_id']; foreach ($commonKeys as $key) { if (isset($context->data[$key])) { $value = $context->data[$key]; if (is_string($value) || is_numeric($value)) { return (string) $value; } } } return null; } /** * Build metadata from exception and context * * @param Throwable $exception Exception * @param ExceptionContextData $context Context * @return array Metadata */ private function buildMetadata(Throwable $exception, ExceptionContextData $context): array { $metadata = [ 'exception_class' => $exception::class, 'exception_code' => $exception->getCode(), 'exception_file' => $exception->getFile(), 'exception_line' => $exception->getLine(), ]; // Add context data if (! empty($context->data)) { $metadata['context_data'] = $context->data; } // Add operation and component if ($context->operation !== null) { $metadata['operation'] = $context->operation; } if ($context->component !== null) { $metadata['component'] = $context->component; } // Add request/session IDs if ($context->requestId !== null) { $metadata['request_id'] = $context->requestId; } if ($context->sessionId !== null) { $metadata['session_id'] = $context->sessionId; } // Add tags if (! empty($context->tags)) { $metadata['tags'] = $context->tags; } // Add previous exception if exists if ($exception->getPrevious() !== null) { $previous = $exception->getPrevious(); $metadata['previous_exception'] = [ 'class' => $previous::class, 'message' => $previous->getMessage(), ]; } // Merge with existing metadata (but exclude internal fields) $excludeKeys = ['auditable', 'audit_action', 'entity_type']; foreach ($context->metadata as $key => $value) { if (! in_array($key, $excludeKeys, true)) { $metadata[$key] = $value; } } return $metadata; } /** * Get context from provider * * @param Throwable $exception Exception * @return ExceptionContextData|null Context or null if not found */ private function getContext(Throwable $exception): ?ExceptionContextData { if ($this->contextProvider === null) { return null; } return $this->contextProvider->get($exception); } }