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
60.35
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.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
4.01
 determineLogLevel
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
16.19
 extractErrorCode
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
9.19
 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 App\Support\Row;
9use PDOException;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12use Psr\Http\Server\MiddlewareInterface;
13use Psr\Http\Server\RequestHandlerInterface;
14use Throwable;
15
16/**
17 * Middleware that logs errors to the database.
18 *
19 * This middleware catches exceptions, logs them based on severity,
20 * then re-throws them for the ExceptionMiddleware to handle.
21 */
22final class ErrorLoggingMiddleware implements MiddlewareInterface
23{
24    public function __construct(
25        private readonly ErrorLogService $errorLogService,
26    ) {}
27
28    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
29    {
30        try {
31            return $handler->handle($request);
32        } catch (Throwable $exception) {
33            $this->logException($exception, $request);
34            throw $exception;
35        }
36    }
37
38    private function logException(Throwable $exception, ServerRequestInterface $request): void
39    {
40        try {
41            // Determine log level based on an exception type
42            $level = $this->determineLogLevel($exception);
43
44            // Extract error code
45            $errorCode = $this->extractErrorCode($exception);
46
47            // Get user info from request attributes (set by JwtAuthMiddleware)
48            $attributes = $request->getAttributes();
49            $userId = Row::nullableInt($attributes, 'userId');
50            $sessionId = Row::nullableInt($attributes, 'sessionId');
51
52            // Get request info
53            $serverParams = $request->getServerParams();
54            $ipAddress = $this->getClientIp($serverParams);
55            $userAgent = $request->getHeaderLine('User-Agent') ?: null;
56
57            // Get request body (will be sanitized by the service)
58            $requestBody = (array)$request->getParsedBody();
59
60            // Build context with additional useful info
61            $context = $this->buildContext($request, $exception);
62
63            // Log the error
64            $this->errorLogService->log(
65                level: $level,
66                message: $exception->getMessage(),
67                errorCode: $errorCode,
68                exception: $exception,
69                requestUri: $request->getUri()->getPath(),
70                requestMethod: $request->getMethod(),
71                userId: $userId,
72                sessionId: $sessionId,
73                ipAddress: $ipAddress,
74                userAgent: $userAgent,
75                requestBody: count($requestBody) > 0 ? $requestBody : null,
76                context: $context
77            );
78        } catch (Throwable $loggingException) {
79            // If logging fails, we don't want to mask the original exception
80            // Just silently fail - the original exception will still be thrown
81            // In production, you might want to log this to a file as a fallback
82            error_log('ErrorLoggingMiddleware failed: ' . $loggingException->getMessage());
83        }
84    }
85
86    private function determineLogLevel(Throwable $exception): string
87    {
88        // Critical: Database connection issues, system failures
89        if ($exception instanceof PDOException) {
90            // Check if it's a connection issue vs a query issue
91            $code = $exception->getCode();
92            if (is_string($code) && str_starts_with($code, '08')) {
93                // Connection exceptions (08xxx codes)
94                return 'critical';
95            }
96            // Other database errors are regular errors
97            return 'error';
98        }
99
100        // Map exception class names to levels
101        $exceptionClass = $exception::class;
102
103        // Validation errors are typically warnings (client errors)
104        if (str_contains($exceptionClass, 'InvalidArgumentException')) {
105            return 'warning';
106        }
107
108        // Domain exceptions (business logic violations) are notices
109        if (str_contains($exceptionClass, 'DomainException')) {
110            return 'notice';
111        }
112
113        // Authentication errors
114        if (
115            str_contains($exceptionClass, 'Authentication')
116            || str_contains($exceptionClass, 'Unauthorized')
117        ) {
118            return 'warning';
119        }
120
121        // HTTP exceptions based on status code
122        $code = $exception->getCode();
123        if (is_int($code)) {
124            if ($code >= 500) {
125                return 'error';
126            }
127            if ($code >= 400) {
128                return 'warning';
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<mixed> $serverParams
170     */
171    private function getClientIp(array $serverParams): ?string
172    {
173        // Check for forwarded IP (behind proxy/load balancer)
174        $forwardedFor = Row::nullableString($serverParams, 'HTTP_X_FORWARDED_FOR');
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 = Row::nullableString($serverParams, 'HTTP_X_REAL_IP');
183        if ($realIp !== null && $realIp !== '') {
184            return $realIp;
185        }
186
187        // Fall back to remote address
188        return Row::nullableString($serverParams, 'REMOTE_ADDR');
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}