## Analytics — Live Activity Feed - Dedicated /stats/live standalone page (mirrors /stats/logs pattern) - Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs - checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change - latestVisitId sentinel (-1) prevents unnecessary first-poll re-render - Cache key versioned by latestVisitId — fixes bug where concurrent users could permanently miss a visit due to stale thundering-herd cache - 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId) - Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror) - time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade - SELECT only 12 needed columns instead of SELECT *; limit 25 rows - wire:poll.5s.visible — polling stops when widget is off-screen ## Link Organization - Folders: one folder per link, clickable → filtered link list, link count badge - Tags: up to 5 tags per link, clickable → filtered link list, link count badge - Archiving: soft-archive links instead of permanent deletion; restorable ## REST API - Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD) - Per-key rate limiting: individual requests/minute per API key ## Translations - Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s') ## Docs - README.md: corrected API scopes/rate-limiting docs (features were already implemented) - README.md: added Folders, Tags, Archiving to features list - README.md: added v5.0.0 changelog entry
198 lines
7.4 KiB
PHP
198 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace Bjanczak\FilamentShortUrl\Jobs;
|
|
|
|
use Bjanczak\FilamentShortUrl\Events\ShortUrlVisited;
|
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Queued job for recording short URL visits.
|
|
*
|
|
* By dispatching this to a queue, the redirect response is sent to the visitor
|
|
* immediately — tracking happens asynchronously without adding latency.
|
|
*/
|
|
class TrackShortUrlVisitJob implements ShouldQueue
|
|
{
|
|
use Dispatchable;
|
|
use InteractsWithQueue;
|
|
use Queueable;
|
|
use SerializesModels;
|
|
|
|
/** @var int Max retry attempts if the job fails */
|
|
public int $tries = 3;
|
|
|
|
/** @var int Delay between retries (seconds) */
|
|
public int $backoff = 5;
|
|
|
|
public function __construct(
|
|
public readonly int $shortUrlId,
|
|
public readonly string $ipAddress,
|
|
public readonly string $userAgent,
|
|
public readonly ?string $refererUrl = null,
|
|
public readonly ?string $countryCode = null,
|
|
public readonly ?string $city = null,
|
|
public readonly ?string $utmSource = null,
|
|
public readonly ?string $utmMedium = null,
|
|
public readonly ?string $utmCampaign = null,
|
|
public readonly ?string $utmTerm = null,
|
|
public readonly ?string $utmContent = null,
|
|
public readonly bool $isQrScan = false,
|
|
public readonly ?string $browserLanguage = null,
|
|
public readonly ?string $selectedVariant = null,
|
|
) {
|
|
$this->onQueue(config('filament-short-url.queue_name', 'default'));
|
|
}
|
|
|
|
public function handle(ShortUrlTracker $tracker): void
|
|
{
|
|
// Re-fetch fresh model to avoid acting on stale state
|
|
$shortUrl = ShortUrl::find($this->shortUrlId);
|
|
|
|
if (! $shortUrl || ! $shortUrl->track_visits) {
|
|
return;
|
|
}
|
|
|
|
// Reconstruct a minimal Request-like object for the tracker
|
|
$request = Request::create('/', 'GET', [], [], [], [
|
|
'REMOTE_ADDR' => $this->ipAddress,
|
|
'HTTP_USER_AGENT' => $this->userAgent,
|
|
'HTTP_REFERER' => $this->refererUrl,
|
|
]);
|
|
|
|
$countryCode = $this->countryCode;
|
|
$city = $this->city;
|
|
|
|
$visit = $tracker->record(
|
|
shortUrl: $shortUrl,
|
|
request: $request,
|
|
preResolvedCountryCode: $countryCode,
|
|
preResolvedCity: $city,
|
|
utmSource: $this->utmSource,
|
|
utmMedium: $this->utmMedium,
|
|
utmCampaign: $this->utmCampaign,
|
|
utmTerm: $this->utmTerm,
|
|
utmContent: $this->utmContent,
|
|
isQrScan: $this->isQrScan,
|
|
browserLanguage: $this->browserLanguage,
|
|
selectedVariant: $this->selectedVariant,
|
|
);
|
|
|
|
// Null means the tracker detected a bot/crawler — nothing to dispatch.
|
|
if ($visit === null) {
|
|
return;
|
|
}
|
|
|
|
// Skip webhooks and GA4 for proxy/VPN visits. The visit is still persisted
|
|
// to the database for audit purposes, but external integrations should only
|
|
// receive events for legitimate human traffic — matching what the stats UI shows.
|
|
if ($visit->is_bot || $visit->is_proxy) {
|
|
return;
|
|
}
|
|
|
|
// Fire event for user listeners
|
|
ShortUrlVisited::dispatch($shortUrl, $visit);
|
|
|
|
// Check if limit is reached now
|
|
if ($shortUrl->max_visits !== null && $shortUrl->getRealTimeTotalVisits() >= $shortUrl->max_visits) {
|
|
$limitReachedKey = "fsu:limit-reached-webhook-sent:{$shortUrl->id}";
|
|
if (cache()->add($limitReachedKey, true, 86400 * 30)) {
|
|
$shortUrl->dispatchWebhook('limit_reached');
|
|
}
|
|
}
|
|
|
|
// Trigger Webhook if active
|
|
$shortUrl->dispatchWebhook('visited', [
|
|
'visit' => [
|
|
'id' => $visit->id,
|
|
'visited_at' => $visit->visited_at->toIso8601String(),
|
|
'device_type' => $visit->device_type,
|
|
'browser' => $visit->browser,
|
|
'browser_version' => $visit->browser_version,
|
|
'operating_system' => $visit->operating_system,
|
|
'operating_system_version' => $visit->operating_system_version,
|
|
'country' => $visit->country,
|
|
'country_code' => $visit->country_code,
|
|
'city' => $visit->city,
|
|
'referer_url' => $visit->referer_url,
|
|
'referer_host' => $visit->referer_host,
|
|
'utm_source' => $visit->utm_source,
|
|
'utm_medium' => $visit->utm_medium,
|
|
'utm_campaign' => $visit->utm_campaign,
|
|
'utm_term' => $visit->utm_term,
|
|
'utm_content' => $visit->utm_content,
|
|
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
|
'browser_language' => $visit->browser_language,
|
|
],
|
|
]);
|
|
|
|
// Optional GA4 Measurement Protocol integration
|
|
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
|
|
$this->sendGa4Hit($shortUrl, $visit);
|
|
}
|
|
}
|
|
|
|
private function sendGa4Hit(ShortUrl $shortUrl, ShortUrlVisit $visit): void
|
|
{
|
|
$apiSecret = config('filament-short-url.ga4.api_secret');
|
|
$firebaseAppId = config('filament-short-url.ga4.firebase_app_id');
|
|
|
|
// GA4 Measurement Protocol requires a client_id. We generate a deterministic UUID
|
|
// based on the visitor IP and User Agent to allow sessions/retention tracking
|
|
// without violating privacy (the IP and UA are hashed and cannot be reversed).
|
|
$hash = md5($this->ipAddress.'|'.$this->userAgent);
|
|
$clientId = sprintf('%08s-%04s-%04s-%04s-%12s',
|
|
substr($hash, 0, 8),
|
|
substr($hash, 8, 4),
|
|
substr($hash, 12, 4),
|
|
substr($hash, 16, 4),
|
|
substr($hash, 20, 12)
|
|
);
|
|
|
|
$payload = [
|
|
'client_id' => $clientId,
|
|
'events' => [
|
|
[
|
|
'name' => 'short_url_visit',
|
|
'params' => [
|
|
'url_key' => $shortUrl->url_key,
|
|
'destination_url' => $shortUrl->destination_url,
|
|
'device_type' => $visit->device_type ?? 'unknown',
|
|
'country' => $visit->country ?? 'unknown',
|
|
'browser' => $visit->browser ?? 'unknown',
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$queryParams = ['api_secret' => $apiSecret];
|
|
if ($firebaseAppId) {
|
|
$queryParams['firebase_app_id'] = $firebaseAppId;
|
|
} else {
|
|
$queryParams['measurement_id'] = $shortUrl->ga_tracking_id;
|
|
}
|
|
|
|
try {
|
|
Http::timeout(5)
|
|
->post(
|
|
'https://www.google-analytics.com/mp/collect?'.http_build_query($queryParams),
|
|
$payload
|
|
);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[FilamentShortUrl] GA4 Measurement Protocol hit failed', [
|
|
'url_key' => $shortUrl->url_key,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|