Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.42% covered (warning)
77.42%
72 / 93
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ErrorLoggingMiddleware
77.42% covered (warning)
77.42%
72 / 93
28.57% covered (danger)
28.57%
2 / 7
66.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 logException
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
6.02
 determineLogLevel
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
15.36
 extractErrorCode
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
9.65
 getClientIp
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 buildContext
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
13.05
1<?php
2
3declare(strict_types=1);
4
5namespace App\Middleware;
6
7use App\Domain\ErrorLog\Service\ErrorLogService;
8use PDOException;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\ServerRequestInterface;
11use Psr\Http\Server\MiddlewareInterface;
12use Psr\Http\Server\RequestHandlerInterface;
13use Throwable;
14
15/**
16 * Middleware that logs errors to the database.
17 *
18 * This middleware catches exceptions, logs them based on severity,
19 * then re-throws them for the ExceptionMiddleware to handle.
20 */
21final class ErrorLoggingMiddleware implements MiddlewareInterface
22{
23    public function __construct(
24        private readonly ErrorLogService $errorLogService,
25    ) {}
26
27    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
28    {
29        try {
30            return $handler->handle($request);
31        } catch (Throwable $exception) {
32            $this->logException($exception, $request);
33            throw $exception;
34        }
35    }
36
37    private function logException(Throwable $exception, ServerRequestInterface $request): void
38    {
39        try {
40            // Determine log level based on an exception type
41            $level = $this->determineLogLevel($exception);
42
43            // Extract error code
44            $errorCode = $this->extractErrorCode($exception);
45
46            // Get user info from request attributes (set by JwtAuthMiddleware)
47            $userId = $request->getAttribute('userId');
48            $sessionId = $request->getAttribute('sessionId');
49
50            // Get request info
51            $serverParams = $request->getServerParams();
52            $ipAddress = $this->getClientIp($serverParams);
53            $userAgent = $request->getHeaderLine('User-Agent') ?: null;
54
55            // Get request body (will be sanitized by the service)
56            $requestBody = (array)$request->getParsedBody();
57
58            // Build context with additional useful info
59            $context = $this->buildContext($request, $exception);
60
61            // Log the error
62            $this->errorLogService->log(
63                level: $level,
64                message: $exception->getMessage(),
65                errorCode: $errorCode,
66                exception: $exception,
67                requestUri: $request->getUri()->getPath(),
68                requestMethod: $request->getMethod(),
69                userId: $userId !== null ? (int)$userId : null,
70                sessionId: $sessionId !== null ? (int)$sessionId : null,
71                ipAddress: $ipAddress,
72                userAgent: $userAgent,
73                requestBody: count($requestBody) > 0 ? $requestBody : null,
74                context: $context
75            );
76        } catch (Throwable $loggingException) {
77            // If logging fails, we don't want to mask the original exception
78            // Just silently fail - the original exception will still be thrown
79            // In production, you might want to log this to a file as a fallback
80            error_log('ErrorLoggingMiddleware failed: ' . $loggingException->getMessage());
81        }
82    }
83
84    private function determineLogLevel(Throwable $exception): string
85    {
86        // Critical: Database connection issues, system failures
87        if ($exception instanceof PDOException) {
88            // Check if it's a connection issue vs a query issue
89            $code = $exception->getCode();
90            if (is_string($code) && str_starts_with($code, '08')) {
91                // Connection exceptions (08xxx codes)
92                return 'critical';
93            }
94            // Other database errors are regular errors
95            return 'error';
96        }
97
98        // Map exception class names to levels
99        $exceptionClass = $exception::class;
100
101        // Validation errors are typically warnings (client errors)
102        if (str_contains($exceptionClass, 'InvalidArgumentException')) {
103            return 'warning';
104        }
105
106        // Domain exceptions (business logic violations) are notices
107        if (str_contains($exceptionClass, 'DomainException')) {
108            return 'notice';
109        }
110
111        // Authentication errors
112        if (
113            str_contains($exceptionClass, 'Authentication')
114            || str_contains($exceptionClass, 'Unauthorized')
115        ) {
116            return 'warning';
117        }
118
119        // HTTP exceptions based on status code
120        if (method_exists($exception, 'getCode')) {
121            $code = $exception->getCode();
122            if (is_int($code)) {
123                if ($code >= 500) {
124                    return 'error';
125                }
126                if ($code >= 400) {
127                    return 'warning';
128                }
129            }
130        }
131
132        // Default to error level
133        return 'error';
134    }
135
136    private function extractErrorCode(Throwable $exception): ?string
137    {
138        $code = $exception->getCode();
139
140        // PDO exceptions have SQLSTATE codes
141        if ($exception instanceof PDOException) {
142            return is_string($code) ? $code : (string)$code;
143        }
144
145        // HTTP-like codes
146        if (is_int($code) && $code > 0) {
147            return (string)$code;
148        }
149
150        // Extract from class name for custom exceptions
151        $className = $exception::class;
152        if (str_contains($className, 'NotFound')) {
153            return '404';
154        }
155        if (str_contains($className, 'Unauthorized')) {
156            return '401';
157        }
158        if (str_contains($className, 'Forbidden')) {
159            return '403';
160        }
161        if (str_contains($className, 'Conflict')) {
162            return '409';
163        }
164
165        return null;
166    }
167
168    /**
169     * @param array<string, mixed> $serverParams
170     */
171    private function getClientIp(array $serverParams): ?string
172    {
173        // Check for forwarded IP (behind proxy/load balancer)
174        $forwardedFor = $serverParams['HTTP_X_FORWARDED_FOR'] ?? null;
175        if ($forwardedFor !== null && $forwardedFor !== '') {
176            // Take the first IP in the chain
177            $ips = explode(',', $forwardedFor);
178            return trim($ips[0]);
179        }
180
181        // Check for real IP header
182        $realIp = $serverParams['HTTP_X_REAL_IP'] ?? null;
183        if ($realIp !== null && $realIp !== '') {
184            return $realIp;
185        }
186
187        // Fall back to remote address
188        return $serverParams['REMOTE_ADDR'] ?? null;
189    }
190
191    /**
192     * Build additional context for the error log.
193     *
194     * @param ServerRequestInterface $request
195     * @param Throwable $exception
196     * @return array<string, mixed>
197     */
198    private function buildContext(ServerRequestInterface $request, Throwable $exception): array
199    {
200        $context = [];
201
202        // Include route info if available
203        $route = $request->getAttribute('__route__');
204        if ($route !== null && is_object($route) && method_exists($route, 'getName')) {
205            $context['routeName'] = $route->getName();
206        }
207
208        // Include route arguments
209        $routeArgs = $request->getAttribute('__routeArguments__');
210        if ($routeArgs !== null && is_array($routeArgs) && count($routeArgs) > 0) {
211            $context['routeArgs'] = $routeArgs;
212        }
213
214        // Include query parameters (excluding sensitive data)
215        $queryParams = $request->getQueryParams();
216        if (count($queryParams) > 0) {
217            $context['queryParams'] = $queryParams;
218        }
219
220        // Include exception class for easier filtering
221        $context['exceptionClass'] = $exception::class;
222
223        // Include previous exception info if present
224        $previous = $exception->getPrevious();
225        if ($previous !== null) {
226            $context['previousException'] = [
227                'class' => $previous::class,
228                'message' => $previous->getMessage(),
229                'code' => $previous->getCode(),
230            ];
231        }
232
233        return $context;
234    }
235}