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    ];
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}