mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
9.8 KiB
PHP
259 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Data\Traffic\TrafficOverviewData;
|
|
|
|
/**
|
|
* Merges Sentinel traffic data from several sources (servers, or the several app keys of
|
|
* one resource) into one view. Counters are summed; latency takes the worst value and
|
|
* uniques are summed, so both are flagged approximate once more than one source is merged.
|
|
* A resource on a Sentinel with resource routes is one source: Sentinel merges its keys
|
|
* exactly, so one server stays exact.
|
|
*/
|
|
class TrafficAnalyticsAggregator
|
|
{
|
|
/** @var array<int, TrafficOverviewData> */
|
|
private array $overviews = [];
|
|
|
|
/** @var array<string, array{path: string, domain: ?string, requests: int, bytesOut: int, s4xx: int, s5xx: int, p95: float}> */
|
|
private array $paths = [];
|
|
|
|
/** @var array<string, array<string, array{value: string, requests: int, bytesOut: int}>> */
|
|
private array $breakdowns;
|
|
|
|
/** @var array<int, array<string, int|float>> */
|
|
private array $series = [];
|
|
|
|
private ?string $attribution = null;
|
|
|
|
/**
|
|
* @param array<int, string> $dimensions
|
|
*/
|
|
public function __construct(private array $dimensions = [])
|
|
{
|
|
$this->breakdowns = array_fill_keys($dimensions, []);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TrafficOverviewData> $overviews
|
|
* @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool}
|
|
*/
|
|
public static function sumOverviews(array $overviews): array
|
|
{
|
|
$multi = count($overviews) > 1;
|
|
$sum = fn (string $prop) => array_sum(array_map(fn ($o) => $o->{$prop}, $overviews));
|
|
$max = fn (string $prop) => empty($overviews) ? 0.0 : max(array_map(fn ($o) => $o->{$prop}, $overviews));
|
|
|
|
$overview = new TrafficOverviewData(
|
|
requests: $sum('requests'),
|
|
bytesIn: $sum('bytesIn'),
|
|
bytesOut: $sum('bytesOut'),
|
|
s2xx: $sum('s2xx'),
|
|
s3xx: $sum('s3xx'),
|
|
s4xx: $sum('s4xx'),
|
|
s5xx: $sum('s5xx'),
|
|
latencyP50: (float) $max('latencyP50'),
|
|
latencyP95: (float) $max('latencyP95'),
|
|
latencyP99: (float) $max('latencyP99'),
|
|
uniqueVisitors: $sum('uniqueVisitors'),
|
|
);
|
|
|
|
return [
|
|
'overview' => $overview,
|
|
'latencyApproximate' => $multi,
|
|
'uniquesApproximate' => $multi,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fetch every shape for one app key (or the whole server when null) and merge it in.
|
|
*
|
|
* @param callable(string): ?string $domainForKey resolves a path row's app key to its domain
|
|
*/
|
|
public function collect(SentinelTrafficClient $client, ?string $appKey, string $from, string $to, string $range, callable $domainForKey, int $limit = 50): void
|
|
{
|
|
$this->collectScope($client, $appKey, false, $from, $to, $range, $domainForKey, $limit);
|
|
}
|
|
|
|
/**
|
|
* Fetch every shape for one resource on one server and merge it in. Uses Sentinel's
|
|
* resource scope when the server has it (one exact source). Otherwise every key of the
|
|
* resource is fetched and merged as its own source, as before.
|
|
*
|
|
* @param callable(string): ?string $domainForKey resolves a path row's app key to its domain
|
|
*/
|
|
public function collectResource(SentinelTrafficClient $client, string $resourceUuid, string $from, string $to, string $range, callable $domainForKey, int $limit = 50): void
|
|
{
|
|
if ($client->prefetchResourceScope($resourceUuid, $from, $to, $this->dimensions, $range, $limit, $limit)) {
|
|
$this->collectScope($client, $resourceUuid, true, $from, $to, $range, $domainForKey, $limit);
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($client->prefetchResource($resourceUuid, $from, $to, $this->dimensions, $range, $limit, $limit) as $key) {
|
|
$this->collect($client, $key, $from, $to, $range, $domainForKey, $limit);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param callable(string): ?string $domainForKey
|
|
*/
|
|
private function collectScope(SentinelTrafficClient $client, ?string $key, bool $resourceScope, string $from, string $to, string $range, callable $domainForKey, int $limit): void
|
|
{
|
|
$this->addOverview($resourceScope ? $client->resourceOverview($key, $from, $to) : $client->overview($key, $from, $to));
|
|
$this->addPaths($resourceScope ? $client->resourcePaths($key, $from, $to, $limit) : $client->paths($key, $from, $to, $limit), $domainForKey);
|
|
|
|
foreach ($this->dimensions as $dimension) {
|
|
$this->addBreakdown($dimension, $resourceScope
|
|
? $client->resourceBreakdown($key, $dimension, $from, $to, $limit)
|
|
: $client->breakdown($key, $dimension, $from, $to, $limit));
|
|
}
|
|
|
|
$this->attribution ??= $client->attribution();
|
|
|
|
$this->addSeries($resourceScope ? $client->resourceSeries($key, $range) : $client->series($key, $range));
|
|
}
|
|
|
|
public function addOverview(TrafficOverviewData $overview): void
|
|
{
|
|
$this->overviews[] = $overview;
|
|
}
|
|
|
|
/**
|
|
* Merge path rows keyed by (app, path), so the same path under two apps stays two
|
|
* rows, each with the domain of the row's Sentinel `app` key. Sentinel's `__other__`
|
|
* overflow rows (the long tail past its top-N cap) fold into one row without a domain.
|
|
*
|
|
* @param iterable<int, mixed> $paths
|
|
* @param callable(string): ?string $domainForKey
|
|
*/
|
|
public function addPaths(iterable $paths, callable $domainForKey): void
|
|
{
|
|
foreach ($paths as $path) {
|
|
$data = $path->toArray();
|
|
$pathStr = (string) ($data['path'] ?? '');
|
|
$appId = $pathStr === '__other__' ? '' : (string) ($data['app'] ?? '');
|
|
$key = $appId."\n".$pathStr;
|
|
|
|
$this->paths[$key] ??= [
|
|
'path' => $pathStr,
|
|
'domain' => $appId !== '' ? $domainForKey($appId) : null,
|
|
'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0,
|
|
];
|
|
$this->paths[$key]['requests'] += (int) ($data['requests'] ?? 0);
|
|
$this->paths[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
|
$this->paths[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0);
|
|
$this->paths[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0);
|
|
$this->paths[$key]['p95'] = max($this->paths[$key]['p95'], (float) ($data['p95'] ?? 0));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param iterable<int, mixed> $rows
|
|
*/
|
|
public function addBreakdown(string $dimension, iterable $rows): void
|
|
{
|
|
foreach ($rows as $row) {
|
|
$data = $row->toArray();
|
|
$value = (string) ($data['value'] ?? '');
|
|
|
|
$this->breakdowns[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0];
|
|
$this->breakdowns[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0);
|
|
$this->breakdowns[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
|
|
|
// City rows carry coordinates; the first server that has them wins.
|
|
if (isset($data['lat'], $data['lon'])) {
|
|
$this->breakdowns[$dimension][$value]['lat'] ??= (float) $data['lat'];
|
|
$this->breakdowns[$dimension][$value]['lon'] ??= (float) $data['lon'];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sum status series by bucket. Uniques are summed (approximate); p95 takes the worst bucket.
|
|
*
|
|
* @param iterable<int, mixed> $buckets
|
|
*/
|
|
public function addSeries(iterable $buckets): void
|
|
{
|
|
foreach ($buckets as $bucket) {
|
|
$data = $bucket->toArray();
|
|
$ts = (int) ($data['bucket'] ?? 0);
|
|
|
|
$this->series[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
|
|
foreach (['s2xx', 's3xx', 's4xx', 's5xx', 'requests', 'bytesIn', 'bytesOut', 'uniqueVisitors'] as $field) {
|
|
$this->series[$ts][$field] += (int) ($data[$field] ?? 0);
|
|
}
|
|
$this->series[$ts]['p95'] = max($this->series[$ts]['p95'], (float) ($data['p95'] ?? 0));
|
|
}
|
|
}
|
|
|
|
public function hasOverview(): bool
|
|
{
|
|
return $this->overviews !== [];
|
|
}
|
|
|
|
/**
|
|
* @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool}
|
|
*/
|
|
public function overview(): array
|
|
{
|
|
return self::sumOverviews($this->overviews);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{path: string, domain: ?string, requests: int, bytesOut: int, s4xx: int, s5xx: int, p95: float}>
|
|
*/
|
|
public function topPaths(int $limit = 50): array
|
|
{
|
|
return self::topByRequests(array_values($this->paths), $limit);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<int, array{value: string, requests: int, bytesOut: int, lat?: float, lon?: float}>>
|
|
*/
|
|
public function breakdowns(int $limit = 50): array
|
|
{
|
|
$result = [];
|
|
foreach ($this->dimensions as $dimension) {
|
|
$rows = self::topByRequests(array_values($this->breakdowns[$dimension] ?? []), PHP_INT_MAX);
|
|
if ($dimension === 'referer') {
|
|
$rows = groupRefererBreakdownRows($rows);
|
|
}
|
|
$result[$dimension] = array_slice($rows, 0, $limit);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, int|float>>
|
|
*/
|
|
public function series(): array
|
|
{
|
|
$series = $this->series;
|
|
ksort($series);
|
|
|
|
return array_values($series);
|
|
}
|
|
|
|
public function attribution(): ?string
|
|
{
|
|
return $this->attribution;
|
|
}
|
|
|
|
/**
|
|
* @template T of array{requests: int}
|
|
*
|
|
* @param array<int, T> $rows
|
|
* @return array<int, T>
|
|
*/
|
|
public static function topByRequests(array $rows, int $limit = 50): array
|
|
{
|
|
usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
|
|
|
|
return array_slice($rows, 0, $limit);
|
|
}
|
|
}
|