adminPath) && 1 !== preg_match(self::MACHINES, $userAgent); } public function record(string $path, int $status, string $referrer, string $siteHost, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): void { $path = $this->normalisePath($path, $status); $host = $this->referrerHost($referrer, $siteHost); $key = [ 'website_id' => 'default', 'day' => $now->format('Y-m-d'), 'hour' => (int) $now->format('G'), 'path' => $path, 'status' => $status, 'referrer_host' => $host, ]; // Update first, insert only when there was nothing to update. That is one query in the // common case and needs no platform specific upsert syntax, which matters because the // target database is MySQL and the development one is SQLite. if (1 === $this->increment($key)) { return; } try { $this->db->insert('stat_hit', [...$key, 'count' => 1]); } catch (UniqueConstraintViolationException) { // Another request inserted the same row in between; then incrementing works. $this->increment($key); } } /** * The numbers for a range of days, ending today. * * @return array{ * days: list, * total: int, pages: list, * referrers: list, * missing: list, * hours: list, * range: int, from: string, compactedBefore: string, busiest: int * } */ public function overview(int $range = 30, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array { $range = max(1, min(365, $range)); $from = $now->modify(sprintf('-%d days', $range - 1))->format('Y-m-d'); $where = 'website_id = ? AND day >= ?'; $params = ['default', $from]; $perDay = []; foreach ($this->db->fetchAllAssociative( 'SELECT day, SUM(count) AS total FROM stat_hit WHERE '.$where.' AND status = 200 GROUP BY day', $params, ) as $row) { $perDay[$row['day']] = (int) $row['total']; } // Every day in the range, including the ones with nothing: a gap in a chart is a // statement, a missing bar is a puzzle. $days = []; for ($index = $range - 1; $index >= 0; --$index) { $day = $now->modify(sprintf('-%d days', $index))->format('Y-m-d'); $days[] = ['day' => $day, 'count' => $perDay[$day] ?? 0]; } $rows = fn (string $select, string $extra, string $column): array => array_map( static fn (array $row): array => [$column => (string) $row[$column], 'count' => (int) $row['total']], $this->db->fetchAllAssociative( sprintf('SELECT %s, SUM(count) AS total FROM stat_hit WHERE %s %s GROUP BY %s ORDER BY total DESC LIMIT 15', $select, $where, $extra, $select), $params, ), ); $hours = []; foreach ($this->db->fetchAllAssociative( 'SELECT hour, SUM(count) AS total FROM stat_hit WHERE '.$where.' AND status = 200 AND hour >= 0 GROUP BY hour', $params, ) as $row) { $hours[(int) $row['hour']] = (int) $row['total']; } return [ 'days' => $days, 'total' => array_sum(array_column($days, 'count')), 'busiest' => max(1, max(array_column($days, 'count'))), 'pages' => $rows('path', 'AND status = 200', 'path'), 'referrers' => $rows('referrer_host', "AND status = 200 AND referrer_host <> ''", 'referrer_host'), 'missing' => $rows('path', 'AND status = 404', 'path'), 'hours' => array_map(static fn (int $hour): array => ['hour' => $hour, 'count' => $hours[$hour] ?? 0], range(0, 23)), 'range' => $range, 'from' => $from, 'compactedBefore' => $now->modify(sprintf('-%d days', self::COMPACT_AFTER_DAYS))->format('Y-m-d'), ]; } /** Every counted row of a range, for the CSV export. */ public function export(int $range, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): iterable { return $this->db->iterateAssociative( 'SELECT day, hour, path, status, referrer_host, count FROM stat_hit WHERE website_id = ? AND day >= ? ORDER BY day DESC, hour DESC, count DESC', ['default', $now->modify(sprintf('-%d days', max(1, min(365, $range)) - 1))->format('Y-m-d')], ); } /** * Merges the hours of older days into one row each and drops what is beyond the retention. * * @return array{compacted: int, removed: int} */ public function prune(\DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array { $removed = $this->db->executeStatement( 'DELETE FROM stat_hit WHERE website_id = ? AND day < ?', ['default', $now->modify(sprintf('-%d days', self::KEEP_DAYS))->format('Y-m-d')], ); $border = $now->modify(sprintf('-%d days', self::COMPACT_AFTER_DAYS))->format('Y-m-d'); $compacted = 0; $this->db->transactional(function () use ($border, &$compacted): void { $groups = $this->db->fetchAllAssociative( 'SELECT day, path, status, referrer_host, SUM(count) AS total FROM stat_hit WHERE website_id = ? AND day < ? AND hour >= 0 GROUP BY day, path, status, referrer_host', ['default', $border], ); foreach ($groups as $group) { $key = ['website_id' => 'default', 'day' => $group['day'], 'path' => $group['path'], 'status' => (int) $group['status'], 'referrer_host' => $group['referrer_host']]; $this->db->executeStatement( 'DELETE FROM stat_hit WHERE website_id = ? AND day = ? AND path = ? AND status = ? AND referrer_host = ?', array_values($key), ); $this->db->insert('stat_hit', [...$key, 'hour' => -1, 'count' => (int) $group['total']]); ++$compacted; } }); return ['compacted' => $compacted, 'removed' => $removed]; } private function increment(array $key): int { return $this->db->executeStatement( 'UPDATE stat_hit SET count = count + 1 WHERE website_id = ? AND day = ? AND hour = ? AND path = ? AND status = ? AND referrer_host = ?', array_values($key), ); } /** * A 404 keeps its address only if it looks like an address of this site. Crawlers probe * thousands of invented paths; each one would otherwise become a row of its own. */ private function normalisePath(string $path, int $status): string { $path = '/'.trim(parse_url($path, PHP_URL_PATH) ?: '/', '/'); if (strlen($path) > 200 || (404 === $status && 1 !== preg_match('#^/([a-z0-9]+(?:-[a-z0-9]+)*)(/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$#D', $path))) { return self::OTHER; } return $path; } /** The bare host of an external referrer; empty for a direct visit or a link from this site. */ private function referrerHost(string $referrer, string $siteHost): string { $host = strtolower((string) parse_url($referrer, PHP_URL_HOST)); if ('' === $host || $host === strtolower($siteHost) || strlen($host) > 100) { return ''; } return 1 === preg_match('/^[a-z0-9.-]+$/D', $host) ? $host : ''; } }