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>
166 lines
5.7 KiB
PHP
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(),
|
|
];
|
|
}
|
|
}
|