Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.50% covered (success)
90.50%
200 / 221
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Controller
90.50% covered (success)
90.50%
200 / 221
80.00% covered (warning)
80.00%
4 / 5
14.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 metrics
89.12% covered (warning)
89.12%
172 / 193
0.00% covered (danger)
0.00%
0 / 1
5.03
 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_pronouns'     => ['type' => 'gauge', $this->stats->usersPronouns()],
108            'licenses'             => [
109                'type' => 'gauge',
110                'help' => 'The total number of licenses',
111                ['labels' => ['type' => 'has_car'], 'value' => $this->stats->licenses('has_car')],
112                ['labels' => ['type' => 'forklift', 'confirmed' => 'no'],
113                    'value' => $this->stats->licenses('forklift')],
114                ['labels' => ['type' => 'forklift', 'confirmed' => 'yes'],
115                    'value' => $this->stats->licenses('forklift', true)],
116                ['labels' => ['type' => 'car', 'confirmed' => 'no'], 'value' => $this->stats->licenses('car')],
117                ['labels' => ['type' => 'car', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('car', true)],
118                ['labels' => ['type' => '3.5t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('3.5t')],
119                ['labels' => ['type' => '3.5t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('3.5t', true)],
120                ['labels' => ['type' => '7.5t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('7.5t')],
121                ['labels' => ['type' => '7.5t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('7.5t', true)],
122                ['labels' => ['type' => '12t', 'confirmed' => 'no'], 'value' => $this->stats->licenses('12t')],
123                ['labels' => ['type' => '12t', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('12t', true)],
124                ['labels' => ['type' => 'ifsg_light', 'confirmed' => 'no'],
125                    'value' => $this->stats->licenses('ifsg_light')],
126                ['labels' => ['type' => 'ifsg_light', 'confirmed' => 'yes'],
127                    'value' => $this->stats->licenses('ifsg_light', true)],
128                ['labels' => ['type' => 'ifsg', 'confirmed' => 'no'], 'value' => $this->stats->licenses('ifsg')],
129                ['labels' => ['type' => 'ifsg', 'confirmed' => 'yes'], 'value' => $this->stats->licenses('ifsg', true)],
130            ],
131            'users_email'          => [
132                'type' => 'gauge',
133                ['labels' => ['type' => 'system'], 'value' => $this->stats->email('system')],
134                ['labels' => ['type' => 'humans'], 'value' => $this->stats->email('humans')],
135                ['labels' => ['type' => 'goodie'], 'value' => $this->stats->email('goodie')],
136                ['labels' => ['type' => 'news'], 'value' => $this->stats->email('news')],
137            ],
138            'users_working'        => [
139                'type' => 'gauge',
140                ['labels' => ['freeloader' => false], $this->stats->currentlyWorkingUsers(false)],
141                ['labels' => ['freeloader' => true], $this->stats->currentlyWorkingUsers(true)],
142            ],
143            'work_seconds'         => [
144                'help' => 'Working users',
145                'type' => 'histogram',
146                [
147                    'labels' => ['state' => 'done'],
148                    'value'  => $this->stats->workBuckets($metrics['work'], true, false),
149                    'sum' => $this->stats->workSeconds(true, false),
150                ],
151                [
152                    'labels' => ['state' => 'planned'],
153                    'value'  => $this->stats->workBuckets($metrics['work'], false, false),
154                    'sum' => $this->stats->workSeconds(false, false),
155                ],
156                [
157                    'labels' => ['state' => 'freeloaded'],
158                    'value'  => $this->stats->workBuckets($metrics['work'], null, true),
159                    'sum' => $this->stats->workSeconds(null, true),
160                ],
161            ],
162            'worklog_seconds'      => [
163                'type' => 'histogram',
164                $this->stats->worklogBuckets($metrics['work']) + ['sum' => $this->stats->worklogSeconds()],
165            ],
166            'vouchers'             => [
167                'type' => 'histogram',
168                $this->stats->vouchersBuckets($metrics['voucher']) + ['sum' => $this->stats->vouchers()],
169            ],
170            'goodies_issued'       => ['type' => 'counter', 'help' => 'Issued Goodies', $this->stats->goodies()],
171            'tshirt_sizes'         => [
172                'type' => 'gauge',
173                'help' => 'The sizes users have configured',
174            ] + $userTshirtSizes,
175            'locales'              => ['type' => 'gauge', 'help' => 'The locales users have configured'] + $userLocales,
176            'themes'               => ['type' => 'gauge', 'help' => 'The themes users have configured'] + $userThemes,
177            'locations'            => ['type' => 'gauge', $this->stats->locations()],
178            'angel_types'          => ['type' => 'gauge', 'help' => 'Angel types with member states'] + $angelTypes,
179            'angel_types_sum'      => ['type' => 'gauge', $this->stats->angelTypesSum()],
180            'shift_types'          => ['type' => 'gauge', $this->stats->shiftTypes()],
181            'shifts'               => ['type' => 'gauge', $this->stats->shifts()],
182            'announcements'        => [
183                'type' => 'gauge',
184                ['labels' => ['type' => 'news'], 'value' => $this->stats->announcements(false)],
185                ['labels' => ['type' => 'meeting'], 'value' => $this->stats->announcements(true)],
186            ],
187            'comments'             => ['type' => 'gauge', $this->stats->comments()],
188            'questions'            => [
189                'type' => 'gauge',
190                ['labels' => ['state' => 'answered'], 'value' => $this->stats->questions(true)],
191                ['labels' => ['state' => 'pending'], 'value' => $this->stats->questions(false)],
192            ],
193            'faq'                  => ['type' => 'gauge', $this->stats->faq()],
194            'messages'             => ['type' => 'gauge', $this->stats->messages()],
195            'password_resets'      => ['type' => 'gauge', $this->stats->passwordResets()],
196            'registration_enabled' => ['type' => 'gauge', $this->config->get('registration_enabled')],
197            'database'             => [
198                'type' => 'gauge',
199                ['labels' => ['type' => 'read'], 'value' => $this->stats->databaseRead()],
200                ['labels' => ['type' => 'write'], 'value' => $this->stats->databaseWrite()],
201            ],
202            'sessions'             => ['type' => 'gauge', $this->stats->sessions()],
203            'oauth'                => ['type' => 'gauge', 'help' => 'The configured OAuth providers'] + $userOauth,
204            'log_entries'          => [
205                'type' => 'counter',
206                [
207                    'labels' => ['level' => LogLevel::EMERGENCY],
208                    'value'  => $this->stats->logEntries(LogLevel::EMERGENCY),
209                ],
210                ['labels' => ['level' => LogLevel::ALERT], 'value' => $this->stats->logEntries(LogLevel::ALERT)],
211                ['labels' => ['level' => LogLevel::CRITICAL], 'value' => $this->stats->logEntries(LogLevel::CRITICAL)],
212                ['labels' => ['level' => LogLevel::ERROR], 'value' => $this->stats->logEntries(LogLevel::ERROR)],
213                ['labels' => ['level' => LogLevel::WARNING], 'value' => $this->stats->logEntries(LogLevel::WARNING)],
214                ['labels' => ['level' => LogLevel::NOTICE], 'value' => $this->stats->logEntries(LogLevel::NOTICE)],
215                ['labels' => ['level' => LogLevel::INFO], 'value' => $this->stats->logEntries(LogLevel::INFO)],
216                ['labels' => ['level' => LogLevel::DEBUG], 'value' => $this->stats->logEntries(LogLevel::DEBUG)],
217            ],
218        ];
219
220        $data['scrape_duration_seconds'] = [
221            'type' => 'gauge',
222            'help' => 'Duration of the current request',
223            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT', $now),
224        ];
225
226        $data['scrape_memory_bytes'] = [
227            'type' => 'gauge',
228            'help' => 'Memory usage of the current request',
229            memory_get_usage(),
230        ];
231
232        return $this->response
233            ->withHeader('Content-Type', 'text/plain; version=0.0.4')
234            ->withContent($this->engine->get('/metrics', $data));
235    }
236
237    public function stats(): Response
238    {
239        $this->checkAuth(true);
240
241        $data = [
242            'user_count'         => $this->stats->usersState() + $this->stats->usersState(null, false),
243            'arrived_user_count' => $this->stats->usersState(),
244            'done_work_hours'    => round($this->stats->workSeconds(true) / 60 / 60),
245            'users_in_action'    => $this->stats->currentlyWorkingUsers(),
246        ];
247
248        return $this->response
249            ->withHeader('Content-Type', 'application/json')
250            ->withContent(json_encode($data));
251    }
252
253    /**
254     * Ensure that the request is authorized
255     */
256    protected function checkAuth(bool $isJson = false): void
257    {
258        $apiKey = $this->config->get('api_key');
259        if (empty($apiKey) || $this->request->get('api_key') == $apiKey) {
260            return;
261        }
262
263        $message = 'The api_key is invalid';
264        $headers = [];
265
266        if ($isJson) {
267            $message = json_encode(['error' => $message]);
268            $headers['Content-Type'] = 'application/json';
269        }
270
271        throw new HttpForbidden($message, $headers);
272    }
273
274    /**
275     * Formats the stats collection as stats data
276     */
277    protected function formatStats(Collection $data, string $config, string $dataField, ?string $label = null): array
278    {
279        $return = [];
280        foreach ($this->config->get($config) as $name => $description) {
281            $count = $data->where($dataField, '=', $name)->sum('count');
282            $return[] = [
283                'labels' => [($label ?: $dataField) => $name],
284                $count,
285            ];
286        }
287
288        return $return;
289    }
290}