Passed
Push — master ( 005b19...46306d )
by Sergei
02:54
created

Logger::assertLevelIsSupported()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 7
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 11
ccs 8
cts 8
cp 1
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Log;
6
7
use InvalidArgumentException;
8
use Psr\Log\LoggerInterface;
9
use Psr\Log\LoggerTrait;
10
use Psr\Log\LogLevel;
11
use Stringable;
12
use Throwable;
13
use Yiisoft\Log\Message\CategoryFilter;
14
15
use function array_filter;
16
use function count;
17
use function debug_backtrace;
18
use function gettype;
19
use function implode;
20
use function in_array;
21
use function is_string;
22
use function memory_get_usage;
23
use function microtime;
24
use function register_shutdown_function;
25
use function sprintf;
26
27
/**
28
 * Logger records logged messages in memory and sends them to different targets according to {@see Logger::$targets}.
29
 *
30
 * You can call the method {@see Logger::log()} to record a single log message.
31
 *
32
 * For more details and usage information on Logger,
33
 * see [PSR-3 specification](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md).
34
 *
35
 * When the application ends or {@see Logger::$flushInterval} is reached, Logger will call {@see Logger::flush()}
36
 * to send logged messages to different log targets, such as file or email according to the {@see Logger::$targets}.
37
 *
38
 * @psalm-import-type Backtrace from Message
39
 * @psalm-import-type LogMessageContext from Message
40
 */
41
final class Logger implements LoggerInterface
42
{
43
    use LoggerTrait;
44
45
    /**
46
     * The list of log message levels. See {@see LogLevel} constants for valid level names.
47
     */
48
    private const LEVELS = [
49
        LogLevel::EMERGENCY,
50
        LogLevel::ALERT,
51
        LogLevel::CRITICAL,
52
        LogLevel::ERROR,
53
        LogLevel::WARNING,
54
        LogLevel::NOTICE,
55
        LogLevel::INFO,
56
        LogLevel::DEBUG,
57
    ];
58
59
    /**
60
     * @var Message[] The log messages.
61
     */
62
    private array $messages = [];
63
64
    /**
65
     * @var Target[] the log targets. Each array element represents a single {@see \Yiisoft\Log\Target} instance.
66
     */
67
    private array $targets = [];
68
69
    /**
70
     * @var string[] Array of paths to exclude from tracing when tracing is enabled with {@see Logger::setTraceLevel()}.
71
     */
72
    private array $excludedTracePaths = [];
73
74
    /**
75
     * @var int How many log messages should be logged before they are flushed from memory and sent to targets.
76
     *
77
     * Defaults to 1000, meaning the {@see Logger::flush()} method will be invoked once every 1000 messages logged.
78
     * Set this property to be 0 if you don't want to flush messages until the application terminates.
79
     * This property mainly affects how much memory will be taken by the logged messages.
80
     * A smaller value means less memory, but will increase the execution
81
     * time due to the overhead of {@see Logger::flush()}.
82
     */
83
    private int $flushInterval = 1000;
84
85
    /**
86
     * @var int How much call stack information (file name and line number) should be logged for each log message.
87
     *
88
     * If it is greater than 0, at most that number of call stacks will be logged.
89
     * Note that only application call stacks are counted.
90
     */
91
    private int $traceLevel = 0;
92
93
    /**
94
     * Initializes the logger by registering {@see Logger::flush()} as a shutdown function.
95
     *
96
     * @param Target[] $targets The log targets.
97
     */
98 56
    public function __construct(array $targets = [])
99
    {
100 56
        $this->setTargets($targets);
101
102 56
        register_shutdown_function(function () {
103
            // make regular flush before other shutdown functions, which allows session data collection and so on
104
            $this->flush();
105
            // make sure log entries written by shutdown functions are also flushed
106
            // ensure "flush()" is called last when there are multiple shutdown functions
107
            register_shutdown_function([$this, 'flush'], true);
108 56
        });
109
    }
110
111
    /**
112
     * Returns the text display of the specified level.
113
     *
114
     * @param mixed $level The message level, e.g. {@see LogLevel::ERROR}, {@see LogLevel::WARNING}.
115
     *
116
     * @throws \Psr\Log\InvalidArgumentException for invalid log message level.
117
     *
118
     * @return string The text display of the level.
119
     * @deprecated since 2.1, to be removed in 3.0. Use {@see LogLevel::assertLevelIsValid()} instead.
120
     */
121 9
    public static function validateLevel(mixed $level): string
122
    {
123 9
        if (!is_string($level)) {
124 7
            throw new \Psr\Log\InvalidArgumentException(sprintf(
125 7
                'The log message level must be a string, %s provided.',
126 7
                gettype($level)
127 7
            ));
128
        }
129
130 2
        if (!in_array($level, self::LEVELS, true)) {
131 1
            throw new \Psr\Log\InvalidArgumentException(sprintf(
132 1
                'Invalid log message level "%s" provided. The following values are supported: "%s".',
133 1
                $level,
134 1
                implode('", "', self::LEVELS)
135 1
            ));
136
        }
137
138 1
        return $level;
139
    }
140
141
    /**
142
     * @return Target[] The log targets. Each array element represents a single {@see \Yiisoft\Log\Target} instance.
143
     */
144 1
    public function getTargets(): array
145
    {
146 1
        return $this->targets;
147
    }
148
149
    /**
150
     * @psalm-param LogMessageContext $context
151
     * @psalm-suppress MoreSpecificImplementedParamType,MixedArgumentTypeCoercion
152
     */
153 31
    public function log(mixed $level, string|Stringable $message, array $context = []): void
154
    {
155 31
        self::assertLevelIsString($level);
156
157 31
        $context['time'] ??= microtime(true);
158 31
        $context['trace'] ??= $this->collectTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS));
