self::MAX_EVENTS_PER_BATCH ) { $events = array_slice( $events, 0, self::MAX_EVENTS_PER_BATCH ); } $stats_table = $wpdb->prefix . 'adpresso_stats'; $clicks_log_table = $wpdb->prefix . 'adpresso_clicks_log'; $grouped_stats = []; $clicks_to_log = []; $fallbacks = []; // One request = one User-Agent, checked once for the whole batch rather than per event. $is_crawler_ua = self::is_crawler_request(); foreach ( $events as $event ) { if ( ! is_array( $event ) ) { continue; } $event_type = sanitize_key( $event['type'] ?? '' ); // Pro: a fallback stood in, counted per placement and reason, never as an impression. if ( 'fallback' === $event_type ) { $reason = sanitize_key( $event['reason'] ?? '' ); $placement_id = self::to_id( $event['placementId'] ?? 0 ); if ( $is_premium && ! $is_crawler_ua && $placement_id > 0 && in_array( $reason, self::FALLBACK_REASONS, true ) ) { $fallbacks[ $placement_id ][ 'fallback_' . $reason ] = ( $fallbacks[ $placement_id ][ 'fallback_' . $reason ] ?? 0 ) + 1; } continue; } // Whitelisted, and impression_av gated, *before* a group entry is created to prevent empty (0/0/0) rows. if ( ! in_array( $event_type, [ 'impression', 'impression_av', 'click' ], true ) ) { continue; } if ( 'impression_av' === $event_type && ! $is_premium ) { continue; } // A redirect click (LinkRedirector) can additionally carry its own verdict in 'fraud_hint' (e.g. Traffic Guard's IP/rate checks, when active). $is_fraudulent_click = $is_crawler_ua || ! empty( $event['fraud_hint'] ); if ( 'click' === $event_type && $is_fraudulent_click && ! $is_premium ) { continue; } // Known crawlers never count, in any tier. if ( $is_crawler_ua && 'click' !== $event_type ) { continue; } $placement_id = self::to_id( $event['placementId'] ?? 0 ); $group_id = self::to_id( $event['groupId'] ?? 0 ); $ad_id = self::to_id( $event['adId'] ?? 0 ); if ( 0 === $ad_id ) { continue; } // Pro keeps a crawler click in the click log (flagged), but not in the statistics. if ( 'click' === $event_type && $is_crawler_ua ) { $clicks_to_log[] = [ 'event' => $event, 'is_fraudulent' => true ]; continue; } $key = "{$placement_id}-{$group_id}-{$ad_id}"; if ( ! isset( $grouped_stats[ $key ] ) ) { $grouped_stats[ $key ] = [ 'placement_id' => $placement_id, 'group_id' => $group_id, 'ad_id' => $ad_id, 'impressions' => 0, 'impressions_av' => 0, 'clicks' => 0, ]; } switch ( $event_type ) { case 'impression': $grouped_stats[ $key ]['impressions'] ++; break; case 'impression_av': $grouped_stats[ $key ]['impressions_av'] ++; break; case 'click': // Pro counts + logs a fraud_hint-flagged click anyway. $grouped_stats[ $key ]['clicks'] ++; if ( $is_premium ) { $clicks_to_log[] = [ 'event' => $event, 'is_fraudulent' => $is_fraudulent_click ]; } break; } } foreach ( $fallbacks as $placement_id => $metrics ) { DeliveryCounters::record( $metrics, (int) $placement_id ); } if ( empty( $grouped_stats ) && empty( $clicks_to_log ) ) { return true; } $date_utc = current_time( 'mysql', true ); if ( empty( $grouped_stats ) ) { self::log_clicks( $clicks_to_log, $clicks_log_table, $ip_hash_salt, $date_utc ); return true; } // Stats days follow the site's timezone (older rows keep their UTC day); works under SHORTINIT too. $date_only = substr( current_time( 'mysql' ), 0, 10 ); $values = []; foreach ( $grouped_stats as $stat ) { $values[] = $wpdb->prepare( '(%s, %d, %d, %d, %d, %d, %d)', $date_only, $stat['placement_id'], $stat['group_id'], $stat['ad_id'], $stat['impressions'], $stat['clicks'], $stat['impressions_av'] ); } $quoted_stats_table = $wpdb->prepare( '%i', $stats_table ); $query = "INSERT INTO {$quoted_stats_table} (stat_date, placement_id, group_id, ad_id, impressions, clicks, impressions_av) VALUES " . implode( ',', $values ) . " ON DUPLICATE KEY UPDATE impressions = impressions + VALUES(impressions), clicks = clicks + VALUES(clicks), impressions_av = impressions_av + VALUES(impressions_av)"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is prepared via %i above; bulk insert values are individually prepared above too. $result = $wpdb->query( $query ); if ( false === $result ) { return false; } self::log_clicks( $clicks_to_log, $clicks_log_table, $ip_hash_salt, $date_utc ); return true; } /** * @param array $clicks_to_log * @param string $table_name * @param string $ip_hash_salt * @param string $timestamp_utc * * @return void */ private static function log_clicks( array $clicks_to_log, string $table_name, string $ip_hash_salt, string $timestamp_utc ): void { foreach ( $clicks_to_log as $click_to_log ) { self::log_click_details( $click_to_log['event'], $table_name, $ip_hash_salt, $timestamp_utc, $click_to_log['is_fraudulent'] ); } } /** * @param array $event * @param string $table_name * @param string $ip_hash_salt * @param string $timestamp_utc * @param bool $is_fraudulent * * @return void */ private static function log_click_details( array $event, string $table_name, string $ip_hash_salt, string $timestamp_utc, bool $is_fraudulent ): void { global $wpdb; $ip = wp_privacy_anonymize_ip( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); $user_agent = substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), 0, self::USER_AGENT_MAX_LENGTH ); $target_url = substr( esc_url_raw( $event['targetUrl'] ?? '' ), 0, self::URL_MAX_LENGTH ); $referrer_url = substr( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) ), 0, self::URL_MAX_LENGTH ); $source = sanitize_key( $event['source'] ?? 'js' ); if ( ! in_array( $source, self::CLICK_SOURCES, true ) ) { $source = 'js'; } $wpdb->insert( $table_name, [ 'timestamp_utc' => $timestamp_utc, 'placement_id' => self::to_id( $event['placementId'] ?? 0 ), 'group_id' => self::to_id( $event['groupId'] ?? 0 ), 'ad_id' => self::to_id( $event['adId'] ?? 0 ), 'ip_hash' => hash( 'sha256', $ip . $ip_hash_salt ), 'user_agent' => $user_agent, 'target_url' => $target_url, 'referrer_url' => $referrer_url, 'is_fraudulent' => $is_fraudulent ? 1 : 0, 'source' => $source, ] ); } /** * Strict ID parsing: absint() would turn a virtual placement's uniqid ("6ab6e...") into 6 * and credit the stats to an unrelated placement. Anything but plain digits counts as 0. * * @param mixed $value * * @return int */ private static function to_id( $value ): int { if ( is_int( $value ) ) { return max( 0, $value ); } return is_string( $value ) && ctype_digit( $value ) ? (int) $value : 0; } /** * Loads CrawlerDetect without an autoloader (fast-track.php, SHORTINIT), from the build's packages/ or the dev vendor/. * * @param string $plugin_dir Plugin root with trailing slash. * * @return bool Whether the class is available afterwards. */ public static function load_crawler_detect( string $plugin_dir ): bool { if ( class_exists( 'Jaybizzle\CrawlerDetect\CrawlerDetect', false ) ) { return true; } foreach ( [ 'packages', 'vendor' ] as $base ) { $src = $plugin_dir . $base . '/jaybizzle/crawler-detect/src/'; if ( ! is_file( $src . 'CrawlerDetect.php' ) ) { continue; } foreach ( [ 'Fixtures/AbstractProvider.php', 'Fixtures/Crawlers.php', 'Fixtures/Exclusions.php', 'Fixtures/Headers.php', 'CrawlerDetect.php' ] as $file ) { require_once $src . $file; } return true; } return false; } /** * Whether "Local" tracking (AdPresso's own statistics) is on. * * @param mixed $activate_tracking The 'activate_tracking' setting: a toggle_list, i.e. [ 'frontend' => '1', 'activate_active_view' => '0' ]. * * @return bool */ public static function is_local_tracking_enabled( mixed $activate_tracking ): bool { return self::is_toggle_on( $activate_tracking, 'frontend' ); } /** * Whether Active View is on. It is a child of "Local" in the settings, so it needs that too. * * @param mixed $activate_tracking The 'activate_tracking' setting. * * @return bool */ public static function is_active_view_enabled( mixed $activate_tracking ): bool { return self::is_local_tracking_enabled( $activate_tracking ) && self::is_toggle_on( $activate_tracking, 'activate_active_view' ); } /** * A toggle that was never saved counts as on: both tracking toggles default to 1. * * @param mixed $toggle_list * @param string $key * * @return bool */ private static function is_toggle_on( mixed $toggle_list, string $key ): bool { return ! is_array( $toggle_list ) || ! array_key_exists( $key, $toggle_list ) || (bool) $toggle_list[ $key ]; } /** * Whether the current request's User-Agent matches a known crawler/bot signature. * * @return bool */ private static function is_crawler_request(): bool { // Can be loaded by the fast-track.php file. Prevent errors. if ( ! class_exists( 'Jaybizzle\CrawlerDetect\CrawlerDetect' ) ) { return false; } $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ); return ( new \Jaybizzle\CrawlerDetect\CrawlerDetect() )->isCrawler( $user_agent ); } }