Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
222 / 222
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
Controller
100.00% covered (success)
100.00%
222 / 222
100.00% covered (success)
100.00%
5 / 5
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 metrics
100.00% covered (success)
100.00%
194 / 194
100.00% covered (success)
100.00%
1 / 1
5
 stats
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 checkAuth
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 formatStats
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers\Metrics;
6
7use Engelsystem\Config\Config;
8use Engelsystem\Controllers\BaseController;
9use Engelsystem\Helpers\Version;
10use Engelsystem\Http\Exceptions\HttpForbidden;
11use Engelsystem\Http\Request;
12use Engelsystem\Http\Response;
13use Illuminate\Support\Collection;
14use Psr\Log\LogLevel;
15
16class Controller extends BaseController
17{
18    public function __construct(
19        protected Response $response,
20        protected MetricsEngine $engine,
21        protected Config $config,
22        protected Request $request,
23        protected Stats $stats,
24        protected Version $version
25    ) {
26    }
27
28    public function metrics(): Response
29    {
30        $now = microtime(true);
31        $this->checkAuth();
32        $metrics = $this->config->get('metrics');
33        foreach (['work', 'voucher'] as $type) {
34            sort($metrics[$type]);
35            $metrics[$type] = array_merge($metrics[$type], ['+Inf']);
36        }
37
38        $userTshirtSizes = $this->formatStats($this->stats->tshirtSizes(), 'tshirt_sizes', 'shirt_size', 'size');
39        $userLocales = $this->formatStats($this->stats->languages(), 'locales', 'language', 'locale');
40        $userThemes = $this->formatStats($this->stats->themes(), 'themes', 'theme');
41        $userOauth = $this->formatStats($this->stats->oauth(), 'oauth', 'provider');
42
43        $themes = $this->config->get('themes');
44        foreach ($userThemes as $key => $theme) {
45            $userThemes[$key]['labels']['name'] = $themes[$theme['labels']['theme']]['name'];
46        }
47
48        $oauthProviders = $this->config->get('oauth');
49        foreach ($userOauth as $key => $oauth) {
50            $provider = $oauth['labels']['provider'];
51            $name = $oauthProviders[$provider]['name'] ?? $provider;
52            $userOauth[$key]['labels']['name'] = $name;
53        }
54
55        $angelTypes = [];
56        foreach ($this->stats->angelTypes() as $angelType) {
57            $angelTypes[] = [
58                'labels' => [
59                    'name' => $angelType['name'],
60                    'restricted' => $angelType['restricted'],
61                    'members' => 'unconfirmed',
62                ], 'value' => $angelType['unconfirmed'],
63            ];
64            $angelTypes[] = [
65                'labels' => [
66                    'name' => $angelType['name'],
67                    'restricted' => $angelType['restricted'],
68                    'members' => 'confirmed',
69                ], 'value' => $angelType['confirmed'],
70            ];
71            $angelTypes[] = [
72                'labels' => [
73                    'name' => $angelType['name'],
74                    'restricted' => $angelType['restricted'],
75                    'members' => 'supporters',
76                ], 'value' => $angelType['supporters'],
77            ];
78        }
79
80        $data = [
81            $this->config->get('app_name') . ' stats',
82            'info'                 => [
83                'type' => 'gauge',
84                'help' => 'About the environment',
85                [
86                    'labels' => [
87                        'os'      => PHP_OS_FAMILY,
88                        'php'     => implode('.', [PHP_MAJOR_VERSION, PHP_MINOR_VERSION]),
89                        'version' => $this->version->getVersion(),
90                    ],
91                    'value'  => 1,
92                ],
93            ],
94            'users'                => [
95                'type' => 'gauge',
96                ['labels' => ['state' => 'incoming', 'working' => 'no'],
97                    'value' => $this->stats->usersState(false, false)],
98                ['labels' => ['state' => 'incoming', 'working' => 'yes'],
99                    'value' => $this->stats->usersState(true, false)],
100                ['labels' => ['state' => 'arrived', 'working' => 'no'],
101                    'value' => $this->stats->usersState(false)],
102                ['labels' => ['state' => 'arrived', 'working' => 'yes'],
103                    'value' => $this->stats->usersState(true)],
104            ],
105            'users_info' => ['type' => 'gauge', $this->stats->usersInfo()],
106            'users_force_active'   => ['type' => 'gauge', $this->stats->forceActiveUsers()],
107            'users_force_food'   => ['type' => 'gauge', $this->stats->forceFoodUsers()],
108            'users_pronouns'     => ['type' => 'gauge', $this->stats->usersPronouns()],
109            'licenses'             => [
110                'type' => 'gauge',
111                'help' => 'The total number of licenses',
112                ['labels' => ['type' => 'has_car'], 'value' => $this->stats->licenses('has_car')],
113                ['labels' => ['type' => 'forklift', 'confirmed' => 'no'],
114                    'value' => $this->stats->licenses('forklift')],
115                ['labels' => ['type' => 'forklift', 'confirmed' => 'yes'],
116                    'value' => $this->stats->licenses('forklift', true)],
117                ['labels' => ['type' => 'car', 'confirmed' => 'no'], 'value' => $this->stats->licenses('car')],
118                ['labels' => ['type' => 'car', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('car', true)],
119                ['labels' => ['type' => '3.5t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('3.5t')],
120                ['labels' => ['type' => '3.5t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('3.5t', true)],
121                ['labels' => ['type' => '7.5t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('7.5t')],
122                ['labels' => ['type' => '7.5t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('7.5t', true)],
123                ['labels' => ['type' => '12t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('12t')],
124                ['labels' => ['type' => '12t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('12t', true)],
125                ['labels' => ['type' => 'ifsg_light', 'confirmed' => 'no'],
126                    'value' => $this->stats->licenses('ifsg_light')],
127                ['labels' => ['type' => 'ifsg_light', 'confirmed' => 'yes'],
128                    'value' => $this->stats->licenses('ifsg_light', true)],
129                ['labels' => ['type' => 'ifsg', 'confirmed' => 'no'], 'value' => $this->stats->licenses('ifsg')],
130                ['labels' => ['type' => 'ifsg', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('ifsg', true)],
131            ],
132            'users_email'          => [
133                'type' => 'gauge',
134                ['labels' => ['type' => 'system'], 'value' => $this->stats->email('system')],
135                ['labels' => ['type' => 'humans'], 'value' => $this->stats->email('humans')],
136                ['labels' => ['type' => 'goodie'], 'value' => $this->stats->email('goodie')],
137                ['labels' => ['type' => 'news'], 'value' => $this->stats->email('news')],
138            ],
139            'users_working'        => [
140                'type' => 'gauge',
141                ['labels' => ['freeloader' => false], $this->stats->currentlyWorkingUsers(false)],
142                ['labels' => ['freeloader' => true], $this->stats->currentlyWorkingUsers(true)],
143            ],
144            'work_seconds'         => [
145                'help' => 'Working users',
146                'type' => 'histogram',
147                [
148                    'labels' => ['state' => 'done'],
149                    'value'  => $this->stats->workBuckets($metrics['work'], true, false),
150                    'sum' => $this->stats->workSeconds(true, false),
151                ],
152                [
153                    'labels' => ['state' => 'planned'],
154                    'value'  => $this->stats->workBuckets($metrics['work'], false, false),
155                    'sum' => $this->stats->workSeconds(false, false),
156                ],
157                [
158                    'labels' => ['state' => 'freeloaded'],
159                    'value'  => $this->stats->workBuckets($metrics['work'], null, true),
160                    'sum' => $this->stats->workSeconds(null, true),
161                ],
162            ],
163            'worklog_seconds'      => [
164                'type' => 'histogram',
165                $this->stats->worklogBuckets($metrics['work']) + ['sum' => $this->stats->worklogSeconds()],
166            ],
167            'vouchers'             => [
168                'type' => 'histogram',
169                $this->stats->vouchersBuckets($metrics['voucher']) + ['sum' => $this->stats->vouchers()],
170            ],
171            'goodies_issued'       => ['type' => 'counter', 'help' => 'Issued Goodies', $this->stats->goodies()],
172            'tshirt_sizes'         => [
173                'type' => 'gauge',
174                'help' => 'The sizes users have configured',
175            ] + $userTshirtSizes,
176            'locales'              => ['type' => 'gauge', 'help' => 'The locales users have configured'] + $userLocales,
177            'themes'               => ['type' => 'gauge', 'help' => 'The themes users have configured'] + $userThemes,
178            'locations'            => ['type' => 'gauge', $this->stats->locations()],
179            'angel_types'          => ['type' => 'gauge', 'help' => 'Angel types with member states'] + $angelTypes,
180            'angel_types_sum'      => ['type' => 'gauge', $this->stats->angelTypesSum()],
181            'shift_types'          => ['type' => 'gauge', $this->stats->shiftTypes()],
182            'shifts'               => ['type' => 'gauge', $this->stats->shifts()],
183            'announcements'        => [
184                'type' => 'gauge',
185                ['labels' => ['type' => 'news'], 'value' => $this->stats->announcements(false)],
186                ['labels' => ['type' => 'meeting'], 'value' => $this->stats->announcements(true)],
187            ],
188            'comments'             => ['type' => 'gauge', $this->stats->comments()],
189            'questions'            => [
190                'type' => 'gauge',
191                ['labels' => ['state' => 'answered'], 'value' => $this->stats->questions(true)],
192                ['labels' => ['state' => 'pending'], 'value' => $this->stats->questions(false)],
193            ],
194            'faq'                  => ['type' => 'gauge', $this->stats->faq()],
195            'messages'             => ['type' => 'gauge', $this->stats->messages()],
196            'password_resets'      => ['type' => 'gauge', $this->stats->passwordResets()],
197            'registration_enabled' => ['type' => 'gauge', $this->config->get('registration_enabled')],
198            'database'             => [
199                'type' => 'gauge',
200                ['labels' => ['type' => 'read'], 'value' => $this->stats->databaseRead()],
201                ['labels' => ['type' => 'write'], 'value' => $this->stats->databaseWrite()],
202            ],
203            'sessions'             => ['type' => 'gauge', $this->stats->sessions()],
204            'oauth'                => ['type' => 'gauge', 'help' => 'The configured OAuth providers'] + $userOauth,
205            'log_entries'          => [
206                'type' => 'counter',
207                [
208                    'labels' => ['level' => LogLevel::EMERGENCY],
209                    'value'  => $this->stats->logEntries(LogLevel::EMERGENCY),
210                ],
211                ['labels' => ['level' => LogLevel::ALERT], 'value' => $this->stats->logEntries(LogLevel::ALERT)],
212                ['labels' => ['level' => LogLevel::CRITICAL], 'value' => $this->stats->logEntries(LogLevel::CRITICAL)],
213                ['labels' => ['level' => LogLevel::ERROR], 'value' => $this->stats->logEntries(LogLevel::ERROR)],
214                ['labels' => ['level' => LogLevel::WARNING], 'value' => $this->stats->logEntries(LogLevel::WARNING)],
215                ['labels' => ['level' => LogLevel::NOTICE], 'value' => $this->stats->logEntries(LogLevel::NOTICE)],
216                ['labels' => ['level' => LogLevel::INFO], 'value' => $this->stats->logEntries(LogLevel::INFO)],
217                ['labels' => ['level' => LogLevel::DEBUG], 'value' => $this->stats->logEntries(LogLevel::DEBUG)],
218            ],
219        ];
220
221        $data['scrape_duration_seconds'] = [
222            'type' => 'gauge',
223            'help' => 'Duration of the current request',
224            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT', $now),
225        ];
226
227        $data['scrape_memory_bytes'] = [
228            'type' => 'gauge',
229            'help' => 'Memory usage of the current request',
230            memory_get_usage(),
231        ];
232
233        return $this->response
234            ->withHeader('Content-Type', 'text/plain; version=0.0.4')
235            ->withContent($this->engine->get('/metrics', $data));
236    }
237
238    public function stats(): Response
239    {
240        $this->checkAuth(true);
241
242        $data = [
243            'user_count'         => $this->stats->usersState() + $this->stats->usersState(null, false),
244            'arrived_user_count' => $this->stats->usersState(),
245            'done_work_hours'    => round($this->stats->workSeconds(true) / 60 / 60),
246            'users_in_action'    => $this->stats->currentlyWorkingUsers(),
247        ];
248
249        return $this->response
250            ->withHeader('Content-Type', 'application/json')
251            ->withContent(json_encode($data));
252    }
253
254    /**
255     * Ensure that the request is authorized
256     */
257    protected function checkAuth(bool $isJson = false): void
258    {
259        $apiKey = $this->config->get('api_key');
260        if (empty($apiKey) || $this->request->get('api_key') == $apiKey) {
261            return;
262        }
263
264        $message = 'The api_key is invalid';
265        $headers = [];
266
267        if ($isJson) {
268            $message = json_encode(['error' => $message]);
269            $headers['Content-Type'] = 'application/json';
270        }
271
272        throw new HttpForbidden($message, $headers);
273    }
274
275    /**
276     * Formats the stats collection as stats data
277     */
278    protected function formatStats(Collection $data, string $config, string $dataField, ?string $label = null): array
279    {
280        $return = [];
281        foreach ($this->config->get($config) as $name => $description) {
282            $count = $data->where($dataField, '=', $name)->sum('count');
283            $return[] = [
284                'labels' => [($label ?: $dataField) => $name],
285                $count,
286            ];
287        }
288
289        return $return;
290    }
291}