classifier->classify($request); $visitorKey = $this->resolveVisitorKey($request, $classification); $existing = $this->readRecord($visitorKey); $user = $request->user(); $now = now()->toIso8601String(); $record = [ 'visitor_key' => $visitorKey, 'type' => $classification['is_bot'] ? (string) $classification['type'] : ($user ? 'human_logged' : 'human_guest'), 'bot_family' => $classification['is_bot'] ? $classification['family'] : null, 'user_id' => $this->resolveUserId($user), 'user_name' => $this->resolveUserName($user), 'ip_masked' => $this->maskIp($this->resolveIp($request)), 'ip_hash' => hash('sha256', $this->resolveIp($request)), 'user_agent' => $this->truncate((string) $request->userAgent(), 512), 'browser' => $this->detectBrowser((string) $request->userAgent()), 'platform' => $this->detectPlatform((string) $request->userAgent()), 'current_url' => $this->currentUrl($request), 'route_name' => $request->route()?->getName(), 'referer' => $this->truncate((string) $request->headers->get('referer', ''), 512) ?: null, 'first_seen_at' => is_string($existing['first_seen_at'] ?? null) ? $existing['first_seen_at'] : $now, 'last_seen_at' => $now, 'hits' => (int) ($existing['hits'] ?? 0) + 1, ]; $this->storeRecord($visitorKey, $record, self::TTL_SECONDS); $this->addIndexMember($visitorKey); } catch (\Throwable $e) { Log::warning('Online visitor tracking failed', [ 'error' => $e->getMessage(), 'path' => $request->path(), ]); } } /** * @return array> */ public function all(): array { try { $visitorKeys = array_values(array_unique(array_filter(array_map('strval', $this->readIndexMembers())))); } catch (\Throwable $e) { Log::warning('Online visitor index read failed', ['error' => $e->getMessage()]); return []; } $records = []; $expired = []; foreach ($visitorKeys as $visitorKey) { try { $record = $this->readRecord($visitorKey); } catch (\Throwable $e) { Log::warning('Online visitor record read failed', [ 'error' => $e->getMessage(), 'visitor_key' => $visitorKey, ]); $record = null; } if ($record === null) { $expired[] = $visitorKey; continue; } $records[] = $record; } if ($expired !== []) { try { $this->removeIndexMembers($expired); } catch (\Throwable $e) { Log::warning('Online visitor index cleanup failed', ['error' => $e->getMessage()]); } } usort($records, static function (array $left, array $right): int { return strtotime((string) ($right['last_seen_at'] ?? '')) <=> strtotime((string) ($left['last_seen_at'] ?? '')); }); return $records; } /** * @return array{total:int,logged:int,guests:int,bots:int,search_bots:int,ai_bots:int,social_bots:int,seo_bots:int,suspicious_bots:int} */ public function summary(): array { $records = $this->all(); return [ 'total' => count($records), 'logged' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_logged')), 'guests' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_guest')), 'bots' => count(array_filter($records, static fn (array $record): bool => str_ends_with((string) ($record['type'] ?? ''), '_bot'))), 'search_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'search_bot')), 'ai_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'ai_bot')), 'social_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'social_bot')), 'seo_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'seo_bot')), 'suspicious_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'suspicious_bot')), ]; } /** * @return array */ public function activePages(): array { $counts = []; foreach ($this->all() as $record) { $url = trim((string) ($record['current_url'] ?? '')); if ($url === '') { continue; } $counts[$url] = ($counts[$url] ?? 0) + 1; } arsort($counts); $pages = []; foreach ($counts as $url => $visitors) { $pages[] = [ 'url' => $url, 'visitors' => $visitors, ]; } return $pages; } public function forget(string $visitorKey): void { try { $this->deleteRecord($visitorKey); $this->removeIndexMembers([$visitorKey]); } catch (\Throwable $e) { Log::warning('Online visitor forget failed', [ 'error' => $e->getMessage(), 'visitor_key' => $visitorKey, ]); } } /** * @return array */ protected function readIndexMembers(): array { return array_map('strval', Redis::smembers(self::INDEX_KEY)); } /** * @return array|null */ protected function readRecord(string $visitorKey): ?array { $raw = Redis::get($this->recordKey($visitorKey)); if (! is_string($raw) || $raw === '') { return null; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : null; } /** * @param array $record */ protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void { Redis::setex( $this->recordKey($visitorKey), $ttlSeconds, (string) json_encode($record, JSON_UNESCAPED_SLASHES) ); } protected function addIndexMember(string $visitorKey): void { Redis::sadd(self::INDEX_KEY, $visitorKey); } /** * @param array $visitorKeys */ protected function removeIndexMembers(array $visitorKeys): void { if ($visitorKeys === []) { return; } Redis::srem(self::INDEX_KEY, ...$visitorKeys); } protected function deleteRecord(string $visitorKey): void { Redis::del($this->recordKey($visitorKey)); } /** * @param array{is_bot: bool, type: ?string, family: ?string} $classification */ private function resolveVisitorKey(Request $request, array $classification): string { $user = $request->user(); if ($user) { return 'user:' . $user->getAuthIdentifier(); } $ip = $this->resolveIp($request); $userAgent = (string) $request->userAgent(); if ($classification['is_bot']) { return 'bot:' . hash('sha256', $ip . '|' . $userAgent); } $sessionCookieName = (string) config('session.cookie', 'laravel_session'); $sessionCookie = (string) $request->cookies->get($sessionCookieName, ''); $guestSeed = $sessionCookie !== '' ? 'session:' . $sessionCookie : 'fingerprint:' . $ip . '|' . $userAgent . '|' . (string) $request->header('Accept-Language', ''); return 'guest:' . hash('sha256', $guestSeed); } private function resolveIp(Request $request): string { $cloudflareIp = trim((string) $request->headers->get('CF-Connecting-IP', '')); if ($cloudflareIp !== '' && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) { return $cloudflareIp; } return (string) ($request->ip() ?: '0.0.0.0'); } private function maskIp(string $ip): string { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $parts = explode('.', $ip); return sprintf('%s.%s.xxx.xxx', $parts[0] ?? '0', $parts[1] ?? '0'); } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $parts = explode(':', $ip); $parts = array_pad($parts, 4, ''); return sprintf('%s:%s:xxxx:xxxx', $parts[0] ?: '::', $parts[1] ?: '::'); } return 'unknown'; } private function detectBrowser(string $userAgent): string { $normalized = strtolower($userAgent); return match (true) { str_contains($normalized, 'edg/') => 'Edge', str_contains($normalized, 'opr/') || str_contains($normalized, 'opera') => 'Opera', str_contains($normalized, 'chrome') && ! str_contains($normalized, 'edg/') => 'Chrome', str_contains($normalized, 'firefox') => 'Firefox', str_contains($normalized, 'safari') && ! str_contains($normalized, 'chrome') => 'Safari', default => 'Unknown', }; } private function detectPlatform(string $userAgent): string { $normalized = strtolower($userAgent); return match (true) { str_contains($normalized, 'windows') => 'Windows', str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios') => 'iOS', str_contains($normalized, 'android') => 'Android', str_contains($normalized, 'mac os') || str_contains($normalized, 'macintosh') => 'macOS', str_contains($normalized, 'linux') => 'Linux', default => 'Unknown', }; } private function currentUrl(Request $request): string { $path = '/' . ltrim($request->path(), '/'); return $path === '//' ? '/' : $path; } private function recordKey(string $visitorKey): string { return self::KEY_PREFIX . ':' . $visitorKey; } private function truncate(string $value, int $limit): string { return Str::limit($value, $limit, ''); } private function resolveUserId(?Authenticatable $user): ?int { if ($user === null) { return null; } $identifier = $user->getAuthIdentifier(); return is_numeric($identifier) ? (int) $identifier : null; } private function resolveUserName(?Authenticatable $user): ?string { if ($user === null) { return null; } $name = data_get($user, 'name') ?? data_get($user, 'username') ?? data_get($user, 'email'); return is_string($name) && $name !== '' ? $name : 'User'; } }