presseportale/app/Services/Analytics/UmamiClient.php
Kevin Adametz b4870f98bb admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden
Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:29 +00:00

166 lines
5.7 KiB
PHP

<?php
namespace App\Services\Analytics;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Nur lesender Client für die selbst gehostete Umami-Instanz (Roadmap 6.16).
*
* Anmeldung mit einem Umami-Benutzer der Rolle „View only“; das Token wird
* zwischengespeichert und bei einer 401 einmal erneuert. Die Websites hängen
* am Team, nicht am Benutzer — ihre IDs stehen je Marke in
* `config/domains.php`.
*
* Eigenheiten der Umami-v3-API: Seiten heißen `path` (nicht `url`), ein
* Filter `path=c.<Teil>` 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<string, string> $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<string, string> $filters
* @return list<array{x: string, y: int}>
*/
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<string, scalar> $query
* @return array<mixed>
*/
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<string, scalar> $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(),
];
}
}