159 31
        $context['memory'] ??= memory_get_usage();
160 31
        $context['category'] ??= CategoryFilter::DEFAULT;
161
162 31
        $this->messages[] = new Message($level, $message, $context);
163
164 31
        if ($this->flushInterval > 0 && count($this->messages) >= $this->flushInterval) {
165 21
            $this->flush();
166
        }
167
    }
168
169
    /**
170
     * Flushes log messages from memory to targets.
171
     *
172
     * @param bool $final Whether this is a final call during a request.
173
     */
174 23
    public function flush(bool $final = false): void
175
    {
176 23
        $messages = $this->messages;
177
        // https://github.com/yiisoft/yii2/issues/5619
178
        // new messages could be logged while the existing ones are being handled by targets
179 23
        $this->messages = [];
180
181 23
        $this->dispatch($messages, $final);
182
    }
183
184
    /**
185
     * Sets how many log messages should be logged before they are flushed from memory and sent to targets.
186
     *
187
     * @param int $flushInterval The number of messages to accumulate before flushing.
188
     *
189
     * @see Logger::$flushInterval
190
     */
191 21
    public function setFlushInterval(int $flushInterval): self
192
    {
193 21
        $this->flushInterval = $flushInterval;
194 21
        return $this;
195
    }
196
197
    /**
198
     * Sets how much call stack information (file name and line number) should be logged for each log message.
199
     *
200
     * @param int $traceLevel The number of call stack information.
201
     *
202
     * @see Logger::$traceLevel
203
     */
204 2
    public function setTraceLevel(int $traceLevel): self
205
    {
206 2
        $this->traceLevel = $traceLevel;
207 2
        return $this;
208
    }
209
210
    /**
211
     * Sets an array of paths to exclude from tracing when tracing is enabled with {@see Logger::setTraceLevel()}.
212
     *
213
     * @param string[] $excludedTracePaths The paths to exclude from tracing.
214
     *
215
     * @throws InvalidArgumentException for non-string values.
216
     *
217
     * @see Logger::$excludedTracePaths
218
     */
219 8
    public function setExcludedTracePaths(array $excludedTracePaths): self
220
    {
221 8
        foreach ($excludedTracePaths as $excludedTracePath) {
222
            /** @psalm-suppress DocblockTypeContradiction */
223 8
            if (!is_string($excludedTracePath)) {
224 7
                throw new InvalidArgumentException(sprintf(
225 7
                    'The trace path must be a string, %s received.',
226 7
                    gettype($excludedTracePath)
227 7
                ));
228
            }
229
        }
230
231 1
        $this->excludedTracePaths = $excludedTracePaths;
232 1
        return $this;
233
    }
234
235
    /**
236
     * Asserts that the log message level is valid.
237
     *
238
     * @param mixed $level The message level.
239
     *
240
     * @throws \Psr\Log\InvalidArgumentException When the log message level is not a string or is not supported.
241
     */
242 14
    public static function assertLevelIsValid(mixed $level): void
