diff --git a/app/Data/Traffic/TrafficPathData.php b/app/Data/Traffic/TrafficPathData.php index 22fa8359da..077243bf10 100644 --- a/app/Data/Traffic/TrafficPathData.php +++ b/app/Data/Traffic/TrafficPathData.php @@ -14,8 +14,7 @@ class TrafficPathData extends Data public int $s5xx, public float $p50, public float $p95, - // Owning app key (Sentinel returns this per path row so the UI can show the - // domain). Empty on older Sentinel builds that predate the field. + // Owning app key, so the UI can show the domain of each path row. public string $app = '', ) {} diff --git a/app/Data/Traffic/TrafficSeriesBucketData.php b/app/Data/Traffic/TrafficSeriesBucketData.php index d2b7df480d..564b5f09bb 100644 --- a/app/Data/Traffic/TrafficSeriesBucketData.php +++ b/app/Data/Traffic/TrafficSeriesBucketData.php @@ -13,9 +13,7 @@ class TrafficSeriesBucketData extends Data public int $s3xx, public int $s4xx, public int $s5xx, - // Per-bucket aggregates (added to Sentinel's series alongside the status classes) - // so every KPI card can draw a real sparkline. Older Sentinel omits these; they - // default to 0 and requests falls back to the status-class sum. + // Per-bucket aggregates, so every KPI card can draw a real sparkline. public int $requests = 0, public int $bytesIn = 0, public int $bytesOut = 0, @@ -25,18 +23,13 @@ class TrafficSeriesBucketData extends Data public static function fromSentinel(array $row): self { - $s2xx = (int) data_get($row, 's2xx', 0); - $s3xx = (int) data_get($row, 's3xx', 0); - $s4xx = (int) data_get($row, 's4xx', 0); - $s5xx = (int) data_get($row, 's5xx', 0); - return new self( bucket: (int) data_get($row, 'bucket', 0), - s2xx: $s2xx, - s3xx: $s3xx, - s4xx: $s4xx, - s5xx: $s5xx, - requests: (int) data_get($row, 'requests', $s2xx + $s3xx + $s4xx + $s5xx), + s2xx: (int) data_get($row, 's2xx', 0), + s3xx: (int) data_get($row, 's3xx', 0), + s4xx: (int) data_get($row, 's4xx', 0), + s5xx: (int) data_get($row, 's5xx', 0), + requests: (int) data_get($row, 'requests', 0), bytesIn: (int) data_get($row, 'bytes_in', 0), bytesOut: (int) data_get($row, 'bytes_out', 0), uniqueVisitors: (int) data_get($row, 'unique_visitors', 0), diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index 073b25e57e..6ec70a26b2 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -58,7 +58,8 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue } /** - * Older versions left occupying executions of a deleted server active, which blocked deleting the App. + * Removes executions that still occupy a runner of a deleted server (for example a server deleted + * without model events), because they block deleting the GitHub App. */ private function removeRunnersOfDeletedServers(): void { diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 08014942fc..00b0e0d23f 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -81,15 +81,12 @@ class Analytics extends Component /** * Per-bucket status-class time series for the stacked area chart, summed across - * target servers and sorted by bucket. Empty when no target Sentinel exposes the - * series endpoint (older builds), which flips the chart back to the status donut. + * target servers and sorted by bucket. * * @var array */ public array $series = []; - public bool $hasSeries = false; - /** * Servers that could run traffic analytics but have it off — drives the nudge banner. * @@ -341,7 +338,7 @@ class Analytics extends Component [$from, $to] = $this->window(); // A selected application or service is queried in Sentinel's resource scope, or under - // all of its keys on an older Sentinel. + // all of its keys on a Sentinel without resource routes (before 1.0.2). $resource = $this->selectedResource(); $servers = $this->targetServers(); $domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain']; @@ -356,27 +353,24 @@ class Analytics extends Component if ($resource !== null) { // One server in Sentinel's resource scope is exact; a merge across servers - // or the per-key fallback of an older Sentinel stays approximate. + // or the per-key fallback (Sentinel before 1.0.2) stays approximate. $aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, $domainForKey); continue; } - // Warm every server-wide endpoint in one docker exec instead of ~15 serial - // SSH round-trips; the per-call methods below then read from cache. + // Warm every server-wide endpoint, including the leaderboard's per-key + // overviews, in one dashboard fetch; the per-call methods below then read + // from cache. $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); - if ($leaderboardKeys !== []) { - if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) { - Log::warning('Traffic analytics leaderboard truncated', [ - 'server' => $server->uuid, - 'total' => count($leaderboardKeys), - 'shown' => self::MAX_LEADERBOARD_APPS, - ]); - $leaderboardKeys = array_slice($leaderboardKeys, 0, self::MAX_LEADERBOARD_APPS); - } - // Warm the leaderboard's per-key overviews in a second batched exec. - $client->prefetchAppOverviews($leaderboardKeys, $from, $to); + if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) { + Log::warning('Traffic analytics leaderboard truncated', [ + 'server' => $server->uuid, + 'total' => count($leaderboardKeys), + 'shown' => self::MAX_LEADERBOARD_APPS, + ]); + $leaderboardKeys = array_slice($leaderboardKeys, 0, self::MAX_LEADERBOARD_APPS); } $aggregator->collect($client, null, $from, $to, $this->range, $domainForKey); @@ -435,31 +429,21 @@ class Analytics extends Component $this->breakdowns = $aggregator->breakdowns(); $this->attribution = $aggregator->attribution(); $this->series = $aggregator->series(); - $this->hasSeries = $this->series !== []; $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); } /** - * Payload for the status chart: the stacked-area time series when available, - * plus the donut totals as a fallback for older Sentinel builds. + * Payload for the status chart: the stacked-area time series and the KPI sparklines. * * @return array */ protected function chartPayload(): array { $device = $this->deviceChartData(); - $overview = $this->overview ?? []; return [ - 'hasSeries' => $this->hasSeries, 'range' => $this->range, - 'seriesData' => [ - $overview['s2xx'] ?? 0, - $overview['s3xx'] ?? 0, - $overview['s4xx'] ?? 0, - $overview['s5xx'] ?? 0, - ], 'timeSeries' => [ 'categories' => array_column($this->series, 'bucket'), 'requests' => $this->requestsSpark(), @@ -491,7 +475,6 @@ class Analytics extends Component $this->breakdowns = []; $this->attribution = null; $this->series = []; - $this->hasSeries = false; } public function errorRate(): float diff --git a/app/Livewire/Concerns/BuildsTrafficChartPayload.php b/app/Livewire/Concerns/BuildsTrafficChartPayload.php index 66af46efa5..eb57c855fb 100644 --- a/app/Livewire/Concerns/BuildsTrafficChartPayload.php +++ b/app/Livewire/Concerns/BuildsTrafficChartPayload.php @@ -11,24 +11,19 @@ namespace App\Livewire\Concerns; trait BuildsTrafficChartPayload { /** - * Per-bucket total requests, for the Requests spark. Derived from the status-class - * counts (every request carries a status class), so it always matches real traffic - * regardless of whether Sentinel populates the explicit per-bucket `requests` field. + * Per-bucket total requests, for the Requests spark. * * @return array */ public function requestsSpark(): array { - return array_map( - fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0), - $this->series, - ); + return array_map(fn ($b) => (int) ($b['requests'] ?? 0), $this->series); } /** * Whether there is plottable request-over-time data for the Requests chart. False when - * Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic - * in the range), so the views can render a no-data state instead of a blank chart. + * there are no series buckets (no data) or every bucket is empty (no traffic in the + * range), so the views can render a no-data state instead of a blank chart. */ public function hasRequestSeries(): bool { @@ -49,8 +44,7 @@ trait BuildsTrafficChartPayload } /** - * Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for - * older Sentinel builds that don't emit per-bucket byte counts. + * Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. * * @return array */ diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php index 2d3228e139..c4cc5cc57c 100644 --- a/app/Livewire/Dashboard/TrafficAnalytics.php +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -74,16 +74,12 @@ class TrafficAnalytics extends Component try { $client = $this->trafficClient($server); - $aggregator->addOverview($client->overview(null, $from, $to)); - + $overview = $client->overview(null, $from, $to); // Per-bucket status series, summed across servers, for the sparklines. - // Isolated so a series hiccup (older Sentinel) never drops a server's overview. - try { - $aggregator->addSeries($client->series(null, $this->range)); - } catch (\Throwable $e) { - // Leave this server out of the sparkline series. - \Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]); - } + $series = $client->series(null, $this->range); + + $aggregator->addOverview($overview); + $aggregator->addSeries($series); } catch (\Throwable $e) { // Skip unreachable/failed servers so one bad server doesn't break the whole summary. \Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]); diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index 4d5e5b9220..cbc1caac1a 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -17,7 +17,8 @@ use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyExceptio /** * Traffic analytics tab of one resource (Application or Service). A resource can record * under several Sentinel keys (compose services, previews). Sentinel's resource scope - * merges them exactly; an older Sentinel gets every key fetched and merged here. + * merges them exactly; a Sentinel without resource routes (before 1.0.2) gets every key + * fetched and merged here. */ abstract class ResourceTrafficAnalytics extends Component { @@ -52,15 +53,12 @@ abstract class ResourceTrafficAnalytics extends Component public ?string $attribution = null; /** - * Per-bucket status-class time series for the stacked area chart. Empty when this - * resource's Sentinel lacks the series endpoint, which flips the chart to the donut. + * Per-bucket status-class time series for the stacked area chart. * * @var array */ public array $series = []; - public bool $hasSeries = false; - /** @var array */ protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; @@ -135,8 +133,8 @@ abstract class ResourceTrafficAnalytics extends Component [$from, $to] = SentinelTrafficClient::rangeWindow($this->range); $client = app(SentinelTrafficClient::class, ['server' => $resource->server()]); - // Sentinel's resource scope merges every key exactly in one docker exec. An older - // Sentinel falls back to all keys of the resource, merged here (approximate). + // Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel + // before 1.0.2 falls back to all keys of the resource, merged here (approximate). $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); $aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey)); @@ -148,7 +146,6 @@ abstract class ResourceTrafficAnalytics extends Component $this->breakdowns = $aggregator->breakdowns(); $this->attribution = $aggregator->attribution(); $this->series = $aggregator->series(); - $this->hasSeries = $this->series !== []; $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); } catch (\Throwable $e) { @@ -157,8 +154,7 @@ abstract class ResourceTrafficAnalytics extends Component } /** - * Payload for the status chart: the stacked-area time series when available, - * plus the donut totals as a fallback for older Sentinel builds. + * Payload for the status chart: the stacked-area time series and the KPI sparklines. * * @return array */ @@ -167,14 +163,7 @@ abstract class ResourceTrafficAnalytics extends Component $device = $this->deviceChartData(); return [ - 'hasSeries' => $this->hasSeries, 'range' => $this->range, - 'seriesData' => [ - $this->overview['s2xx'] ?? 0, - $this->overview['s3xx'] ?? 0, - $this->overview['s4xx'] ?? 0, - $this->overview['s5xx'] ?? 0, - ], 'timeSeries' => [ 'categories' => array_column($this->series, 'bucket'), 'requests' => $this->requestsSpark(), diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 9264e3421f..73c2b70556 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -22,7 +22,7 @@ class SentinelTrafficClient /** * Seconds to remember that this server's Sentinel has no `/api/resource/...` routes. Longer - * than the data cache: every probe on an older Sentinel costs one or two wasted execs. + * than the data cache: every probe on a Sentinel before 1.0.2 costs one wasted exec. */ public const RESOURCE_SCOPE_ABSENCE_TTL = 300; @@ -117,10 +117,7 @@ class SentinelTrafficClient * Per-bucket status-class time series for the stacked-area chart. * * The series endpoints take a single `range` knob (24h/7d/30d) rather than - * from/to, and always return a fixed-length, zero-filled array when present. - * An older Sentinel without the route answers 404 (empty/non-array body); - * we return an empty collection in that case so callers can gracefully fall - * back to the donut instead of surfacing an error. + * from/to, and always return a fixed-length, zero-filled array. * * @return Collection */ @@ -168,9 +165,8 @@ class SentinelTrafficClient } /** - * Warm every resource endpoint in one exec with the resource dashboard bundle. When the - * bundle is unavailable, batch the individual resource endpoints instead. Returns false - * (and remembers it) when Sentinel has no resource routes; the caller then uses the + * Warm every resource endpoint in one exec with the resource dashboard bundle. Returns + * false (and remembers it) when Sentinel has no resource routes; the caller then uses the * per-key path with prefetchResource(). * * @param array $dimensions @@ -183,27 +179,15 @@ class SentinelTrafficClient } $bundle = $this->fetchBundle($this->dashboardUrl($resourceUuid, $from, $to, $range, $pathLimit, $breakdownLimit, 0, self::SCOPE_RESOURCE)); - if ($bundle !== null) { - $this->seedFromDashboard($resourceUuid, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle, self::SCOPE_RESOURCE); - - return true; - } - - $overviewUrl = $this->overviewUrl($resourceUuid, $from, $to, self::SCOPE_RESOURCE); - $this->warm([ - ...$this->endpointUrls($resourceUuid, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit, self::SCOPE_RESOURCE), - $this->attributionUrl(), - ]); - - // After a real batch, an uncached overview means the route is absent: no second probe. - $supported = (! $this->usesBatchableTransport() || Cache::has($this->cacheKey($overviewUrl))) - && $this->genuineOverview($overviewUrl) !== null; - - if (! $supported) { + if ($bundle === null) { $this->markResourceScopeAbsent(); + + return false; } - return $supported; + $this->seedFromDashboard($resourceUuid, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle, self::SCOPE_RESOURCE); + + return true; } public function apps(): array @@ -294,8 +278,7 @@ class SentinelTrafficClient /** * Warm every endpoint for several app keys. One key uses prefetchServerWide(); several - * keys batch their dashboard bundles (or, on an older Sentinel, their individual - * endpoints) into one exec instead of one SSH round-trip per key. + * keys batch their dashboard bundles into one exec instead of one SSH round-trip per key. * * @param array $appKeys * @param array $dimensions @@ -315,31 +298,27 @@ class SentinelTrafficClient return; } - if (Cache::get($this->dashboardAbsenceKey()) !== true) { - $dashboardUrls = array_map( - fn (string $appKey) => $this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, 0), - $appKeys - ); - $this->warm($dashboardUrls); + if (Cache::get($this->dashboardAbsenceKey()) === true) { + return; + } - if (Cache::has($this->cacheKey($dashboardUrls[0]))) { - // Each call now reads its bundle from cache and seeds the per-endpoint cache. - foreach ($appKeys as $appKey) { - $this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit); - } + $dashboardUrls = array_map( + fn (string $appKey) => $this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, 0), + $appKeys + ); + $this->warm($dashboardUrls); - return; - } - - // Older Sentinel without the dashboard route: remember it like fetchDashboard() does. + if (! Cache::has($this->cacheKey($dashboardUrls[0]))) { + // No traffic routes (Sentinel 0.0.x) or unreachable: remember it like fetchDashboard() does. Cache::put($this->dashboardAbsenceKey(), true, 60); + + return; } - $urls = [$this->attributionUrl()]; + // Each call now reads its bundle from cache and seeds the per-endpoint cache. foreach ($appKeys as $appKey) { - array_push($urls, ...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit)); + $this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit); } - $this->warm($urls); } public function attribution(): ?string @@ -350,13 +329,11 @@ class SentinelTrafficClient } /** - * Warm the 60s response cache for every endpoint the dashboard reads, in as few SSH - * round-trips as possible. Prefers Sentinel's aggregate `/traffic/dashboard` (one call - * that returns every shape, including the per-app leaderboard), and falls back to a - * single batched `docker exec` over the individual endpoints when that route is absent - * (older Sentinel). Best-effort: any failure leaves the per-call methods to fetch - * individually. Returns the recorded app uuids so the caller can warm the per-app - * overviews when the fallback path is taken. + * Warm the 60s response cache for every endpoint the dashboard reads with one call to + * Sentinel's aggregate `/traffic/dashboard`, which returns every shape including the + * per-app leaderboard. Best-effort: any failure leaves the per-call methods to fetch + * individually. Returns the leaderboard's app uuids for the server-wide view (an empty + * list for one key, or when the bundle is unavailable). * * @param array $dimensions * @return array @@ -364,33 +341,17 @@ class SentinelTrafficClient public function prefetchServerWide(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): array { $bundle = $this->fetchDashboard($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit); - if ($bundle !== null) { - $this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle); - - if ($appKey !== null) { - return []; - } - - return $this->safeReportedKeys(array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? [])); + if ($bundle === null) { + return []; } - // Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints. - $urls = [ - ...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit), - $this->attributionUrl(), - ]; - // The per-application leaderboard only exists on the unfiltered view. - if ($appKey === null) { - $urls[] = $this->appsUrl(); - } - - $this->warm($urls); + $this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle); if ($appKey !== null) { return []; } - return $this->safeReportedKeys($this->apps()); + return $this->safeReportedKeys(array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? [])); } /** @@ -416,8 +377,8 @@ class SentinelTrafficClient } /** - * Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (older - * Sentinel 404s) or the response isn't a real bundle. The bundle always carries an + * Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (Sentinel + * 0.0.x 404s), the server is unreachable, or the response isn't a real bundle. The bundle always carries an * `overview` member — even for an empty range — so its presence distinguishes a genuine * response from a stub/`{}`. * @@ -425,10 +386,10 @@ class SentinelTrafficClient */ private function fetchDashboard(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): ?array { - // Older Sentinel 404s this route. raw() throws on that (and doesn't cache the failure), - // so without a marker every refresh would re-probe over SSH before falling back to the - // batch. Remember the absence for the same 60s window as the data cache: at most one - // wasted probe per minute, and a Sentinel upgrade is picked up on the next window. + // Sentinel 0.0.x 404s this route. raw() throws on that (and doesn't cache the failure), + // so without a marker every refresh would re-probe over SSH. Remember the absence for + // the same 60s window as the data cache: at most one wasted probe per minute, and a + // Sentinel upgrade is picked up on the next window. $absenceKey = $this->dashboardAbsenceKey(); if (Cache::get($absenceKey) === true) { return null; @@ -511,11 +472,7 @@ class SentinelTrafficClient */ private function fetchSeries(string $url): Collection { - $rows = json_decode($this->raw($url), true); - - if (! is_array($rows) || $rows === []) { - return collect(); - } + $rows = json_decode($this->raw($url), true) ?? []; return collect($rows)->map(fn ($r) => TrafficSeriesBucketData::fromSentinel($r)); } @@ -550,44 +507,11 @@ class SentinelTrafficClient } } - /** - * Warm the per-app overview cache for the leaderboard in one batched exec. - * - * @param array $appKeys - */ - public function prefetchAppOverviews(array $appKeys, string $from, string $to): void - { - $urls = array_map(fn ($appKey) => $this->overviewUrl($appKey, $from, $to), $appKeys); - - $this->warm($urls); - } - private function dashboardAbsenceKey(): string { return 'traffic:dashboard-absent:'.$this->server->uuid; } - /** - * The individual endpoint urls (overview, paths, series, breakdowns) for one key or the - * whole server, used by the batched fallback on Sentinel builds without the dashboard. - * - * @param array $dimensions - * @return array - */ - private function endpointUrls(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit, int $breakdownLimit, string $scope = self::SCOPE_APP): array - { - $urls = [ - $this->overviewUrl($appKey, $from, $to, $scope), - $this->pathsUrl($appKey, $from, $to, $pathLimit, $scope), - $this->seriesUrl($appKey, $range, $scope), - ]; - foreach ($dimensions as $dimension) { - $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit, $scope); - } - - return $urls; - } - private function overviewUrl(?string $appKey, string $from, string $to, string $scope = self::SCOPE_APP): string { $path = $this->appScopedPath($appKey, 'overview', $scope); diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php index bc50a41e57..fd2c6cf34c 100644 --- a/app/Services/TrafficAnalyticsAggregator.php +++ b/app/Services/TrafficAnalyticsAggregator.php @@ -68,8 +68,6 @@ class TrafficAnalyticsAggregator /** * Fetch every shape for one app key (or the whole server when null) and merge it in. - * The series fetch is isolated: a failure (or an older Sentinel without the endpoint) - * never discards the other data; an empty series flips the chart to the donut. * * @param callable(string): ?string $domainForKey resolves a path row's app key to its domain */ @@ -104,7 +102,7 @@ class TrafficAnalyticsAggregator 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), $key, $domainForKey); + $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 @@ -114,11 +112,7 @@ class TrafficAnalyticsAggregator $this->attribution ??= $client->attribution(); - try { - $this->addSeries($resourceScope ? $client->resourceSeries($key, $range) : $client->series($key, $range)); - } catch (\Throwable) { - // Leave this source out of the series; the donut fallback covers it. - } + $this->addSeries($resourceScope ? $client->resourceSeries($key, $range) : $client->series($key, $range)); } public function addOverview(TrafficOverviewData $overview): void @@ -128,24 +122,22 @@ class TrafficAnalyticsAggregator /** * Merge path rows keyed by (app, path), so the same path under two apps stays two - * rows, each with its own domain. Sentinel's per-row `app` wins; older Sentinel omits - * it, so a key-scoped fetch falls back to the key it queried. + * rows, each with the domain of the row's Sentinel `app` key. * * @param iterable $paths * @param callable(string): ?string $domainForKey */ - public function addPaths(iterable $paths, ?string $fallbackAppKey, callable $domainForKey): void + public function addPaths(iterable $paths, callable $domainForKey): void { foreach ($paths as $path) { $data = $path->toArray(); $pathStr = (string) ($data['path'] ?? ''); $appId = (string) ($data['app'] ?? ''); - $resolveId = $appId !== '' ? $appId : ($fallbackAppKey ?? ''); - $key = $resolveId."\n".$pathStr; + $key = $appId."\n".$pathStr; $this->paths[$key] ??= [ 'path' => $pathStr, - 'domain' => $resolveId !== '' ? $domainForKey($resolveId) : null, + 'domain' => $appId !== '' ? $domainForKey($appId) : null, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0, ]; $this->paths[$key]['requests'] += (int) ($data['requests'] ?? 0); diff --git a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php index 34582b4fe6..f347165646 100644 --- a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php @@ -172,7 +172,6 @@ it('loads the per-app status time series when Sentinel exposes the series endpoi loadLazy(Livewire::test(Analytics::class, ['application' => $application])) ->assertOk() - ->assertSet('hasSeries', true) ->assertSet('series', [ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0], ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0], @@ -201,22 +200,6 @@ it('decorates per-app paths with the app domain and surfaces AI agents', functio expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com'); }); -it('falls back to the donut for the per-app chart when the series endpoint is absent', function () { - $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true); - - $responses = fakeAnalyticsResponses(); - unset($responses['/traffic/series']); - - $fake = new FakeAnalyticsTrafficClient($application->destination->server); - $fake->responses = $responses; - app()->bind(SentinelTrafficClient::class, fn () => $fake); - - loadLazy(Livewire::test(Analytics::class, ['application' => $application])) - ->assertOk() - ->assertSet('hasSeries', false) - ->assertSet('series', []); -}); - it('shows an empty state when traffic analytics is disabled for the server', function () { $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false); diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index b5cd80f6b5..f6007d7e00 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -36,7 +36,12 @@ class FakeGlobalAnalyticsTrafficClient extends SentinelTrafficClient function fakeGlobalAnalyticsResponses(array $appUuids = []): array { return [ - '/traffic/apps' => json_encode($appUuids), + // The leaderboard keys come from the dashboard bundle; the fake's raw() then serves + // every per-call fetch from the entries below. + '/traffic/dashboard' => json_encode([ + 'overview' => [], + 'apps' => array_map(fn (string $uuid) => ['uuid' => $uuid, 'overview' => []], $appUuids), + ]), '/traffic/overview' => json_encode([ 'requests' => 1000, 'bytes_in' => 5000, @@ -271,7 +276,6 @@ it('builds a stacked status time series when Sentinel exposes the series endpoin loadLazy(Livewire::test(Analytics::class)) ->assertOk() - ->assertSet('hasSeries', true) ->assertSet('series', [ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0], ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0], @@ -317,23 +321,6 @@ it('derives KPI sparklines, device-donut data, and top hosts for the chart paylo expect($instance->topHosts[0]['requests'])->toBe(1000); }); -it('falls back to the donut when Sentinel lacks the series endpoint', function () { - $server = bootEnabledGlobalServer(); - - // Same responses minus the series entry — an older Sentinel returns 404 (empty body). - $responses = fakeGlobalAnalyticsResponses(); - unset($responses['/traffic/series']); - - $fake = new FakeGlobalAnalyticsTrafficClient($server); - $fake->responses = $responses; - app()->bind(SentinelTrafficClient::class, fn () => $fake); - - loadLazy(Livewire::test(Analytics::class)) - ->assertOk() - ->assertSet('hasSeries', false) - ->assertSet('series', []); -}); - it('does not disclose another team application name for a sentinel-reported uuid', function () { $server = bootEnabledGlobalServer(); diff --git a/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php b/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php index 2b6f6589e7..2a683601b7 100644 --- a/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php @@ -231,66 +231,23 @@ it('serves every resource endpoint from one resource dashboard exec', function ( ->and($client->fetched)->toHaveCount(1); }); -it('falls back to one batch of individual resource endpoints when the resource dashboard is absent', function () { - $client = new class($this->server) extends SentinelTrafficClient - { - public int $batchCalls = 0; - - protected function remoteFetch(string $url): string - { - if (str_contains($url, '/traffic/dashboard')) { - return ''; - } - throw new RuntimeException("unexpected fetch: {$url}"); - } - - protected function batchRemoteFetch(array $urls): string - { - $this->batchCalls++; - - return implode("\x1e", array_map(fn ($url) => match (true) { - str_contains($url, '/resource/res/traffic/overview') => json_encode(resourceScopeOverview(3, 2)), - str_contains($url, '/attribution') => '{"attribution":"demo"}', - str_contains($url, '/resource/res/traffic/') => '[]', - default => throw new RuntimeException("unexpected batch url: {$url}"), - }, $urls))."\x1e"; - } - }; - - expect($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeTrue() - ->and($client->batchCalls)->toBe(1) - ->and($client->resourceOverview('res', 'F', 'T')->requests)->toBe(3) - ->and($client->attribution())->toBe('demo'); - $client->resourceBreakdown('res', 'country', 'F', 'T'); -}); - it('remembers absent resource routes and does not probe them again within the ttl', function () { $client = new class($this->server) extends SentinelTrafficClient { public int $remoteFetches = 0; - public int $batchCalls = 0; - protected function remoteFetch(string $url): string { $this->remoteFetches++; return ''; } - - protected function batchRemoteFetch(array $urls): string - { - $this->batchCalls++; - - return implode("\x1e", array_fill(0, count($urls), ''))."\x1e"; - } }; expect($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeFalse() ->and($client->prefetchResourceScope('res', 'F', 'T', ['country'], '24h'))->toBeFalse() ->and($client->tryResourceOverview('res', 'F', 'T'))->toBeNull() ->and($client->remoteFetches)->toBe(1) - ->and($client->batchCalls)->toBe(1) ->and($client->supportsResourceScope())->toBeFalse(); $this->travel(SentinelTrafficClient::RESOURCE_SCOPE_ABSENCE_TTL + 1)->seconds(); @@ -394,7 +351,10 @@ it('uses the resource scope when the global analytics filter selects a resource' it('keeps the unfiltered leaderboard on per-key rows without resource queries', function () { bindResourceScopeFake([ '/resource/' => new RuntimeException('the leaderboard must not query the resource scope'), - '/traffic/apps' => json_encode([$this->apiKey, $this->webKey]), + '/traffic/dashboard' => json_encode([ + 'overview' => [], + 'apps' => [['uuid' => $this->apiKey, 'overview' => []], ['uuid' => $this->webKey, 'overview' => []]], + ]), "/app/{$this->apiKey}/traffic/overview" => json_encode(resourceScopeOverview(100, 10)), "/app/{$this->webKey}/traffic/overview" => json_encode(resourceScopeOverview(50, 8)), '/traffic/overview' => json_encode(resourceScopeOverview(150, 15)), diff --git a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php index 4b2f6ce84e..3140b2c514 100644 --- a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php +++ b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php @@ -61,6 +61,19 @@ function resourceKeysOverview(int $requests, int $uniques = 10, float $p95 = 20. ]); } +/** + * The server-wide dashboard bundle; the global leaderboard reads its keys from `apps`. + * + * @param array $keys + */ +function resourceKeysDashboard(array $keys): string +{ + return json_encode([ + 'overview' => [], + 'apps' => array_map(fn (string $key) => ['uuid' => $key, 'overview' => []], $keys), + ]); +} + function bindResourceKeysFake(array $responses): void { app()->bind(SentinelTrafficClient::class, function ($app, $params) use ($responses) { @@ -187,39 +200,6 @@ it('warms the key list and every key bundle in two batched execs', function () { $client->breakdown('res-web', 'country', 'F', 'T'); }); -it('batches the individual endpoints of every key when Sentinel lacks the dashboard route', function () { - $client = new class($this->server) extends SentinelTrafficClient - { - public int $batchCalls = 0; - - protected function batchRemoteFetch(array $urls): string - { - $this->batchCalls++; - - return implode("\x1e", array_map(fn ($url) => match (true) { - str_contains($url, '/traffic/apps') => json_encode(['res-api', 'res-web']), - str_contains($url, '/traffic/dashboard') => 'Not Found', - str_contains($url, '/attribution') => '{"attribution":"demo"}', - str_contains($url, '/overview') => '{"requests":3}', - default => '[]', - }, $urls))."\x1e"; - } - - protected function remoteFetch(string $url): string - { - throw new RuntimeException("individual fetch should not run for: {$url}"); - } - }; - - $keys = $client->prefetchResource('res', 'F', 'T', ['country'], '24h'); - - expect($keys)->toBe(['res-api', 'res-web']) - ->and($client->batchCalls)->toBe(3) - ->and($client->overview('res-api', 'F', 'T')->requests)->toBe(3) - ->and($client->overview('res-web', 'F', 'T')->requests)->toBe(3) - ->and($client->attribution())->toBe('demo'); -}); - it('merges overviews, paths, breakdowns, and series of several keys', function () { $aggregator = new TrafficAnalyticsAggregator(['country']); @@ -231,9 +211,8 @@ it('merges overviews, paths, breakdowns, and series of several keys', function ( $aggregator->addPaths(collect([ TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 10, 'p95' => 5]), TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 5, 'p95' => 9]), - ]), 'k-api', $domainForKey); - // Older Sentinel omits `app`: the queried key is the fallback. - $aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '/', 'requests' => 30])]), 'k-web', $domainForKey); + ]), $domainForKey); + $aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-web', 'requests' => 30])]), $domainForKey); $aggregator->addBreakdown('country', collect([TrafficBreakdownData::fromSentinel(['value' => 'US', 'requests' => 4, 'bytes_out' => 1])])); $aggregator->addBreakdown('country', collect([TrafficBreakdownData::fromSentinel(['value' => 'US', 'requests' => 6, 'bytes_out' => 2])])); @@ -307,7 +286,7 @@ it('groups compose keys of one application into one leaderboard row', function ( $webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ - '/traffic/apps' => json_encode([$apiKey, $webKey, 'orphan-key']), + '/traffic/dashboard' => resourceKeysDashboard([$apiKey, $webKey, 'orphan-key']), "/app/{$apiKey}/traffic/overview" => resourceKeysOverview(100), "/app/{$webKey}/traffic/overview" => resourceKeysOverview(50), '/app/orphan-key/traffic/overview' => resourceKeysOverview(5), @@ -345,7 +324,7 @@ it('lists services in the filter, links service rows to service analytics, and q $serviceKey = $service->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ - '/traffic/apps' => json_encode([$serviceKey]), + '/traffic/dashboard' => resourceKeysDashboard([$serviceKey]), "/app/{$serviceKey}/traffic/overview" => resourceKeysOverview(70), '/traffic/overview' => resourceKeysOverview(70), ]); @@ -385,7 +364,7 @@ it('never names or links a key owned by another team resource', function () { $otherKey = $otherService->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ - '/traffic/apps' => json_encode([$otherKey]), + '/traffic/dashboard' => resourceKeysDashboard([$otherKey]), '/traffic/overview' => resourceKeysOverview(10), ]); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php index e489f8d80b..d1f824f1f6 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php @@ -1,5 +1,6 @@ $client->overview(null, $from, $to))->toThrow(RuntimeException::class); - $client->prefetchAppOverviews(['app'], $from, $to); + expect($client->prefetchServerWide(null, $from, $to, [], '24h'))->toBe([]); Process::assertNothingRan(); }); @@ -92,3 +93,28 @@ it('fetches a healthy server with a bounded ssh and curl timeout', function () { Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30 && str_contains($process->command, '--max-time')); }); + +it('shows no data without an error when Sentinel has no traffic routes (0.0.x answers 404)', function () { + markTrafficServerReachable($this->server); + // Gin's 404 body for every unknown route, including /api/traffic/dashboard. + Process::fake(fn () => Process::result(output: '404 page not found')); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSet('overview', null) + ->assertSet('topApps', []) + ->assertSet('series', []) + ->assertNotDispatched('error'); + + loadLazy(Livewire::test(TrafficAnalytics::class)) + ->assertOk() + ->assertSet('overview', null) + ->assertSet('series', []) + ->assertNotDispatched('error'); + + Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, '/api/traffic/dashboard')); +}); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php index 0ea6f05d2e..b3946c00ba 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php @@ -131,17 +131,6 @@ it('builds the per-app series url and defaults an unknown range to 24h', functio ->toContain('range=24h'); }); -it('returns an empty series when the endpoint is absent (older Sentinel 404)', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $client = new FakeTrafficClient($server); - - // Empty body / unparseable / empty array all mean "no series" → donut fallback. - foreach (['', 'Not Found', '{}', '[]'] as $body) { - $client->response = $body; - expect($client->series(null, '24h'))->toBeEmpty(); - } -}); - it('rejects a malicious app key for the series endpoint', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $client = new FakeTrafficClient($server); @@ -200,54 +189,12 @@ it('serves every endpoint from one aggregate dashboard fetch when Sentinel expos $client->breakdown(null, 'country', 'F', 'T'); }); -it('warms every server-wide endpoint in a single batched exec and per-call methods hit cache', function () { +it('probes the absent dashboard route only once per cache window and returns no leaderboard', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); - // Batches responses; individual remoteFetch() must never run once the batch has warmed - // the cache, proving the round-trips collapsed into one exec. - $client = new class($server) extends SentinelTrafficClient - { - public array $batchedCalls = []; - - protected function batchRemoteFetch(array $urls): string - { - $this->batchedCalls[] = $urls; - - // One framed body per url, matched by endpoint so ordering stays irrelevant. - $bodies = array_map(fn ($url) => match (true) { - str_contains($url, '/traffic/apps') => json_encode(['app-a', 'app-b']), - str_contains($url, '/attribution') => '{"attribution":"demo"}', - str_contains($url, '/overview') => '{"requests":1}', - default => '[]', // paths, series, breakdowns - }, $urls); - - return implode("\x1e", $bodies)."\x1e"; - } - - protected function remoteFetch(string $url): string - { - throw new RuntimeException("individual fetch should not run for: {$url}"); - } - }; - - $apps = $client->prefetchServerWide(null, 'F', 'T', ['country', 'browser'], '24h'); - - expect($client->batchedCalls)->toHaveCount(1) - ->and($apps)->toBe(['app-a', 'app-b']); - - // These now read from the warmed cache; remoteFetch() would throw if they didn't. - expect($client->overview(null, 'F', 'T')->requests)->toBe(1); - $client->breakdown(null, 'country', 'F', 'T'); - $client->series(null, '24h'); - $client->attribution(); -}); - -it('probes the absent dashboard route only once per cache window, then reuses the batch fallback', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - - // Older Sentinel: the dashboard route 404s (unparseable body), so raw() throws and the - // client falls back to the batch. The absence must be remembered so a second prefetch in - // the same window doesn't re-probe the dashboard over SSH. + // Sentinel 0.0.x has no traffic routes: Gin answers 404 with a plain-text body, so raw() + // throws. The absence must be remembered so a second prefetch in the same window doesn't + // re-probe the dashboard over SSH, and nothing falls back to individual endpoints. $client = new class($server) extends SentinelTrafficClient { public int $dashboardProbes = 0; @@ -259,7 +206,7 @@ it('probes the absent dashboard route only once per cache window, then reuses th if (str_contains($url, '/traffic/dashboard')) { $this->dashboardProbes++; - return 'Not Found'; + return '404 page not found'; } throw new RuntimeException("unexpected individual fetch: {$url}"); } @@ -267,22 +214,15 @@ it('probes the absent dashboard route only once per cache window, then reuses th protected function batchRemoteFetch(array $urls): string { $this->batchCalls++; - $bodies = array_map(fn ($url) => match (true) { - str_contains($url, '/traffic/apps') => json_encode(['app-a']), - str_contains($url, '/attribution') => '{"attribution":"demo"}', - str_contains($url, '/overview') => '{"requests":1}', - default => '[]', - }, $urls); - return implode("\x1e", $bodies)."\x1e"; + return ''; } }; - $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'); - $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'); - - expect($client->dashboardProbes)->toBe(1) - ->and($client->batchCalls)->toBe(1); + expect($client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'))->toBe([]) + ->and($client->prefetchServerWide(null, 'F', 'T', ['country'], '24h'))->toBe([]) + ->and($client->dashboardProbes)->toBe(1) + ->and($client->batchCalls)->toBe(0); }); it('double-quotes the url in the remote curl command so & is not a shell background operator', function () { @@ -342,14 +282,3 @@ it('skips an unsafe app key in the dashboard bundle and keeps the other apps', f ->and($client->overview('app-b', 'F', 'T')->requests)->toBe(1); Log::shouldHaveReceived('warning')->withArgs(fn ($message) => str_contains($message, 'unsafe'))->once(); }); - -it('skips an unsafe app key reported by an older Sentinel without the dashboard route', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - Log::spy(); - - $client = new FakeTrafficClient($server); - $client->response = json_encode(['app-a', 'bad"key', 'app-b']); - - expect($client->prefetchServerWide(null, 'F', 'T', [], '24h'))->toBe(['app-a', 'app-b']); - Log::shouldHaveReceived('warning')->withArgs(fn ($message) => str_contains($message, 'unsafe'))->once(); -});