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