Fixed the "Traffic Over Time" chart of the Statistics page drawing the unique visitors bar on top of the views bar instead of in front of it: every bar was as tall as the views plus the unique visitors, and days whose total went past the top of the scale were squeezed to full height (a day with 18,422 views showed the same bar as a day with 28,653)
Fixed the chart bars not starting on the baseline: the date labels below the bars no longer take part of the bar height, so a bar now ends exactly on the gridline of its value
Added the 'Use the GeoIP Network Type' option (Settings > Privacy & Filtering, on by default): a visit is classified as bot traffic when the GeoIP driver selected in the Localization settings reports a hosting or datacenter network ('AS type' hosting with ipinfo.io paid plans, MaxMind Insights or IP2Location, the hosting flag of ip-api.com, including its free plan), or when the AS number of the visitor's network belongs to a known hosting provider (works with every driver that returns the AS number, such as the ipinfo.io free plan)
Privacy relays (iCloud Private Relay, Cloudflare WARP) are never flagged: their egress addresses belong to CDN and hosting networks, but their users are real people
Added a list of hosting AS numbers (Alibaba Cloud, DigitalOcean, Hetzner, Vultr, Linode, OVHcloud, Oracle Cloud, Tencent Cloud, Scaleway, Contabo, M247, Leaseweb) and the 'Additional Hosting AS Numbers' setting to add your own
Every address flagged this way is saved: its later visits are classified as bot traffic at once, without a new GeoIP lookup (no API quota used). The new 'Hosting IP Addresses' page (Settings > Privacy & Filtering) lists them with their network, type, hits and last visit, and lets you forget a false positive
Added the --lookup-network option to the stats:reclassify-bots command: it resolves the network of the visitors already recorded through the GeoIP driver (most viewed addresses first, limited by --max-lookups, with an optional --lookup-delay for rate-limited providers) and re-classifies their past visits
The stats:purge command now forgets the flagged hosting addresses not seen for 365 days (stats.bot_ips_retention_days)
Added the 'Hosting network (GeoIP)' reason to the bot traffic breakdown
Improved the admin dashboard "Site Traffic" widget speed: with a few months of collected data it could take 15 seconds or more to load, it now loads in a few milliseconds. Today's views and visitors are no longer counted with a full scan of every recorded page view
Improved the dashboard widget to read the week, last week and month totals from the daily aggregates, like the 30-day chart already did, instead of counting raw page views
Added a covering index on the page views table (is_bot, viewed_at, visitor_hash) so today's and yesterday's unique visitors are counted from the index alone
Fixed the dashboard "This Week" comparison: the previous week's total skipped its last day (Sunday)
Fixed the dashboard today, yesterday, week and month counts using day boundaries one time zone offset away from the configured display time zone
Bot detection: catch spoofed browsers and datacenter traffic
Added behavioural bot detection (BotDetector): a client advertising a real browser User-Agent is now also required to send the headers that browser always sends (Sec-Fetch-*, Accept-Language, Accept). This catches scrapers and traffic bots behind rotating proxy pools, which copy a genuine Chrome User-Agent and so passed the crawler token list and the search-engine IP ranges untouched
Added datacenter/cloud IP filtering (config/datacenter-ips.php) covering Alibaba Cloud, DigitalOcean, Hetzner, Vultr, Linode, OVHcloud, Oracle Cloud, Tencent Cloud, Scaleway, Contabo and M247/Leaseweb, plus an admin-editable list of additional ranges
Bot hits are now stored and flagged (stats_page_views.bot_reason) instead of being discarded, so the filtered share is visible in the settings screen and the classification can be reviewed and reversed. A new "Store bot hits" setting restores the old drop-on-arrival behaviour
Flagged bot hits no longer touch the six aggregate tables, and no longer trigger a GeoIP lookup: a rotating-IP flood never hits the per-IP GeoIP cache and would otherwise exhaust an API-backed driver's quota
Redirects (3xx) are no longer counted as page views, only responses that actually rendered a page (HTTP 200) are. A redirect has no content the visitor sees and its destination is tracked separately, so counting both inflated the totals; it also stopped link-following crawlers registering a view every time they hit a redirect-only endpoint such as /auth/connect/{provider}
Added the stats:reclassify-bots command to re-classify already-recorded page views and rebuild the aggregate tables from the corrected rows (dry run by default; --force to apply, --recompute to rebuild)
Added StatsService::recomputeAggregates() which rebuilds stats_daily, stats_countries, stats_cities, stats_browsers, stats_platforms, stats_referrers and stats_referrer_urls for a date range directly from stats_page_views, one transaction per day
Page-view tracking is now dispatched as a queued job (RecordPageViewJob) and deferred to ->afterResponse(), so the HTTP response is no longer blocked by the per-request DB writes
Added composite indexes on stats_page_views(visitor_hash, viewed_at, path) and (visitor_hash, country_code, viewed_at) to back the unique-visitor subqueries
GeoIP lookups are now memoized per request and cached for stats.geoip_cache_minutes (default 60) so repeat visitors and bots skip the driver
The admin dashboard traffic widget caches its 14 aggregate queries for stats.widget_cache_minutes (default 5)
Hardened the stats:purge schedule with onOneServer() + withoutOverlapping() + runInBackground()
Added STATS_QUEUE_CONNECTION / STATS_QUEUE_NAME .env settings to control where the tracking job runs