*/ private static $memo = array(); /** * Register the local recorder. * * Runs at `shutdown` priority 1 — after Xpay_Agent_Analytics::maybe_capture() * (priority 0) so we never affect its behaviour, and late enough that the * response is already sent. * * @return void */ public static function register() { add_action( 'shutdown', array( __CLASS__, 'maybe_record' ), 1 ); } /** * Record an AI-bot hit locally. * * Deliberately mirrors the sent path's exclusions, then diverges in exactly * one way: it does NOT check the telemetry opt-in, because nothing here is * sent. See the class docblock. * * @return void */ public static function maybe_record() { try { if ( is_admin() || wp_doing_cron() ) { return; } if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { return; } if ( ! empty( $_SERVER['HTTP_X_XPAY_PROBE'] ) ) { return; // our own discovery self-probe } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua ) { return; } $class = Xpay_Agent_Analytics::classify_bot( $ua ); if ( ! $class ) { return; // not a known AI bot — we record nothing at all about humans. } $token = Xpay_Agent_Analytics::matched_token( $ua ); if ( '' === $token ) { return; } self::increment( $token, $class ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch unset( $e ); // Never break the host site for a statistic. } } /** * Increment today's counter for one bot. * * @param string $token Bot token, e.g. "GPTBot". * @param string $class realtime|training|search|crawler. * @return void */ private static function increment( $token, $class ) { // ⛔ This runs on the shutdown hook of every AI-crawler request, and // WordPress does not close the connection first — the write is on the // crawler's critical path. A naive read-modify-write of one option per hit // also loses increments under a real crawl (GPTBot happily runs several // requests a second) and serialises them on a single row lock. // // So coalesce: accumulate in the object cache and only touch the option // when the pending batch is worth a write. // // ⚠️ THE COALESCING ONLY WORKS WITH A PERSISTENT OBJECT CACHE. Without // one, wp_cache_* is per-request, so `$last` is always 0 and every // AI-crawler hit performs one get_option + update_option — on the // crawler's open connection, since this runs at shutdown priority 1, // before Order_Events' dispatcher (priority 100). That is one write per // bot VISIT, not per pageview, and it never corrupts a count — but on a // heavily-crawled store on shared hosting it is real DB churn. Do not // describe this as "degrading to previous behaviour": 0.6.3 wrote // nothing here at all, so this is new load. Revisit alongside the 0.7.1 // instrumentation work. $pending = wp_cache_get( 'xpay_pending_bots', 'xpay' ); $pending = is_array( $pending ) ? $pending : array(); $k = $token . '|' . $class; $pending[ $k ] = isset( $pending[ $k ] ) ? $pending[ $k ] + 1 : 1; $last = (int) wp_cache_get( 'xpay_pending_bots_at', 'xpay' ); $now = time(); if ( $last && ( $now - $last ) < MINUTE_IN_SECONDS && array_sum( $pending ) < 25 ) { wp_cache_set( 'xpay_pending_bots', $pending, 'xpay', 5 * MINUTE_IN_SECONDS ); return; } wp_cache_set( 'xpay_pending_bots', array(), 'xpay', 5 * MINUTE_IN_SECONDS ); wp_cache_set( 'xpay_pending_bots_at', $now, 'xpay', 5 * MINUTE_IN_SECONDS ); self::flush_pending( $pending ); } /** * Write a coalesced batch of bot hits to the rolling option. * * @param array $pending token|class => count. * @return void */ private static function flush_pending( $pending ) { if ( ! $pending ) { return; } $day = gmdate( 'Y-m-d' ); $data = get_option( self::BOT_OPTION, array() ); if ( ! is_array( $data ) ) { $data = array(); } if ( ! isset( $data[ $day ] ) ) { $data[ $day ] = array(); // Prune on write rather than on a cron, so an inactive store never // accumulates and we don't own another scheduled job. if ( count( $data ) > self::RETAIN_DAYS ) { ksort( $data ); $data = array_slice( $data, -self::RETAIN_DAYS, null, true ); } } foreach ( $pending as $k => $n ) { $parts = explode( '|', $k ); $token = $parts[0]; $class = isset( $parts[1] ) ? $parts[1] : 'crawler'; if ( ! isset( $data[ $day ][ $token ] ) ) { $data[ $day ][ $token ] = array( 'n' => 0, 'c' => $class, ); } $data[ $day ][ $token ]['n'] += (int) $n; } update_option( self::BOT_OPTION, $data, false ); } /** * Bot totals for the last N days. * * @param int $days Window. * @return array{total:int,by_bot:array,by_class:array,days:array} */ public static function bots( $days = 30 ) { $key = 'bots:' . (int) $days; if ( isset( self::$memo[ $key ] ) ) { return self::$memo[ $key ]; } $data = get_option( self::BOT_OPTION, array() ); $out = array( 'total' => 0, 'by_bot' => array(), 'by_class' => array(), 'days' => array(), ); if ( ! is_array( $data ) ) { self::$memo[ $key ] = $out; return $out; } $floor = gmdate( 'Y-m-d', time() - ( (int) $days * DAY_IN_SECONDS ) ); foreach ( $data as $day => $tokens ) { if ( $day < $floor || ! is_array( $tokens ) ) { continue; } $out['days'][ $day ] = 0; foreach ( $tokens as $token => $row ) { $n = isset( $row['n'] ) ? (int) $row['n'] : 0; $class = isset( $row['c'] ) ? (string) $row['c'] : 'crawler'; $out['total'] += $n; $out['days'][ $day ] += $n; $out['by_bot'][ $token ] = ( isset( $out['by_bot'][ $token ] ) ? $out['by_bot'][ $token ] : 0 ) + $n; $out['by_class'][ $class ] = ( isset( $out['by_class'][ $class ] ) ? $out['by_class'][ $class ] : 0 ) + $n; } } arsort( $out['by_bot'] ); ksort( $out['days'] ); self::$memo[ $key ] = $out; return $out; } /** * ⛔ REMOVED — do not reintroduce without a retained denominator. * * This used to read `xpay_wc_human_daily` and present it as "human pageviews * over the last N days". It is not that. `Xpay_Agent_Analytics::flush()` runs * on a 15-minute cron and calls `delete_option()` on that key after sending, * so it holds at most ~15 minutes of pageviews and is frequently empty. * * Dividing 30 days of bot hits by 15 minutes of human hits produced an * "AI vs human traffic" figure in the thousands of percent, which flipped to * "not measured" seconds after each flush. On a product whose entire pitch is * that its numbers are qualified and checkable, that is the one bug we cannot * ship. * * To bring the ratio back, `flush()` must first roll each sent day into a * separate retained option, and the ratio should be bots / (bots + humans) — * bots/humans exceeds 100% legitimately and reads as broken. */ /** * AI-attributed orders in the last N days, from local order meta only. * * ⛔ Windows are derived from WooCommerce's own `date_created` in UTC via * wc_get_orders' `date_created` arg. We do NOT read any xpay timestamp for * this: our stored `placed_at` is store-local wearing a `Z`, which silently * shifts every window and every join built on it. * * ⛔ The returned `match_rate` is the whole point. Never render `attributed` * without it — a share of orders we can explain is a fact; a count on its own * invites the reader to assume it is complete. * * @param int $days Window. * @return array{orders:int,attributed:int,revenue:float,by_source:array,match_rate:?float,currency:string} */ public static function attributed_orders( $days = 30 ) { $key = 'orders:' . (int) $days; if ( isset( self::$memo[ $key ] ) ) { return self::$memo[ $key ]; } $out = array( 'truncated' => false, 'orders' => 0, 'attributed' => 0, 'revenue' => 0.0, 'by_source' => array(), 'match_rate' => null, 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', ); if ( ! function_exists( 'wc_get_orders' ) ) { self::$memo[ $key ] = $out; return $out; } $after = gmdate( 'Y-m-d H:i:s', time() - ( (int) $days * DAY_IN_SECONDS ) ); $limit = 500; $orders = wc_get_orders( array( 'limit' => $limit, 'status' => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ), 'date_created' => '>' . $after, 'return' => 'objects', ) ); if ( ! is_array( $orders ) ) { self::$memo[ $key ] = $out; return $out; } // ⛔ A capped count must never be labelled as a total. On a store doing // more than $limit orders in the window we are looking at the newest // slice only; the match RATE over that slice is still meaningful, the // absolute counts and revenue are not, and the UI has to say so. $out['truncated'] = ( count( $orders ) >= $limit ); foreach ( $orders as $order ) { if ( ! is_a( $order, 'WC_Order' ) ) { continue; } $out['orders']++; // Layer order matches the classifier's own confidence ladder: // deterministic stamp first, then the resolved attribution blob. $source = (string) $order->get_meta( '_xpay_ref_inbound' ); if ( '' === $source ) { $blob = $order->get_meta( '_xpay_agent_attribution' ); if ( is_array( $blob ) && ! empty( $blob['source'] ) ) { $source = (string) $blob['source']; } elseif ( is_string( $blob ) && '' !== $blob ) { $source = $blob; } } if ( '' === $source ) { continue; } // The stamp can carry "source|confidence|method"; we only want source. if ( false !== strpos( $source, '|' ) ) { $source = strtok( $source, '|' ); } $source = sanitize_key( $source ); if ( '' === $source ) { continue; } $total = (float) $order->get_total(); $out['attributed']++; $out['revenue'] += $total; if ( ! isset( $out['by_source'][ $source ] ) ) { $out['by_source'][ $source ] = array( 'n' => 0, 'rev' => 0.0, ); } $out['by_source'][ $source ]['n']++; $out['by_source'][ $source ]['rev'] += $total; } if ( $out['orders'] > 0 ) { $out['match_rate'] = round( ( $out['attributed'] / $out['orders'] ) * 100, 1 ); } uasort( $out['by_source'], static function ( $a, $b ) { return $b['rev'] <=> $a['rev']; } ); self::$memo[ $key ] = $out; return $out; } /** * Human-readable label for an attribution source key. * * @param string $key Source key. * @return string */ public static function source_label( $key ) { $map = array( 'chatgpt' => 'ChatGPT', 'claude' => 'Claude', 'perplexity' => 'Perplexity', 'gemini' => 'Gemini', 'copilot' => 'Copilot', 'meta_ai' => 'Meta AI', 'you_com' => 'You.com', 'deepseek' => 'DeepSeek', 'grok' => 'Grok', 'mistral' => 'Mistral', 'kagi' => 'Kagi', 'phind' => 'Phind', 'poe' => 'Poe', ); $key = strtolower( $key ); if ( isset( $map[ $key ] ) ) { return $map[ $key ]; } return ucwords( str_replace( array( '-', '_' ), ' ', $key ) ); } }