Profiler::getMessages()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
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 16
    public function __construct(private LoggerInterface $logger, array $targets = [])
60
    {
61 16
        $this->setTargets($targets);
62 14
        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 17
    private function setTargets(array $targets): void
111
    {
112 17
        foreach ($targets as $name => $target) {
113 6
            if (!($target instanceof TargetInterface)) {
114 2
                throw new InvalidArgumentException(
115 2
                    sprintf(
116 2
                        'Target "%s" should be an instance of %s, "%s" given.',
117 2
                        $name,
118 2
                        TargetInterface::class,
119 2
                        get_debug_type($target)
120 2
                    )
121 2
                );
122
            }
123
        }
124
125
        /** @var TargetInterface[] $targets */
126 15
        $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 9
            $context,
139 9
            [
140 9
                'token' => $token,
141 9
                'category' => $category,
142 9
                'nestedLevel' => $this->nestedLevel,
143 9
                'time' => microtime(true),
144 9
                'beginTime' => microtime(true),
145 9
                'beginMemory' => memory_get_usage(),
146 9
            ]
147 9
        );
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 1
                    'Unexpected %s::end() call for category "%s" token "%s". A matching begin() was not found.',
167 1
                    self::class,
168 1
                    $category,
169 1
                    $token
170 1
                )
171 1
            );
172
        }
173
174 8
        $message = array_pop($this->pendingMessages[$category][$token]);
175
        /**
176
         * @psalm-suppress TypeDoesNotContainType, DocblockTypeContradiction
177
         *
178
         * @link https://github.com/vimeo/psalm/issues/7376
179
         */
180 8
        if (empty($this->pendingMessages[$category][$token])) {
181 8
            unset($this->pendingMessages[$category][$token]);
182
183 8
            if (empty($this->pendingMessages[$category])) {
184 8
                unset($this->pendingMessages[$category]);
185
            }
186
        }
187
188 8
        if (array_key_exists('beginTime', $context)) {
189 1
            throw new InvalidArgumentException('It is forbidden to override "beginTime" in context.');
190
        }
191
192 7
        if (array_key_exists('beginMemory', $context)) {
193 1
            throw new InvalidArgumentException('It is forbidden to override "beginMemory" in context.');
194
        }
195
196 6
        $context = array_merge(
197 6
            $message->context(),
198 6
            $context,
199 6
            [
200 6
                'endTime' => microtime(true),
201 6
                'endMemory' => memory_get_usage(),
202 6
            ]
203 6
        );
204
        /**
205
         * @psalm-var array&array{
206
         *       beginTime: float,
207
         *       endTime: float,
208
         *       beginMemory: int,
209
         *       endMemory: int,
210
         *     } $context
211
         */
212
213 6
        $context['duration'] = $context['endTime'] - $context['beginTime'];
214 6
        $context['memoryDiff'] = $context['endMemory'] - $context['beginMemory'];
215
216 6
        $this->messages[] = new Message($category, $message->token(), $context);
217 6
        $this->nestedLevel--;
218
    }
219
220 1
    public function findMessages(string $token): array
221
    {
222 1
        $messages = $this->messages;
223 1
        return array_filter($messages, static fn (Message $message) => $message->token() === $token);
224
    }
225
226 4
    public function flush(): void
227
    {
228 4
        foreach ($this->pendingMessages as $category => $categoryMessages) {
229 1
            $this->logCategoryMessages($category, $categoryMessages);
230
        }
231
232 4
        $this->pendingMessages = [];
233 4
        $this->nestedLevel = 0;
234
235 4
        if (empty($this->messages)) {
236 2
            return;
237
        }
238
239 2
        $messages = $this->messages;
240
241
        // New messages could appear while the existing ones are being handled by targets.
242 2
        $this->messages = [];
243
244 2
        $this->dispatch($messages);
245
    }
246
247
    /**
248
     * Dispatches the profiling messages to targets.
249
     *
250
     * @param Message[] $messages The profiling messages.
251
     */
252 2
    private function dispatch(array $messages): void
253
    {
254 2
        foreach ($this->targets as $target) {
255 1
            $target->collect($messages);
256
        }
257
    }
258
259
    /**
260
     * @psalm-param array<string,list<Message>> $categoryMessages
261
     */
262 1
    private function logCategoryMessages(string $category, array $categoryMessages): void
263
    {
264 1
        foreach ($categoryMessages as $token => $messages) {
265 1
            if (!empty($messages)) {
266 1
                $this->logger->log(
267 1
                    LogLevel::WARNING,
268 1
                    sprintf(
269 1
                        'Unclosed profiling entry detected: category "%s" token "%s" %s',
270 1
                        $category,
271 1
                        $token,
272 1
                        __METHOD__
273 1
                    )
274 1
                );
275
            }
276
        }
277
    }
278
279 11
    private function getCategoryFromContext(array $context): string
280
    {
281 11
        if (!array_key_exists('category', $context)) {
282 8
            return 'application';
283
        }
284
285 3
        $category = $context['category'];
286 3
        if (!is_string($category)) {
287 1
            throw new InvalidArgumentException(
288 1
                sprintf(
289 1
                    'Category should be a string, "%s" given.',
290 1
                    get_debug_type($category)
291 1
                )
292 1
            );
293
        }
294
295 2
        return $category;
296
    }
297
}
298