243
    {
244 14
        self::assertLevelIsString($level);
245 8
        self::assertLevelIsSupported($level);
246
    }
247
248
    /**
249
     * Asserts that the log message level is a string.
250
     *
251
     * @param mixed $level The message level.
252
     *
253
     * @throws \Psr\Log\InvalidArgumentException When the log message level is not a string.
254
     *
255
     * @psalm-assert string $level
256
     */
257 37
    public static function assertLevelIsString(mixed $level): void
258
    {
259 37
        if (is_string($level)) {
260 31
            return;
261
        }
262
263 6
        throw new \Psr\Log\InvalidArgumentException(
264 6
            sprintf('The log message level must be a string, %s provided.', gettype($level))
265 6
        );
266
    }
267
268
    /**
269
     * Asserts that the log message level is supported.
270
     *
271
     * @param string $level The message level.
272
     *
273
     * @throws \Psr\Log\InvalidArgumentException When the log message level is not supported.
274
     */
275 134
    public static function assertLevelIsSupported(string $level): void
276
    {
277 134
        if (in_array($level, self::LEVELS, true)) {
278 133
            return;
279
        }
280
281 1
        throw new \Psr\Log\InvalidArgumentException(
282 1
            sprintf(
283 1
                'Invalid log message level "%s" provided. The following values are supported: "%s".',
284 1
                $level,
285 1
                implode('", "', self::LEVELS)
286 1
            )
287 1
        );
288
    }
289
290
    /**
291
     * Sets a target to {@see Logger::$targets}.
292
     *
293
     * @param Target[] $targets The log targets. Each array element represents a single {@see \Yiisoft\Log\Target}
294
     * instance or the configuration for creating the log target instance.
295
     *
296
     * @throws InvalidArgumentException for non-instance Target.
297
     */
298 56
    private function setTargets(array $targets): void
299
    {
300 56
        foreach ($targets as $target) {
301 56
            if (!($target instanceof Target)) {
302 8
                throw new InvalidArgumentException('You must provide an instance of \Yiisoft\Log\Target.');
303
            }
304
        }
305
306 56
        $this->targets = $targets;
307
    }
308
309
    /**
310
     * Dispatches the logged messages to {@see Logger::$targets}.
311
     *
312
     * @param Message[] $messages The log messages.
313
     * @param bool $final Whether this method is called at the end of the current application.
314
     */
315 26
    private function dispatch(array $messages, bool $final): void
316
    {
317 26
        $targetErrors = [];
318
319 26
        foreach ($this->targets as $target) {
320 26
            if ($target->isEnabled()) {
321
                try {
322 25
                    $target->collect($messages, $final);
323 1
                } catch (Throwable $e) {
324 1
                    $target->disable();
325 1
                    $targetErrors[] = new Message(
326 1
                        LogLevel::WARNING,
327 1
                        'Unable to send log via ' . $target::class . ': ' . $e::class . ': ' . $e->getMessage(),
328 1
                        ['time' => microtime(true), 'exception' => $e],
329 1
                    );
330
                }
331
            }
332
        }
333
334 26
        if (!empty($targetErrors)) {
335 1
            $this->dispatch($targetErrors, true);
336
        }
337
    }
338
339
    /**
340
     * Collects a trace when tracing is enabled with {@see Logger::setTraceLevel()}.
341
     *
342
     * @param array $backtrace The list of call stack information.
343
     * @psalm-param Backtrace|list<array{object?:object,args?:array}> $backtrace
344
     *
345
     * @return array Collected a list of call stack information.
346
     * @psalm-return Backtrace
347
     */
348 31
    private function collectTrace(array $backtrace): array
349
    {
350 31
        $traces = [];
351
352 31
        if ($this->traceLevel > 0) {
353 2
            $count = 0;
354
355 2
            foreach ($backtrace as $trace) {
356 2
                if (isset($trace['file'], $trace['line'])) {
357 2
                    $excludedMatch = array_filter(
358 2
                        $this->excludedTracePaths,
359 2
                        static fn ($path) => str_contains($trace['file'], $path)
360 2
                    );
361
362 2
                    if (empty($excludedMatch)) {
363 2
                        unset($trace['object'], $trace['args']);
364 2
                        $traces[] = $trace;
365 2
                        if (++$count >= $this->traceLevel) {
366 1
                            break;
367
                        }
368
                    }
369
                }
370
            }
371
        }
372
373 31
        return $traces;
374
    }
375
}
376