Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
MetricsEngine
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
10 / 10
30
100.00% covered (success)
100.00%
1 / 1
 get
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
10
 formatHistogram
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 expandData
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 formatData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 renderLabels
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 renderValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 formatValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 escape
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 canRender
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 share
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers\Metrics;
6
7use Engelsystem\Renderer\EngineInterface;
8
9class MetricsEngine implements EngineInterface
10{
11    protected string $prefix = 'engelsystem_';
12
13    /**
14     * Render metrics
15     *
16     * @param mixed[] $data
17     *
18     *
19     * @example $data = ['foo' => [['labels' => ['foo'=>'bar'], 'value'=>42]], 'bar'=>123]
20     */
21    public function get(string $path, array $data = []): string
22    {
23        $return = [];
24        foreach ($data as $name => $list) {
25            if (is_int($name)) {
26                $return[] = '# ' . $this->escape($list);
27                continue;
28            }
29
30            $list = is_array($list) ? $list : [$list];
31            $name = $this->prefix . $name;
32
33            if (isset($list['help'])) {
34                $return[] = sprintf('# HELP %s %s', $name, $this->escape($list['help']));
35                unset($list['help']);
36            }
37
38            $type = null;
39            if (isset($list['type'])) {
40                $type = $list['type'];
41                $return[] = sprintf('# TYPE %s %s', $name, $list['type']);
42                unset($list['type']);
43            }
44
45            $list = (!isset($list['value']) || !isset($list['labels'])) ? $list : [$list];
46            foreach ($list as $row) {
47                $row = $this->expandData($row);
48
49                if ($type == 'histogram') {
50                    $return = array_merge($return, $this->formatHistogram($row, $name));
51
52                    continue;
53                }
54
55                $return[] = $this->formatData($name, $row);
56            }
57        }
58
59        return implode("\n", $return) . "\n";
60    }
61
62    /**
63     * @return string[]
64     */
65    protected function formatHistogram(array $row, string $name): array
66    {
67        $return = [];
68        $data = ['labels' => $row['labels']];
69
70        if (!isset($row['value']['+Inf'])) {
71            $row['value']['+Inf'] = !empty($row['value']) ? max($row['value']) : 'NaN';
72        }
73        asort($row['value']);
74
75        foreach ($row['value'] as $le => $value) {
76            $return[] = $this->formatData(
77                $name . '_bucket',
78                array_merge_recursive($data, ['value' => $value, 'labels' => ['le' => $le]])
79            );
80        }
81
82        $sum = $row['sum'] ?? 'NaN';
83        $count = $row['value']['+Inf'];
84        $return[] = $this->formatData($name . '_sum', $data + ['value' => $sum]);
85        $return[] = $this->formatData($name . '_count', $data + ['value' => $count]);
86
87        return $return;
88    }
89
90    /**
91     * Expand the value to be an array
92     */
93    protected function expandData(mixed $data): array
94    {
95        $data = is_array($data) ? $data : [$data];
96        $return = ['labels' => [], 'value' => null];
97
98        if (isset($data['labels'])) {
99            $return['labels'] = $data['labels'];
100            unset($data['labels']);
101        }
102
103        if (isset($data['sum'])) {
104            $return['sum'] = $data['sum'];
105            unset($data['sum']);
106        }
107
108        if (isset($data['value'])) {
109            $return['value'] = $data['value'];
110            unset($data['value']);
111        } else {
112            $return['value'] = $data;
113        }
114
115        return $return;
116    }
117
118    /**
119     *
120     * @see https://prometheus.io/docs/instrumenting/exposition_formats/
121     */
122    protected function formatData(string $name, mixed $row): string
123    {
124        return sprintf(
125            '%s%s %s',
126            $name,
127            $this->renderLabels($row['labels']),
128            $this->renderValue($row['value'])
129        );
130    }
131
132    protected function renderLabels(array $labels): string
133    {
134        if (empty($labels)) {
135            return '';
136        }
137
138        foreach ($labels as $type => $value) {
139            $labels[$type] = $type . '="' . $this->formatValue($value) . '"';
140        }
141
142        return '{' . implode(',', $labels) . '}';
143    }
144
145    protected function renderValue(mixed $row): mixed
146    {
147        if (is_array($row)) {
148            $row = array_pop($row);
149        }
150
151        return $this->formatValue($row);
152    }
153
154    protected function formatValue(mixed $value): mixed
155    {
156        if (is_bool($value)) {
157            return (int) $value;
158        }
159
160        return $this->escape($value);
161    }
162
163    protected function escape(mixed $value): mixed
164    {
165        $replace = [
166            '\\' => '\\\\',
167            '"'  => '\\"',
168            "\n" => '\\n',
169        ];
170
171        return str_replace(
172            array_keys($replace),
173            array_values($replace),
174            (string) $value
175        );
176    }
177
178    public function canRender(string $path): bool
179    {
180        return $path == '/metrics';
181    }
182
183    /**
184     * Does nothing as shared data will only result in unexpected behaviour
185     *
186     * @param string|mixed[] $key
187     */
188    public function share(string|array $key, mixed $value = null): void
189    {
190    }
191}