Passed
Pull Request — master (#42)
by Sergei
02:04
created

Profiler::getCategoryFromContext()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 9
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 17
ccs 10
cts 10
cp 1
crap 3
rs 9.9666
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Profiler;
6
7
use InvalidArgumentException;
8
use Psr\Log\LoggerInterface;
9
use Psr\Log\LogLevel;
10
use RuntimeException;
11
use Yiisoft\Profiler\Target\TargetInterface;
12
13
use function array_key_exists;
14
use function get_class;
15
use function gettype;
16
use function is_float;
17
use function is_int;
18
use function is_object;
19
use function is_string;
20
21
/**
22
 * Profiler provides profiling support. It stores profiling messages in the memory and sends them to different targets
23
 * according to {@see Profiler::$targets}.
24
 */
25
final class Profiler implements ProfilerInterface
26
{
27
    /**
28
     * @var bool Whether to profiler is enabled. Defaults to true.
29
     * You may use this field to disable writing of the profiling messages and thus save the memory usage.
30
     */
31
    private bool $enabled = true;
32
33
    /**
34
     * @var Message[] Complete profiling messages.
35
     *
36
     * @see TargetInterface::collect()
37
     */
38
    private array $messages = [];
39
40
    /**
41
     * @var LoggerInterface Logger to be used for message export.
42
     */
43
    private LoggerInterface $logger;
44
45
    /**
46
     * @var array Pending profiling messages, e.g. the ones which have begun but not ended yet.
47
     *
48
     * @psalm-var array<string,array<string,list<Message>>>
49
     */
50
    private array $pendingMessages = [];
51
52
    /**
53
     * @var int Current profiling messages nested level.
54
     */
55
    private int $nestedLevel = 0;
56
57
    /**
58
     * @var TargetInterface[] Profiling targets. Each array element represents
59
     * a single {@see TargetInterface} instance.
60
     */
61
    private array $targets = [];
62
63
    /**
64
     * Initializes the profiler by registering {@see flush()} as a shutdown function.
65
     *
66
     * @param LoggerInterface $logger Logger to use.
67
     * @param array $targets Profiling targets to use.
68
     */
69 14
    public function __construct(LoggerInterface $logger, array $targets = [])
70
    {
71 14
        $this->logger = $logger;
72 14
        $this->setTargets($targets);
73 12
        register_shutdown_function([$this, 'flush']);
74 12
    }
75
76
    /**
77
     * Enable or disable profiler.
78
     *
79
     * @return $this
80
     */
81 1
    public function enable(bool $value = true): self
82
    {
83 1
        $new = clone $this;
84 1
        $new->enabled = $value;
85 1
        return $new;
86
    }
87
88
    /**
89
     * @return bool If profiler is enabled.
90
     *
91
     * {@see enable}
92
     */
93 1
    public function isEnabled(): bool
94
    {
95 1
        return $this->enabled;
96
    }
97
98
    /**
99
     * Returns profiler messages.
100
     *
101
     * @return Message[] The profiler messages.
102
     */
103 6
    public function getMessages(): array
104
    {
105 6
        return $this->messages;
106
    }
107
108
    /**
109
     * @return TargetInterface[] Profiling targets. Each array element represents
110
     * a single {@see TargetInterface} profiling target instance.
111
     */
112 1
    public function getTargets(): array
113
    {
114 1
        return $this->targets;
115
    }
116
117
    /**
118
     * @param array $targets Profiling targets. Each array element represents
119
     * a single {@see TargetInterface} instance.
120
     */
121 15
    private function setTargets(array $targets): void
122
    {
123 15
        foreach ($targets as $name => $target) {
124 4
            if (!($target instanceof TargetInterface)) {
125 2
                throw new InvalidArgumentException(
126 2
                    sprintf(
127 2
                        'Target "%s" should be an instance of %s, "%s" given.',
128
                        $name,
129 2
                        TargetInterface::class,
130 2
                        $this->getDebugType($target)
131
                    )
132
                );
133
            }
134
        }
135
        /** @var TargetInterface[] $targets */
136
137 13
        $this->targets = $targets;
138 13
    }
139
140 9
    public function begin(string $token, array $context = []): void
141
    {
142 9
        if (!$this->enabled) {
143 1
            return;
144
        }
145
146 9
        $category = $this->getCategoryFromContext($context);
147
148 9
        $context = array_merge(
149 9
            $context,
150
            [
151 9
                'token' => $token,
152 9
                'category' => $category,
153 9
                'nestedLevel' => $this->nestedLevel,
154 9
                'time' => microtime(true),
155 9
                'beginTime' => microtime(true),
156 9
                'beginMemory' => memory_get_usage(),
157
            ]
158
        );
159
160 9
        $message = new Message($category, $token, $context);
161
162 9
        $this->pendingMessages[$category][$token][] = $message;
163 9
        $this->nestedLevel++;
164 9
    }
165
166 10
    public function end(string $token, array $context = []): void
167
    {
168 10
        if (!$this->enabled) {
169 1
            return;
170
        }
171
172 10
        $category = $this->getCategoryFromContext($context);
173
174 9
        if (empty($this->pendingMessages[$category][$token])) {
175 1
            throw new RuntimeException(
176 1
                sprintf(
177 1
                    'Unexpected %s::end() call for category "%s" token "%s". A matching begin() was not found.',
178 1
                    self::class,
179
                    $category,
180
                    $token
181
                )
182
            );
183
        }
184
185 8
        $message = array_pop($this->pendingMessages[$category][$token]);
186
        /** @psalm-suppress DocblockTypeContradiction See {@link https://github.com/vimeo/psalm/issues/7376} */
187 8
        if (empty($this->pendingMessages[$category][$token])) {
188 8
            unset($this->pendingMessages[$category][$token]);
189
190 8
            if (empty($this->pendingMessages[$category])) {
191 8
                unset($this->pendingMessages[$category]);
192
            }
193
        }
194
195 8
        if (array_key_exists('beginTime', $context)) {
196 1
            throw new InvalidArgumentException('Forbidden to override "beginTime" in context.');
197
        }
198
199 7
        if (array_key_exists('beginMemory', $context)) {
200 1
            throw new InvalidArgumentException('Forbidden to override "beginMemory" in context.');
201
        }
202
203 6
        $context = array_merge(
204 6
            $message->context(),
205
            $context,
206
            [
207 6
                'endTime' => microtime(true),
208 6
                'endMemory' => memory_get_usage(),
209
            ]
210
        );
211
        /**
212
         * @psalm-var array&array{
213
         *       beginTime: float,
214
         *       endTime: float,
215
         *       beginMemory: int,
216
         *       endMemory: int,
217
         *     } $context
218
         */
219
220 6
        $context['duration'] = $context['endTime'] - $context['beginTime'];
221 6
        $context['memoryDiff'] = $context['endMemory'] - $context['beginMemory'];
222
223 6
        $this->messages[] = new Message($category, $message->token(), $context);
224 6
        $this->nestedLevel--;
225 6
    }
226
227 1
    public function findMessages(string $token): array
228
    {
229 1
        $messages = $this->messages;
230 1
        return array_filter($messages, static function (Message $message) use ($token) {
231 1
            return $message->token() === $token;
232 1
        });
233
    }
234
235 4
    public function flush(): void
236
    {
237 4
        foreach ($this->pendingMessages as $category => $categoryMessages) {
238 1
            $this->logCategoryMessages($category, $categoryMessages);
239
        }
240
241 4
        $this->pendingMessages = [];
242 4
        $this->nestedLevel = 0;
243
244 4
        if (empty($this->messages)) {
245 2
            return;
246
        }
247
248 2
        $messages = $this->messages;
249
250
        // New messages could appear while the existing ones are being handled by targets.
251 2
        $this->messages = [];
252
253 2
        $this->dispatch($messages);
254 2
    }
255
256
    /**
257
     * Dispatches the profiling messages to targets.
258
     *
259
     * @param Message[] $messages The profiling messages.
260
     */
261 2
    private function dispatch(array $messages): void
262
    {
263 2
        foreach ($this->targets as $target) {
264 1
            $target->collect($messages);
265
        }
266 2
    }
267
268
    /**
269
     * @param string $category
270
     * @param array $categoryMessages
271
     *
272
     * @psalm-param array<string,list<Message>> $categoryMessages
273
     */
274 1
    private function logCategoryMessages(string $category, array $categoryMessages): void
275
    {
276 1
        foreach ($categoryMessages as $token => $messages) {
277 1
            if (!empty($messages)) {
278 1
                $this->logger->log(
279 1
                    LogLevel::WARNING,
280 1
                    sprintf(
281 1
                        'Unclosed profiling entry detected: category "%s" token "%s" %s',
282
                        $category,
283
                        $token,
284 1
                        __METHOD__
285
                    )
286
                );
287
            }
288
        }
289 1
    }
290
291 11
    private function getCategoryFromContext(array $context): string
292
    {
293 11
        if (!array_key_exists('category', $context)) {
294 8
            return 'application';
295
        }
296
297 3
        $category = $context['category'];
298 3
        if (!is_string($category)) {
299 1
            throw new InvalidArgumentException(
300 1
                sprintf(
301 1
                    'Category should be a string, "%s" given.',
302 1
                    $this->getDebugType($category)
303
                )
304
            );
305
        }
306
307 2
        return $category;
308
    }
309
310
    /**
311
     * @param mixed $variable
312
     */
313 3
    private function getDebugType($variable): string
314
    {
315 3
        return is_object($variable) ? get_class($variable) : gettype($variable);
316
    }
317
}
318