Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.13% |
86 / 106 |
|
16.67% |
1 / 6 |
CRAP | |
0.00% |
0 / 1 |
LoggerService | |
81.13% |
86 / 106 |
|
16.67% |
1 / 6 |
44.71 | |
0.00% |
0 / 1 |
checkRateLimit | |
77.42% |
24 / 31 |
|
0.00% |
0 / 1 |
11.15 | |||
logError | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
4 | |||
logWarning | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
logInfo | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
logRequest | |
74.36% |
29 / 39 |
|
0.00% |
0 / 1 |
17.30 | |||
filterSensitiveHeaders | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace BO\Zmscitizenapi\Services\Core; |
6 | |
7 | use BO\Zmscitizenapi\Application; |
8 | use BO\Zmscitizenapi\Helper\ClientIpHelper; |
9 | use BO\Zmscitizenapi\Localization\ErrorMessages; |
10 | use Psr\Http\Message\RequestInterface; |
11 | use Psr\Http\Message\ResponseInterface; |
12 | use Psr\Http\Message\ServerRequestInterface; |
13 | |
14 | class 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 | } |