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