Passed
Pull Request — master (#55)
by Rustam
02:15
created

Profiler::getDebugType()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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