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