shouldCaptureAnalytics($request, $response)) { $this->pendingData = $this->buildAnalyticsData($request); } return $response; } /** * Handle tasks after the response has been sent to the browser. */ public function terminate(Request $request, Response $response): void { if ($this->pendingData !== null) { $this->sendAnalytics($this->pendingData); } } /** * Determine if analytics should be captured for this request. */ protected function shouldCaptureAnalytics(Request $request, Response $response): bool { return $this->isAnalyticsEnabled() && $this->hasSiteKey() && $this->hasIngestSecret() && $this->isPageview($request, $response) && ! $this->isIgnoredPath($request) && ! $this->isIgnoredIp($request) && ! $this->shouldIgnoreBot($request) && ! $this->respectsDoNotTrack($request); } /** * Check if analytics tracking is enabled. */ private function isAnalyticsEnabled(): bool { return config('mattitja-analytics.enabled', true); } /** * Check if site key is configured. */ private function hasSiteKey(): bool { return ! empty(config('mattitja-analytics.site_key')); } /** * Check if the ingest secret is configured. Without it the API ignores the * forwarded visitor IP/UA and rejects the server-to-server request. */ private function hasIngestSecret(): bool { return ! empty(config('mattitja-analytics.ingest_secret')); } /** * Only successful GET requests rendering HTML count as pageviews * (skips form posts, redirects, JSON/Livewire calls and assets). */ protected function isPageview(Request $request, Response $response): bool { if (! $request->isMethod('GET') || $request->expectsJson()) { return false; } if (! $response->isSuccessful()) { return false; } $contentType = (string) $response->headers->get('Content-Type', 'text/html'); return str_contains($contentType, 'text/html'); } /** * Check if bot traffic should be ignored. */ private function shouldIgnoreBot(Request $request): bool { return $this->isBot($request) && ! config('mattitja-analytics.capture_bots', false); } /** * Check if the request path should be ignored. */ protected function isIgnoredPath(Request $request): bool { $ignoredPaths = config('mattitja-analytics.ignore_paths', []); $path = $request->path(); foreach ($ignoredPaths as $pattern) { if (fnmatch($pattern, $path)) { return true; } } return false; } /** * Check if the request IP should be ignored. */ protected function isIgnoredIp(Request $request): bool { $ignoredIps = config('mattitja-analytics.ignore_ips', []); return in_array($request->ip(), $ignoredIps, true); } /** * Detect if the request is from a bot. */ protected function isBot(Request $request): bool { $userAgent = $request->userAgent() ?? ''; foreach (self::BOT_PATTERNS as $pattern) { if (stripos($userAgent, $pattern) !== false) { return true; } } return false; } /** * Check if the request respects Do Not Track header. */ protected function respectsDoNotTrack(Request $request): bool { if (! config('mattitja-analytics.respect_dnt', true)) { return false; } return $request->header('DNT') === '1'; } /** * Build analytics data payload from request. */ protected function buildAnalyticsData(Request $request): PageviewData { return new PageviewData( siteKey: config('mattitja-analytics.site_key'), url: $request->fullUrl(), ip: $request->ip(), userAgent: $request->userAgent() ?? '', referrer: $request->header('referer'), acceptLanguage: $request->header('Accept-Language'), ); } /** * Send analytics data to the API. */ protected function sendAnalytics(PageviewData $data): void { try { app(AnalyticsClient::class)->send($data); } catch (\Throwable $e) { Log::error('Failed to send analytics data', [ 'error' => $e->getMessage(), ]); } } }