6 - Send ntfy alert when poll failures cross threshold
This commit is contained in:
parent
8d063a8262
commit
6ab175a466
7 changed files with 183 additions and 9 deletions
|
|
@ -63,3 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10
|
||||
|
||||
NTFY_URL=
|
||||
NTFY_TOPIC=
|
||||
NTFY_THRESHOLD=
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Lvl0\FediDiscover\Models\Instance;
|
||||
|
||||
class PollAlertService
|
||||
{
|
||||
public function recordFailure(Instance $instance): void
|
||||
public function recordFailure(Instance $instance, string $message): void
|
||||
{
|
||||
$instance->increment('consecutive_poll_failures');
|
||||
$instance->refresh();
|
||||
|
||||
$ntfyUrl = config('services.ntfy.url');
|
||||
$ntfyThreshold = (int) config('services.ntfy.threshold');
|
||||
$ntfyTopic = config('services.ntfy.topic');
|
||||
|
||||
if ($ntfyUrl === null || $ntfyThreshold === 0 || $ntfyTopic === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($instance->consecutive_poll_failures < $ntfyThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(5)
|
||||
->withBody($instance->url . ' - ' . $message, 'text/plain')
|
||||
->post(rtrim($ntfyUrl, '/') . '/' . $ntfyTopic);
|
||||
} catch (Exception $e) {
|
||||
logger()->warning('ntfy alert failed', ['instance' => $instance->url, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'ntfy' => [
|
||||
'url' => env('NTFY_URL') ?: null,
|
||||
'topic' => env('NTFY_TOPIC') ?: null,
|
||||
'threshold' => env('NTFY_THRESHOLD'),
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -61,5 +61,4 @@ public function pages(): HasMany
|
|||
{
|
||||
return $this->hasMany(Page::class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Enums\PageStatusEnum;
|
||||
use App\Models\Page;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Lvl0\FediDiscover\Config\InstanceType;
|
||||
|
|
@ -63,23 +64,23 @@ public function test_admin_instances_page_shows_error_count_per_instance(): void
|
|||
Page::factory()
|
||||
->count(3)
|
||||
->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"])
|
||||
->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Failed]);
|
||||
->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Failed]);
|
||||
|
||||
Page::factory()
|
||||
->count(2)
|
||||
->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"])
|
||||
->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Fetched]);
|
||||
->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Fetched]);
|
||||
|
||||
// Second instance: 1 failed + 4 non-failed pages
|
||||
Page::factory()
|
||||
->count(1)
|
||||
->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"])
|
||||
->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Failed]);
|
||||
->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Failed]);
|
||||
|
||||
Page::factory()
|
||||
->count(4)
|
||||
->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"])
|
||||
->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Fetched]);
|
||||
->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Fetched]);
|
||||
|
||||
$response = $this->get('/admin/instances');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Services\PollAlertService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Lvl0\FediDiscover\Config\InstanceType;
|
||||
use Lvl0\FediDiscover\Models\Instance;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -14,19 +15,157 @@ class PollAlertServiceTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_recordFailure_increments_consecutive_poll_failures_on_the_instance(): void
|
||||
public function test_record_failure_increments_consecutive_poll_failures_on_the_instance(): void
|
||||
{
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 0]);
|
||||
|
||||
$service = new PollAlertService();
|
||||
$service->recordFailure($instance);
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
$this->assertDatabaseHas('fedi_discover_instances', [
|
||||
'id' => $instance->id,
|
||||
'consecutive_poll_failures' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_no_alert_sent_below_threshold(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => 'trove-alerts',
|
||||
'services.ntfy.threshold' => 3,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 1]); // will become 2 after recordFailure
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_alert_sent_when_threshold_is_reached(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => 'trove-alerts',
|
||||
'services.ntfy.threshold' => 3,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = exactly at threshold
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://ntfy.example.com/trove-alerts'
|
||||
&& $request->method() === 'POST';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_alert_sent_when_count_exceeds_threshold(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => 'trove-alerts',
|
||||
'services.ntfy.threshold' => 3,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 3]); // will become 4 after recordFailure = above threshold
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://ntfy.example.com/trove-alerts'
|
||||
&& $request->method() === 'POST';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_no_alert_sent_when_threshold_is_zero(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => 'trove-alerts',
|
||||
'services.ntfy.threshold' => 0,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 5]);
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_no_alert_sent_when_topic_is_null(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => null,
|
||||
'services.ntfy.threshold' => 3,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = at threshold
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'test');
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_alert_body_contains_instance_url_and_message(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||
'services.ntfy.topic' => 'trove-alerts',
|
||||
'services.ntfy.threshold' => 3,
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()
|
||||
->type(InstanceType::Mastodon)
|
||||
->enabled()
|
||||
->create([
|
||||
'url' => 'https://mastodon.social',
|
||||
'consecutive_poll_failures' => 2, // will become 3 = at threshold
|
||||
]);
|
||||
|
||||
$service = new PollAlertService;
|
||||
$service->recordFailure($instance, 'connection refused after 3 retries');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->body(), 'https://mastodon.social')
|
||||
&& str_contains($request->body(), 'connection refused after 3 retries');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue