Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.85% covered (warning)
79.85%
107 / 134
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoggerService
79.85% covered (warning)
79.85%
107 / 134
50.00% covered (danger)
50.00%
5 / 10
83.58
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 checkRateLimit
59.38% covered (warning)
59.38%
19 / 32
0.00% covered (danger)
0.00%
0 / 1
19.11
 logError
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 logWarning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logRequest
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
10.24
 buildLogPath
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 formatQueryParamForLog
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 appendResponseErrors
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
20.70
 filterSensitiveHeaders
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Slim;
6
7use BO\Slim\Helper\ClientIp;
8use Psr\Http\Message\RequestInterface;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\ServerRequestInterface;
11use Psr\SimpleCache\CacheInterface;
12
13class LoggerService
14{
15    private const SENSITIVE_HEADERS = [
16        'authorization',
17        'cookie',
18        'x-api-key',
19        'auth-key',
20        'authkey',
21        'captchatoken',
22    ];
23
24    private const SENSITIVE_PARAMS = [
25        'authkey',
26        'auth_key',
27        'auth-key',
28        'key',
29        'captchatoken',
30        'captcha-token',
31    ];
32
33    private const IMPORTANT_HEADERS = [
34        'user-agent',
35    ];
36
37    private const CACHE_KEY_PREFIX = 'logger.';
38    private const CACHE_REQUEST_COUNTER_KEY = self::CACHE_KEY_PREFIX . 'request';
39    private const CACHE_ERROR_REQUEST_COUNTER_KEY = self::CACHE_KEY_PREFIX . 'request_error';
40
41    public static ?CacheInterface $cache = null;
42
43    /** @var callable|null fn(ServerRequestInterface $request, ?string $rawBody): array */
44    public static $requestContextEnricher = null;
45
46    /** @var callable|null fn(string $errorCode): mixed */
47    public static $errorCodeResolver = null;
48
49    public static int $maxRequests = 1000;
50    public static int $maxErrorRequests = 0;
51    public static int $responseLength = 1048576;
52    public static int $stackLines = 10;
53    public static int $cacheTtl = 60;
54    public static int $maxRetries = 3;
55    public static int $backoffMin = 100;
56    public static int $lockTimeout = 30;
57
58    /**
59     * @param array{
60     *   maxRequests?: int,
61     *   maxErrorRequests?: int,
62     *   responseLength?: int,
63     *   stackLines?: int,
64     *   messageSize?: int,
65     *   cacheTtl?: int,
66     *   maxRetries?: int,
67     *   backoffMin?: int,
68     *   backoffMax?: int,
69     *   lockTimeout?: int
70     * } $config
71     */
72    public static function configure(array $config): void
73    {
74        if (isset($config['maxRequests'])) {
75            self::$maxRequests = (int) $config['maxRequests'];
76        }
77        if (isset($config['maxErrorRequests'])) {
78            self::$maxErrorRequests = (int) $config['maxErrorRequests'];
79        }
80        if (isset($config['responseLength'])) {
81            self::$responseLength = (int) $config['responseLength'];
82        }
83        if (isset($config['stackLines'])) {
84            self::$stackLines = (int) $config['stackLines'];
85        }
86        if (isset($config['cacheTtl'])) {
87            self::$cacheTtl = (int) $config['cacheTtl'];
88        }
89        if (isset($config['maxRetries'])) {
90            self::$maxRetries = (int) $config['maxRetries'];
91        }
92        if (isset($config['backoffMin'])) {
93            self::$backoffMin = (int) $config['backoffMin'];
94        }
95        if (isset($config['lockTimeout'])) {
96            self::$lockTimeout = (int) $config['lockTimeout'];
97        }
98    }
99
100    private static function checkRateLimit(int $maxAllowed, string $counterKey): bool
101    {
102        if ($maxAllowed <= 0) {
103            return true;
104        }
105
106        if (self::$cache === null) {
107            \App::$log->notice('Cache not available for rate limiting');
108            return true;
109        }
110
111        $attempt = 0;
112        $lockKey = $counterKey . '_lock';
113
114        while ($attempt < self::$maxRetries) {
115            try {
116                if (!self::$cache->has($lockKey)) {
117                    if (self::$cache->set($lockKey, true, self::$lockTimeout)) {
118                        try {
119                            $data = self::$cache->get($counterKey);
120                            if ($data === null) {
121                                self::$cache->set($counterKey, [
122                                    'count' => 1,
123                                    'timestamp' => time(),
124                                ], self::$cacheTtl);
125                                return true;
126                            }
127
128                            if (!is_array($data) || !isset($data['count'])) {
129                                self::$cache->delete($counterKey);
130                                return true;
131                            }
132
133                            $count = (int) $data['count'];
134                            if ($count >= $maxAllowed) {
135                                return false;
136                            }
137
138                            $data['count'] = $count + 1;
139                            self::$cache->set($counterKey, $data, self::$cacheTtl);
140                            return true;
141                        } finally {
142                            self::$cache->delete($lockKey);
143                        }
144                    }
145                }
146            } catch (\Throwable $e) {
147                \App::$log->warning('Rate limiting error', ['exception' => $e->getMessage()]);
148            }
149
150            $attempt++;
151            usleep(self::$backoffMin * 1000);
152        }
153
154        return true;
155    }
156
157    public static function logError(
158        \Throwable $exception,
159        ?RequestInterface $request = null,
160        ?ResponseInterface $response = null,
161        array $context = []
162    ): void {
163        $data = [
164            'exception' => get_class($exception),
165            'message' => $exception->getMessage(),
166            'code' => $exception->getCode(),
167            'file' => $exception->getFile(),
168            'line' => $exception->getLine(),
169            'trace' => array_slice(explode("\n", $exception->getTraceAsString()), 0, self::$stackLines),
170        ];
171
172        if ($request) {
173            $data['request'] = [
174                'method' => $request->getMethod(),
175                'uri' => (string) $request->getUri(),
176                'headers' => self::filterSensitiveHeaders($request->getHeaders()),
177            ];
178        }
179
180        if ($response) {
181            $data['response'] = [
182                'status' => $response->getStatusCode(),
183                'headers' => self::filterSensitiveHeaders($response->getHeaders()),
184            ];
185        }
186
187        \App::$log->error($exception->getMessage(), array_merge($data, $context));
188    }
189
190    public static function logWarning(string $message, array $context = []): void
191    {
192        \App::$log->warning($message, $context);
193    }
194
195    public static function logInfo(string $message, array $context = []): void
196    {
197        \App::$log->info($message, $context);
198    }
199
200    /**
201     * @SuppressWarnings(PHPMD.NPathComplexity)
202     */
203    public static function logRequest(ServerRequestInterface $request, ResponseInterface $response): void
204    {
205        $statusCode = $response->getStatusCode();
206        $rateLimitKey = $statusCode >= 400
207            ? self::CACHE_ERROR_REQUEST_COUNTER_KEY
208            : self::CACHE_REQUEST_COUNTER_KEY;
209        $rateLimitMax = $statusCode >= 400
210            ? self::$maxErrorRequests
211            : self::$maxRequests;
212
213        if (!self::checkRateLimit($rateLimitMax, $rateLimitKey)) {
214            return;
215        }
216
217        $uri = $request->getUri();
218        $path = preg_replace('#/+#', '/', $uri->getPath());
219        $logPath = self::buildLogPath($path, $request->getQueryParams());
220
221        $data = [
222            'method' => $request->getMethod(),
223            'path' => $logPath,
224            'status' => $response->getStatusCode(),
225            'ip' => ClientIp::getClientIp(),
226            'headers' => self::filterSensitiveHeaders($request->getHeaders()),
227        ];
228
229        $bodyStream = $response->getBody();
230        $rawBody = $bodyStream !== null ? (string) $bodyStream : null;
231        if ($bodyStream !== null && $bodyStream->isSeekable()) {
232            $bodyStream->rewind();
233        }
234
235        if (self::$requestContextEnricher !== null) {
236            $processContext = (self::$requestContextEnricher)($request, $rawBody);
237            if (!empty($processContext)) {
238                $data = array_merge($data, $processContext);
239            }
240        }
241
242        $data = self::appendResponseErrors($data, $response->getStatusCode(), $rawBody);
243
244        $level = $response->getStatusCode() >= 400 ? 'error' : 'info';
245        \App::$log->$level('HTTP Request', $data);
246    }
247
248    private static function buildLogPath(string $path, array $queryParams): string
249    {
250        $queryParts = [];
251        foreach ($queryParams as $key => $value) {
252            if (preg_match('#^/|//#', (string) $key)) {
253                continue;
254            }
255            if (!is_array($value) && preg_match('#^/|//#', (string) $value)) {
256                continue;
257            }
258            $queryParts[] = self::formatQueryParamForLog($key, $value);
259        }
260
261        return $path . ($queryParts ? '?' . implode('&', $queryParts) : '');
262    }
263
264    private static function formatQueryParamForLog(mixed $key, mixed $value): string
265    {
266        $encodedKey = urlencode((string) $key);
267        if (in_array(strtolower((string) $key), self::SENSITIVE_PARAMS, true)) {
268            return "$encodedKey=****";
269        }
270        if (is_array($value)) {
271            return $encodedKey . '=' . urlencode(json_encode($value, JSON_UNESCAPED_UNICODE) ?: '[]');
272        }
273
274        return $encodedKey . '=' . urlencode((string) $value);
275    }
276
277    /**
278     * @param array<string, mixed> $data
279     * @return array<string, mixed>
280     */
281    private static function appendResponseErrors(array $data, int $statusCode, ?string $rawBody): array
282    {
283        if ($statusCode < 400 || empty($rawBody)) {
284            return $data;
285        }
286
287        $decodedBody = json_decode($rawBody, true);
288        if (json_last_error() !== JSON_ERROR_NONE || !isset($decodedBody['errors'])) {
289            return $data;
290        }
291
292        $errorMessages = [];
293        foreach ($decodedBody['errors'] as $error) {
294            if (isset($error['errorCode']) && self::$errorCodeResolver !== null) {
295                $errorMessages[] = (self::$errorCodeResolver)((string) $error['errorCode']);
296            } else {
297                $errorMessages[] = $error;
298            }
299        }
300
301        $data['errors'] = $errorMessages;
302
303        return $data;
304    }
305
306    private static function filterSensitiveHeaders(array $headers): array
307    {
308        $filtered = [];
309        foreach ($headers as $name => $values) {
310            $lower = strtolower((string) $name);
311            if (in_array($lower, self::SENSITIVE_HEADERS, true)) {
312                $filtered[$name] = ['[REDACTED]'];
313            } elseif (in_array($lower, self::IMPORTANT_HEADERS, true)) {
314                $filtered[$name] = $values;
315            }
316        }
317
318        return $filtered;
319    }
320}