` sucht nach „enthält“, Zeiten in Millisekunden. */ class UmamiClient { private const string TOKEN_CACHE_KEY = 'umami.api-token'; /** Marken mit eigener Umami-Website. */ public const array BRANDS = ['pressekonto', 'presseecho', 'businessportal24']; public function isConfigured(): bool { return filled(config('services.umami.api_url')) && filled(config('services.umami.api_username')) && filled(config('services.umami.api_password')); } public function websiteId(string $brand): ?string { $id = config('domains.domains.'.$brand.'.brand.umami_website_id'); return is_string($id) && $id !== '' ? $id : null; } /** * Kennzahlen eines Zeitraums, optional gefiltert (z. B. `['path' => 'c.preise']`). * * @param array $filters * @return array{pageviews: int, visitors: int, visits: int, bounces: int, totaltime: int} */ public function stats(string $websiteId, CarbonInterface $from, CarbonInterface $to, array $filters = []): array { $data = $this->get('/api/websites/'.$websiteId.'/stats', $this->range($from, $to) + $filters); return [ 'pageviews' => (int) ($data['pageviews'] ?? 0), 'visitors' => (int) ($data['visitors'] ?? 0), 'visits' => (int) ($data['visits'] ?? 0), 'bounces' => (int) ($data['bounces'] ?? 0), 'totaltime' => (int) ($data['totaltime'] ?? 0), ]; } /** * Rangliste einer Dimension: `path`, `referrer`, `channel`, `country`, * `browser`, `os`, `device`, `event` … * * @param array $filters * @return list */ public function metrics(string $websiteId, string $type, CarbonInterface $from, CarbonInterface $to, array $filters = [], int $limit = 10): array { $data = $this->get('/api/websites/'.$websiteId.'/metrics', $this->range($from, $to) + $filters + [ 'type' => $type, 'limit' => $limit, ]); return array_values(array_map( static fn (array $row): array => ['x' => (string) ($row['x'] ?? ''), 'y' => (int) ($row['y'] ?? 0)], array_filter($data, 'is_array'), )); } /** * @param array $query * @return array */ private function get(string $path, array $query): array { $response = $this->send($path, $query, $this->token()); if ($response->status() === 401) { Cache::forget(self::TOKEN_CACHE_KEY); $response = $this->send($path, $query, $this->token()); } if (! $response->successful()) { throw new UmamiUnavailable(sprintf('Umami antwortet mit %d auf %s.', $response->status(), $path)); } $data = $response->json(); return is_array($data) ? $data : []; } /** * @param array $query */ private function send(string $path, array $query, string $token): Response { try { return Http::baseUrl($this->baseUrl()) ->withToken($token) ->acceptJson() ->connectTimeout(5) ->timeout((int) config('services.umami.api_timeout_seconds', 15)) ->get($path, $query); } catch (ConnectionException $e) { throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e); } } private function token(): string { if (! $this->isConfigured()) { throw new UmamiUnavailable('Umami-API nicht konfiguriert (UMAMI_API_URL, UMAMI_API_USERNAME, UMAMI_API_PASSWORD).'); } return Cache::remember(self::TOKEN_CACHE_KEY, now()->addHours(12), function (): string { try { $response = Http::baseUrl($this->baseUrl()) ->acceptJson() ->connectTimeout(5) ->timeout((int) config('services.umami.api_timeout_seconds', 15)) ->post('/api/auth/login', [ 'username' => (string) config('services.umami.api_username'), 'password' => (string) config('services.umami.api_password'), ]); } catch (ConnectionException $e) { throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e); } $token = $response->json('token'); if (! $response->successful() || ! is_string($token) || $token === '') { throw new UmamiUnavailable(sprintf('Umami-Anmeldung abgelehnt (%d).', $response->status())); } return $token; }); } private function baseUrl(): string { return rtrim((string) config('services.umami.api_url'), '/'); } /** * @return array{startAt: int, endAt: int} */ private function range(CarbonInterface $from, CarbonInterface $to): array { return [ 'startAt' => $from->getTimestampMs(), 'endAt' => $to->getTimestampMs(), ]; } }