diff --git a/app/Filament/Pages/Settings/DataIntegration.php b/app/Filament/Pages/Settings/DataIntegration.php index b3d4dec33..5930293d8 100644 --- a/app/Filament/Pages/Settings/DataIntegration.php +++ b/app/Filament/Pages/Settings/DataIntegration.php @@ -7,15 +7,18 @@ use App\Settings\DataIntegrationSettings; use Filament\Actions\Action; use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Pages\SettingsPage; use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; +use Filament\Support\Icons\Heroicon; use Illuminate\Support\Facades\Auth; class DataIntegration extends SettingsPage @@ -52,16 +55,14 @@ public function form(Schema $schema): Schema { return $schema ->components([ - Grid::make([ - 'default' => 1, - 'md' => 3, - ]) + Tabs::make() ->schema([ - Section::make(__('settings/data_integration.influxdb_v2')) - ->description(__('settings/data_integration.influxdb_v2_description')) + Tab::make(__('settings/data_integration.influxdb_v2')) + ->icon(Heroicon::OutlinedCircleStack) ->schema([ Toggle::make('influxdb_v2_enabled') ->label(__('settings/data_integration.influxdb_v2_enabled')) + ->helpertext(__('settings/data_integration.influxdb_v2_description')) ->reactive() ->columnSpanFull(), Grid::make(['default' => 1, 'md' => 3]) @@ -127,7 +128,26 @@ public function form(Schema $schema): Schema ]), ]), ]) - ->compact() + ->columnSpanFull(), + Tab::make(__('settings/data_integration.prometheus')) + ->icon(Heroicon::OutlinedChartBar) + ->schema([ + Toggle::make('prometheus_enabled') + ->label(__('settings/data_integration.prometheus_enabled')) + ->helperText(__('settings/data_integration.influxdb_v2_description')) + ->reactive() + ->columnSpanFull(), + Grid::make(['default' => 1, 'md' => 3]) + ->hidden(fn (Get $get) => $get('prometheus_enabled') !== true) + ->schema([ + TagsInput::make('prometheus_allowed_ips') + ->label(__('settings/data_integration.prometheus_allowed_ips')) + ->helperText(__('settings/data_integration.prometheus_allowed_ips_helper')) + ->placeholder('192.168.1.100') + ->splitKeys(['Tab', ',', ' ']) + ->columnSpanFull(), + ]), + ]) ->columnSpanFull(), ]) ->columnSpanFull(), diff --git a/app/Http/Controllers/MetricsController.php b/app/Http/Controllers/MetricsController.php new file mode 100644 index 000000000..984be20d8 --- /dev/null +++ b/app/Http/Controllers/MetricsController.php @@ -0,0 +1,28 @@ +settings->prometheus_enabled) { + abort(404); + } + + $metrics = $this->metricsService->generateMetrics(); + + return response($metrics, 200, [ + 'Content-Type' => 'text/plain; version=0.0.4; charset=utf-8', + ]); + } +} diff --git a/app/Http/Middleware/PrometheusAllowedIpMiddleware.php b/app/Http/Middleware/PrometheusAllowedIpMiddleware.php new file mode 100644 index 000000000..c599b8a6f --- /dev/null +++ b/app/Http/Middleware/PrometheusAllowedIpMiddleware.php @@ -0,0 +1,43 @@ +settings->prometheus_allowed_ips)) { + return $next($request); + } + + $clientIp = $request->ip(); + $allowedIps = $this->settings->prometheus_allowed_ips; + + foreach ($allowedIps as $allowedIp) { + if (str_contains($allowedIp, '/')) { + if (Network::ipInRange($clientIp, $allowedIp)) { + return $next($request); + } + } elseif ($clientIp === $allowedIp) { + return $next($request); + } + } + + abort(403); + } +} diff --git a/app/Listeners/ProcessSpeedtestDataIntegrations.php b/app/Listeners/ProcessSpeedtestDataIntegrations.php index effb9bdbd..8b2360b9f 100644 --- a/app/Listeners/ProcessSpeedtestDataIntegrations.php +++ b/app/Listeners/ProcessSpeedtestDataIntegrations.php @@ -6,6 +6,7 @@ use App\Events\SpeedtestFailed; use App\Jobs\Influxdb\v2\WriteResult; use App\Settings\DataIntegrationSettings; +use Illuminate\Support\Facades\Cache; class ProcessSpeedtestDataIntegrations { @@ -24,5 +25,9 @@ public function handle(SpeedtestCompleted|SpeedtestFailed $event): void if ($this->settings->influxdb_v2_enabled) { WriteResult::dispatch($event->result); } + + if ($this->settings->prometheus_enabled) { + Cache::forever('prometheus:latest_result', $event->result->id); + } } } diff --git a/app/Services/PrometheusMetricsService.php b/app/Services/PrometheusMetricsService.php new file mode 100644 index 000000000..7b433369d --- /dev/null +++ b/app/Services/PrometheusMetricsService.php @@ -0,0 +1,249 @@ +emptyMetrics(); + } + + $lastResult = Result::find($resultId); + + if (! $lastResult) { + return $this->emptyMetrics(); + } + + $this->registerMetrics($registry, $lastResult); + + $renderer = new RenderTextFormat; + + return $renderer->render($registry->getMetricFamilySamples()); + } + + protected function registerMetrics(CollectorRegistry $registry, Result $result): void + { + $labels = $this->buildLabels($result); + $labelNames = array_keys($labels); + $labelValues = array_values($labels); + + // Download speed in bytes + $downloadBytesGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_bytes', + 'Download speed in bytes per second', + $labelNames + ); + $downloadBytesGauge->set($result->download, $labelValues); + + // Upload speed in bytes + $uploadBytesGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_bytes', + 'Upload speed in bytes per second', + $labelNames + ); + $uploadBytesGauge->set($result->upload, $labelValues); + + // Download speed in bits per second + $downloadBitsGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_bits', + 'Download speed in bits per second', + $labelNames + ); + $downloadBitsGauge->set(toBits($result->download), $labelValues); + + // Upload speed in bits per second + $uploadBitsGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_bits', + 'Upload speed in bits per second', + $labelNames + ); + $uploadBitsGauge->set(toBits($result->upload), $labelValues); + + // Ping latency in milliseconds + $pingGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'ping_ms', + 'Ping latency in milliseconds', + $labelNames + ); + $pingGauge->set($result->ping, $labelValues); + + // Ping jitter + $pingJitterGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'ping_jitter_ms', + 'Ping jitter in milliseconds', + $labelNames + ); + $pingJitterGauge->set($result->ping_jitter ?? 0, $labelValues); + + // Download jitter + $downloadJitterGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_jitter_ms', + 'Download jitter in milliseconds', + $labelNames + ); + $downloadJitterGauge->set($result->download_jitter ?? 0, $labelValues); + + // Upload jitter + $uploadJitterGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_jitter_ms', + 'Upload jitter in milliseconds', + $labelNames + ); + $uploadJitterGauge->set($result->upload_jitter ?? 0, $labelValues); + + // Packet loss + $packetLossGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'packet_loss_percent', + 'Packet loss percentage', + $labelNames + ); + $packetLossGauge->set($result->packet_loss ?? 0, $labelValues); + + // Ping latency low/high + $pingLowGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'ping_low_ms', + 'Ping low latency in milliseconds', + $labelNames + ); + $pingLowGauge->set($result->ping_low ?? 0, $labelValues); + + $pingHighGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'ping_high_ms', + 'Ping high latency in milliseconds', + $labelNames + ); + $pingHighGauge->set($result->ping_high ?? 0, $labelValues); + + // Download latency metrics (IQM = Interquartile Mean - more reliable than average) + $downloadLatencyIqmGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_latency_iqm_ms', + 'Download latency interquartile mean in milliseconds', + $labelNames + ); + $downloadLatencyIqmGauge->set($result->downloadlatencyiqm ?? 0, $labelValues); + + $downloadLatencyLowGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_latency_low_ms', + 'Download latency low in milliseconds', + $labelNames + ); + $downloadLatencyLowGauge->set($result->downloadlatency_low ?? 0, $labelValues); + + $downloadLatencyHighGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_latency_high_ms', + 'Download latency high in milliseconds', + $labelNames + ); + $downloadLatencyHighGauge->set($result->downloadlatency_high ?? 0, $labelValues); + + // Upload latency metrics + $uploadLatencyIqmGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_latency_iqm_ms', + 'Upload latency interquartile mean in milliseconds', + $labelNames + ); + $uploadLatencyIqmGauge->set($result->uploadlatencyiqm ?? 0, $labelValues); + + $uploadLatencyLowGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_latency_low_ms', + 'Upload latency low in milliseconds', + $labelNames + ); + $uploadLatencyLowGauge->set($result->uploadlatency_low ?? 0, $labelValues); + + $uploadLatencyHighGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_latency_high_ms', + 'Upload latency high in milliseconds', + $labelNames + ); + $uploadLatencyHighGauge->set($result->uploadlatency_high ?? 0, $labelValues); + + // Bytes transferred during test + $downloadedBytesGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'downloaded_bytes', + 'Total bytes downloaded during test', + $labelNames + ); + $downloadedBytesGauge->set($result->downloaded_bytes ?? 0, $labelValues); + + $uploadedBytesGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'uploaded_bytes', + 'Total bytes uploaded during test', + $labelNames + ); + $uploadedBytesGauge->set($result->uploaded_bytes ?? 0, $labelValues); + + // Test duration + $downloadElapsedGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'download_elapsed_ms', + 'Download test duration in milliseconds', + $labelNames + ); + $downloadElapsedGauge->set($result->download_elapsed ?? 0, $labelValues); + + $uploadElapsedGauge = $registry->getOrRegisterGauge( + 'speedtest_tracker', + 'upload_elapsed_ms', + 'Upload test duration in milliseconds', + $labelNames + ); + $uploadElapsedGauge->set($result->upload_elapsed ?? 0, $labelValues); + } + + protected function buildLabels(Result $result): array + { + return [ + 'server_id' => (string) ($result->server_id ?? ''), + 'server_name' => $result->server_name ?? '', + 'server_country' => $result->server_country ?? '', + 'server_location' => $result->server_location ?? '', + 'isp' => $result->isp ?? '', + 'scheduled' => $result->scheduled ? 'true' : 'false', + 'healthy' => $result->healthy ? 'true' : 'false', + 'status' => $result->status->value, + 'app_name' => config('app.name', 'Speedtest Tracker'), + ]; + } + + protected function emptyMetrics(): string + { + return "# no data available\n"; + } +} diff --git a/app/Settings/DataIntegrationSettings.php b/app/Settings/DataIntegrationSettings.php index 2cc29515e..31811c768 100644 --- a/app/Settings/DataIntegrationSettings.php +++ b/app/Settings/DataIntegrationSettings.php @@ -18,6 +18,10 @@ class DataIntegrationSettings extends Settings public bool $influxdb_v2_verify_ssl; + public bool $prometheus_enabled; + + public array $prometheus_allowed_ips = []; + public static function group(): string { return 'dataintegration'; diff --git a/composer.json b/composer.json index 3c107977b..e8fa378f0 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "livewire/livewire": "^3.6.4", "lorisleiva/laravel-actions": "^2.9.1", "maennchen/zipstream-php": "^2.4", + "promphp/prometheus_client_php": "^2.14", "saloonphp/laravel-plugin": "^3.0", "secondnetwork/blade-tabler-icons": "^3.35.0", "spatie/laravel-json-api-paginate": "^1.16.3", diff --git a/composer.lock b/composer.lock index 56e6d0ef7..4077fe867 100644 --- a/composer.lock +++ b/composer.lock @@ -5462,6 +5462,74 @@ }, "time": "2025-09-19T23:02:26+00:00" }, + { + "name": "promphp/prometheus_client_php", + "version": "v2.14.1", + "source": { + "type": "git", + "url": "https://github.com/PromPHP/prometheus_client_php.git", + "reference": "a283aea8269287dc35313a0055480d950c59ac1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/a283aea8269287dc35313a0055480d950c59ac1f", + "reference": "a283aea8269287dc35313a0055480d950c59ac1f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "endclothing/prometheus_client_php": "*", + "jimdo/prometheus_client_php": "*", + "lkaemmerling/prometheus_client_php": "*" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5.4", + "phpstan/phpstan-phpunit": "^1.1.0", + "phpstan/phpstan-strict-rules": "^1.1.0", + "phpunit/phpunit": "^9.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/polyfill-apcu": "^1.6" + }, + "suggest": { + "ext-apc": "Required if using APCu.", + "ext-pdo": "Required if using PDO.", + "ext-redis": "Required if using Redis.", + "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", + "symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Prometheus\\": "src/Prometheus/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Lukas Kämmerling", + "email": "kontakt@lukas-kaemmerling.de" + } + ], + "description": "Prometheus instrumentation library for PHP applications.", + "support": { + "issues": "https://github.com/PromPHP/prometheus_client_php/issues", + "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.14.1" + }, + "time": "2025-04-14T07:59:43+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/database/settings/2025_11_25_191005_create_prometheus_settings.php b/database/settings/2025_11_25_191005_create_prometheus_settings.php new file mode 100644 index 000000000..da7c8025f --- /dev/null +++ b/database/settings/2025_11_25_191005_create_prometheus_settings.php @@ -0,0 +1,12 @@ +migrator->add('dataintegration.prometheus_enabled', false); + $this->migrator->add('dataintegration.prometheus_allowed_ips', []); + } +} diff --git a/lang/en/settings/data_integration.php b/lang/en/settings/data_integration.php index 0f3887671..f55a77441 100644 --- a/lang/en/settings/data_integration.php +++ b/lang/en/settings/data_integration.php @@ -33,6 +33,13 @@ 'influxdb_bulk_write_success' => 'Finished bulk data load to Influxdb.', 'influxdb_bulk_write_success_body' => 'Data has been sent to InfluxDB, check if the data was received.', + // Prometheus + 'prometheus' => 'Prometheus', + 'prometheus_enabled' => 'Enable', + 'prometheus_enabled_helper_text' => 'When enabled, metrics for each new speedtest will be available at the /prometheus endpoint.', + 'prometheus_allowed_ips' => 'Allowed IP Addresses', + 'prometheus_allowed_ips_helper' => 'List of IP addresses or CIDR ranges (e.g., 192.168.1.0/24) allowed to access the metrics endpoint. Leave empty to allow all IPs.', + // Common labels 'org' => 'Org', 'bucket' => 'Bucket', diff --git a/routes/web.php b/routes/web.php index 140056470..19ca32a2a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,8 @@ middleware(['getting-started', 'public-dashboard']) ->name('home'); +Route::get('/prometheus', MetricsController::class) + ->middleware(PrometheusAllowedIpMiddleware::class) + ->name('prometheus'); + Route::view('/getting-started', 'getting-started') ->name('getting-started'); diff --git a/tests/Feature/MetricsEndpointTest.php b/tests/Feature/MetricsEndpointTest.php new file mode 100644 index 000000000..cadf0ce28 --- /dev/null +++ b/tests/Feature/MetricsEndpointTest.php @@ -0,0 +1,126 @@ +fill(['prometheus_enabled' => false])->save(); + + $response = $this->get('/prometheus'); + + $response->assertNotFound(); + }); + + test('returns metrics when prometheus is enabled and no IP restrictions', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => [], + ])->save(); + + Result::factory()->create(); + + $response = $this->get('/prometheus'); + + $response->assertSuccessful(); + $response->assertHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + }); + + test('returns 403 when IP is not in allowed list', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => ['192.168.1.100', '10.0.0.1'], + ])->save(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '192.168.1.50', + ]); + + $response->assertForbidden(); + }); + + test('returns metrics when IP is in allowed list', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => ['192.168.1.100', '10.0.0.1'], + ])->save(); + + Result::factory()->create(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '192.168.1.100', + ]); + + $response->assertSuccessful(); + $response->assertHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + }); + + test('allows access with empty array', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => [], + ])->save(); + + Result::factory()->create(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '10.0.0.1', + ]); + + $response->assertSuccessful(); + }); + + test('allows access when IP is in CIDR range', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => ['192.168.1.0/24'], + ])->save(); + + Result::factory()->create(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '192.168.1.150', + ]); + + $response->assertSuccessful(); + }); + + test('denies access when IP is not in CIDR range', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => ['192.168.1.0/24'], + ])->save(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '192.168.2.1', + ]); + + $response->assertForbidden(); + }); + + test('supports mixed IP addresses and CIDR ranges', function () { + app(DataIntegrationSettings::class)->fill([ + 'prometheus_enabled' => true, + 'prometheus_allowed_ips' => ['10.0.0.1', '192.168.1.0/24'], + ])->save(); + + Result::factory()->create(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '192.168.1.50', + ]); + + $response->assertSuccessful(); + + $response = $this->get('/prometheus', [ + 'REMOTE_ADDR' => '10.0.0.1', + ]); + + $response->assertSuccessful(); + }); +});