Traffic statistics retention does not delete records with site_id = NULL

Hi,

I noticed an issue with the internal dashboard traffic statistics in OctoberCMS 4.2.18.

The table dashboard_traffic_stats_pageviews had grown to about 1.7 GB, because traffic recording was not limited to a specific retention period at first. I later configured the dashboard traffic statistics retention to 2 months in the backend.

After that, I ran:

\Dashboard\Models\TrafficStatisticsPageview::purgeOldRecords();

The method is executed, but old records were still present in the table.

After checking the implementation, the method looks like this:

public static function purgeOldRecords()
{
    $months = TrafficLogger::instance()->getRetentionMonths();
    if (!$months) {
        return;
    }

    $query = (new static)->where('ev_datetime', '<', now()->subMonths($months)->toDateTimeString());

    if (Site::hasFeature('dashboard_traffic_statistics')) {
        $siteId = Site::getEditSite()?->id;
        if ($siteId) {
            $query->where('site_id', $siteId);
        }
    }

    $query->delete();
}

The problem seems to be that my records have site_id = NULL.

Example query:

SELECT
    site_id,
    COUNT(*) AS records,
    MIN(ev_datetime) AS oldest,
    MAX(ev_datetime) AS newest
FROM dashboard_traffic_stats_pageviews
GROUP BY site_id
ORDER BY site_id;

Result before manual cleanup:

site_id | records | oldest              | newest
NULL    | 738208  | 2026-01-30 07:55:05 | 2026-04-30 09:57:48

After manually deleting old records with:

DELETE FROM dashboard_traffic_stats_pageviews
WHERE site_id IS NULL
  AND ev_datetime < DATE_SUB(NOW(), INTERVAL 2 MONTH);

the result became:

site_id | records | oldest              | newest
NULL    | 391931  | 2026-02-28 09:59:45 | 2026-04-30 09:59:48

So the retention itself works correctly when applied directly to ev_datetime.

I also checked how site_id is written in modules/dashboard/classes/TrafficLogger.php:

$pageview->site_id = Site::getActiveSite()?->id;

However, even new records still get site_id = NULL.

Example for the last hour:

SELECT
    site_id,
    COUNT(*) AS records,
    MIN(ev_datetime) AS oldest,
    MAX(ev_datetime) AS newest
FROM dashboard_traffic_stats_pageviews
WHERE ev_datetime >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
GROUP BY site_id
ORDER BY site_id;

Result:

site_id | records | oldest              | newest
NULL    | 163     | 2026-04-30 09:05:47 | 2026-04-30 10:05:02

So it looks like Site::getActiveSite()?->id returns null in the traffic logging context, while purgeOldRecords() may use Site::getEditSite()?->id and then only deletes records for the selected edit site.

This means records with site_id = NULL are not removed by purgeOldRecords() when a backend edit site is active.

  • Could this be a bug in the dashboard traffic statistics retention logic?
  • Should purgeOldRecords() also delete records with site_id IS NULL?
  • Should the purge maybe not filter by site_id at all when applying the global retention period?
  • Is it expected that TrafficLogger writes site_id = NULL when there is only one active site or no multisite setup?
  • Is there an official console command for purging old dashboard traffic statistics, or is TrafficStatisticsPageview::purgeOldRecords() the intended way?

Hi @neralex,

Good catch! this is indeed a bug. The purgeOldRecords() method was filtering by Site::getEditSite()->id, which only applies in a backend context and skips records with site_id = NULL. Since retention is a global policy, old records should be purged regardless of their site association.

A patch will be included in v4.2.19.The fix removes the per-site filtering from purgeOldRecords() entirely.

In the meantime: the manual cleanup query you used is the right workaround.

To answer your other questions:

  • It is expected that site_id is NULL when the dashboard_traffic_statistics multisite feature is not enabled in your config.
  • The purge is triggered automatically on ~1% of pageview requests. There is no dedicated console command for it, but calling TrafficStatisticsPageview::purgeOldRecords() via tinker is fine too.

Thankyou for the detailed report.

1 Like