*/ private array $overviews = []; /** @var array */ private array $paths = []; /** @var array> */ private array $breakdowns; /** @var array> */ private array $series = []; private ?string $attribution = null; /** * @param array $dimensions */ public function __construct(private array $dimensions = []) { $this->breakdowns = array_fill_keys($dimensions, []); } /** * @param array $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 $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 $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 $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 */ public function topPaths(int $limit = 50): array { return self::topByRequests(array_values($this->paths), $limit); } /** * @return array> */ 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> */ 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 $rows * @return array */ 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); } }