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(), ]); } } }