fix(traffic): merge overflow paths and distinguish rows by domain

Combine Sentinel overflow rows into one unlinked "Other paths" row.
Show domains beside paths and include them in Livewire row keys.
Add regression coverage for overflow aggregation across app keys.
This commit is contained in:
Andras Bacsai
2026-10-07 13:40:02 +02:00
parent d08c082d07
commit a6655fe992
3 changed files with 35 additions and 9 deletions
+3 -2
View File
@@ -122,7 +122,8 @@ class TrafficAnalyticsAggregator
/**
* 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.
* rows, each with the domain of the row's Sentinel `app` key. Sentinel's `__other__`
* overflow rows (the long tail past its top-N cap) fold into one row without a domain.
*
* @param iterable<int, mixed> $paths
* @param callable(string): ?string $domainForKey
@@ -132,7 +133,7 @@ class TrafficAnalyticsAggregator
foreach ($paths as $path) {
$data = $path->toArray();
$pathStr = (string) ($data['path'] ?? '');
$appId = (string) ($data['app'] ?? '');
$appId = $pathStr === '__other__' ? '' : (string) ($data['app'] ?? '');
$key = $appId."\n".$pathStr;
$this->paths[$key] ??= [
@@ -2,7 +2,9 @@
Paginated "top paths" list. Each row shows the request path, a proportional
request-volume bar, and right-aligned compact metrics (requests / bytes / p95).
The path links to its live URL when the owning domain is known (new tab,
rel="noopener noreferrer nofollow"). Expects `$paths` in scope; optional
rel="noopener noreferrer nofollow"); the domain shows next to the path so the
same path of two apps stays apart. Sentinel's `__other__` overflow row reads
as "Other paths". Expects `$paths` in scope; optional
`$keyPrefix` to namespace wire:keys.
@param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 'p95']
@@ -32,21 +34,32 @@
@php
$domain = $path['domain'] ?? null;
$pathStr = (string) ($path['path'] ?? '');
$href = $domain ? 'https://'.$domain.$pathStr : null;
$isOther = $pathStr === '__other__';
$href = $domain && ! $isOther ? 'https://'.$domain.$pathStr : null;
$requests = (int) ($path['requests'] ?? 0);
$s4xx = (int) ($path['s4xx'] ?? 0);
$s5xx = (int) ($path['s5xx'] ?? 0);
$errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0;
$width = min(100, round(($requests / $maxRequests) * 100, 1));
@endphp
<div wire:key="{{ $keyPrefix }}-{{ md5($pathStr) }}"
<div wire:key="{{ $keyPrefix }}-{{ md5($domain."\n".$pathStr) }}"
x-show="{{ $loop->index }} >= page * per && {{ $loop->index }} < (page + 1) * per"
class="flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]">
@if ($href)
<a href="{{ $href }}" target="_blank" rel="noopener noreferrer nofollow"
class="min-w-0 flex-1 truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $pathStr }}</a>
@if ($isOther)
<span class="min-w-0 flex-1 truncate text-[12px] text-neutral-500 dark:text-fg-dim"
title="All paths outside the top paths that Sentinel tracks">Other paths</span>
@else
<span class="min-w-0 flex-1 truncate font-mono text-[12px] text-black dark:text-fg">{{ $pathStr }}</span>
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
@if ($href)
<a href="{{ $href }}" target="_blank" rel="noopener noreferrer nofollow"
class="truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $pathStr }}</a>
@else
<span class="truncate font-mono text-[12px] text-black dark:text-fg">{{ $pathStr }}</span>
@endif
@if ($domain)
<span class="max-w-48 shrink-0 truncate text-[11px] text-neutral-400 dark:text-fg-faint" title="{{ $domain }}">{{ $domain }}</span>
@endif
</span>
@endif
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
@@ -237,6 +237,18 @@ it('merges overviews, paths, breakdowns, and series of several keys', function (
->and($aggregator->series()[0]['s2xx'])->toBe(5);
});
it('folds the overflow path rows of all keys into one unlinked row', function () {
$aggregator = new TrafficAnalyticsAggregator([]);
$domains = ['k-api' => 'api.test', 'k-web' => 'web.test'];
$domainForKey = fn (string $key) => $domains[$key] ?? null;
$aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '__other__', 'app' => 'k-api', 'requests' => 7, 's4xx' => 1, 'p95' => 3])]), $domainForKey);
$aggregator->addPaths(collect([TrafficPathData::fromSentinel(['path' => '__other__', 'app' => 'k-web', 'requests' => 5, 's4xx' => 2, 'p95' => 8])]), $domainForKey);
expect($aggregator->topPaths())->toHaveCount(1)
->and($aggregator->topPaths()[0])->toMatchArray(['path' => '__other__', 'domain' => null, 'requests' => 12, 's4xx' => 3, 'p95' => 8.0]);
});
it('shows a compose application with the data of all its compose service keys', function () {
$application = makeTrafficComposeApplication();
$apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api');