Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.21% covered (warning)
59.21%
45 / 76
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ErrorLogService
59.21% covered (warning)
59.21%
45 / 76
11.76% covered (danger)
11.76%
2 / 17
76.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 info
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 notice
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 warning
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 critical
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 alert
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 emergency
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getById
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 list
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 resolve
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLogLevels
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitize
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
6.84
 formatStackTrace
55.00% covered (warning)
55.00%
11 / 20
0.00% covered (danger)
0.00%
0 / 1
2.36
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\ErrorLog\Service;
6
7use App\Domain\ErrorLog\Data\ErrorLogData;
8use App\Domain\ErrorLog\Data\ErrorLogFilterData;
9use App\Domain\ErrorLog\Repository\ErrorLogRepository;
10use App\Domain\SystemSettings\Repository\SystemSettingsRepository;
11use Throwable;
12
13/**
14 * Service for error logging business logic.
15 */
16final class ErrorLogService
17{
18    /**
19     * PSR-3 log levels with numeric values.
20     */
21    public const array LOG_LEVELS = [
22        'debug' => 100,
23        'info' => 200,
24        'notice' => 300,
25        'warning' => 400,
26        'error' => 500,
27        'critical' => 600,
28        'alert' => 700,
29        'emergency' => 800,
30    ];
31
32    /**
33     * Fields to strip from request body for security.
34     */
35    private const array SENSITIVE_FIELDS = [
36        'password',
37        'password_hash',
38        'confirm_password',
39        'current_password',
40        'new_password',
41        'token',
42        'refresh_token',
43        'access_token',
44        'api_key',
45        'secret',
46        'ssn',
47        'ssn_encrypted',
48        'credit_card',
49        'card_number',
50        'cvv',
51        'cvc',
52        'pin',
53    ];
54
55    public function __construct(
56        private readonly ErrorLogRepository $repository,
57        private readonly SystemSettingsRepository $settingsRepository,
58    ) {}
59
60    /**
61     * Log an error if it meets the threshold.
62     *
63     * @param array<string, mixed> $context
64     * @param string $level
65     * @param string $message
66     * @param ?string $errorCode
67     * @param ?Throwable $exception
68     * @param ?string $requestUri
69     * @param ?string $requestMethod
70     * @param ?int $userId
71     * @param ?int $sessionId
72     * @param ?string $ipAddress
73     * @param ?string $userAgent
74     * @param array<string, mixed>|null $requestBody
75     */
76    public function log(
77        string $level,
78        string $message,
79        ?string $errorCode = null,
80        ?Throwable $exception = null,
81        ?string $requestUri = null,
82        ?string $requestMethod = null,
83        ?int $userId = null,
84        ?int $sessionId = null,
85        ?string $ipAddress = null,
86        ?string $userAgent = null,
87        ?array $requestBody = null,
88        array $context = []
89    ): ?int {
90        $levelValue = self::LOG_LEVELS[$level] ?? 500;
91
92        // Check threshold
93        $threshold = $this->settingsRepository->getLogLevelThreshold();
94        if ($levelValue < $threshold['value']) {
95            return null;
96        }
97
98        // Sanitize request body
99        $sanitizedBody = $requestBody !== null ? $this->sanitize($requestBody) : null;
100
101        // Build stack trace from exception
102        $stackTrace = null;
103        if ($exception !== null) {
104            $stackTrace = $this->formatStackTrace($exception);
105        }
106
107        return $this->repository->insert([
108            'level' => $level,
109            'levelValue' => $levelValue,
110            'errorCode' => $errorCode,
111            'message' => $message,
112            'stackTrace' => $stackTrace,
113            'requestUri' => $requestUri,
114            'requestMethod' => $requestMethod,
115            'userId' => $userId,
116            'sessionId' => $sessionId,
117            'ipAddress' => $ipAddress,
118            'userAgent' => $userAgent,
119            'requestBody' => $sanitizedBody,
120            'context' => count($context) > 0 ? $context : null,
121        ]);
122    }
123
124    /**
125     * Convenience methods for each log level.
126     *
127     * @param array<string, mixed> $context
128     * @param string $message
129     */
130    public function debug(string $message, array $context = []): ?int
131    {
132        return $this->log('debug', $message, context: $context);
133    }
134
135    /**
136     * @param array<string, mixed> $context
137     * @param string $message
138     */
139    public function info(string $message, array $context = []): ?int
140    {
141        return $this->log('info', $message, context: $context);
142    }
143
144    /**
145     * @param array<string, mixed> $context
146     * @param string $message
147     */
148    public function notice(string $message, array $context = []): ?int
149    {
150        return $this->log('notice', $message, context: $context);
151    }
152
153    /**
154     * @param array<string, mixed> $context
155     * @param string $message
156     */
157    public function warning(string $message, array $context = []): ?int
158    {
159        return $this->log('warning', $message, context: $context);
160    }
161
162    /**
163     * @param array<string, mixed> $context
164     * @param string $message
165     * @param ?Throwable $exception
166     */
167    public function error(string $message, ?Throwable $exception = null, array $context = []): ?int
168    {
169        return $this->log('error', $message, exception: $exception, context: $context);
170    }
171
172    /**
173     * @param array<string, mixed> $context
174     * @param string $message
175     * @param ?Throwable $exception
176     */
177    public function critical(string $message, ?Throwable $exception = null, array $context = []): ?int
178    {
179        return $this->log('critical', $message, exception: $exception, context: $context);
180    }
181
182    /**
183     * @param array<string, mixed> $context
184     * @param string $message
185     * @param ?Throwable $exception
186     */
187    public function alert(string $message, ?Throwable $exception = null, array $context = []): ?int
188    {
189        return $this->log('alert', $message, exception: $exception, context: $context);
190    }
191
192    /**
193     * @param array<string, mixed> $context
194     * @param string $message
195     * @param ?Throwable $exception
196     */
197    public function emergency(string $message, ?Throwable $exception = null, array $context = []): ?int
198    {
199        return $this->log('emergency', $message, exception: $exception, context: $context);
200    }
201
202    /**
203     * Get error log by ID.
204     * @param int $errorId
205     */
206    public function getById(int $errorId): ?ErrorLogData
207    {
208        return $this->repository->findById($errorId);
209    }
210
211    /**
212     * List error logs with filtering.
213     *
214     * @param ErrorLogFilterData $filter
215     * @return array{data: ErrorLogData[], total: int, page: int, limit: int, countsByLevel: array<string, int>, unresolvedCount: int}
216     */
217    public function list(ErrorLogFilterData $filter): array
218    {
219        $result = $this->repository->findAll($filter);
220
221        return [
222            ...$result,
223            'countsByLevel' => $this->repository->getCountsByLevel(),
224            'unresolvedCount' => $this->repository->getUnresolvedCount(),
225        ];
226    }
227
228    /**
229     * Resolve an error log.
230     * @param int $errorId
231     * @param int $resolvedBy
232     * @param string $notes
233     */
234    public function resolve(int $errorId, int $resolvedBy, string $notes): bool
235    {
236        return $this->repository->resolve($errorId, $resolvedBy, $notes);
237    }
238
239    /**
240     * Delete error logs by IDs.
241     *
242     * @param int[] $ids
243     */
244    public function delete(array $ids): int
245    {
246        return $this->repository->deleteByIds($ids);
247    }
248
249    /**
250     * Get available log levels.
251     *
252     * @return array<string, int>
253     */
254    public function getLogLevels(): array
255    {
256        return self::LOG_LEVELS;
257    }
258
259    /**
260     * Sanitize data by removing sensitive fields.
261     *
262     * @param array<string, mixed> $data
263     * @return array<string, mixed>
264     */
265    private function sanitize(array $data): array
266    {
267        $sanitized = [];
268
269        foreach ($data as $key => $value) {
270            $lowerKey = strtolower($key);
271
272            // Check if this is a sensitive field
273            $isSensitive = false;
274            foreach (self::SENSITIVE_FIELDS as $sensitiveField) {
275                if (str_contains($lowerKey, $sensitiveField)) {
276                    $isSensitive = true;
277                    break;
278                }
279            }
280
281            if ($isSensitive) {
282                $sanitized[$key] = '[REDACTED]';
283            } elseif (is_array($value)) {
284                $sanitized[$key] = $this->sanitize($value);
285            } else {
286                $sanitized[$key] = $value;
287            }
288        }
289
290        return $sanitized;
291    }
292
293    /**
294     * Format exception stack trace.
295     * @param Throwable $exception
296     */
297    private function formatStackTrace(Throwable $exception): string
298    {
299        $trace = sprintf(
300            "%s: %s in %s:%d\n\nStack trace:\n%s",
301            $exception::class,
302            $exception->getMessage(),
303            $exception->getFile(),
304            $exception->getLine(),
305            $exception->getTraceAsString()
306        );
307
308        // Include previous exceptions
309        $previous = $exception->getPrevious();
310        while ($previous !== null) {
311            $trace .= sprintf(
312                "\n\nCaused by: %s: %s in %s:%d\n%s",
313                $previous::class,
314                $previous->getMessage(),
315                $previous->getFile(),
316                $previous->getLine(),
317                $previous->getTraceAsString()
318            );
319            $previous = $previous->getPrevious();
320        }
321
322        return $trace;
323    }
324}