From c5621b82cfeddee23b81871a53035fde747f73a9 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 18 Dec 2018 02:23:44 +0100 Subject: Implemented /metrics endpoint and reimplemented /stats closes #418 (/metrics endpoint) Usage: ```yaml scrape_configs: - job_name: 'engelsystem' static_configs: - targets: ['engelsystem.example.com:80'] ``` --- src/Controllers/Metrics/Controller.php | 131 +++++++++++++++++++++++++++ src/Controllers/Metrics/MetricsEngine.php | 137 ++++++++++++++++++++++++++++ src/Controllers/Metrics/Stats.php | 144 ++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/Controllers/Metrics/Controller.php create mode 100644 src/Controllers/Metrics/MetricsEngine.php create mode 100644 src/Controllers/Metrics/Stats.php (limited to 'src/Controllers') diff --git a/src/Controllers/Metrics/Controller.php b/src/Controllers/Metrics/Controller.php new file mode 100644 index 00000000..01fe1d6a --- /dev/null +++ b/src/Controllers/Metrics/Controller.php @@ -0,0 +1,131 @@ +config = $config; + $this->engine = $engine; + $this->request = $request; + $this->response = $response; + $this->stats = $stats; + } + + /** + * @return Response + */ + public function metrics() + { + $now = microtime(true); + $this->checkAuth(); + + $data = [ + $this->config->get('app_name') . ' stats', + 'users' => [ + 'type' => 'gauge', + ['labels' => ['state' => 'incoming'], 'value' => $this->stats->newUsers()], + ['labels' => ['state' => 'arrived', 'working' => 'no'], 'value' => $this->stats->arrivedUsers(false)], + ['labels' => ['state' => 'arrived', 'working' => 'yes'], 'value' => $this->stats->arrivedUsers(true)], + ], + 'users_working' => [ + 'type' => 'gauge', + ['labels' => ['freeloader' => false], $this->stats->currentlyWorkingUsers(false)], + ['labels' => ['freeloader' => true], $this->stats->currentlyWorkingUsers(true)], + ], + 'work_seconds' => [ + 'type' => 'gauge', + ['labels' => ['state' => 'done'], 'value' => $this->stats->workSeconds(true, false)], + ['labels' => ['state' => 'planned'], 'value' => $this->stats->workSeconds(false, false)], + ['labels' => ['state' => 'freeloaded'], 'value' => $this->stats->workSeconds(null, true)], + ], + 'registration_enabled' => ['type' => 'gauge', $this->config->get('registration_enabled')], + ]; + + $data['scrape_duration_seconds'] = [ + 'type' => 'gauge', + 'help' => 'Duration of the current request', + microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT', $now) + ]; + + return $this->response + ->withHeader('Content-Type', 'text/plain; version=0.0.4') + ->withContent($this->engine->get('/metrics', $data)); + } + + /** + * @return Response + */ + public function stats() + { + $this->checkAuth(true); + + $data = [ + 'user_count' => $this->stats->newUsers() + $this->stats->arrivedUsers(), + 'arrived_user_count' => $this->stats->arrivedUsers(), + 'done_work_hours' => round($this->stats->workSeconds(true) / 60 / 60, 0), + 'users_in_action' => $this->stats->currentlyWorkingUsers(), + ]; + + return $this->response + ->withHeader('Content-Type', 'application/json') + ->withContent(json_encode($data)); + } + + /** + * Ensure that the if the request is authorized + * + * @param bool $isJson + */ + protected function checkAuth($isJson = false) + { + $apiKey = $this->config->get('api_key'); + if (empty($apiKey) || $this->request->get('api_key') == $apiKey) { + return; + } + + $message = 'The api_key is invalid'; + $headers = []; + + if ($isJson) { + $message = json_encode(['error' => $message]); + $headers['Content-Type'] = 'application/json'; + } + + throw new HttpForbidden($message, $headers); + } +} diff --git a/src/Controllers/Metrics/MetricsEngine.php b/src/Controllers/Metrics/MetricsEngine.php new file mode 100644 index 00000000..eeb47d8a --- /dev/null +++ b/src/Controllers/Metrics/MetricsEngine.php @@ -0,0 +1,137 @@ + [['labels' => ['foo'=>'bar'], 'value'=>42]], 'bar'=>123] + * + * @param string $path + * @param mixed[] $data + * @return string + */ + public function get($path, $data = []): string + { + $return = []; + foreach ($data as $name => $list) { + if (is_int($name)) { + $return[] = '# ' . $this->escape($list); + continue; + } + + $list = is_array($list) ? $list : [$list]; + $name = 'engelsystem_' . $name; + + if (isset($list['help'])) { + $return[] = sprintf('# HELP %s %s', $name, $this->escape($list['help'])); + unset($list['help']); + } + + if (isset($list['type'])) { + $return[] = sprintf('# TYPE %s %s', $name, $list['type']); + unset($list['type']); + } + + $list = (!isset($list['value']) || !isset($list['labels'])) ? $list : [$list]; + foreach ($list as $row) { + $row = is_array($row) ? $row : [$row]; + + $return[] = $this->formatData($name, $row); + } + } + + return implode("\n", $return); + } + + /** + * @param string $path + * @return bool + */ + public function canRender($path): bool + { + return $path == '/metrics'; + } + + /** + * @param string $name + * @param array|mixed $row + * @see https://prometheus.io/docs/instrumenting/exposition_formats/ + * @return string + */ + protected function formatData($name, $row): string + { + return sprintf( + '%s%s %s', + $name, + $this->renderLabels($row), + $this->renderValue($row)); + } + + /** + * @param array|mixed $row + * @return mixed + */ + protected function renderLabels($row): string + { + $labels = []; + if (!is_array($row) || empty($row['labels'])) { + return ''; + } + + foreach ($row['labels'] as $type => $value) { + $labels[$type] = $type . '="' . $this->formatValue($value) . '"'; + } + + return '{' . implode(',', $labels) . '}'; + } + + /** + * @param array|mixed $row + * @return mixed + */ + protected function renderValue($row) + { + if (isset($row['value'])) { + return $this->formatValue($row['value']); + } + + return $this->formatValue(array_pop($row)); + } + + /** + * @param mixed $value + * @return mixed + */ + protected function formatValue($value) + { + if (is_bool($value)) { + return (int)$value; + } + + return $this->escape($value); + } + + /** + * @param mixed $value + * @return mixed + */ + protected function escape($value) + { + $replace = [ + '\\' => '\\\\', + '"' => '\\"', + "\n" => '\\n', + ]; + + return str_replace( + array_keys($replace), + array_values($replace), + $value + ); + } +} diff --git a/src/Controllers/Metrics/Stats.php b/src/Controllers/Metrics/Stats.php new file mode 100644 index 00000000..891f8c80 --- /dev/null +++ b/src/Controllers/Metrics/Stats.php @@ -0,0 +1,144 @@ +db = $db; + } + + /** + * The number of not arrived users + * + * @param null $working + * @return int + */ + public function arrivedUsers($working = null): int + { + $query = $this + ->getQuery('users') + ->join('users_state', 'user_id', '=', 'id') + ->where('arrived', '=', 1); + + if (!is_null($working)) { + // @codeCoverageIgnoreStart + $query + ->leftJoin('UserWorkLog', 'UserWorkLog.user_id', '=', 'users.id') + ->leftJoin('ShiftEntry', 'ShiftEntry.UID', '=', 'users.id') + ->groupBy('users.id'); + + $query->where(function ($query) use ($working) { + /** @var QueryBuilder $query */ + if ($working) { + $query + ->whereNotNull('ShiftEntry.SID') + ->orWhereNotNull('UserWorkLog.work_hours'); + + return; + } + $query + ->whereNull('ShiftEntry.SID') + ->whereNull('UserWorkLog.work_hours'); + }); + // @codeCoverageIgnoreEnd + } + + return $query + ->count(); + } + + /** + * The number of not arrived users + * + * @return int + */ + public function newUsers(): int + { + return $this + ->getQuery('users') + ->join('users_state', 'user_id', '=', 'id') + ->where('arrived', '=', 0) + ->count(); + } + + /** + * The number of currently working users + * + * @param null $freeloaded + * @return int + * @codeCoverageIgnore + */ + public function currentlyWorkingUsers($freeloaded = null): int + { + $query = $this + ->getQuery('users') + ->join('ShiftEntry', 'ShiftEntry.UID', '=', 'users.id') + ->join('Shifts', 'Shifts.SID', '=', 'ShiftEntry.SID') + ->where('Shifts.start', '<=', time()) + ->where('Shifts.end', '>', time()); + + if (!is_null($freeloaded)) { + $query->where('ShiftEntry.freeloaded', '=', $freeloaded); + } + + return $query->count(); + } + + /** + * The number of worked shifts + * + * @param bool|null $done + * @param bool|null $freeloaded + * @return int + * @codeCoverageIgnore + */ + public function workSeconds($done = null, $freeloaded = null): int + { + $query = $this + ->getQuery('ShiftEntry') + ->join('Shifts', 'Shifts.SID', '=', 'ShiftEntry.SID'); + + if (!is_null($freeloaded)) { + $query->where('freeloaded', '=', $freeloaded); + } + + if (!is_null($done)) { + $query->where('end', ($done == true ? '<' : '>='), time()); + } + + return $query->sum($this->raw('end - start')); + } + + /** + * @param string $table + * @return QueryBuilder + */ + protected function getQuery(string $table): QueryBuilder + { + return $this->db + ->getConnection() + ->table($table); + } + + /** + * @param mixed $value + * @return QueryExpression + * @codeCoverageIgnore + */ + protected function raw($value) + { + return $this->db->getConnection()->raw($value); + } +} -- cgit v1.2.3-54-g00ecf