responses as $needle => $response) { if (str_contains($url, $needle)) { if ($response instanceof Throwable) { throw $response; } return $response; } } return '{}'; } } function resourceKeysOverview(int $requests, int $uniques = 10, float $p95 = 20.0): string { return json_encode([ 'requests' => $requests, 'bytes_in' => $requests * 10, 'bytes_out' => $requests * 100, 'status' => ['s2xx' => $requests, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0], 'latency' => ['p50' => 5.0, 'p95' => $p95, 'p99' => 50.0], 'unique_visitors' => $uniques, ]); } /** * 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) { $client = new FakeResourceKeysTrafficClient($params['server']); $client->responses = $responses; return $client; }); } beforeEach(function () { Cache::flush(); Server::flushIdentityMap(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->actingAs($this->user); session(['currentTeam' => $this->team]); $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, ]); $this->server->settings->is_traffic_analytics_enabled = true; $this->server->settings->save(); $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); $this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Storefront']); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); function makeTrafficComposeApplication(): Application { return Application::factory()->create([ 'name' => 'Compose Shop', 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_domains' => json_encode([ 'api' => ['domain' => 'https://api.shop.test'], 'web' => ['domain' => 'https://www.shop.test'], ]), 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => StandaloneDocker::class, ]); } function makeTrafficService(Server $server, StandaloneDocker $destination, Environment $environment, string $name, string $fqdn): Service { $service = Service::factory()->create([ 'name' => $name, 'server_id' => $server->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'environment_id' => $environment->id, ]); ServiceApplication::create([ 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine', 'fqdn' => $fqdn, ]); return $service; } it('resolves the bare uuid and every uuid-prefixed key, without false prefix matches', function () { $client = new FakeResourceKeysTrafficClient($this->server); $client->responses = ['/traffic/apps' => json_encode([ 'abc', 'abc-api-1a2b', 'abc-12', 'abcx', 'abcd-web', 'xabc', 'other', 'abc-bad key', 42, ])]; expect($client->resourceKeys('abc'))->toBe(['abc', 'abc-12', 'abc-api-1a2b']) ->and($client->resourceKeys('zzz'))->toBe(['zzz']) ->and(SentinelTrafficClient::keyBelongsTo('abcx', 'abc'))->toBeFalse() ->and(SentinelTrafficClient::keyBelongsTo('abc-web', 'abc'))->toBeTrue() ->and(SentinelTrafficClient::candidateOwnerUuids('abc-12-web'))->toBe(['abc-12-web', 'abc-12', 'abc']); expect(fn () => $client->resourceKeys("x'; touch /tmp/pwned; '"))->toThrow(InvalidArgumentException::class); }); it('falls back to the bare uuid when the recorded key list is unavailable', function () { $client = new FakeResourceKeysTrafficClient($this->server); $client->responses = ['/traffic/apps' => new RuntimeException('unreachable')]; expect($client->resourceKeys('abc'))->toBe(['abc']); }); it('warms the key list and every key bundle in two batched execs', function () { $client = new class($this->server) extends SentinelTrafficClient { public array $batches = []; protected function batchRemoteFetch(array $urls): string { $this->batches[] = $urls; return implode("\x1e", array_map(fn ($url) => match (true) { str_contains($url, '/traffic/apps') => json_encode(['res', 'res-api', 'res-web', 'other']), str_contains($url, '/app/res-api/traffic/dashboard') => json_encode(['overview' => ['requests' => 7], 'paths' => [], 'breakdowns' => [], 'series' => [], 'attribution' => null]), str_contains($url, '/traffic/dashboard') => json_encode(['overview' => ['requests' => 1], 'paths' => [], 'breakdowns' => [], 'series' => [], 'attribution' => null]), 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', 'res-api', 'res-web']) ->and($client->batches)->toHaveCount(2) // The bare-uuid bundle came with the key list; only the other two are fetched. ->and($client->batches[1])->toHaveCount(2); // Served from the seeded cache; remoteFetch() would throw otherwise. expect($client->overview('res-api', 'F', 'T')->requests)->toBe(7) ->and($client->overview('res-web', 'F', 'T')->requests)->toBe(1); $client->breakdown('res-web', 'country', 'F', 'T'); }); it('merges overviews, paths, breakdowns, and series of several keys', function () { $aggregator = new TrafficAnalyticsAggregator(['country']); $aggregator->addOverview(TrafficOverviewData::fromSentinel(json_decode(resourceKeysOverview(100, 10, 20.0), true))); $aggregator->addOverview(TrafficOverviewData::fromSentinel(json_decode(resourceKeysOverview(50, 5, 40.0), true))); $domains = ['k-api' => 'api.test', 'k-web' => 'web.test']; $domainForKey = fn (string $key) => $domains[$key] ?? null; $aggregator->addPaths(collect([ TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 10, 'p95' => 5]), TrafficPathData::fromSentinel(['path' => '/', 'app' => 'k-api', 'requests' => 5, 'p95' => 9]), ]), $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])])); $aggregator->addSeries(collect([TrafficSeriesBucketData::fromSentinel(['bucket' => 2000, 's2xx' => 1]), TrafficSeriesBucketData::fromSentinel(['bucket' => 1000, 's2xx' => 2])])); $aggregator->addSeries(collect([TrafficSeriesBucketData::fromSentinel(['bucket' => 1000, 's2xx' => 3])])); $overview = $aggregator->overview(); expect($overview['overview']->requests)->toBe(150) ->and($overview['overview']->uniqueVisitors)->toBe(15) ->and($overview['overview']->latencyP95)->toBe(40.0) ->and($overview['latencyApproximate'])->toBeTrue() ->and($overview['uniquesApproximate'])->toBeTrue(); $paths = $aggregator->topPaths(); expect($paths)->toHaveCount(2) ->and($paths[0])->toMatchArray(['path' => '/', 'domain' => 'web.test', 'requests' => 30]) ->and($paths[1])->toMatchArray(['path' => '/', 'domain' => 'api.test', 'requests' => 15, 'p95' => 9.0]); expect($aggregator->breakdowns()['country'])->toBe([['value' => 'US', 'requests' => 10, 'bytesOut' => 3]]); expect(array_column($aggregator->series(), 'bucket'))->toBe([1000, 2000]) ->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'); $webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ '/traffic/apps' => json_encode([$apiKey, $webKey, 'someone-else']), "/app/{$apiKey}/traffic/overview" => resourceKeysOverview(100), "/app/{$webKey}/traffic/overview" => resourceKeysOverview(50), "/app/{$apiKey}/traffic/paths" => json_encode([['path' => '/v1/orders', 'app' => $apiKey, 'requests' => 100]]), "/app/{$webKey}/traffic/paths" => json_encode([['path' => '/checkout', 'app' => $webKey, 'requests' => 50]]), "/app/{$application->uuid}/traffic" => new RuntimeException('bare uuid must not be queried when compose keys exist'), ]); $component = loadLazy(Livewire::test(ApplicationAnalytics::class, ['application' => $application])) ->assertOk() ->assertSee('150') ->assertSee('api.shop.test') ->assertSee('www.shop.test') ->assertSet('uniquesApproximate', true); $paths = collect($component->instance()->topPaths)->keyBy('path'); expect($component->instance()->overview['requests'])->toBe(150) ->and($paths['/v1/orders']['domain'])->toBe('api.shop.test') ->and($paths['/checkout']['domain'])->toBe('www.shop.test'); }); it('sums every compose key on the application analytics page', function () { $application = makeTrafficComposeApplication(); $apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api'); $webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ '/traffic/apps' => json_encode([$apiKey, $webKey]), "/app/{$apiKey}/traffic/overview" => resourceKeysOverview(4000), "/app/{$webKey}/traffic/overview" => resourceKeysOverview(200), ]); loadLazy(Livewire::test(ApplicationAnalytics::class, ['application' => $application])) ->assertOk() ->assertSee('4,200'); }); it('groups compose keys of one application into one leaderboard row', function () { $application = makeTrafficComposeApplication(); $apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api'); $webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ '/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), '/traffic/overview' => resourceKeysOverview(155), '/traffic/paths' => json_encode([['path' => '/v1', 'app' => $apiKey, 'requests' => 9]]), ]); $instance = loadLazy(Livewire::test(GlobalAnalytics::class)) ->assertOk() ->assertSee('Compose Shop') ->assertDontSee($apiKey) ->instance(); $rows = collect($instance->topApps)->keyBy('uuid'); expect($rows)->toHaveCount(2) ->and($rows[$application->uuid]['name'])->toBe('Compose Shop') ->and($rows[$application->uuid]['requests'])->toBe(150) ->and($rows[$application->uuid]['link'])->toBe(route('project.application.analytics', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'application_uuid' => $application->uuid, ])) ->and($rows['orphan-key']['name'])->toBe('orphan-key') ->and($rows['orphan-key']['link'])->toBeNull(); // Hosts stay per served domain; path rows carry the compose service domain. $hosts = collect($instance->topHosts)->keyBy('host'); expect($hosts['api.shop.test']['requests'])->toBe(100) ->and($hosts['www.shop.test']['requests'])->toBe(50) ->and($instance->topPaths[0]['domain'])->toBe('api.shop.test'); }); it('lists services in the filter, links service rows to service analytics, and queries all service keys', function () { $service = makeTrafficService($this->server, $this->destination, $this->environment, 'Umami', 'https://stats.shop.test'); $serviceKey = $service->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ '/traffic/dashboard' => resourceKeysDashboard([$serviceKey]), "/app/{$serviceKey}/traffic/overview" => resourceKeysOverview(70), '/traffic/overview' => resourceKeysOverview(70), ]); $component = loadLazy(Livewire::test(GlobalAnalytics::class)) ->assertOk() ->assertSee('Umami'); $serviceLink = route('project.service.analytics', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'service_uuid' => $service->uuid, ]); $row = $component->instance()->topApps[0]; expect($row['uuid'])->toBe($service->uuid) ->and($row['name'])->toBe('Umami') ->and($row['isService'])->toBeTrue() ->and($row['domain'])->toBe('stats.shop.test') ->and($row['link'])->toBe($serviceLink); $component->assertSee($serviceLink, false); expect(collect($component->instance()->appGroupedOptions)->firstWhere('value', $service->uuid)['label'])->toBe('Umami (Service)'); $component->set('appUuid', $service->uuid) ->assertSet('appUuid', $service->uuid) ->assertDontSee('Top applications'); expect($component->instance()->overview['requests'])->toBe(70); }); it('never names or links a key owned by another team resource', function () { $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id, 'network' => 'other-team-test']); $otherService = makeTrafficService($otherServer, $otherDestination, $otherEnvironment, 'Secret Other Service', 'https://secret.other.test'); $otherKey = $otherService->uuid.'-'.traefikSafeServiceNameSegment('web'); bindResourceKeysFake([ '/traffic/dashboard' => resourceKeysDashboard([$otherKey]), '/traffic/overview' => resourceKeysOverview(10), ]); $component = loadLazy(Livewire::test(GlobalAnalytics::class)) ->assertOk() ->assertDontSee('Secret Other Service') ->assertDontSee('secret.other.test') ->assertSee($otherKey); expect($component->instance()->topApps[0]['link'])->toBeNull() ->and($component->instance()->appOptions)->not->toHaveKey($otherService->uuid); // A bookmarked filter for another team's service is dropped, not queried. loadLazy(Livewire::withQueryParams(['app' => $otherService->uuid])->test(GlobalAnalytics::class)) ->assertSet('appUuid', ''); }); it('finds the preview domain for normal and compose preview keys', function () { $application = Application::factory()->create([ 'fqdn' => 'https://shop.test', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => StandaloneDocker::class, ]); $application->previews()->create([ 'pull_request_id' => 12, 'pull_request_html_url' => 'https://github.com/coollabsio/shop/pull/12', 'fqdn' => 'https://12.shop.test', 'docker_compose_domains' => json_encode(['api' => ['domain' => 'https://api-12.shop.test']]), ]); $resource = TrafficResource::for($application->fresh()); expect($resource->domainForKey("{$application->uuid}-pr-12"))->toBe('12.shop.test') ->and($resource->domainForKey("{$application->uuid}-12"))->toBe('12.shop.test') ->and($resource->domainForKey("{$application->uuid}-12-api"))->toBe('api-12.shop.test') ->and($resource->domainForKey($application->uuid))->toBe('shop.test'); });