data = $data; $this->rules = $rules; } /** * Alert ID for a type and subject. * * @param string $type Finding type. * @param string $subject Page URL, event name or ''. * @return string */ public static function id( $type, $subject = '' ) { return substr( md5( $type . '|' . $subject ), 0, 10 ); } /** * Checks one site. * * @param Profile $site Site. * @param array $period Period with s, e, ps and pe. * @param string[] $open IDs of alerts already open; they clear at the lower "clear" level. * @return array{findings:array[],errors:array,sources:string[]} */ public function detect( Profile $site, array $period, array $open = array() ) { $open = array_flip( $open ); $findings = array(); $errors = array(); $sources = array(); $search = $site->wants( Profile::SECTION_SEARCH ) && '' !== (string) $site->gsc_site; $ga = $site->wants( Profile::SECTION_CONVERSIONS ) && '' !== (string) $site->ga_property && $site->all_conversion_events(); $page_leads = array(); if ( $ga ) { $sources[] = self::ANALYTICS; try { $found = $this->analytics( $site, $period, $open, ! $search ); $findings = array_merge( $findings, $found['findings'] ); $page_leads = $found['page_leads']; } catch ( ApiException $e ) { $errors[ self::ANALYTICS ] = $e->getMessage(); } } elseif ( $site->wants( Profile::SECTION_TRAFFIC ) && '' !== (string) $site->ga_property && ! $search ) { $sources[] = self::ANALYTICS; try { $findings = array_merge( $findings, $this->sessions_only( $site, $period, $open ) ); } catch ( ApiException $e ) { $errors[ self::ANALYTICS ] = $e->getMessage(); } } if ( $search ) { $sources[] = self::SEARCH; try { $findings = array_merge( $this->search( $site, $period, $open, $page_leads ), $findings ); } catch ( ApiException $e ) { $errors[ self::SEARCH ] = $e->getMessage(); } } foreach ( $findings as $i => $finding ) { $findings[ $i ]['id'] = self::id( $finding['type'], $finding['subject'] ); } return array( 'findings' => $findings, 'errors' => $errors, 'sources' => $sources, ); } /** * Search Console checks. * * @param Profile $site Site. * @param array $p Period. * @param array $open Open alert IDs (flipped). * @param array $page_leads Normalised path => leads in the earlier period. * @return array[] */ private function search( Profile $site, array $p, array $open, array $page_leads ) { $now = self::totals( $this->data->search( $site, 'date', $p['s'], $p['e'] ) ); $prev = self::totals( $this->data->search( $site, 'date', $p['ps'], $p['pe'] ) ); $out = array(); if ( 0.0 === $now['clicks'] && $prev['clicks'] >= $this->rules['zero']['min_prev'] ) { return array( self::finding( self::NO_CLICKS, '', self::SEARCH, 'high', array( 'now' => $now, 'prev' => $prev, ) ), ); } $pages = self::join( $this->data->search( $site, 'page', $p['s'], $p['e'] ), $this->data->search( $site, 'page', $p['ps'], $p['pe'] ) ); $queries = self::join( $this->data->search( $site, 'query', $p['s'], $p['e'] ), $this->data->search( $site, 'query', $p['ps'], $p['pe'] ) ); $rule = $this->rules['clicks']; $change = Rules::change( $now['clicks'], $prev['clicks'] ); $limit = isset( $open[ self::id( self::CLICKS ) ] ) ? $rule['clear'] : $rule['trigger']; if ( null !== $change && $prev['clicks'] >= $rule['min_prev'] && $change <= $limit ) { $out[] = self::finding( self::CLICKS, '', self::SEARCH, $change <= $rule['high'] ? 'high' : 'medium', array( 'now' => $now, 'prev' => $prev, 'change' => round( $change, 1 ), 'pages' => self::losers( $pages ), 'signals' => Signals::search( $now, $prev, $queries ), ) ); return $out; } $site_change = $change; $ranking = $this->rankings( $queries, isset( $open[ self::id( self::RANKINGS ) ] ) ); if ( $ranking ) { $out[] = $ranking; } foreach ( $this->pages( $site, $p, $pages, $page_leads, $open, $site_change ) as $finding ) { $out[] = $finding; } return $out; } /** * Keywords that slipped down the results while the site's clicks held up. * * @param array $queries Joined query rows. * @param bool $is_open Whether the alert is already open. * @return array|null */ private function rankings( array $queries, $is_open ) { $rule = $this->rules['rankings']; $worse = $is_open ? $rule['clear'] : $rule['worse']; $hits = array(); $lost = 0.0; foreach ( $queries as $row ) { if ( ( $row['pc'] < $rule['min_prev_clicks'] && $row['pi'] < $rule['min_prev_impr'] ) || ! $row['pi'] || ! $row['i'] ) { continue; } if ( $row['pos'] - $row['ppos'] >= $worse ) { $hits[] = $row; $lost += max( 0, $row['pc'] - $row['c'] ); } } $enough = count( $hits ) >= $rule['min_keywords'] || ( $hits && $lost >= $rule['min_lost'] ); if ( ! $hits || ( ! $is_open && ! $enough ) ) { return null; } usort( $hits, static function ( $a, $b ) { $clicks = ( $b['pc'] - $b['c'] ) <=> ( $a['pc'] - $a['c'] ); return 0 !== $clicks ? $clicks : ( ( $b['pos'] - $b['ppos'] ) <=> ( $a['pos'] - $a['ppos'] ) ); } ); return self::finding( self::RANKINGS, '', self::SEARCH, $lost >= $rule['high_lost'] ? 'high' : 'medium', array( 'keywords' => array_slice( $hits, 0, $rule['list'] ), 'count' => count( $hits ), 'lost' => $lost, ) ); } /** * Important pages that lost clicks while the site as a whole held up. * * Important: one of the top pages by clicks in the earlier period, or a * page that brought leads. * * @param Profile $site Site. * @param array $p Period. * @param array $pages Joined page rows. * @param array $page_leads Normalised path => leads before. * @param array $open Open alert IDs (flipped). * @param float $site_change The whole site's clicks change, in percent. * @return array[] */ private function pages( Profile $site, array $p, array $pages, array $page_leads, array $open, $site_change = null ) { $rule = $this->rules['page']; $top = $pages; usort( $top, static function ( $a, $b ) { return $b['pc'] <=> $a['pc']; } ); $top = array_flip( array_column( array_slice( $top, 0, $rule['top'] ), 'key' ) ); // Pages the agency marked as important are watched too, with the same thresholds. $chosen = array_flip( $site->priorities()['pages'] ); $hits = array(); foreach ( $pages as $row ) { $leads = isset( $page_leads[ self::path( $row['key'] ) ] ) ? $page_leads[ self::path( $row['key'] ) ] : 0; $reason = isset( $top[ $row['key'] ] ) ? 'top' : ( $leads >= $rule['min_leads'] ? 'leads' : ( isset( $chosen[ Priorities::path( $row['key'] ) ] ) ? 'chosen' : '' ) ); $is_open = isset( $open[ self::id( self::PAGE, $row['key'] ) ] ); if ( '' === $reason && ! $is_open ) { continue; } $change = Rules::change( $row['c'], $row['pc'] ); $lost = $row['pc'] - $row['c']; if ( null === $change || $row['pc'] < $rule['min_prev'] ) { continue; } if ( $is_open ? $change <= $rule['clear'] : ( $change <= $rule['trigger'] && $lost >= $rule['min_lost'] ) ) { $hits[] = array( $row, $change, $lost, $leads, '' === $reason ? 'top' : $reason ); } } usort( $hits, static function ( $a, $b ) { return $b[2] <=> $a[2]; } ); $out = array(); foreach ( array_slice( $hits, 0, $rule['max'] ) as $hit ) { list( $row, $change, $lost, $leads, $reason ) = $hit; $queries = self::join( $this->data->search( $site, 'query', $p['s'], $p['e'], $row['key'] ), $this->data->search( $site, 'query', $p['ps'], $p['pe'], $row['key'] ) ); $now = array( 'clicks' => $row['c'], 'impr' => $row['i'], 'pos' => $row['i'] ? $row['pos'] : null, ); $prev = array( 'clicks' => $row['pc'], 'impr' => $row['pi'], 'pos' => $row['pi'] ? $row['ppos'] : null, ); $out[] = self::finding( self::PAGE, $row['key'], self::SEARCH, // Big percentages on small pages are common; "high" needs real volume too. $change <= $rule['high'] && $row['pc'] >= 100 ? 'high' : 'medium', array( 'page' => $row['key'], 'row' => $row, 'change' => round( $change, 1 ), 'lost' => $lost, 'leads' => $leads, 'reason' => $reason, 'site_change' => null === $site_change ? null : round( $site_change, 1 ), 'signals' => Signals::search( $now, $prev, $queries ), ) ); } return $out; } /** * GA4 checks: leads down, and conversion events that stopped recording. * * @param Profile $site Site. * @param array $p Period. * @param array $open Open alert IDs (flipped). * @param bool $sessions_only Whether to also check sessions (no Search Console). * @return array{findings:array[],page_leads:array} */ private function analytics( Profile $site, array $p, array $open, $sessions_only ) { $events = array_values( array_unique( $site->all_conversion_events() ) ); $cur = $this->data->events( $site, $p['s'], $p['e'] ); $prev = $this->data->events( $site, $p['ps'], $p['pe'] ); $now = Summary::split( $site, $cur )['total']; $before = Summary::split( $site, $prev )['total']; $out = array(); // Sessions are supporting evidence; a failure here shouldn't hide a leads drop. $sessions = null; try { $sessions = array( $this->data->sessions( $site, $p['s'], $p['e'] ), $this->data->sessions( $site, $p['ps'], $p['pe'] ) ); } catch ( ApiException $e ) { unset( $e ); } // Events with nothing in the last 7 days after a steady 28 days before. $rule = $this->rules['tracking']; $week_s = Periods::add_days( $p['e'], -6 ); $last7 = $this->data->events( $site, $week_s, $p['e'] ); $before4 = $this->data->events( $site, Periods::add_days( $p['e'], -34 ), Periods::add_days( $p['e'], -7 ) ); $week_v = null; $stopped = array(); foreach ( $events as $event ) { $was = isset( $before4[ $event ] ) ? (int) $before4[ $event ] : 0; $is = isset( $last7[ $event ] ) ? (int) $last7[ $event ] : 0; $is_open = isset( $open[ self::id( self::TRACKING, $event ) ] ); if ( 0 !== $is || $was < ( $is_open ? 1 : $rule['min_before'] ) ) { continue; } if ( null === $week_v ) { $week_v = $this->data->sessions( $site, $week_s, $p['e'] ); } if ( $week_v < $rule['min_sessions'] && ! $is_open ) { continue; } $stopped[ $event ] = $was; $out[] = self::finding( self::TRACKING, $event, self::ANALYTICS, 'high', array( 'event' => $event, 'before' => $was, 'since' => $week_s, 'until' => $p['e'], 'sessions' => $week_v, ) ); } $rule = $this->rules['leads']; $change = Rules::change( $now, $before ); $limit = isset( $open[ self::id( self::LEADS ) ] ) ? $rule['clear'] : $rule['trigger']; if ( null !== $change && $before >= $rule['min_prev'] && $change <= $limit ) { $by_event = array(); $lost = max( 1, $before - $now ); $by_stop = 0; foreach ( $events as $event ) { $c = isset( $cur[ $event ] ) ? (int) $cur[ $event ] : 0; $b = isset( $prev[ $event ] ) ? (int) $prev[ $event ] : 0; if ( $c || $b ) { $by_event[] = array( 'name' => $event, 'now' => $c, 'prev' => $b, ); } if ( isset( $stopped[ $event ] ) ) { $by_stop += max( 0, $b - $c ); } } // When stopped events explain most of the drop, the tracking alert says it better. if ( ! $stopped || $by_stop / $lost < 0.5 ) { usort( $by_event, static function ( $a, $b ) { return ( $a['now'] - $a['prev'] ) <=> ( $b['now'] - $b['prev'] ); } ); $out[] = self::finding( self::LEADS, '', self::ANALYTICS, $change <= $rule['high'] && $before >= 2 * $rule['min_prev'] ? 'high' : 'medium', array( 'now' => $now, 'prev' => $before, 'change' => round( $change, 1 ), 'sessions' => $sessions, 'events' => array_slice( $by_event, 0, self::EVIDENCE_ROWS ), 'pages' => $this->lead_pages( $site, $p ), 'stopped' => array_keys( $stopped ), 'label' => $site->conversion_label(), ) ); } } // The priority events on their own: a drop there matters even when the other conversions hide it. $picked = $site->priority_events(); if ( $picked && count( $picked ) < count( $events ) && ! self::has_type( $out, self::LEADS ) ) { $found = $this->priority_drop( $site, $p, $open, $picked, $cur, $prev, $stopped, $sessions ); if ( $found ) { $out[] = $found; } } if ( $sessions_only && $sessions ) { $found = $this->sessions_finding( $sessions[0], $sessions[1], $open ); if ( $found ) { $out[] = $found; } } // Leads per page in the earlier period, so pages that brought leads count as important. $page_leads = array(); try { foreach ( $this->data->landing_pages( $site, $p['ps'], $p['pe'] ) as $path => $map ) { $page_leads[ self::path( $path ) ] = Summary::split( $site, $map )['total']; } } catch ( ApiException $e ) { unset( $e ); } return array( 'findings' => $out, 'page_leads' => $page_leads, ); } /** * Priority events down, with the same rule as all conversions. * * @param Profile $site Site. * @param array $p Period. * @param array $open Open alert IDs (flipped). * @param string[] $picked Priority events. * @param array $cur Event counts now. * @param array $prev Event counts before. * @param array $stopped Events that stopped recording (event => count before). * @param array|null $sessions Sessions now and before. * @return array|null */ private function priority_drop( Profile $site, array $p, array $open, array $picked, array $cur, array $prev, array $stopped, $sessions ) { $rule = $this->rules['leads']; $now = 0; $before = 0; $rows = array(); $by_stop = 0; foreach ( $picked as $event ) { $c = isset( $cur[ $event ] ) ? (int) $cur[ $event ] : 0; $b = isset( $prev[ $event ] ) ? (int) $prev[ $event ] : 0; $now += $c; $before += $b; if ( $c || $b ) { $rows[] = array( 'name' => $event, 'now' => $c, 'prev' => $b, ); } if ( isset( $stopped[ $event ] ) ) { $by_stop += max( 0, $b - $c ); } } $change = Rules::change( $now, $before ); $limit = isset( $open[ self::id( self::LEADS ) ] ) ? $rule['clear'] : $rule['trigger']; if ( null === $change || $before < $rule['min_prev'] || $change > $limit ) { return null; } // A stopped event already explains it. if ( $by_stop / max( 1, $before - $now ) >= 0.5 ) { return null; } usort( $rows, static function ( $a, $b ) { return ( $a['now'] - $a['prev'] ) <=> ( $b['now'] - $b['prev'] ); } ); return self::finding( self::LEADS, '', self::ANALYTICS, $change <= $rule['high'] && $before >= 2 * $rule['min_prev'] ? 'high' : 'medium', array( 'now' => $now, 'prev' => $before, 'change' => round( $change, 1 ), 'sessions' => $sessions, 'events' => array_slice( $rows, 0, self::EVIDENCE_ROWS ), // Landing pages count every conversion, so they'd mislead here. 'pages' => array(), 'stopped' => array_keys( $stopped ), 'label' => $site->conversion_label(), 'priority' => $picked, ) ); } /** * Whether a finding of a type is already in the list. * * @param array[] $findings Findings. * @param string $type Type. * @return bool */ private static function has_type( array $findings, $type ) { foreach ( $findings as $f ) { if ( $f['type'] === $type ) { return true; } } return false; } /** * Landing pages that lost the most leads. * * @param Profile $site Site. * @param array $p Period. * @return array[] Rows with path, now and prev. */ private function lead_pages( Profile $site, array $p ) { try { $cur = $this->data->landing_pages( $site, $p['s'], $p['e'] ); $prev = $this->data->landing_pages( $site, $p['ps'], $p['pe'] ); } catch ( ApiException $e ) { return array(); } $rows = array(); foreach ( array_unique( array_merge( array_keys( $cur ), array_keys( $prev ) ) ) as $path ) { $now = isset( $cur[ $path ] ) ? Summary::split( $site, $cur[ $path ] )['total'] : 0; $then = isset( $prev[ $path ] ) ? Summary::split( $site, $prev[ $path ] )['total'] : 0; if ( $then > $now && '(not set)' !== $path ) { $rows[] = array( 'path' => (string) $path, 'now' => $now, 'prev' => $then, ); } } usort( $rows, static function ( $a, $b ) { return ( $a['now'] - $a['prev'] ) <=> ( $b['now'] - $b['prev'] ); } ); return array_slice( $rows, 0, self::EVIDENCE_ROWS ); } /** * Organic sessions check, for sites without Search Console. * * @param Profile $site Site. * @param array $p Period. * @param array $open Open alert IDs (flipped). * @return array[] */ private function sessions_only( Profile $site, array $p, array $open ) { $found = $this->sessions_finding( $this->data->sessions( $site, $p['s'], $p['e'] ), $this->data->sessions( $site, $p['ps'], $p['pe'] ), $open ); return $found ? array( $found ) : array(); } /** * Sessions finding, or null. * * @param int $now Sessions now. * @param int $prev Sessions before. * @param array $open Open alert IDs (flipped). * @return array|null */ private function sessions_finding( $now, $prev, array $open ) { $rule = $this->rules['sessions']; $change = Rules::change( $now, $prev ); $limit = isset( $open[ self::id( self::SESSIONS ) ] ) ? $rule['clear'] : $rule['trigger']; if ( null === $change || $prev < $rule['min_prev'] || $change > $limit ) { return null; } return self::finding( self::SESSIONS, '', self::ANALYTICS, $change <= $rule['high'] ? 'high' : 'medium', array( 'now' => $now, 'prev' => $prev, 'change' => round( $change, 1 ), ) ); } /** * A finding. * * @param string $type Type. * @param string $subject Page URL, event or ''. * @param string $source search|analytics. * @param string $severity high|medium. * @param array $data Evidence. * @return array */ private static function finding( $type, $subject, $source, $severity, array $data ) { return array( 'type' => $type, 'subject' => (string) $subject, 'source' => $source, 'severity' => $severity, 'data' => $data, ); } /** * Clicks, impressions, CTR and impression-weighted position of date rows. * * @param array[] $rows Rows. * @return array{clicks:float,impr:float,ctr:float,pos:float|null} */ private static function totals( array $rows ) { $c = 0.0; $i = 0.0; $w = 0.0; foreach ( $rows as $row ) { $c += isset( $row['clicks'] ) ? (float) $row['clicks'] : 0.0; $i += isset( $row['impressions'] ) ? (float) $row['impressions'] : 0.0; $w += ( isset( $row['position'] ) ? (float) $row['position'] : 0.0 ) * ( isset( $row['impressions'] ) ? (float) $row['impressions'] : 0.0 ); } return array( 'clicks' => $c, 'impr' => $i, 'ctr' => $i > 0 ? $c / $i : 0.0, 'pos' => $i > 0 ? round( $w / $i, 1 ) : null, ); } /** * Joins this period's and the earlier period's rows by key. * * @param array[] $cur Rows now. * @param array[] $prev Rows before. * @return array[] Rows with key, c, pc, i, pi, pos and ppos. */ private static function join( array $cur, array $prev ) { $out = array(); foreach ( array( 'now' => $cur, 'prev' => $prev, ) as $when => $rows ) { foreach ( $rows as $row ) { $key = isset( $row['keys'][0] ) ? (string) $row['keys'][0] : ''; if ( '' === $key ) { continue; } if ( ! isset( $out[ $key ] ) ) { $out[ $key ] = array( 'key' => $key, 'c' => 0.0, 'pc' => 0.0, 'i' => 0.0, 'pi' => 0.0, 'pos' => 0.0, 'ppos' => 0.0, ); } $p = 'now' === $when ? '' : 'p'; $out[ $key ][ $p . 'c' ] = (float) $row['clicks']; $out[ $key ][ $p . 'i' ] = (float) $row['impressions']; $out[ $key ][ $p . 'pos' ] = round( (float) $row['position'], 1 ); } } return array_values( $out ); } /** * Rows that lost the most clicks. * * @param array[] $rows Joined rows. * @return array[] */ private static function losers( array $rows ) { $rows = array_values( array_filter( $rows, static function ( $r ) { return $r['pc'] > $r['c']; } ) ); usort( $rows, static function ( $a, $b ) { return ( $a['c'] - $a['pc'] ) <=> ( $b['c'] - $b['pc'] ); } ); return array_slice( $rows, 0, self::EVIDENCE_ROWS ); } /** * A page URL or path as a lowercase path without a trailing slash, like * GA4 landing pages are stored by the conversions report. * * @param string $url URL or path. * @return string */ public static function path( $url ) { $path = (string) wp_parse_url( (string) $url, PHP_URL_PATH ); if ( '' === $path && '/' !== substr( (string) $url, 0, 1 ) ) { $path = '/'; } $path = strtolower( rtrim( '' === $path ? (string) $url : $path, '/' ) ); return '' === $path ? '/' : $path; } }