dbh instanceof \mysqli ); while ( true ) { $records = Traffic_Buffer::read_buffer_chunk( $busy_file, $handle, $chunk_size ); if ( empty( $records ) ) { break; } // Accumulate in memory for this chunk. $site_stats = array(); $page_stats = array(); $referrer_stats = array(); $tech_stats = array(); foreach ( $records as $record ) { $date = isset( $record['date'] ) ? $record['date'] : gmdate( 'Y-m-d' ); $path = isset( $record['path'] ) ? $record['path'] : '/'; $referrer = isset( $record['referrer'] ) ? $record['referrer'] : ''; $is_new = ! empty( $record['is_new'] ) ? 1 : 0; if ( ! isset( $site_stats[ $date ] ) ) { $site_stats[ $date ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $site_stats[ $date ]['pageviews'] += 1; $site_stats[ $date ]['visitors'] += $is_new; if ( ! isset( $page_stats[ $date ][ $path ] ) ) { $page_stats[ $date ][ $path ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $page_stats[ $date ][ $path ]['pageviews'] += 1; $page_stats[ $date ][ $path ]['visitors'] += $is_new; if ( '' !== $referrer ) { if ( ! isset( $referrer_stats[ $date ][ $referrer ] ) ) { $referrer_stats[ $date ][ $referrer ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $referrer_stats[ $date ][ $referrer ]['pageviews'] += 1; $referrer_stats[ $date ][ $referrer ]['visitors'] += $is_new; } $device_type = isset( $record['device_type'] ) ? $record['device_type'] : 'desktop'; $browser = isset( $record['browser'] ) ? $record['browser'] : 'Other'; $os = isset( $record['os'] ) ? $record['os'] : 'Other'; $tech_key = $device_type . '|' . $browser . '|' . $os; if ( ! isset( $tech_stats[ $date ][ $tech_key ] ) ) { $tech_stats[ $date ][ $tech_key ] = array( 'device_type' => $device_type, 'browser' => $browser, 'os' => $os, 'visitors' => 0, 'pageviews' => 0, ); } $tech_stats[ $date ][ $tech_key ]['pageviews'] += 1; $tech_stats[ $date ][ $tech_key ]['visitors'] += $is_new; // Track unique visitors for realtime by IP hash. $ip_hash = isset( $record['ip_hash'] ) ? $record['ip_hash'] : md5( 'unknown' . gmdate( 'Y-m-d' ) ); $timestamp = isset( $record['timestamp'] ) ? (int) $record['timestamp'] : time(); if ( ! isset( $realtime[ $ip_hash ] ) || $realtime[ $ip_hash ] < $timestamp ) { $realtime[ $ip_hash ] = $timestamp; } } // Write this chunk to DB in a transaction. if ( $use_transaction ) { $wpdb->query( 'START TRANSACTION' ); } try { self::write_aggregated_stats( $site_stats, $page_stats, $referrer_stats, $tech_stats ); if ( $use_transaction ) { $wpdb->query( 'COMMIT' ); } } catch ( \Throwable $e ) { if ( $use_transaction ) { $wpdb->query( 'ROLLBACK' ); } // Close handle if still open. if ( null !== $handle && is_resource( $handle ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose( $handle ); } // Don't remove buffer on failure — data can be retried. return 0; } $total_count += count( $records ); // Free chunk memory before reading the next. unset( $site_stats, $page_stats, $referrer_stats, $tech_stats, $records ); // If handle was closed by read_buffer_chunk (EOF reached), stop. if ( null === $handle ) { break; } } if ( 0 === $total_count ) { Traffic_Buffer::remove_buffer( $busy_file ); return 0; } // Only remove buffer after successful DB commit. Traffic_Buffer::remove_buffer( $busy_file ); // Update realtime counter via transient (P1). self::update_realtime( $realtime ); return $total_count; } /** * Write aggregated stats to the database entities. * * Extracted to share between run() and write_batch(). * * @param array $site_stats Site-level stats. * @param array $page_stats Per-page stats. * @param array $referrer_stats Per-referrer stats. * @param array $tech_stats Per-tech-combo stats. * * @return void * * @since 5.1.0 */ private static function write_aggregated_stats( array $site_stats, array $page_stats, array $referrer_stats, array $tech_stats ): void { foreach ( $site_stats as $date => $stats ) { Traffic_Site_Stats_Entity::upsert( $date, $stats['visitors'], $stats['pageviews'] ); } foreach ( $page_stats as $date => $paths ) { foreach ( $paths as $path => $stats ) { $path_id = Traffic_Paths_Entity::get_or_create( $path ); if ( $path_id > 0 ) { Traffic_Page_Stats_Entity::upsert( $date, $path_id, $stats['visitors'], $stats['pageviews'] ); } } } foreach ( $referrer_stats as $date => $referrers ) { foreach ( $referrers as $referrer_url => $stats ) { $referrer_id = Traffic_Referrer_Urls_Entity::get_or_create( $referrer_url ); if ( $referrer_id > 0 ) { Traffic_Referrer_Stats_Entity::upsert( $date, $referrer_id, $stats['visitors'], $stats['pageviews'] ); } } } foreach ( $tech_stats as $date => $combos ) { foreach ( $combos as $stats ) { $tech_id = Traffic_Tech_Entity::get_or_create( $stats['device_type'], $stats['browser'], $stats['os'] ); if ( $tech_id > 0 ) { Traffic_Tech_Stats_Entity::upsert( $date, $tech_id, $stats['visitors'], $stats['pageviews'] ); } } } } /** * Write a batch of records directly to the database (P2 — in-memory buffer flush). * * Accumulates records first, then writes in a single pass to reduce query count. * * @param array $records Array of pageview data arrays. * * @return void * * @since 5.1.0 */ public static function write_batch( array $records ): void { if ( empty( $records ) ) { return; } $site_stats = array(); $page_stats = array(); $referrer_stats = array(); $tech_stats = array(); $realtime = array(); foreach ( $records as $record ) { $date = isset( $record['date'] ) ? $record['date'] : gmdate( 'Y-m-d' ); $path = isset( $record['path'] ) ? $record['path'] : '/'; $referrer = isset( $record['referrer'] ) ? $record['referrer'] : ''; $is_new = ! empty( $record['is_new'] ) ? 1 : 0; if ( ! isset( $site_stats[ $date ] ) ) { $site_stats[ $date ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $site_stats[ $date ]['pageviews'] += 1; $site_stats[ $date ]['visitors'] += $is_new; if ( ! isset( $page_stats[ $date ][ $path ] ) ) { $page_stats[ $date ][ $path ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $page_stats[ $date ][ $path ]['pageviews'] += 1; $page_stats[ $date ][ $path ]['visitors'] += $is_new; if ( '' !== $referrer ) { if ( ! isset( $referrer_stats[ $date ][ $referrer ] ) ) { $referrer_stats[ $date ][ $referrer ] = array( 'visitors' => 0, 'pageviews' => 0, ); } $referrer_stats[ $date ][ $referrer ]['pageviews'] += 1; $referrer_stats[ $date ][ $referrer ]['visitors'] += $is_new; } $device_type = isset( $record['device_type'] ) ? $record['device_type'] : 'desktop'; $browser = isset( $record['browser'] ) ? $record['browser'] : 'Other'; $os = isset( $record['os'] ) ? $record['os'] : 'Other'; $tech_key = $device_type . '|' . $browser . '|' . $os; if ( ! isset( $tech_stats[ $date ][ $tech_key ] ) ) { $tech_stats[ $date ][ $tech_key ] = array( 'device_type' => $device_type, 'browser' => $browser, 'os' => $os, 'visitors' => 0, 'pageviews' => 0, ); } $tech_stats[ $date ][ $tech_key ]['pageviews'] += 1; $tech_stats[ $date ][ $tech_key ]['visitors'] += $is_new; // Track unique visitors for realtime by IP hash. $ip_hash = isset( $record['ip_hash'] ) ? $record['ip_hash'] : md5( 'unknown' . gmdate( 'Y-m-d' ) ); $timestamp = isset( $record['timestamp'] ) ? (int) $record['timestamp'] : time(); if ( ! isset( $realtime[ $ip_hash ] ) || $realtime[ $ip_hash ] < $timestamp ) { $realtime[ $ip_hash ] = $timestamp; } } self::write_aggregated_stats( $site_stats, $page_stats, $referrer_stats, $tech_stats ); self::update_realtime( $realtime ); } /** * Update the realtime pageview counter option. * * @param array $new_visitors IP hash => last-seen timestamp map. * * @return void * * @since 5.1.0 */ private static function update_realtime( array $new_visitors ): void { // Use options (DB-backed) instead of transients to avoid data loss // on sites with volatile object caches (Redis/Memcached) that may // evict the key between cron writes and dashboard reads. $existing = \get_option( self::REALTIME_OPTION, array() ); if ( ! is_array( $existing ) ) { $existing = array(); } // Merge new visitors — keep the latest timestamp per IP hash. foreach ( $new_visitors as $ip_hash => $timestamp ) { if ( ! isset( $existing[ $ip_hash ] ) || $existing[ $ip_hash ] < $timestamp ) { $existing[ $ip_hash ] = $timestamp; } } // Prune visitors not seen in the last 30 minutes. $cutoff = time() - 1800; foreach ( $existing as $ip_hash => $timestamp ) { if ( $timestamp < $cutoff ) { unset( $existing[ $ip_hash ] ); } } \update_option( self::REALTIME_OPTION, $existing, false ); } /** * Get the realtime unique visitor count (last 30 minutes). * * Bypasses the persistent object cache (Redis / Memcached) so the * admin dashboard always reads the freshest value written by the * aggregation cron — even when that cron runs in a separate process. * Stale entries are pruned on every read to keep the option compact. * * @return int * * @since 5.1.0 */ public static function get_realtime_count(): int { // Flush any stale persistent-cache copy so get_option() hits the DB. \wp_cache_delete( self::REALTIME_OPTION, 'options' ); $data = \get_option( self::REALTIME_OPTION, array() ); if ( ! is_array( $data ) ) { return 0; } $cutoff = time() - 1800; $total = 0; $pruned = false; foreach ( $data as $ip_hash => $timestamp ) { if ( $timestamp >= $cutoff ) { ++$total; } else { unset( $data[ $ip_hash ] ); $pruned = true; } } // Write back pruned data so the option stays compact and the // cache is refreshed with the authoritative value. if ( $pruned ) { \update_option( self::REALTIME_OPTION, $data, false ); } return $total; } /** * Prune old traffic data beyond retention period. * * @param int $days Number of days to retain. * * @return void * * @since 5.1.0 */ public static function prune( int $days = 365 ): void { $cutoff_date = gmdate( 'Y-m-d', strtotime( "-{$days} days" ) ); // Use delete_query from entities — it has the 1146 error recovery built in. Traffic_Site_Stats_Entity::delete_query( 'DELETE FROM `' . Traffic_Site_Stats_Entity::get_table_name() . '` WHERE date < %s', array( $cutoff_date ) ); Traffic_Page_Stats_Entity::delete_query( 'DELETE FROM `' . Traffic_Page_Stats_Entity::get_table_name() . '` WHERE date < %s', array( $cutoff_date ) ); Traffic_Referrer_Stats_Entity::delete_query( 'DELETE FROM `' . Traffic_Referrer_Stats_Entity::get_table_name() . '` WHERE date < %s', array( $cutoff_date ) ); // Clean orphaned paths. $paths_table = Traffic_Paths_Entity::get_table_name(); $stats_table = Traffic_Page_Stats_Entity::get_table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared Traffic_Paths_Entity::delete_query( "DELETE p FROM `{$paths_table}` p LEFT JOIN `{$stats_table}` s ON p.id = s.path_id WHERE s.path_id IS NULL" ); // Clean orphaned referrer URLs. $urls_table = Traffic_Referrer_Urls_Entity::get_table_name(); $ref_stats_table = Traffic_Referrer_Stats_Entity::get_table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared Traffic_Referrer_Urls_Entity::delete_query( "DELETE u FROM `{$urls_table}` u LEFT JOIN `{$ref_stats_table}` s ON u.id = s.referrer_id WHERE s.referrer_id IS NULL" ); // Prune tech stats. Traffic_Tech_Stats_Entity::delete_query( 'DELETE FROM `' . Traffic_Tech_Stats_Entity::get_table_name() . '` WHERE date < %s', array( $cutoff_date ) ); // Clean orphaned tech combos. $tech_table = Traffic_Tech_Entity::get_table_name(); $tech_stats_table = Traffic_Tech_Stats_Entity::get_table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared Traffic_Tech_Entity::delete_query( "DELETE t FROM `{$tech_table}` t LEFT JOIN `{$tech_stats_table}` s ON t.id = s.tech_id WHERE s.tech_id IS NULL" ); } } }