'activeUsers', 'newUsers' => 'newUsers', 'sessions' => 'sessions', 'views' => 'screenPageViews', 'engaged' => 'engagedSessions', 'avgSeconds' => 'averageSessionDuration', ); $out = $this->run_report( $token, $property, array( 'dateRanges' => array( array( 'startDate' => (string) $start, 'endDate' => (string) $end ) ), 'metrics' => array_map( static function ( $name ) { return array( 'name' => $name ); }, array_values( $metrics ) ), ) ); if ( isset( $out['error'] ) ) { return $out; } $row = (array) ( ( $out['data']['rows'] ?? array() )[0] ?? array() ); $mv = (array) ( $row['metricValues'] ?? array() ); $totals = array(); $i = 0; foreach ( $metrics as $key => $unused ) { $raw = (float) ( $mv[ $i ]['value'] ?? 0 ); $totals[ $key ] = (int) round( $raw ); $i++; } // Derived here rather than asked for: GA4's own engagementRate is a // percentage of sessions, and computing it from the two counts we already // have keeps the numbers on screen consistent with each other. $totals['engagedPct'] = $totals['sessions'] > 0 ? (int) round( ( $totals['engaged'] / $totals['sessions'] ) * 100 ) : 0; $totals['perVisit'] = $totals['sessions'] > 0 ? round( $totals['views'] / $totals['sessions'], 1 ) : 0.0; return array( 'totals' => $totals ); } /** * The busiest pages in a window — path, views, users. * * @param string $token Bearer token. * @param string $property The GA4 property ID. * @param string $start Y-m-d start date. * @param string $end Y-m-d end date. * @param int $limit Rows to ask for. * @return array { pages?: array, error?: string } */ public function top_pages( $token, $property, $start, $end, $limit = self::ROW_LIMIT ) { $out = $this->run_report( $token, $property, array( 'dateRanges' => array( array( 'startDate' => (string) $start, 'endDate' => (string) $end ) ), 'dimensions' => array( array( 'name' => 'pagePath' ) ), 'metrics' => array( array( 'name' => 'screenPageViews' ), array( 'name' => 'activeUsers' ) ), 'orderBys' => array( array( 'desc' => true, 'metric' => array( 'metricName' => 'screenPageViews' ) ) ), 'limit' => max( 1, min( 200, (int) $limit ) ), ) ); if ( isset( $out['error'] ) ) { return $out; } $pages = array(); foreach ( (array) ( $out['data']['rows'] ?? array() ) as $row ) { $path = (string) ( ( (array) ( $row['dimensionValues'] ?? array() ) )[0]['value'] ?? '' ); if ( '' === $path ) { continue; } $mv = (array) ( $row['metricValues'] ?? array() ); $pages[] = array( 'path' => $path, 'views' => (int) round( (float) ( $mv[0]['value'] ?? 0 ) ), 'users' => (int) round( (float) ( $mv[1]['value'] ?? 0 ) ), ); } return array( 'pages' => $pages ); } /** * Hosts that mean "an AI assistant sent this reader", as GA4 spells them. * * GA4 reports the referring HOST in `sessionSource`, so this is a host list, * not the referrer-matching logic in {@see \Agentimus\Activity\Referrals}. * The two are deliberately independent: they are meant to be two separate * measurements of the same thing, and sharing a list would make them agree * for the wrong reason. * * @var array */ const AI_SOURCES = array( 'chatgpt.com', 'chat.openai.com', 'openai.com', 'perplexity.ai', 'www.perplexity.ai', 'gemini.google.com', 'bard.google.com', 'claude.ai', 'copilot.microsoft.com', 'bing.com/chat', 'grok.com', 'x.ai', 'duckduckgo.com/aichat', 'chat.deepseek.com', 'deepseek.com', 'you.com', 'phind.com', 'poe.com', 'meta.ai', 'mistral.ai', 'chat.mistral.ai', ); /** * GA4's own count of readers an assistant sent, beside its total — and, * from the same rows, its count of readers each SEARCH ENGINE sent. * * The point of asking Google this when the plugin already counts referrals * itself: the two disagree, and the disagreement is informative. GA4 misses * every reader who declined its script; the local count misses every reader * whose referrer was stripped in transit. Neither is wrong — they are * different instruments — and a screen showing both, with the reason, tells * an owner more than either number pretending to be the truth. * * The engine split rides along for the same reason: Search Console and * Webmaster Tools report the engines' clicks, GA4 reports the arrivals it * saw — one instrument per engine on each side, so Bing's human traffic is * measured exactly the way Google's is. Engines count ORGANIC sessions * only (the medium dimension exists so a paid click can't pose as a search * result); the AI split keeps summing across mediums, unchanged. * * @param string $token Bearer token. * @param string $property The GA4 property ID. * @param string $start Y-m-d start date. * @param string $end Y-m-d end date. * @return array { split?: array{ai:int,other:int,total:int,bySource:array,engines:array}, error?: string } */ public function ai_split( $token, $property, $start, $end ) { $out = $this->run_report( $token, $property, array( 'dateRanges' => array( array( 'startDate' => (string) $start, 'endDate' => (string) $end ) ), 'dimensions' => array( array( 'name' => 'sessionSource' ), array( 'name' => 'sessionMedium' ) ), 'metrics' => array( array( 'name' => 'sessions' ) ), 'orderBys' => array( array( 'desc' => true, 'metric' => array( 'metricName' => 'sessions' ) ) ), // Generous: the tail is summed into "other", so a low cap would // quietly shrink the total this split is measured against — // raised with the medium dimension, which multiplies the rows. 'limit' => 1000, ) ); if ( isset( $out['error'] ) ) { return $out; } $ai = 0; $total = 0; $by_source = array(); $engines = array(); foreach ( (array) ( $out['data']['rows'] ?? array() ) as $row ) { $dims = (array) ( $row['dimensionValues'] ?? array() ); $source = strtolower( (string) ( $dims[0]['value'] ?? '' ) ); $medium = strtolower( (string) ( $dims[1]['value'] ?? '' ) ); $sessions = (int) round( (float) ( ( (array) ( $row['metricValues'] ?? array() ) )[0]['value'] ?? 0 ) ); $total += $sessions; if ( '' === $source ) { continue; } // AI first: bing.com/chat must land on Copilot, never on Bing. if ( self::is_ai_source( $source ) ) { $ai += $sessions; $by_source[ $source ] = ( isset( $by_source[ $source ] ) ? $by_source[ $source ] : 0 ) + $sessions; continue; } if ( 'organic' === $medium ) { $engine = self::engine_source( $source ); if ( '' !== $engine ) { $engines[ $engine ] = ( isset( $engines[ $engine ] ) ? $engines[ $engine ] : 0 ) + $sessions; } } } arsort( $by_source ); arsort( $engines ); return array( 'split' => array( 'ai' => $ai, 'other' => max( 0, $total - $ai ), 'total' => $total, 'bySource' => $by_source, 'engines' => $engines, ), ); } /** * Whether a GA4 `sessionSource` value names an AI assistant. * * Matched on the host, and on a host BOUNDARY — a plain substring test would * count `notchatgpt.com.example` as ChatGPT. Filterable, because the list of * assistants changes faster than any release cycle. * * @param string $source Lowercased sessionSource value. * @return bool */ public static function is_ai_source( $source ) { /** * Filter the GA4 session sources counted as AI assistants. * * @param array $hosts Host strings. * @param string $source The value being tested. */ $hosts = (array) apply_filters( 'agentimus_ga4_ai_sources', self::AI_SOURCES, $source ); foreach ( $hosts as $host ) { $host = strtolower( (string) $host ); if ( '' === $host ) { continue; } if ( $source === $host ) { return true; } // A subdomain of a listed host, or a listed path prefix like // bing.com/chat — never a bare substring. if ( substr( $source, -( strlen( $host ) + 1 ) ) === '.' . $host ) { return true; } if ( false !== strpos( $host, '/' ) && 0 === strpos( $source, $host ) ) { return true; } } return false; } /** * GA4 `sessionSource` spellings of the classic search engines, mapped to * one canonical key each — GA4 writes the bare name for organic traffic * ('bing') and the host for the odd referral-shaped arrival ('bing.com'), * and both mean the same engine. * * @var array */ const ENGINE_SOURCES = array( 'google' => 'google', 'google.com' => 'google', 'bing' => 'bing', 'bing.com' => 'bing', 'duckduckgo' => 'duckduckgo', 'duckduckgo.com' => 'duckduckgo', 'yahoo' => 'yahoo', 'yahoo.com' => 'yahoo', 'ecosia' => 'ecosia', 'ecosia.org' => 'ecosia', 'brave' => 'brave', 'search.brave.com' => 'brave', 'startpage' => 'startpage', 'startpage.com' => 'startpage', 'qwant' => 'qwant', 'qwant.com' => 'qwant', 'baidu' => 'baidu', 'baidu.com' => 'baidu', 'yandex' => 'yandex', 'yandex.ru' => 'yandex', 'yandex.com' => 'yandex', ); /** * The canonical engine a GA4 `sessionSource` value names, or ''. * * Exact match, or a subdomain of a listed host (cn.bing.com) — never a * bare substring, for the same reason {@see is_ai_source()} refuses one. * Callers check {@see is_ai_source()} FIRST: bing.com/chat is Copilot. * Filterable, like the AI list, because spellings drift. * * @param string $source Lowercased sessionSource value. * @return string */ public static function engine_source( $source ) { /** * Filter the GA4 session sources counted as classic search engines. * * @param array $map source spelling => canonical engine key. * @param string $source The value being tested. */ $map = (array) apply_filters( 'agentimus_ga4_engine_sources', self::ENGINE_SOURCES, $source ); if ( isset( $map[ $source ] ) ) { return (string) $map[ $source ]; } foreach ( $map as $host => $engine ) { $host = strtolower( (string) $host ); if ( false !== strpos( $host, '.' ) && substr( $source, -( strlen( $host ) + 1 ) ) === '.' . $host ) { return (string) $engine; } } return ''; } /** * A GA4 property ID, or '' if the input isn't one. * * ⚠️ REJECTS rather than salvages. Stripping non-digits looks tidy and is * wrong here: the mistake people actually make is pasting the measurement ID * (`G-ABC123`), and stripping turns that into `123` — a real, valid-looking * property ID belonging to somebody else. A confusing permission error hours * later is the BEST case; the worst is that it answers. Anything that isn't * already digits is refused, and the message names the mistake. * * @param string $property Raw input. * @return string Digits, or ''. */ public static function clean_property( $property ) { $property = trim( (string) $property ); return preg_match( '/^[0-9]+$/', $property ) ? $property : ''; } /** * Confirm a property ID actually answers for this key, before it is saved. * * A property the key cannot read fails at REPORT time otherwise — hours later, * on a cron, where the owner never sees it and the screen just stays empty. * * @param string $token Bearer token. * @param string $property The GA4 property ID. * @return array { ok?: true, error?: string } */ public function verify( $token, $property ) { $today = gmdate( 'Y-m-d' ); $out = $this->totals( $token, $property, gmdate( 'Y-m-d', time() - 2 * DAY_IN_SECONDS ), $today ); if ( isset( $out['error'] ) ) { return $out; } return array( 'ok' => true ); } /** * POST a runReport body and normalise the transport/API failure modes. * * @param string $token Bearer token. * @param string $property GA4 property ID (digits only — anything else is refused here). * @param array $body The runReport request body. * @return array { data?: array, error?: string } */ private function run_report( $token, $property, array $body ) { $property = self::clean_property( $property ); if ( '' === $property ) { return array( 'error' => __( 'That GA4 property ID doesn’t look right — it’s the numeric ID from Admin → Property details, not the measurement ID that starts with G-.', 'agentimus' ) ); } $response = wp_remote_post( self::API . 'properties/' . rawurlencode( $property ) . ':runReport', array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . (string) $token, 'Content-Type' => 'application/json', ), 'body' => (string) wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return array( 'error' => $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code >= 400 ) { // Google's own words where it has any — an owner can act on "caller does // not have permission" and can do nothing at all with "HTTP 403". $detail = is_array( $json ) && isset( $json['error']['message'] ) ? (string) $json['error']['message'] : ''; if ( '' === $detail && 403 === $code ) { $detail = __( 'Google refused the request. The usual cause: the service account isn’t added to this GA4 property as a viewer yet.', 'agentimus' ); } return array( 'error' => '' !== $detail ? $detail : sprintf( /* translators: %d: HTTP status code. */ __( 'Google Analytics answered with status %d.', 'agentimus' ), $code ) ); } if ( ! is_array( $json ) ) { return array( 'error' => __( 'Google Analytics returned something that isn’t JSON.', 'agentimus' ) ); } return array( 'data' => $json ); } }