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