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