1: <?php declare(strict_types=1);
2:
3: namespace Salient\Core;
4:
5: use Salient\Contract\Core\Metric;
6: use Salient\Utility\Reflect;
7: use LogicException;
8:
9: /**
10: * Collects runtime performance metrics
11: */
12: final class MetricCollector
13: {
14: /**
15: * Group => name => metric
16: *
17: * @var array<string,array<string,Metric::*>>
18: */
19: private array $Metrics = [];
20:
21: /**
22: * Group => name => count
23: *
24: * @var array<string,array<string,int>>
25: */
26: private array $Counters = [];
27:
28: /**
29: * Group => name => start count
30: *
31: * @var array<string,array<string,int>>
32: */
33: private array $TimerRuns = [];
34:
35: /**
36: * Group => name => start nanoseconds
37: *
38: * @var array<string,array<string,int|float>>
39: */
40: private array $RunningTimers = [];
41:
42: /**
43: * Group => name => elapsed nanoseconds
44: *
45: * @var array<string,array<string,int|float>>
46: */
47: private array $ElapsedTime = [];
48:
49: /** @var array<array{array<string,array<string,Metric::*>>,array<string,array<string,int>>,array<string,array<string,int>>,array<string,array<string,int|float>>,array<string,array<string,int|float>>}> */
50: private array $Stack = [];
51:
52: /**
53: * Increment a counter and return its value
54: *
55: * @return int<1,max>
56: */
57: public function count(string $counter, string $group = 'general'): int
58: {
59: $this->assertMetricIs($counter, $group, Metric::COUNTER);
60: $this->Counters[$group][$counter] ??= 0;
61: return ++$this->Counters[$group][$counter];
62: }
63:
64: /**
65: * Start a timer based on the system's high-resolution time
66: */
67: public function startTimer(string $timer, string $group = 'general'): void
68: {
69: $now = hrtime(true);
70: $this->assertMetricIs($timer, $group, Metric::TIMER);
71: if (isset($this->RunningTimers[$group][$timer])) {
72: throw new LogicException(sprintf('Timer already running: %s', $timer));
73: }
74: $this->RunningTimers[$group][$timer] = $now;
75: $this->TimerRuns[$group][$timer] ??= 0;
76: $this->TimerRuns[$group][$timer]++;
77: }
78:
79: /**
80: * Stop a timer and return the elapsed milliseconds
81: */
82: public function stopTimer(string $timer, string $group = 'general'): float
83: {
84: $now = hrtime(true);
85: if (!isset($this->RunningTimers[$group][$timer])) {
86: throw new LogicException(sprintf('Timer not running: %s', $timer));
87: }
88: $elapsed = $now - $this->RunningTimers[$group][$timer];
89: unset($this->RunningTimers[$group][$timer]);
90: $this->ElapsedTime[$group][$timer] ??= 0;
91: $this->ElapsedTime[$group][$timer] += $elapsed;
92:
93: return (float) $elapsed / 1000000;
94: }
95:
96: /**
97: * Push the current state of all metrics onto the stack
98: */
99: public function push(): void
100: {
101: $this->Stack[] = [
102: $this->Metrics,
103: $this->Counters,
104: $this->TimerRuns,
105: $this->RunningTimers,
106: $this->ElapsedTime,
107: ];
108: }
109:
110: /**
111: * Pop metrics off the stack
112: */
113: public function pop(): void
114: {
115: $metrics = array_pop($this->Stack);
116:
117: if (!$metrics) {
118: throw new LogicException('Nothing to pop off the stack');
119: }
120:
121: [
122: $this->Metrics,
123: $this->Counters,
124: $this->TimerRuns,
125: $this->RunningTimers,
126: $this->ElapsedTime,
127: ] = $metrics;
128: }
129:
130: /**
131: * Get the value of a counter
132: */
133: public function getCounter(string $counter, string $group = 'general'): int
134: {
135: return $this->Counters[$group][$counter] ?? 0;
136: }
137:
138: /**
139: * Get counter values
140: *
141: * @template T of string[]|string|null
142: *
143: * @param T $groups If `null` or `["*"]`, all counters are returned,
144: * otherwise only counters in the given groups are returned.
145: * @phpstan-return (T is string ? array<string,int> : array<string,array<string,int>>)
146: * @return array<string,array<string,int>>|array<string,int> An array that
147: * maps groups to counters:
148: *
149: * ```
150: * [
151: * <group> => [
152: * <counter> => <value>, ...
153: * ], ...
154: * ]
155: * ```
156: *
157: * Or, if `$groups` is a string:
158: *
159: * ```
160: * [ <counter> => <value>, ... ]
161: * ```
162: */
163: public function getCounters($groups = null): array
164: {
165: if ($groups === null || $groups === ['*']) {
166: return $this->Counters;
167: } elseif (is_string($groups)) {
168: return $this->Counters[$groups] ?? [];
169: } else {
170: return array_intersect_key($this->Counters, array_flip($groups));
171: }
172: }
173:
174: /**
175: * Get the start count and elapsed milliseconds of a timer
176: *
177: * @return array{float,int}
178: */
179: public function getTimer(
180: string $timer,
181: string $group = 'general',
182: bool $includeRunning = true
183: ): array {
184: return $this->doGetTimer($timer, $group, $includeRunning) ?: [0.0, 0];
185: }
186:
187: /**
188: * Get timer start counts and elapsed milliseconds
189: *
190: * @template T of string[]|string|null
191: *
192: * @param T $groups If `null` or `["*"]`, all timers are returned, otherwise
193: * only timers in the given groups are returned.
194: * @phpstan-return (T is string ? array<string,array{float,int}> : array<string,array<string,array{float,int}>>)
195: * @return array<string,array<string,array{float,int}>>|array<string,array{float,int}>
196: * An array that maps groups to timers:
197: *
198: * ```
199: * [
200: * <group> => [
201: * <timer> => [ <elapsed_ms>, <start_count> ], ...
202: * ], ...
203: * ]
204: * ```
205: *
206: * Or, if `$groups` is a string:
207: *
208: * ```
209: * [ <timer> => [ <elapsed_ms>, <start_count> ], ... ]
210: * ```
211: */
212: public function getTimers(bool $includeRunning = true, $groups = null): array
213: {
214: if ($groups === null || $groups === ['*']) {
215: $timerRuns = $this->TimerRuns;
216: } else {
217: $timerRuns = array_intersect_key($this->TimerRuns, array_flip((array) $groups));
218: }
219:
220: foreach ($timerRuns as $group => $runs) {
221: foreach ($runs as $name => $count) {
222: $timer = $this->doGetTimer($name, $group, $includeRunning, $count, $now);
223: if ($timer === null) {
224: continue;
225: }
226: $timers[$group][$name] = $timer;
227: }
228: }
229:
230: return is_string($groups)
231: ? $timers[$groups] ?? []
232: : $timers ?? [];
233: }
234:
235: /**
236: * @param int|float $now
237: * @return array{float,int}|null
238: */
239: private function doGetTimer(
240: string $timer,
241: string $group,
242: bool $includeRunning,
243: ?int $count = null,
244: &$now = null
245: ) {
246: $count ??= $this->TimerRuns[$group][$timer] ?? null;
247: if ($count === null) {
248: return null;
249: }
250: $elapsed = $this->ElapsedTime[$group][$timer] ?? 0;
251: if ($includeRunning && isset($this->RunningTimers[$group][$timer])) {
252: $now ??= hrtime(true);
253: $elapsed += $now - $this->RunningTimers[$group][$timer];
254: }
255: if (!$elapsed) {
256: return null;
257: }
258: return [(float) $elapsed / 1000000, $count];
259: }
260:
261: /**
262: * @param Metric::* $metric
263: */
264: private function assertMetricIs(string $name, string $group, int $metric): void
265: {
266: $this->Metrics[$group][$name] ??= $metric;
267: if ($this->Metrics[$group][$name] !== $metric) {
268: throw new LogicException(sprintf(
269: 'Not a Metric::%s: %s (group=%s)',
270: Reflect::getConstantName(Metric::class, $metric),
271: $name,
272: $group,
273: ));
274: }
275: }
276: }
277: