Vodiči

Laravel: Monitor Client Tech Stacks with Real-Time AI Alerts

Laravel: Pratite tehnološke sklopove klijenata uz AI upozorenja u stvarnom vremenu

Web-mjesto klijenta može se promijeniti bez implementacije vašeg tima. Migracija hostinga može uvesti novi proxy, nadogradnja CMS-a može zamijeniti dodatke ili užurbani izvođač može zamijeniti pružatelja analitike. Nadzor dostupnosti i dalje će prijavljivati zeleno stanje jer se stranice učitavaju. Ono što se promijenilo jest operativni otisak web-mjesta.

Ovaj vodič izrađuje Laravel monitor koji povremeno šalje važno javno web-mjesto API-ju Website Technology Detector, pohranjuje stabilnu snimku stanja i e-poštom obavještava programera kada se tehnologije ili otkrivene verzije promijene. Dizajn koristi Laravelov HTTP klijent, raspoređivač, red čekanja, bazu podataka, obavijesti i testne imitacije. Također udaljeni odgovor tretira kao nepouzdane podatke umjesto da pretpostavlja da će svako polje uvijek biti prisutno.

Pribavite pristup i kopirajte token usluge

Započnite registracijom računa ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Website Technology Detector.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Odmah ga pohranite u upravitelj lozinki i spremište tajni platforme za implementaciju.

Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Koristit ćemo oblik Bearer jer izbjegava stavljanje vjerodajnice u URL. Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svaku pokrenutu instancu aplikacije prije odbacivanja starih vjerodajnica.

Provjerite krajnju točku prije pisanja koda aplikacije

Točna operacija je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Njezin JSON zahtjev sadrži url. Pokrenite ovaj minimalni zahtjev lokalno, zamijenite rezervirano mjesto bez spremanja rezultirajuće povijesti ljuske ili tokena u repozitorij:

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://client.example"}'

Odgovor sadrži detekcije tehnologija s ocjenom pouzdanosti, popratne dokaze, informacije o verzijama i informacije o preusmjeravanju. Ti su detalji vrijedni za upozorenje, ali pouzdanost i dokazi mogu varirati bez promjene instaliranog skupa tehnologija. Naš će otisak promjene stoga koristiti nazive tehnologija i verzije, a pouzdanost, dokaze i preusmjeravanja zadržat će u pohranjenoj snimci za dijagnostiku.

Stavite vjerodajnicu u nepredanu datoteku .env projekta:

MIHAJLO_TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
MIHAJLO_TECH_DETECTOR_URL=https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies

QUEUE_CONNECTION=database
MAIL_MAILER=smtp
[email protected]

Izložite je putem Laravel konfiguracije umjesto pozivanja env() iz klasa aplikacije:

<?php
// config/services.php

return [
    // Other services...

    'technology_detector' => [
        'url' => env(
            'MIHAJLO_TECH_DETECTOR_URL',
            'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
        ),
        'token' => env('MIHAJLO_TECH_DETECTOR_TOKEN'),
        'connect_timeout' => 3,
        'timeout' => 12,
    ],
];

Arhitektura: zakazani rad bez bučnih upozorenja

Raspoređivač pronalazi omogućena klijentska web-mjesta i šalje jedan jedinstveni zadatak reda čekanja po web-mjestu. Svaki zadatak poziva namjenski API klijent, preslikava odgovor u snimku domene i uspoređuje njezin otisak s prethodnim. Prvo uspješno skeniranje uspostavlja osnovno stanje; ne šalje alarmantnu e-poruku „sve je dodano”.

Red čekanja ovdje je opravdan jer vanjski poziv može biti spor, ograničen stopom ili privremeno nedostupan. Ne bi trebao zauzimati web-zahtjev niti sprječavati skeniranja drugih web-mjesta. Rezultirajući projekt ima ove važne datoteke:

  • app/Services/TechnologyDetectorClient.php za HTTP i klasifikaciju neuspjeha
  • app/Domain/TechnologySnapshot.php za obrambeno preslikavanje i otiske
  • app/Jobs/ScanClientTechnology.php za usporedbu i postojanu pohranu
  • app/Notifications/TechnologyStackChanged.php za e-poštu programeru
  • app/Console/Commands/ScanClientSites.php za zakazano slanje

Pohranite osnovno stanje i snimku za reviziju

Izradite model i migraciju s php artisan make:model ClientSite -m. Tablica čuva najnoviji normalizirani odgovor, a ne token ili proizvoljna zaglavlja zahtjeva:

<?php
// database/migrations/xxxx_xx_xx_create_client_sites_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('client_sites', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('url', 2048)->unique();
            $table->string('notification_email');
            $table->boolean('enabled')->default(true);
            $table->string('last_fingerprint', 64)->nullable();
            $table->json('last_snapshot')->nullable();
            $table->timestamp('last_checked_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('client_sites');
    }
};

// app/Models/ClientSite.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ClientSite extends Model
{
    protected $fillable = [
        'name', 'url', 'notification_email', 'enabled',
    ];

    protected function casts(): array
    {
        return [
            'enabled' => 'boolean',
            'last_snapshot' => 'array',
            'last_checked_at' => 'immutable_datetime',
        ];
    }
}

Izradite ograničeni API klijent svjestan neuspjeha

Klijent izvršava najviše dva neposredna pokušaja. Ponovno pokušava pri neuspjesima veze, HTTP-u 429 i odgovorima poslužitelja 5xx, ali nikada ne pokušava ponovno pri neuspjesima autentifikacije ili provjere valjanosti. Naknadno odgađanje na razini reda čekanja omogućuje sporiji oporavak bez preopterećivanja usluge.

<?php
// app/Services/TechnologyDetectorClient.php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

class DetectorException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct("Technology detector failure: {$kind}", 0, $previous);
    }
}

class TechnologyDetectorClient
{
    public function detect(string $url): array
    {
        $endpoint = (string) config('services.technology_detector.url');
        $token = (string) config('services.technology_detector.token');

        if ($token === '') {
            throw new DetectorException('missing_configuration', false);
        }

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->withToken($token)
                    ->connectTimeout((int) config(
                        'services.technology_detector.connect_timeout', 3
                    ))
                    ->timeout((int) config(
                        'services.technology_detector.timeout', 12
                    ))
                    ->post($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 2) {
                    throw new DetectorException(
                        'connection', true, null, $exception
                    );
                }

                usleep(250_000);
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (! is_array($payload)) {
                    throw new DetectorException('invalid_json_shape', false);
                }

                return $payload;
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt === 1) {
                $retryAfter = ctype_digit(
                    (string) $response->header('Retry-After')
                )
                    ? (int) $response->header('Retry-After')
                    : 1;

                sleep(min(max($retryAfter, 1), 5));
                continue;
            }

            throw new DetectorException(
                match ($status) {
                    401, 403 => 'authentication',
                    422 => 'invalid_request',
                    429 => 'rate_limited',
                    default => 'http_error',
                },
                $retryable,
                $status,
            );
        }

        throw new DetectorException('unexpected_state', false);
    }
}

Primijetite što nedostaje iz iznimki i zapisnika: token, tijelo odgovora i puna zaglavlja. Tijela udaljenih pogrešaka mogu sadržavati odraženi unos ili pojedinosti implementacije, stoga su status i kontrolirana kategorija neuspjeha sigurnija polja za nadzor.

Preslikajte nesiguran odgovor na granici

Omotnice odgovora mogu se razvijati. Preslikavač rekurzivno pronalazi ugovorene zbirke detekcija i preusmjeravanja, provjerava pojedinačne stavke i odbacuje odgovor koji ne sadrži prepoznatljivu zbirku detekcija. To je posljednje pravilo važno: tretiranje promjene sheme kao praznog skupa tehnologija generiralo bi lažno upozorenje „sve tehnologije su uklonjene”.

<?php
// app/Domain/TechnologySnapshot.php

namespace App\Domain;

use App\Services\DetectorException;

final readonly class TechnologySnapshot
{
    public function __construct(
        public array $technologies,
        public array $redirects,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $rows = self::findCollection($payload, ['detections', 'technologies']);

        if ($rows === null) {
            throw new DetectorException('unsupported_response_schema', false);
        }

        $technologies = [];

        foreach ($rows as $row) {
            if (! is_array($row)) {
                continue;
            }

            $name = $row['name'] ?? $row['technology'] ?? null;
            $confidence = $row['confidence'] ?? null;

            if (! is_string($name) || $name === '' || ! is_numeric($confidence)) {
                continue;
            }

            $versions = $row['versions'] ?? ($row['version'] ?? []);
            $versions = is_array($versions) ? $versions : [$versions];

            $technologies[] = [
                'name' => $name,
                'confidence' => (float) $confidence,
                'versions' => array_values(array_filter(
                    $versions,
                    static fn (mixed $value): bool => is_string($value)
                )),
                'evidence' => is_array($row['evidence'] ?? null)
                    ? $row['evidence']
                    : [],
            ];
        }

        usort(
            $technologies,
            static fn (array $a, array $b): int =>
                strcasecmp($a['name'], $b['name'])
        );

        return new self(
            $technologies,
            self::findCollection($payload, ['redirects']) ?? [],
        );
    }

    public function fingerprint(): string
    {
        $stable = array_map(
            static fn (array $item): array => [
                'name' => mb_strtolower($item['name']),
                'versions' => $item['versions'],
            ],
            $this->technologies,
        );

        return hash(
            'sha256',
            json_encode($stable, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
        );
    }

    public function toArray(): array
    {
        return [
            'technologies' => $this->technologies,
            'redirects' => $this->redirects,
        ];
    }

    private static function findCollection(
        array $node,
        array $acceptedKeys
    ): ?array {
        foreach ($acceptedKeys as $key) {
            if (array_key_exists($key, $node) && is_array($node[$key])) {
                return $node[$key];
            }
        }

        foreach ($node as $value) {
            if (is_array($value)) {
                $found = self::findCollection($value, $acceptedKeys);

                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }
}

Dva prihvaćena naziva zbirki izdvajaju kompatibilnost oblika odgovora u jednu datoteku. Usporedite ih s aktualnom službenom dokumentacijom pri integraciji. Ako se dokumentirana omotnica razlikuje, promijenite samo ovaj granični preslikavač umjesto širenja pomaka polja kroz zadatke i kontrolere.

Usporedite snimke u jedinstvenom zadatku reda čekanja

Zadatak izvršava mrežni zahtjev prije otvaranja transakcije. Zatim zaključava redak, sprječavajući da dva radnika istodobno uspoređuju s istim osnovnim stanjem i prepisuju ga. Obavijest se šalje nakon potvrde transakcije.

<?php
// app/Jobs/ScanClientTechnology.php

namespace App\Jobs;

use App\Domain\TechnologySnapshot;
use App\Models\ClientSite;
use App\Notifications\TechnologyStackChanged;
use App\Services\DetectorException;
use App\Services\TechnologyDetectorClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Throwable;

class ScanClientTechnology implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 3;
    public int $uniqueFor = 3600;

    public function __construct(public readonly int $siteId) {}

    public function uniqueId(): string
    {
        return (string) $this->siteId;
    }

    public function backoff(): array
    {
        return [60, 300];
    }

    public function handle(TechnologyDetectorClient $client): void
    {
        $site = ClientSite::query()->findOrFail($this->siteId);

        try {
            $snapshot = TechnologySnapshot::fromPayload(
                $client->detect($site->url)
            );
        } catch (DetectorException $exception) {
            Log::warning('technology_scan_failed', [
                'site_id' => $site->id,
                'kind' => $exception->kind,
                'status' => $exception->status,
                'retryable' => $exception->retryable,
            ]);

            if ($exception->retryable) {
                throw $exception;
            }

            return;
        }

        $change = DB::transaction(function () use ($snapshot): ?array {
            $locked = ClientSite::query()
                ->lockForUpdate()
                ->findOrFail($this->siteId);

            $previous = $locked->last_snapshot;
            $fingerprint = $snapshot->fingerprint();

            $locked->update([
                'last_snapshot' => $snapshot->toArray(),
                'last_fingerprint' => $fingerprint,
                'last_checked_at' => now(),
            ]);

            if ($previous === null || $locked->getOriginal(
                'last_fingerprint'
            ) === $fingerprint) {
                return null;
            }

            return [
                'before' => $previous['technologies'] ?? [],
                'after' => $snapshot->technologies,
            ];
        });

        if ($change !== null) {
            Notification::route('mail', $site->notification_email)
                ->notify(new TechnologyStackChanged(
                    $site->name,
                    $site->url,
                    $change['before'],
                    $change['after'],
                ));
        }

        Log::info('technology_scan_completed', [
            'site_id' => $site->id,
            'technology_count' => count($snapshot->technologies),
            'changed' => $change !== null,
        ]);
    }

    public function failed(?Throwable $exception): void
    {
        Log::error('technology_scan_exhausted', [
            'site_id' => $this->siteId,
            'exception' => $exception?->getMessage(),
        ]);
    }
}

Izradite TechnologyStackChanged s php artisan make:notification TechnologyStackChanged. Neka e-poruka bude sažeta i uključi oba normalizirana popisa:

<?php
// app/Notifications/TechnologyStackChanged.php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class TechnologyStackChanged extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        private readonly string $siteName,
        private readonly string $url,
        private readonly array $before,
        private readonly array $after,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $render = static fn (array $items): string => collect($items)
            ->map(fn (array $item): string =>
                $item['name'].' '.implode(', ', $item['versions'] ?? [])
            )
            ->implode('; ');

        return (new MailMessage)
            ->subject("Technology change: {$this->siteName}")
            ->line("The public technology stack changed for {$this->url}.")
            ->line('Previous: '.($render($this->before) ?: 'None detected'))
            ->line('Current: '.($render($this->after) ?: 'None detected'))
            ->line('Review the stored evidence before treating this as an incident.');
    }
}

Šaljite skeniranja iz Laravelova raspoređivača

Naredba koristi dijeljenje u skupine kako raspoređivač ne bi učitao svaki zapis korisnika u memoriju:

<?php
// app/Console/Commands/ScanClientSites.php

namespace App\Console\Commands;

use App\Jobs\ScanClientTechnology;
use App\Models\ClientSite;
use Illuminate\Console\Command;

class ScanClientSites extends Command
{
    protected $signature = 'clients:scan-technologies';
    protected $description = 'Queue technology scans for enabled client sites';

    public function handle(): int
    {
        ClientSite::query()
            ->where('enabled', true)
            ->select('id')
            ->chunkById(100, function ($sites): void {
                foreach ($sites as $site) {
                    ScanClientTechnology::dispatch($site->id);
                }
            });

        return self::SUCCESS;
    }
}

// routes/console.php

use Illuminate\Support\Facades\Schedule;

Schedule::command('clients:scan-technologies')
    ->hourly()
    ->withoutOverlapping();

Provjere svakog sata polazišna su točka, a ne obećanje. Uskladite učestalost s kvotom plana i važnošću klijenta. Aplikacija treba kontinuirano nadziranog radnika reda čekanja i jedno pokretanje raspoređivača svake minute, obično php artisan schedule:run putem crona.

Testirajte stvarno ponašanje bez pozivanja usluge

Laravelov Http::fake() pruža determinističke vanjske odgovore. Ovaj test značajke dokazuje da prvo skeniranje postaje osnovno stanje, a naknadna promjena verzije šalje obavijest na zahtjev:

<?php
// tests/Feature/TechnologyMonitorTest.php

namespace Tests\Feature;

use App\Jobs\ScanClientTechnology;
use App\Models\ClientSite;
use App\Notifications\TechnologyStackChanged;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class TechnologyMonitorTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_alerts_only_after_the_baseline_changes(): void
    {
        Notification::fake();

        Http::fakeSequence()
            ->push(['detections' => [[
                'name' => 'Laravel',
                'confidence' => 0.98,
                'versions' => ['11'],
                'evidence' => ['public signal'],
            ]]], 200)
            ->push(['detections' => [[
                'name' => 'Laravel',
                'confidence' => 0.97,
                'versions' => ['12'],
                'evidence' => ['public signal'],
            ]]], 200);

        $site = ClientSite::create([
            'name' => 'Important client',
            'url' => 'https://client.example',
            'notification_email' => '[email protected]',
        ]);

        app()->call([new ScanClientTechnology($site->id), 'handle']);
        Notification::assertNothingSent();

        app()->call([new ScanClientTechnology($site->id), 'handle']);
        Notification::assertSentOnDemand(TechnologyStackChanged::class);

        Http::assertSent(fn ($request): bool =>
            $request->method() === 'POST'
            && $request->url() === config('services.technology_detector.url')
            && $request->hasHeader('Authorization')
            && $request['url'] === 'https://client.example'
        );
    }
}

Dodajte usmjerene testove za 401 bez ponovnog pokušaja, 429 nakon kojeg slijedi uspjeh, neispravan JSON, nedostajuće zbirke detekcija, prazne valjane zbirke i promijenjeni redoslijed detekcija koji proizvodi isti otisak. Nikada ne stavljajte stvarni token usluge u fixtures ili varijable kontinuirane integracije, osim ako ga ne zahtijeva zaseban, izričito kontroliran integracijski test.

Sigurnost, implementacija i operativne zamke

Ograničite nadzirane URL-ove na javna http ili https odredišta kojima upravlja administrator. Odbijte URL-ove koji sadrže vjerodajnice i ne prihvaćajte proizvoljne ciljeve skeniranja iz neautentificiranog obrasca. Iako detektor pregledava javna web-mjesta, odabir cilja i dalje može trošiti kvotu ili otkriti poslovno osjetljive izbore nadzora.

Pri implementaciji pokrenite php artisan migrate --force, zatim php artisan config:cache. Ponovno pokrenite radnike reda čekanja nakon promjena konfiguracije ili koda s php artisan queue:restart. Osigurajte da upravljački program predmemorije podržava distribuirana zaključavanja ako se više radnika oslanja na ShouldBeUnique te konfigurirajte pohranu neuspjelih zadataka kako bi iscrpljeni zadaci bili vidljivi.

Uobičajeni se neuspjesi obično mogu dijagnosticirati iz strukturirane kategorije:

  • Autentifikacija: potvrdite token ograničen na uslugu i zapamtite da je ponovno generiranje opozvalo prethodni token.
  • Provjera valjanosti: provjerite je li poslana vrijednost potpuni javni URL i je li JSON svojstvo točno url.
  • Ograničavanje stope: smanjite učestalost skeniranja, provjerite kapacitet plana i poštujte ograničeno odgađanje umjesto dodavanja agresivnih ponovnih pokušaja.
  • Nepodržana shema: usporedite službenu dokumentaciju odgovora s graničnim preslikavačem; ne prepisujte dobro osnovno stanje nagađanim podacima.
  • Nema e-pošte: provjerite radnika reda čekanja, prijenos pošte, neuspjele zadatke i konfiguriranog primatelja prije nego što okrivite detekciju.

Završni kontrolni popis za provjeru

  • Token postoji samo u konfiguraciji tajni podržanoj varijablama okruženja.
  • Točna POST krajnja točka uspijeva s minimalnim zahtjevom.
  • Prvo zadano skeniranje pohranjuje osnovno stanje bez obavještavanja ikoga.
  • Promjena samo pouzdanosti ili samo dokaza ostavlja otisak stabilnim.
  • Promjena tehnologije ili verzije proizvodi jednu obavijest programeru.
  • Odgovori 401, 403 i 422 ne pokušavaju se ponovno naslijepo.
  • Neuspjesi 429, veze i 5xx koriste ograničene ponovne pokušaje i vidljive putanje neuspjeha reda čekanja.
  • Produkcijski raspoređivač i radnik reda čekanja su nadzirani.

Koristan monitor nije onaj koji prijavljuje najviše razlika. To je onaj kojem programeri dovoljno vjeruju da prema njemu djeluju. Odvajanjem promjenjivih dokaza od stabilnog identiteta tehnologije, provjeravanjem API granice i eksplicitnim definiranjem ponašanja pri neuspjehu, ova Laravel integracija pretvara otisak javnog web-mjesta u tihi sustav ranog upozoravanja umjesto još jednog generatora bučne ulazne pošte.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.