feat: Add Prometheus (#2440)

Co-authored-by: Alex Justesen <alexjustesen@users.noreply.github.com>
This commit is contained in:
Sven van Ginkel
2025-12-02 22:31:40 +01:00
committed by GitHub
parent 727ee6d9d2
commit d27000f05f
12 changed files with 577 additions and 8 deletions
@@ -7,15 +7,18 @@ use App\Jobs\Influxdb\v2\TestConnectionJob;
use App\Settings\DataIntegrationSettings; use App\Settings\DataIntegrationSettings;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\SettingsPage; use Filament\Pages\SettingsPage;
use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Grid; 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\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class DataIntegration extends SettingsPage class DataIntegration extends SettingsPage
@@ -52,16 +55,14 @@ class DataIntegration extends SettingsPage
{ {
return $schema return $schema
->components([ ->components([
Grid::make([ Tabs::make()
'default' => 1,
'md' => 3,
])
->schema([ ->schema([
Section::make(__('settings/data_integration.influxdb_v2')) Tab::make(__('settings/data_integration.influxdb_v2'))
->description(__('settings/data_integration.influxdb_v2_description')) ->icon(Heroicon::OutlinedCircleStack)
->schema([ ->schema([
Toggle::make('influxdb_v2_enabled') Toggle::make('influxdb_v2_enabled')
->label(__('settings/data_integration.influxdb_v2_enabled')) ->label(__('settings/data_integration.influxdb_v2_enabled'))
->helpertext(__('settings/data_integration.influxdb_v2_description'))
->reactive() ->reactive()
->columnSpanFull(), ->columnSpanFull(),
Grid::make(['default' => 1, 'md' => 3]) Grid::make(['default' => 1, 'md' => 3])
@@ -127,7 +128,26 @@ class DataIntegration extends SettingsPage
]), ]),
]), ]),
]) ])
->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(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Services\PrometheusMetricsService;
use App\Settings\DataIntegrationSettings;
use Illuminate\Http\Response;
class MetricsController extends Controller
{
public function __construct(
protected PrometheusMetricsService $metricsService,
protected DataIntegrationSettings $settings
) {}
public function __invoke(): Response
{
if (! $this->settings->prometheus_enabled) {
abort(404);
}
$metrics = $this->metricsService->generateMetrics();
return response($metrics, 200, [
'Content-Type' => 'text/plain; version=0.0.4; charset=utf-8',
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use App\Helpers\Network;
use App\Settings\DataIntegrationSettings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PrometheusAllowedIpMiddleware
{
public function __construct(
protected DataIntegrationSettings $settings
) {}
/**
* Handle an incoming request.
*
* @param Closure(Request):Response $next
*/
public function handle(Request $request, Closure $next): Response
{
if (blank($this->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);
}
}
@@ -6,6 +6,7 @@ use App\Events\SpeedtestCompleted;
use App\Events\SpeedtestFailed; use App\Events\SpeedtestFailed;
use App\Jobs\Influxdb\v2\WriteResult; use App\Jobs\Influxdb\v2\WriteResult;
use App\Settings\DataIntegrationSettings; use App\Settings\DataIntegrationSettings;
use Illuminate\Support\Facades\Cache;
class ProcessSpeedtestDataIntegrations class ProcessSpeedtestDataIntegrations
{ {
@@ -24,5 +25,9 @@ class ProcessSpeedtestDataIntegrations
if ($this->settings->influxdb_v2_enabled) { if ($this->settings->influxdb_v2_enabled) {
WriteResult::dispatch($event->result); WriteResult::dispatch($event->result);
} }
if ($this->settings->prometheus_enabled) {
Cache::forever('prometheus:latest_result', $event->result->id);
}
} }
} }
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace App\Services;
use App\Models\Result;
use App\Settings\DataIntegrationSettings;
use Illuminate\Support\Facades\Cache;
use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
use Prometheus\Storage\InMemory;
class PrometheusMetricsService
{
public function __construct(
protected DataIntegrationSettings $settings
) {}
public function generateMetrics(): string
{
$registry = new CollectorRegistry(new InMemory);
$resultId = Cache::get('prometheus:latest_result');
if (! $resultId) {
return $this->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";
}
}
+4
View File
@@ -18,6 +18,10 @@ class DataIntegrationSettings extends Settings
public bool $influxdb_v2_verify_ssl; public bool $influxdb_v2_verify_ssl;
public bool $prometheus_enabled;
public array $prometheus_allowed_ips = [];
public static function group(): string public static function group(): string
{ {
return 'dataintegration'; return 'dataintegration';
+1
View File
@@ -29,6 +29,7 @@
"livewire/livewire": "^3.6.4", "livewire/livewire": "^3.6.4",
"lorisleiva/laravel-actions": "^2.9.1", "lorisleiva/laravel-actions": "^2.9.1",
"maennchen/zipstream-php": "^2.4", "maennchen/zipstream-php": "^2.4",
"promphp/prometheus_client_php": "^2.14",
"saloonphp/laravel-plugin": "^3.0", "saloonphp/laravel-plugin": "^3.0",
"secondnetwork/blade-tabler-icons": "^3.35.0", "secondnetwork/blade-tabler-icons": "^3.35.0",
"spatie/laravel-json-api-paginate": "^1.16.3", "spatie/laravel-json-api-paginate": "^1.16.3",
Generated
+68
View File
@@ -5462,6 +5462,74 @@
}, },
"time": "2025-09-19T23:02:26+00:00" "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", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
@@ -0,0 +1,12 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsMigration;
class CreatePrometheusSettings extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('dataintegration.prometheus_enabled', false);
$this->migrator->add('dataintegration.prometheus_allowed_ips', []);
}
}
+7
View File
@@ -33,6 +33,13 @@ return [
'influxdb_bulk_write_success' => 'Finished bulk data load to Influxdb.', '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.', '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 // Common labels
'org' => 'Org', 'org' => 'Org',
'bucket' => 'Bucket', 'bucket' => 'Bucket',
+6
View File
@@ -1,6 +1,8 @@
<?php <?php
use App\Http\Controllers\HomeController; use App\Http\Controllers\HomeController;
use App\Http\Controllers\MetricsController;
use App\Http\Middleware\PrometheusAllowedIpMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/* /*
@@ -18,6 +20,10 @@ Route::get('/', HomeController::class)
->middleware(['getting-started', 'public-dashboard']) ->middleware(['getting-started', 'public-dashboard'])
->name('home'); ->name('home');
Route::get('/prometheus', MetricsController::class)
->middleware(PrometheusAllowedIpMiddleware::class)
->name('prometheus');
Route::view('/getting-started', 'getting-started') Route::view('/getting-started', 'getting-started')
->name('getting-started'); ->name('getting-started');
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Models\Result;
use App\Settings\DataIntegrationSettings;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
Cache::flush();
});
describe('metrics endpoint', function () {
test('returns 404 when prometheus is disabled', function () {
app(DataIntegrationSettings::class)->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();
});
});