$logging, 'fetches' => $fetches, // What `fetches` holds. Page reads switched off today still leave the last // 30 days' reads in the count, so this follows the rows as well as the switch. 'pages' => $pages, 'files' => $fetches - $pages, 'pagesCounted' => ! empty( $stats['pageViews'] ) || $pages > 0, 'today' => isset( $totals['today'] ) ? (int) $totals['today'] : 0, 'agents' => isset( $totals['agents'] ) ? (int) $totals['agents'] : 0, // Clients caught claiming an identity that verification did not // support. Part of the machine picture, and nothing else on screen // carries it next to a headline count. // ⛔ READ, NOT RECOMPUTED. It arrives in the same totals array as the // three numbers above it, from the same window and the same query // moment — see Repository::stats(). This file used to count it for // itself out of the review queue and reported zero while the log held // eleven. 'impostors' => isset( $totals['impostors'] ) ? (int) $totals['impostors'] : 0, // ⛔ The SPAN these four numbers describe, so a link out of them can // narrow its destination to the same days. Without it, "4 caught faking // an identity" landed on a log showing every impostor ever retained — // a different number under the heading you just clicked. 'from' => isset( $stats['since'] ) ? (string) $stats['since'] : '', 'to' => isset( $stats['today'] ) ? (string) $stats['today'] : '', ); return array( 'window' => $window, // The two headline numbers, and they are NOT summed anywhere. A // combined "total audience" would be a lie built from two different // units — a fetch is not a visit — and inviting the comparison is the // whole point of putting them side by side. 'people' => array( // When analytics is connected, EVERYONE is the headline and the two // named routes become shares of it. Without it, the headline can // only be the routes we can see — which is a smaller claim, and the // limits below say so out loud rather than letting it pose as all. 'all' => $all, 'search' => $search, 'ai' => $ai, 'arrived' => $all['connected'] ? $all['users'] : $search['clicks'] + $ai['visits'], 'whole' => $all['connected'], ), 'machines' => $machines, 'limits' => self::limits( $search, $ai, $machines, $all, $machines['pagesCounted'] ), ); } /** * People arriving from search engines: the engines' own reported clicks. * * Both engines are summed, because a click is a click whoever reported it — * but the card has to NAME them. "From search" over a figure that silently * includes Bing, or silently doesn't, is the same quiet mis-claim this class * exists to prevent, so `source` travels with the number and the panel prints * it in the row label. * * @return array{connected:bool,source:string,clicks:int,impressions:int,rows:int,start:string,end:string,pageCap:int} */ private static function search_half() { $state = Search\Report::source_state(); $sources = isset( $state['sources'] ) ? (array) $state['sources'] : array(); $connected = false; $clicks = 0; $impressions = 0; $rows = 0; $start = ''; $end = ''; $named = array(); foreach ( array( 'google', 'bing' ) as $key ) { if ( empty( $sources[ $key ]['hasData'] ) ) { continue; } $connected = true; $named[] = 'google' === $key ? 'Google' : 'Bing'; // Through Report, not the table: for Bing that applies the daily // series' site-wide sums, so this row and the Search Performance // tiles can never quote two different figures for one engine. $t = Search\Report::window_totals( $key ); $clicks += $t['clicks']; $impressions += $t['impressions']; $rows += $t['rows']; // The widest window any connected source reports, so the label above // these numbers can never claim a span one of them does not cover. $start = ( '' === $start || ( '' !== $t['start'] && $t['start'] < $start ) ) ? $t['start'] : $start; $end = ( '' === $end || $t['end'] > $end ) ? $t['end'] : $end; } return array( 'connected' => $connected, 'source' => implode( ' + ', $named ), 'clicks' => $clicks, 'impressions' => $impressions, 'rows' => $rows, 'start' => $start, 'end' => $end, // Site-wide clicks are never sampled — Google reports them in full // and Bing's come from its daily site-wide series. This cap is // about the PAGE-level figures every other screen draws from the // same rows, and the caveat list is where the card admits it, // since the two numbers sit under one heading. 'pageCap' => Search\Report::page_cap(), // Bing answers about one page per request, so its page-level figures // fill in over several days rather than arriving at once. While any // page is still waiting its turn, the card has to say so — otherwise // a half-finished picture reads as the finished one. 'waiting' => Search\Report::waiting_pages(), ); } /** * What a stale analytics snapshot covers, in words. "Over two days old" * under a "Last 30 days" tag let wpftest's July test numbers pass for * October's: the sentence names the days and when they were fetched. * * @param array $all The analytics half. * @return string */ private static function stale_text( array $all ) { $format = (string) get_option( 'date_format' ); $format = '' !== $format ? $format : 'F j, Y'; $day = static function ( $ymd ) use ( $format ) { $ts = strtotime( (string) $ymd . ' 12:00:00 UTC' ); return $ts ? date_i18n( $format, $ts ) : ''; }; $fetched = (int) $all['fetched'] > 0 ? date_i18n( $format, (int) $all['fetched'] ) : ''; $start = $day( $all['start'] ); $end = $day( $all['end'] ); if ( '' !== $start && '' !== $end ) { return sprintf( /* translators: 1: first day covered, 2: last day covered, 3: date fetched, 4: the window in days. */ __( 'The analytics figures cover %1$s to %2$s and were last updated %3$s — not the last %4$d days.', 'agentimus' ), $start, $end, $fetched, (int) $all['window'] ); } return sprintf( /* translators: %s: date the figures were last fetched. */ __( 'The analytics figures were last updated %s, so they are not today’s.', 'agentimus' ), $fetched ); } /** * Everyone — the GA4 half, when the owner has connected it. * * `activeUsers` is the headline rather than sessions or page views: sessions * count the same reader twice for coming back after lunch, and views count * them again for every click. The question this card asks is "how many * people", so the metric has to be people. * * @param int $window The card's window in days. * @return array{connected:bool,users:int,sessions:int,views:int,stale:bool,fetched:int,window:int} */ private static function analytics_half( $window ) { $settings = new Google\Settings(); $empty = array( 'connected' => false, 'users' => 0, 'newUsers' => 0, 'sessions' => 0, 'views' => 0, 'engaged' => 0, 'engagedPct' => 0, 'avgSeconds' => 0, 'perVisit' => 0.0, 'pages' => array(), // null, not 0: "GA4 has no opinion" and "GA4 counted none" are // different answers, and the screen shows them differently. 'aiSessions' => null, 'otherSessions' => null, 'aiBySource' => array(), // GA4's count of arrivals each search engine sent — the human // half's answer to "from Bing, like Google": one instrument // measuring both engines the same way. 'engineSessions' => null, 'engineBySource' => array(), // Connected in Settings but never successfully polled. The screen // renders this as an invitation with a fetch button — NOT as the // "connect it" pointer, which would tell the owner to do a thing // they just did. 'pending' => false, 'error' => '', 'stale' => false, 'fetched' => 0, 'window' => (int) $window, // The days the numbers cover, as Google was asked for them. A stale // snapshot is NOT "the last N days", and the screen says which it is. 'start' => '', 'end' => '', ); if ( ! $settings->analytics_connected() ) { return $empty; } $snap = Google\Module::analytics_snapshot(); $totals = isset( $snap['totals'] ) && is_array( $snap['totals'] ) ? $snap['totals'] : array(); if ( empty( $totals ) ) { // Connected, but nothing fetched yet (or every attempt failed). Still // NOT "connected: true with zero people" — that reads as a dead site. // `pending` carries the truth, and the last failure rides along so // the invitation can say WHY if a fetch already went wrong. $empty['pending'] = true; $empty['error'] = (string) $settings->get( 'ga4_error', '' ); return $empty; } $fetched = (int) ( isset( $snap['fetched'] ) ? $snap['fetched'] : 0 ); $split = isset( $snap['split'] ) && is_array( $snap['split'] ) ? $snap['split'] : null; $pages = array(); foreach ( array_slice( (array) ( isset( $snap['pages'] ) ? $snap['pages'] : array() ), 0, 6 ) as $row ) { $pages[] = array( 'path' => (string) ( isset( $row['path'] ) ? $row['path'] : '' ), 'views' => (int) ( isset( $row['views'] ) ? $row['views'] : 0 ), 'users' => (int) ( isset( $row['users'] ) ? $row['users'] : 0 ), ); } return array( 'connected' => true, 'users' => (int) ( isset( $totals['users'] ) ? $totals['users'] : 0 ), 'newUsers' => (int) ( isset( $totals['newUsers'] ) ? $totals['newUsers'] : 0 ), 'sessions' => (int) ( isset( $totals['sessions'] ) ? $totals['sessions'] : 0 ), 'views' => (int) ( isset( $totals['views'] ) ? $totals['views'] : 0 ), // How the visit went, not just that it happened. 'engaged' => (int) ( isset( $totals['engaged'] ) ? $totals['engaged'] : 0 ), 'engagedPct' => (int) ( isset( $totals['engagedPct'] ) ? $totals['engagedPct'] : 0 ), 'avgSeconds' => (int) ( isset( $totals['avgSeconds'] ) ? $totals['avgSeconds'] : 0 ), 'perVisit' => (float) ( isset( $totals['perVisit'] ) ? $totals['perVisit'] : 0 ), 'pages' => $pages, // GA4's OWN reading of how many an assistant sent. Kept separate from // the local count on purpose — the Visitors screen shows both and names // the reason they differ, rather than picking a winner. 'aiSessions' => null === $split ? null : (int) ( isset( $split['ai'] ) ? $split['ai'] : 0 ), 'otherSessions' => null === $split ? null : (int) ( isset( $split['other'] ) ? $split['other'] : 0 ), // Which assistants, in Google's reading. Named hosts are turned into // the names people actually use — nobody thinks of their readers as // arriving from "gemini.google.com". 'aiBySource' => null === $split ? array() : self::name_sources( isset( $split['bySource'] ) ? (array) $split['bySource'] : array() ), // GA4's per-engine arrivals, organic only. Older snapshots predate // the engines key; null keeps "no opinion yet" distinct from zero. 'engineSessions' => null === $split || ! isset( $split['engines'] ) ? null : array_sum( array_map( 'intval', (array) $split['engines'] ) ), 'engineBySource' => null === $split || ! isset( $split['engines'] ) ? array() : self::name_engines( (array) $split['engines'] ), // Older than two polls. A number that has quietly stopped moving must // be able to say so; silence looks exactly like a flat week. 'stale' => $fetched > 0 && ( time() - $fetched ) > 2 * DAY_IN_SECONDS, 'fetched' => $fetched, 'window' => (int) ( isset( $snap['window'] ) ? $snap['window'] : $window ), 'start' => (string) ( isset( $snap['start'] ) ? $snap['start'] : '' ), 'end' => (string) ( isset( $snap['end'] ) ? $snap['end'] : '' ), ); } /** * Turn GA4's referring hosts into the names people say out loud. * * An unknown host is kept as-is rather than dropped or relabelled "Other": * a new assistant showing up is exactly the thing worth noticing, and * hiding it behind a bucket is how it goes unnoticed for months. * * @param array $by_source host => visits. * @return array */ private static function name_sources( array $by_source ) { $names = array( 'chatgpt.com' => 'ChatGPT', 'chat.openai.com' => 'ChatGPT', 'openai.com' => 'ChatGPT', 'perplexity.ai' => 'Perplexity', 'www.perplexity.ai' => 'Perplexity', 'gemini.google.com' => 'Gemini', 'bard.google.com' => 'Gemini', 'claude.ai' => 'Claude', 'copilot.microsoft.com' => 'Copilot', 'bing.com/chat' => 'Copilot', 'grok.com' => 'Grok', 'x.ai' => 'Grok', 'chat.deepseek.com' => 'DeepSeek', 'deepseek.com' => 'DeepSeek', 'duckduckgo.com/aichat' => 'DuckDuckGo AI', 'you.com' => 'You.com', 'phind.com' => 'Phind', 'poe.com' => 'Poe', 'meta.ai' => 'Meta AI', 'mistral.ai' => 'Mistral', 'chat.mistral.ai' => 'Mistral', ); // Two hosts can share one name (chatgpt.com and chat.openai.com), and a // reader does not care which — so they are summed, not listed twice. $merged = array(); foreach ( $by_source as $host => $hits ) { $label = isset( $names[ $host ] ) ? $names[ $host ] : (string) $host; $merged[ $label ] = ( isset( $merged[ $label ] ) ? $merged[ $label ] : 0 ) + (int) $hits; } arsort( $merged ); $out = array(); foreach ( array_slice( $merged, 0, 6, true ) as $label => $hits ) { $out[] = array( 'source' => $label, 'hits' => (int) $hits ); } return $out; } /** * The canonical engine keys as the names on the boxes people search in. * * The keys are a fixed vocabulary ({@see \Agentimus\Google\Analytics::ENGINE_SOURCES}), * but an unknown one — a filter added an engine — keeps its key as the * label rather than vanishing: same rule as {@see name_sources()}. * * @param array $engines engine key => sessions, sorted desc. * @return array */ private static function name_engines( array $engines ) { $names = array( 'google' => 'Google', 'bing' => 'Bing', 'duckduckgo' => 'DuckDuckGo', 'yahoo' => 'Yahoo', 'ecosia' => 'Ecosia', 'brave' => 'Brave', 'startpage' => 'Startpage', 'qwant' => 'Qwant', 'baidu' => 'Baidu', 'yandex' => 'Yandex', ); $out = array(); foreach ( array_slice( $engines, 0, 6, true ) as $key => $hits ) { $out[] = array( 'source' => isset( $names[ $key ] ) ? $names[ $key ] : (string) $key, 'hits' => (int) $hits, ); } return $out; } /** * People an AI assistant sent — the referral half, reshaped for the card. * * @param array $referral A Referrals::summary() payload. * @return array{enabled:bool,visits:int,today:int,sources:int,top:array} */ private static function ai_half( array $referral, $window = 30 ) { $totals = isset( $referral['totals'] ) && is_array( $referral['totals'] ) ? $referral['totals'] : array(); $by = isset( $referral['bySource'] ) && is_array( $referral['bySource'] ) ? $referral['bySource'] : array(); $top = array(); foreach ( array_slice( $by, 0, 6 ) as $row ) { $top[] = array( 'source' => (string) ( isset( $row['source'] ) ? $row['source'] : ( isset( $row['label'] ) ? $row['label'] : '' ) ), 'hits' => (int) ( isset( $row['hits'] ) ? $row['hits'] : 0 ), ); } // Where those readers actually landed. The most useful thing on the // screen: it names the pages that are earning the citations. $pages = array(); foreach ( array_slice( (array) ( isset( $referral['topPages'] ) ? $referral['topPages'] : array() ), 0, 6 ) as $row ) { $pages[] = array( 'path' => (string) ( isset( $row['path'] ) ? $row['path'] : '' ), 'hits' => (int) ( isset( $row['hits'] ) ? $row['hits'] : 0 ), ); } $visits = isset( $totals['window'] ) ? (int) $totals['window'] : 0; $prev = 0; if ( ! empty( $referral['enabled'] ) ) { $prev = Activity\Referrals::previous_window_total( $window ); } return array( 'enabled' => ! empty( $referral['enabled'] ), 'visits' => $visits, 'today' => isset( $totals['today'] ) ? (int) $totals['today'] : 0, 'sources' => isset( $referral['sourceCount'] ) ? (int) $referral['sourceCount'] : 0, 'top' => $top, 'pages' => $pages, // The window before this one, same length, no overlap — so the // headline can say whether this is going anywhere. 'prev' => $prev, 'change' => $visits - $prev, // A first window has nothing to compare against, and inventing // "+100%" out of a zero baseline is how dashboards lie cheerfully. 'hasPrev' => $prev > 0, ); } /** * What these numbers cannot say — shipped WITH them, not in a docs page. * * Each entry is only present when it actually applies to this site, so the * list is never boilerplate an owner learns to skip. A limit that is always * on screen is read as decoration; one that appears because it is true today * is read as information. * * @param array $search The search half. * @param array $ai The AI-referral half. * @param array $machines The machine half. * @param array $all The analytics half. * @param bool $pages Whether ordinary page reads are recorded (then counted too). * @return array */ private static function limits( array $search, array $ai, array $machines, array $all = array(), $pages = false ) { $out = array(); // The most important line on the card when analytics is absent: without // it, "People" is two named routes, NOT the site's audience — and a // headline number that quietly means something narrower than it looks is // the exact failure this whole block exists to prevent. if ( empty( $all['connected'] ) ) { $out[] = array( 'key' => 'people-partial', 'scope' => 'humans', 'text' => __( 'Not your whole audience — search and AI only.', 'agentimus' ), ); } else { // Naming the three instruments, not just asserting the sum fails. // "Different sources" left an owner to guess WHY, and the honest // answer is that no two of these numbers were measured by the same // thing: analytics counts people, the engines count their own // clicks, and this site's log counts what actually arrived. $out[] = array( 'key' => 'people-sampled', 'scope' => 'both', 'text' => __( 'Three instruments — analytics, the engines, this site’s own log. They don’t add up.', 'agentimus' ), ); } if ( ! empty( $all['stale'] ) ) { $out[] = array( 'key' => 'people-stale', 'scope' => 'humans', 'text' => self::stale_text( $all ), ); } if ( $search['connected'] ) { $out[] = array( 'key' => 'search-blended', 'scope' => 'humans', // "A floor" is the right idea in the wrong register — it is a // word an owner has to be taught. Say the thing itself. 'text' => __( 'Search hides AI clicks, so AI referrals are a minimum.', 'agentimus' ), ); // The card wears ONE window badge over numbers that do not share a // window: the engines publish on their own delay, so their half ends // before today while the two first-party halves run to now. The // badge cannot say this and the row cannot carry it, so it lands here. $out[] = array( 'key' => 'search-window', 'scope' => 'humans', 'text' => __( 'The engines publish on a delay — their half ends before today.', 'agentimus' ), ); // Bing answers about one page per request, so the site's pages are // worked through a few at a time. The clicks above are whole either // way; what is still filling in is the page-level detail every other // screen draws from the same rows. This says so while it is true, and // says nothing once every page has had its turn. // // ⚠️ This replaced a note reading "Bing names pages for your 10 // busiest only", which was accurate when the poll asked about the // same ten pages for ever. It is not accurate now, and a caveat that // has stopped being true is worse than no caveat at all. $waiting = isset( $search['waiting'] ) ? (int) $search['waiting'] : 0; if ( $waiting > 0 ) { $out[] = array( 'key' => 'search-filling', 'scope' => 'humans', 'text' => sprintf( /* translators: %d: how many pages have not been checked yet. */ _n( 'Bing is still working through your pages — %d has not been checked yet.', 'Bing is still working through your pages — %d have not been checked yet.', $waiting, 'agentimus' ), $waiting ), ); } } else { $out[] = array( 'key' => 'search-missing', 'scope' => 'humans', 'text' => __( 'No search engine connected — empty, not zero.', 'agentimus' ), ); } if ( $machines['enabled'] ) { // What the count holds depends on whether page reads are recorded: "agent // files only" beside a count that is mostly page reads (heera.it 10-27, // 11,456 with page views on) says the opposite of the truth. $out[] = $pages ? array( 'key' => 'machines-pages', 'scope' => 'machines', 'text' => __( 'Agent files and ordinary pages, both counted.', 'agentimus' ), ) : array( 'key' => 'machines-endpoints', 'scope' => 'machines', 'text' => __( 'Agent files only — not ordinary pages.', 'agentimus' ), ); // The freshest instrument on the card, and the only first-party one: // the log writes as requests arrive, so this half runs to NOW while // the engines' half ends before today. The contrast belongs beside // the caveat it answers. $out[] = array( 'key' => 'machines-live', 'scope' => 'machines', 'text' => __( 'Counted live by this site’s own log — today is already in.', 'agentimus' ), ); // Names come from the User-Agent: majors self-identify, scripts may // not — so the fetch COUNT is exact while the agent NAMES are claims // (the verify pass is what catches the liars). $out[] = array( 'key' => 'machines-declared', 'scope' => 'machines', 'text' => __( 'Agents are counted by the name they declare.', 'agentimus' ), ); } else { $out[] = array( 'key' => 'machines-off', 'scope' => 'machines', 'text' => __( 'Not recording — empty, not zero.', 'agentimus' ), ); } // The "AI-sent readers are a floor" warning used to be its own entry. It // is folded into the search line above, because the two said the same // thing from different ends — and because every group here has to fit // ONE line, or the three columns stop reading as three equal blocks. return $out; } }