feat(analytics): add configurable client IP storage modes

Allow full, anonymized, or disabled IP recording per server through the UI
and API, and pass the selected mode to Sentinel. Adapt traffic breakdowns
to the effective IP mode, including views that aggregate multiple servers.
This commit is contained in:
Andras Bacsai
2026-10-07 12:35:22 +02:00
parent 49b954efa2
commit 575bfaa085
19 changed files with 399 additions and 31 deletions
+1
View File
@@ -58,6 +58,7 @@
## Trace infrastructure changes end to end
- For container image changes, inspect Compose services and every relevant Dockerfile build stage.
- Pin a stable release tag instead of using a floating `latest` tag.
- Never give a test build an official image tag (for example `coollabsio/sentinel:<version>`) in the host Docker. Dev instances that use `testing-host` share the host Docker socket and run that tag without a pull. Load such a tag only inside a dev VM, and give the build the same version string, or Coolify's Sentinel check restarts it every minute.
- A successful image pull does not prove that the complete application build no longer uses the old image.
- Do not use `docker compose up --wait` for a stack with one-shot services; wait for the required long-running service's health instead.
+2
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Server;
use App\Enums\TrafficIpMode;
use App\Events\SentinelRestarted;
use App\Models\Server;
use Illuminate\Contracts\Cache\LockTimeoutException;
@@ -58,6 +59,7 @@ class StartSentinel
'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395),
'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false',
'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30),
'TRAFFIC_IP_MODE' => TrafficIpMode::forServer($server)->value,
];
$license = data_get($settings, 'geoip_maxmind_license_key');
if ($settings->is_geoip_enabled && filled($license)) {
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Enums;
use App\Models\Server;
/**
* How Sentinel stores client IPs for the "Top IPs" traffic breakdown (TRAFFIC_IP_MODE).
*/
enum TrafficIpMode: string
{
case Full = 'full';
case Anonymized = 'anonymized';
case Off = 'off';
public static function forServer(Server $server): self
{
return $server->settings?->traffic_ip_mode ?? self::Full;
}
/**
* Effective mode for a view that merges several servers: Off only when every server is off,
* Anonymized when every server that records IPs anonymizes them, otherwise Full.
*
* @param iterable<Server> $servers
*/
public static function forServers(iterable $servers): self
{
$modes = collect($servers)->map(fn (Server $server): self => self::forServer($server));
if ($modes->isEmpty()) {
return self::Full;
}
$recording = $modes->reject(fn (self $mode): bool => $mode === self::Off);
if ($recording->isEmpty()) {
return self::Off;
}
return $recording->every(fn (self $mode): bool => $mode === self::Anonymized)
? self::Anonymized
: self::Full;
}
/**
* Drop the `ip` dimension when Sentinel does not record it.
*
* @param array<int, string> $dimensions
* @return array<int, string>
*/
public function breakdownDimensions(array $dimensions): array
{
if ($this !== self::Off) {
return $dimensions;
}
return array_values(array_filter($dimensions, fn (string $dimension): bool => $dimension !== 'ip'));
}
public function breakdownLabel(): string
{
return $this === self::Anonymized ? 'Top networks' : 'Top IPs';
}
public function breakdownHelper(): string
{
return $this === self::Anonymized
? 'Busiest client networks. IPs are anonymized to /24 (IPv4) and /48 (IPv6).'
: 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).';
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Enums\TrafficIpMode;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\ServerSetting;
@@ -26,6 +27,7 @@ class ServerSentinelController extends Controller
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
];
private function findServerForTeam(int $teamId, string $uuid): ?Server
@@ -55,6 +57,7 @@ class ServerSentinelController extends Controller
'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days,
'is_geoip_enabled' => (bool) $settings->is_geoip_enabled,
'geoip_refresh_days' => (int) $settings->geoip_refresh_days,
'traffic_ip_mode' => TrafficIpMode::forServer($server)->value,
];
if ($this->canReadSensitive()) {
@@ -97,6 +100,7 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'),
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer'),
new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs for the Top IPs breakdown.'),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'),
],
type: 'object',
@@ -152,6 +156,7 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true),
new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'),
],
type: 'object',
),
@@ -198,6 +203,7 @@ class ServerSentinelController extends Controller
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer|min:1',
'geoip_maxmind_license_key' => 'nullable|string|max:255',
'traffic_ip_mode' => 'string|in:full,anonymized,off',
]);
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
+10 -2
View File
@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Enums\TrafficIpMode;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Server;
@@ -79,6 +80,10 @@ class Analytics extends Component
public ?string $attribution = null;
/** Effective TrafficIpMode of the queried servers; decides how the IP breakdown renders. */
#[Locked]
public string $ipMode = 'full';
/**
* Per-bucket status-class time series for the stacked area chart, summed across
* target servers and sorted by bucket.
@@ -342,8 +347,11 @@ class Analytics extends Component
$resource = $this->selectedResource();
$servers = $this->targetServers();
$domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain'];
$ipMode = TrafficIpMode::forServers($servers);
$this->ipMode = $ipMode->value;
$dimensions = $ipMode->breakdownDimensions($this->breakdownDimensions);
$aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions);
$aggregator = new TrafficAnalyticsAggregator($dimensions);
$appRows = [];
$hostTotals = [];
@@ -362,7 +370,7 @@ class Analytics extends Component
// 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);
$leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $dimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) {
Log::warning('Traffic analytics leaderboard truncated', [
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared;
use App\Enums\TrafficIpMode;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Service;
@@ -52,6 +53,10 @@ abstract class ResourceTrafficAnalytics extends Component
public ?string $attribution = null;
/** TrafficIpMode of the resource's server; decides how the IP breakdown renders. */
#[Locked]
public string $ipMode = 'full';
/**
* Per-bucket status-class time series for the stacked area chart.
*
@@ -131,11 +136,14 @@ abstract class ResourceTrafficAnalytics extends Component
$resource = $this->trafficResource();
[$from, $to] = SentinelTrafficClient::rangeWindow($this->range);
$client = app(SentinelTrafficClient::class, ['server' => $resource->server()]);
$server = $resource->server();
$client = app(SentinelTrafficClient::class, ['server' => $server]);
$ipMode = $server ? TrafficIpMode::forServer($server) : TrafficIpMode::Full;
$this->ipMode = $ipMode->value;
// 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 = new TrafficAnalyticsAggregator($ipMode->breakdownDimensions($this->breakdownDimensions));
$aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey));
$result = $aggregator->overview();
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Enums\ProxyTypes;
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -39,6 +40,9 @@ class TrafficAnalyticsSettings extends Component
#[Validate(['nullable', 'string', 'max:255'])]
public ?string $geoipMaxmindLicenseKey = null;
#[Validate(['required', 'string', 'in:full,anonymized,off'])]
public string $trafficIpMode = 'full';
public function mount(): void
{
$this->authorize('update', $this->server);
@@ -56,6 +60,7 @@ class TrafficAnalyticsSettings extends Component
$this->server->settings->is_geoip_enabled = $this->isGeoipEnabled;
$this->server->settings->geoip_refresh_days = $this->geoipRefreshDays;
$this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey;
$this->server->settings->traffic_ip_mode = TrafficIpMode::from($this->trafficIpMode);
$this->server->settings->save();
return;
@@ -69,6 +74,7 @@ class TrafficAnalyticsSettings extends Component
$this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled;
$this->geoipRefreshDays = $this->server->settings->geoip_refresh_days;
$this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key;
$this->trafficIpMode = TrafficIpMode::forServer($this->server)->value;
}
public function toggleTrafficAnalytics(): void
@@ -99,7 +105,7 @@ class TrafficAnalyticsSettings extends Component
$this->authorize('update', $this->server);
$this->syncData(true);
auditLog('ui.server.traffic_analytics.updated', $this->auditContext([
'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'],
'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode'],
]));
$this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.');
} catch (\Throwable $e) {
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@@ -36,6 +37,7 @@ use OpenApi\Attributes as OA;
'traffic_retention_1d_days' => ['type' => 'integer'],
'is_geoip_enabled' => ['type' => 'boolean'],
'geoip_refresh_days' => ['type' => 'integer'],
'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']],
'is_reachable' => ['type' => 'boolean'],
'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'],
@@ -131,6 +133,7 @@ class ServerSetting extends Model
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
'docker_version',
'docker_version_checked_at',
'compose_version',
@@ -167,6 +170,7 @@ class ServerSetting extends Model
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer',
'geoip_maxmind_license_key' => 'encrypted',
'traffic_ip_mode' => TrafficIpMode::class,
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
'backup_compression_cpu_percentage' => 'integer',
@@ -218,7 +222,8 @@ class ServerSetting extends Model
$settings->wasChanged('traffic_retention_1d_days') ||
$settings->wasChanged('is_geoip_enabled') ||
$settings->wasChanged('geoip_refresh_days') ||
$settings->wasChanged('geoip_maxmind_license_key')
$settings->wasChanged('geoip_maxmind_license_key') ||
$settings->wasChanged('traffic_ip_mode')
) {
// Only recreate Sentinel when it is already enabled. Otherwise a change to a
// traffic/geoip tuning knob would turn Sentinel on as a side effect, because
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->string('traffic_ip_mode')->default('full');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('traffic_ip_mode');
});
}
};
+49 -6
View File
@@ -991,7 +991,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -1582,7 +1582,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -2173,7 +2173,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -2659,7 +2659,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -3125,7 +3125,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -9161,6 +9161,9 @@
},
"409": {
"description": "Another start, restart or import of this database is already in progress."
},
"422": {
"description": "The database cannot start yet, for example because a CA certificate is missing."
}
},
"security": [
@@ -9279,6 +9282,9 @@
},
"409": {
"description": "Another start, restart or import of this database is already in progress."
},
"422": {
"description": "The database cannot start yet, for example because a CA certificate is missing."
}
},
"security": [
@@ -17045,6 +17051,15 @@
"geoip_refresh_days": {
"type": "integer"
},
"traffic_ip_mode": {
"description": "How Sentinel stores client IPs for the Top IPs breakdown.",
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
},
"geoip_maxmind_license_key": {
"description": "Only present with read:sensitive.",
"type": "string"
@@ -17153,6 +17168,15 @@
"string",
"null"
]
},
"traffic_ip_mode": {
"description": "How Sentinel stores client IPs: full, anonymized (IPv4 \/24, IPv6 \/48), or off.",
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
}
},
"type": "object"
@@ -23224,6 +23248,13 @@
"default": 36000,
"maximum": 36000,
"minimum": 60
},
"missing_backup_notification_days": {
"description": "Alert after this many days without an execution; 0 disables alerts",
"type": "integer",
"default": 0,
"maximum": 365,
"minimum": 0
}
},
"type": "object",
@@ -23246,7 +23277,8 @@
"retention_amount_s3",
"retention_days_s3",
"retention_max_storage_s3",
"timeout"
"timeout",
"missing_backup_notification_days"
],
"properties": {
"uuid": {
@@ -23308,6 +23340,9 @@
},
"timeout": {
"type": "integer"
},
"missing_backup_notification_days": {
"type": "integer"
}
},
"type": "object"
@@ -24344,6 +24379,14 @@
"geoip_refresh_days": {
"type": "integer"
},
"traffic_ip_mode": {
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
},
"is_reachable": {
"type": "boolean"
},
+29 -5
View File
@@ -655,7 +655,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1041,7 +1041,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1427,7 +1427,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1754,7 +1754,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -2067,7 +2067,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -5985,6 +5985,8 @@ paths:
$ref: '#/components/responses/404'
'409':
description: 'Another start, restart or import of this database is already in progress.'
'422':
description: 'The database cannot start yet, for example because a CA certificate is missing.'
security:
-
bearerAuth: []
@@ -6060,6 +6062,8 @@ paths:
$ref: '#/components/responses/404'
'409':
description: 'Another start, restart or import of this database is already in progress.'
'422':
description: 'The database cannot start yet, for example because a CA certificate is missing.'
security:
-
bearerAuth: []
@@ -10758,6 +10762,7 @@ paths:
traffic_retention_1d_days: { type: integer }
is_geoip_enabled: { type: boolean }
geoip_refresh_days: { type: integer }
traffic_ip_mode: { description: 'How Sentinel stores client IPs for the Top IPs breakdown.', type: string, enum: [full, anonymized, 'off'] }
geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string }
type: object
'401':
@@ -10828,6 +10833,10 @@ paths:
minimum: 1
geoip_maxmind_license_key:
type: [string, 'null']
traffic_ip_mode:
description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'
type: string
enum: [full, anonymized, 'off']
type: object
responses:
'200':
@@ -14741,6 +14750,12 @@ components:
default: 36000
maximum: 36000
minimum: 60
missing_backup_notification_days:
description: 'Alert after this many days without an execution; 0 disables alerts'
type: integer
default: 0
maximum: 365
minimum: 0
type: object
additionalProperties: false
VolumeBackupScheduleResponse:
@@ -14761,6 +14776,7 @@ components:
- retention_days_s3
- retention_max_storage_s3
- timeout
- missing_backup_notification_days
properties:
uuid:
type: string
@@ -14803,6 +14819,8 @@ components:
format: float
timeout:
type: integer
missing_backup_notification_days:
type: integer
type: object
Application:
description: 'Application model'
@@ -15562,6 +15580,12 @@ components:
type: boolean
geoip_refresh_days:
type: integer
traffic_ip_mode:
type: string
enum:
- full
- anonymized
- 'off'
is_reachable:
type: boolean
is_sentinel_enabled:
+9 -6
View File
@@ -370,12 +370,15 @@ $appListboxOptions = array_merge(
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => 'Top IPs',
'rows' => data_get($breakdowns, 'ip', []),
'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@@ -214,12 +214,15 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => 'Top IPs',
'rows' => data_get($breakdowns, 'ip', []),
'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@@ -2,7 +2,7 @@
<form wire:submit.prevent="saveTrafficAnalyticsSettings" class="contents">
@if ($isTrafficAnalyticsEnabled)
<x-unsaved-bar action="saveTrafficAnalyticsSettings"
targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey" />
targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey,trafficIpMode" />
@endif
<x-application.settings-section id="server-traffic-analytics-settings-section" title="Traffic analytics"
@@ -54,6 +54,14 @@
['value' => false, 'label' => 'Disabled'],
]"
helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." />
<x-forms.listbox canGate="update" :canResource="$server"
id="trafficIpMode" label="Client IP addresses"
:options="[
['value' => 'full', 'label' => 'Full'],
['value' => 'anonymized', 'label' => 'Anonymized (/24 IPv4, /48 IPv6)'],
['value' => 'off', 'label' => 'Disabled'],
]"
helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="geoipRefreshDays" label="GeoIP refresh interval" required
helper="Days between GeoIP database update checks." />
+28 -1
View File
@@ -221,7 +221,8 @@ describe('Sentinel API', function () {
->assertJsonPath('is_sentinel_enabled', true)
->assertJsonPath('is_metrics_enabled', true)
->assertJsonPath('traffic_topn', 50)
->assertJsonPath('is_geoip_enabled', true);
->assertJsonPath('is_geoip_enabled', true)
->assertJsonPath('traffic_ip_mode', 'full');
expect($response->json())->not->toHaveKey('sentinel_token')
->and($response->json())->not->toHaveKey('sentinel_custom_url');
@@ -247,6 +248,32 @@ describe('Sentinel API', function () {
->toHaveCount(1);
});
test('PATCH updates the client IP mode and restarts Sentinel', function (string $mode) {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => $mode,
])
->assertOk()
->assertJsonPath('traffic_ip_mode', $mode);
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe($mode);
expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel))
->toHaveCount(1);
})->with(['anonymized', 'off']);
test('PATCH rejects an unknown client IP mode', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => 'partial',
])
->assertUnprocessable()
->assertJsonValidationErrors('traffic_ip_mode');
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe('full');
});
test('PATCH rejects disabling mandatory Sentinel', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
@@ -200,6 +200,24 @@ 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('hides the IP breakdown when the server does not record client IPs', function () {
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
$server = $application->destination->server;
$server->settings->traffic_ip_mode = 'off';
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
$fake = new FakeAnalyticsTrafficClient($server);
$fake->responses = fakeAnalyticsResponses();
app()->bind(SentinelTrafficClient::class, fn () => $fake);
loadLazy(Livewire::test(Analytics::class, ['application' => $application->fresh()]))
->assertOk()
->assertSee('Top user agents')
->assertDontSee('Top IPs')
->assertDontSee('198.51.100.42');
});
it('shows an empty state when traffic analytics is disabled for the server', function () {
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
@@ -1,5 +1,6 @@
<?php
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Models\Application;
use App\Models\Environment;
@@ -469,3 +470,52 @@ it('queries Sentinel with the same window for loads within one cache interval',
->and($fake->urls)->not->toBeEmpty()
->and(array_values(array_diff($fake->urls, $firstUrls)))->toBe([]);
});
it('labels the IP breakdown by the client IP mode of the queried servers', function (string $mode, ?string $label) {
$server = bootEnabledGlobalServer();
$server->settings->traffic_ip_mode = $mode;
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
$fake = new FakeGlobalAnalyticsTrafficClient($server);
$fake->responses = fakeGlobalAnalyticsResponses();
app()->bind(SentinelTrafficClient::class, fn () => $fake);
$component = loadLazy(Livewire::test(Analytics::class))
->assertOk()
->assertSet('ipMode', $mode)
->assertSee('Top user agents');
if ($label === null) {
$component->assertDontSee('Top IPs')
->assertDontSee('Top networks')
->assertDontSee('203.0.113.7');
expect($component->instance()->breakdowns)->not->toHaveKey('ip');
return;
}
$component->assertSee($label)->assertSee('203.0.113.7');
})->with([
'full' => ['full', 'Top IPs'],
'anonymized' => ['anonymized', 'Top networks'],
'off' => ['off', null],
]);
it('combines the client IP mode of several servers', function (array $modes, string $expected) {
$servers = collect($modes)->map(function (string $mode) {
$server = bootEnabledGlobalServer();
$server->settings->traffic_ip_mode = $mode;
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
return $server->fresh();
});
expect(TrafficIpMode::forServers($servers))->toBe(TrafficIpMode::from($expected));
})->with([
'no servers' => [[], 'full'],
'all off' => [['off', 'off'], 'off'],
'anonymized and off' => [['anonymized', 'off'], 'anonymized'],
'full and anonymized' => [['full', 'anonymized'], 'full'],
]);
@@ -44,9 +44,19 @@ it('produces traffic + geoip env when enabled', function () {
expect($env['GEOIP_ENABLED'])->toBe('true');
expect($env['GEOIP_REFRESH_DAYS'])->toBe('30');
expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic');
expect($env['TRAFFIC_IP_MODE'])->toBe('full');
expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH');
});
it('passes the saved client IP mode as sentinel env', function (string $mode) {
$server = trafficProxyServer($this);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->traffic_ip_mode = $mode;
$server->settings->save();
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_IP_MODE'])->toBe($mode);
})->with(['anonymized', 'off']);
it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) {
config()->set('app.env', 'local');
$server = trafficProxyServer($this);
@@ -2,6 +2,7 @@
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\InstanceSettings;
@@ -105,6 +106,51 @@ it('saves traffic analytics settings from the sentinel form', function () {
->and($settings->geoip_refresh_days)->toBe(7);
});
it('saves the client IP mode from the sentinel form', function () {
Queue::fake();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->assertSet('trafficIpMode', 'full')
->assertSee('Client IP addresses')
->set('trafficIpMode', 'anonymized')
->call('saveTrafficAnalyticsSettings')
->assertHasNoErrors();
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Anonymized);
});
it('rejects an unknown client IP mode', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->set('trafficIpMode', 'partial')
->call('saveTrafficAnalyticsSettings')
->assertHasErrors(['trafficIpMode']);
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full);
});
it('does not let a team member change the client IP mode', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$component = Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
$component->set('trafficIpMode', 'off')
->call('saveTrafficAnalyticsSettings');
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full);
});
it('rejects a zero top-n cap', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);