6 - Send ntfy alert when poll failures cross threshold

This commit is contained in:
myrmidex 2026-04-29 21:25:11 +02:00
parent 8d063a8262
commit 6ab175a466
7 changed files with 183 additions and 9 deletions

View file

@ -63,3 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10 CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10
NTFY_URL=
NTFY_TOPIC=
NTFY_THRESHOLD=

View file

@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;

View file

@ -1,14 +1,38 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace App\Services; namespace App\Services;
use Exception;
use Illuminate\Support\Facades\Http;
use Lvl0\FediDiscover\Models\Instance; use Lvl0\FediDiscover\Models\Instance;
class PollAlertService class PollAlertService
{ {
public function recordFailure(Instance $instance): void public function recordFailure(Instance $instance, string $message): void
{ {
$instance->increment('consecutive_poll_failures'); $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()]);
}
} }
} }

View file

@ -14,6 +14,12 @@
| |
*/ */
'ntfy' => [
'url' => env('NTFY_URL') ?: null,
'topic' => env('NTFY_TOPIC') ?: null,
'threshold' => env('NTFY_THRESHOLD'),
],
'postmark' => [ 'postmark' => [
'key' => env('POSTMARK_API_KEY'), 'key' => env('POSTMARK_API_KEY'),
], ],

View file

@ -61,5 +61,4 @@ public function pages(): HasMany
{ {
return $this->hasMany(Page::class); return $this->hasMany(Page::class);
} }
} }

View file

@ -4,6 +4,7 @@
namespace Tests\Feature\Admin; namespace Tests\Feature\Admin;
use App\Enums\PageStatusEnum;
use App\Models\Page; use App\Models\Page;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Config\InstanceType;
@ -63,23 +64,23 @@ public function test_admin_instances_page_shows_error_count_per_instance(): void
Page::factory() Page::factory()
->count(3) ->count(3)
->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"]) ->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() Page::factory()
->count(2) ->count(2)
->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"]) ->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 // Second instance: 1 failed + 4 non-failed pages
Page::factory() Page::factory()
->count(1) ->count(1)
->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"]) ->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() Page::factory()
->count(4) ->count(4)
->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"]) ->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'); $response = $this->get('/admin/instances');

View file

@ -6,6 +6,7 @@
use App\Services\PollAlertService; use App\Services\PollAlertService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance; use Lvl0\FediDiscover\Models\Instance;
use Tests\TestCase; use Tests\TestCase;
@ -14,19 +15,157 @@ class PollAlertServiceTest extends TestCase
{ {
use RefreshDatabase; 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() $instance = Instance::factory()
->type(InstanceType::Mastodon) ->type(InstanceType::Mastodon)
->enabled() ->enabled()
->create(['consecutive_poll_failures' => 0]); ->create(['consecutive_poll_failures' => 0]);
$service = new PollAlertService(); $service = new PollAlertService;
$service->recordFailure($instance); $service->recordFailure($instance, 'test');
$this->assertDatabaseHas('fedi_discover_instances', [ $this->assertDatabaseHas('fedi_discover_instances', [
'id' => $instance->id, 'id' => $instance->id,
'consecutive_poll_failures' => 1, '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');
});
}
} }