Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.13% covered (warning)
81.13%
86 / 106
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoggerService
81.13% covered (warning)
81.13%
86 / 106
16.67% covered (danger)
16.67%
1 / 6
44.71
0.00% covered (danger)
0.00%
0 / 1
 checkRateLimit
77.42% covered (warning)
77.42%
24 / 31
0.00% covered (danger)
0.00%
0 / 1
11.15
 logError
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
4
 logWarning
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 logInfo
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 logRequest
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 1
17.30
 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\Zmscitizenapi\Services\Core;
6
7use BO\Zmscitizenapi\Application;
8use BO\Zmscitizenapi\Helper\ClientIpHelper;
9use BO\Zmscitizenapi\Localization\ErrorMessages;
10use Psr\Http\Message\RequestInterface;
11use Psr\Http\Message\ResponseInterface;
12use Psr\Http\Message\ServerRequestInterface;
13
14class LoggerService
15{
16    private const SENSITIVE_HEADERS = [
17        'authorization',
18        'cookie',
19        'x-api-key',
20        'auth-key',
21        'authkey',
22        'captchaToken'
23    ];
24
25    private const SENSITIVE_PARAMS = [
26        'authkey',
27        'authKey',
28        'auth_key',
29        'auth-key',
30        'key',
31        'captchaToken',
32        'captchatoken',
33        'captcha-token'
34    ];
35
36    private const IMPORTANT_HEADERS = [
37        'user-agent'
38    ];
39    private const CACHE_KEY_PREFIX = 'logger.';
40    private const CACHE_COUNTER_KEY = self::CACHE_KEY_PREFIX . 'counter';
41
42    private static function checkRateLimit(): bool
43    {
44        if (Application::$cache === null) {
45            error_log('Cache not available for rate limiting');
46            return true;
47        }
48
49        $attempt = 0;
50        $key = self::CACHE_COUNTER_KEY;
51        $lockKey = $key . '_lock';
52
53        while ($attempt < 3) { // Max retries
54            try {
55                if (!Application::$cache->has($lockKey)) {
56                    if (Application::$cache->set($lockKey, true, 30)) { // 30 second lock timeout
57                        try {
58                            $data = Application::$cache->get($key);
59                            if ($data === null) {
60                                Application::$cache->set($key, [
61                                    'count' => 1,
62                                    'timestamp' => time()
63                                ], 60);
64                                return true;
65                            }
66
67                            if (!is_array($data) || !isset($data['count'])) {
68                                Application::$cache->delete($key);
69                                return true;
70                            }
71
72                            $count = (int)$data['count'];
73                            if ($count >= 1000) {
74                                return false;
75                            }
76
77                            $data['count'] = $count + 1;
78                            Application::$cache->set($key, $data, 60);
79                            return true;
80                        } finally {
81                            Application::$cache->delete($lockKey);
82                        }
83                    }
84                }
85            } catch (\Throwable $e) {
86                error_log('Rate limiting error: ' . $e->getMessage());
87            }
88
89            $attempt++;
90            usleep(100000); // 100ms backoff
91        }
92
93        return true; // Allow logging if lock can't be acquired
94    }
95
96    public static function logError(\Throwable $exception, ?RequestInterface $request = null, ?ResponseInterface $response = null, array $context = []): void
97    {
98        if (!self::checkRateLimit()) {
99            return;
100        }
101
102        $data = [
103            'exception' => get_class($exception),
104            'message' => $exception->getMessage(),
105            'code' => $exception->getCode(),
106            'file' => $exception->getFile(),
107            'line' => $exception->getLine(),
108            'trace' => array_slice(explode("\n", $exception->getTraceAsString()), 0, 10)
109        ];
110
111        if ($request) {
112            $data['request'] = [
113                'method' => $request->getMethod(),
114                'uri' => (string)$request->getUri(),
115                'headers' => self::filterSensitiveHeaders($request->getHeaders())
116            ];
117        }
118
119        if ($response) {
120            $data['response'] = [
121                'status' => $response->getStatusCode(),
122                'headers' => self::filterSensitiveHeaders($response->getHeaders())
123            ];
124        }
125
126        \App::$log->error($exception->getMessage(), array_merge($data, $context));
127    }
128
129    public static function logWarning(string $message, array $context = []): void
130    {
131        if (!self::checkRateLimit()) {
132            return;
133        }
134        \App::$log->warning($message, $context);
135    }
136
137    public static function logInfo(string $message, array $context = []): void
138    {
139        if (!self::checkRateLimit()) {
140            return;
141        }
142        \App::$log->info($message, $context);
143    }
144
145    public static function logRequest(ServerRequestInterface $request, ResponseInterface $response): void
146    {
147        if (!self::checkRateLimit()) {
148            return;
149        }
150
151        $uri = $request->getUri();
152        $path = preg_replace('#/+#', '/', $uri->getPath());
153
154        // Filter out query params that look like paths
155        $queryParams = array_filter($request->getQueryParams(), function ($key, $value) {
156            return !preg_match('#^/|//#', $key) && !preg_match('#^/|//#', $value);
157        }, ARRAY_FILTER_USE_BOTH);
158
159        $queryParts = [];
160        foreach ($queryParams as $key => $value) {
161            $encodedKey = urlencode($key);
162            // Check if the key (case-insensitive) is in sensitive params
163            if (in_array(strtolower($key), self::SENSITIVE_PARAMS, true)) {
164                $queryParts[] = "$encodedKey=****";
165            } else {
166                $encodedValue = urlencode($value);
167                $queryParts[] = "$encodedKey=$encodedValue";
168            }
169        }
170
171        $data = [
172            'method' => $request->getMethod(),
173            'path' => $path . ($queryParts ? '?' . implode('&', $queryParts) : ''),
174            'status' => $response->getStatusCode(),
175            'ip' => ClientIpHelper::getClientIp(),
176            'headers' => self::filterSensitiveHeaders($request->getHeaders())
177        ];
178
179        if ($response->getStatusCode() >= 400) {
180            $stream = $response->getBody();
181            if ($stream->isSeekable()) {
182                try {
183                    $stream->rewind();
184                    $body = (string)$stream;
185                    $decodedBody = json_decode($body, true);
186                    if (json_last_error() === JSON_ERROR_NONE && isset($decodedBody['errors'])) {
187                        $englishErrors = [];
188                        foreach ($decodedBody['errors'] as $error) {
189                            if (isset($error['errorCode'])) {
190                                $englishErrors[] = ErrorMessages::get($error['errorCode'], 'en');
191                            } else {
192                                $englishErrors[] = $error;
193                            }
194                        }
195                        $data['errors'] = $englishErrors;
196                    }
197                    $stream->rewind();
198                } catch (\Throwable $e) {
199                    $data['error'] = 'Failed to read response body: ' . $e->getMessage();
200                }
201            }
202        }
203
204        $level = $response->getStatusCode() >= 400 ? 'error' : 'info';
205        \App::$log->$level('HTTP Request', $data);
206    }
207
208    private static function filterSensitiveHeaders(array $headers): array
209    {
210        $filtered = [];
211        foreach ($headers as $name => $values) {
212            $lower = strtolower($name);
213            if (in_array($lower, self::SENSITIVE_HEADERS, true)) {
214                $filtered[$name] = ['[REDACTED]'];
215            } elseif (in_array($lower, self::IMPORTANT_HEADERS, true)) {
216                $filtered[$name] = $values;
217            }
218        }
219        return $filtered;
220    }
221}