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