refactor(traffic): remove fallbacks for unreleased Sentinel builds

Every released Sentinel with traffic analytics (1.0.0 and later) has
the dashboard and series routes and the requests, bytes_in and app
fields, so the fallbacks for builds without them are not needed.

- Keep "no data" handling for Sentinel without traffic routes (0.0.x)
- Keep the resource-scope fallback, which Sentinel 1.0.1 needs
This commit is contained in:
Andras Bacsai
2026-10-06 10:29:04 +02:00
parent 460bb67d63
commit 06b5a708b8
15 changed files with 151 additions and 416 deletions
+1 -2
View File
@@ -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 = '',
) {}
+6 -13
View File
@@ -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),
+2 -1
View File
@@ -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
{
+14 -31
View File
@@ -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<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
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<string, mixed>
*/
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
@@ -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<int, int>
*/
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<int, int>
*/
+5 -9
View File
@@ -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()]);
@@ -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<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
public array $series = [];
public bool $hasSeries = false;
/** @var array<int, string> */
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<string, mixed>
*/
@@ -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(),
+41 -117
View File
@@ -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<int, TrafficSeriesBucketData>
*/
@@ -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<int, string> $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<int, string> $appKeys
* @param array<int, string> $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<int, string> $dimensions
* @return array<int, string>
@@ -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<int, string> $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<int, string> $dimensions
* @return array<int, string>
*/
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);
+6 -14
View File
@@ -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<int, mixed> $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);
@@ -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);
@@ -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();
@@ -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)),
@@ -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<int, string> $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),
]);
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Analytics;
use App\Livewire\Dashboard\TrafficAnalytics;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -43,7 +44,7 @@ it('does not run a remote command for a server that is not reachable', function
[$from, $to] = SentinelTrafficClient::rangeWindow('24h');
expect(fn () => $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'));
});
@@ -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();
});