connections = $connections; $this->sites = $sites; $this->google = $google; $this->settings = $settings; } /** * Status of one connection. * * @param string $connection_id Connection ID. * @return array{ok:bool|null,checked:int,message:string} */ public function status( $connection_id ) { $all = $this->all(); $row = isset( $all[ $connection_id ] ) && is_array( $all[ $connection_id ] ) ? $all[ $connection_id ] : array(); return array_merge( array( 'ok' => null, 'checked' => 0, 'message' => '', ), $row ); } /** * Tests one connection: refreshes its token and makes one small API call. * * @param Connection $connection Connection. * @return bool Whether it works. */ public function check( Connection $connection ) { if ( ! $connection->is_connected() ) { return false; } try { $services = $this->google->for_connection( $connection ); $services->credentials()->access_token( true ); // Test every API the connection is allowed to use, so a report error and this check agree. if ( $connection->has_scope( Scopes::SEARCH_CONSOLE ) ) { $services->search_console()->sites( 0 ); } if ( $connection->has_scope( Scopes::ANALYTICS ) ) { $services->analytics()->properties( 0 ); } } catch ( ApiException $e ) { // Quota or network blips aren't a broken connection. if ( in_array( $e->reason(), array( ApiException::QUOTA, ApiException::NETWORK ), true ) ) { $known = $this->status( $connection->id )['ok']; return null === $known ? true : (bool) $known; } $this->record( $connection, false, $e->getMessage() ); return false; } $this->record( $connection, true, '' ); return true; } /** * A step-by-step test of a connection, for the Connections screen: which * Google account it is, whether sign-in works, and what each Google * product lets it see, with plain advice for each problem. * * Makes a handful of small read-only requests. The result is stored, so * the screen shows the last test without calling Google on every load. * Nothing secret (tokens, keys) is stored or shown. * * @param Connection $connection Connection. * @return array{checked:int,checks:array[]} Checks: key, label, ok (true, false or null for "not used"), message, help. */ public function diagnose( Connection $connection ) { $checks = array(); $add = static function ( $key, $label, $ok, $message, $help = '', $reason = '' ) use ( &$checks ) { $checks[] = array( 'key' => $key, 'label' => $label, 'ok' => $ok, 'message' => $message, 'help' => $help, 'reason' => $reason, ); }; $email = (string) $connection->account_email; $sa = Connection::TYPE_SERVICE_ACCOUNT === $connection->type; if ( ! $connection->is_connected() ) { $add( 'signin', __( 'Sign-in', 'turtlereport' ), false, __( 'Setup wasn\'t finished.', 'turtlereport' ), __( 'Use "Sign in with Google again" below, or add the connection again.', 'turtlereport' ) ); return $this->save_diagnosis( $connection, $checks ); } $add( 'account', __( 'Google account', 'turtlereport' ), '' !== $email ? true : null, '' !== $email /* translators: %s: email address. */ ? sprintf( $sa ? __( 'Service account %s', 'turtlereport' ) : __( 'Signed in as %s', 'turtlereport' ), $email ) : __( 'Google didn\'t say which account signed in.', 'turtlereport' ), '' !== $email ? ( $sa ? __( 'This address must be added as a user in each Search Console and GA4 property.', 'turtlereport' ) : __( 'Reports can only use properties this Google account can open.', 'turtlereport' ) ) : __( 'Reconnect to see the account name.', 'turtlereport' ) ); try { $services = $this->google->for_connection( $connection ); $services->credentials()->access_token( true ); $add( 'signin', __( 'Sign-in', 'turtlereport' ), true, __( 'Google accepted the credentials.', 'turtlereport' ) ); } catch ( ApiException $e ) { $add( 'signin', __( 'Sign-in', 'turtlereport' ), false, $e->getMessage(), $sa ? __( 'Upload a new key for this service account.', 'turtlereport' ) : __( 'Use "Sign in with Google again" below, with the same Google account.', 'turtlereport' ) ); $this->record( $connection, false, $e->getMessage() ); return $this->save_diagnosis( $connection, $checks ); } // Search Console. if ( ! $connection->has_scope( Scopes::SEARCH_CONSOLE ) ) { $add( 'gsc', __( 'Search Console', 'turtlereport' ), false, __( 'Permission not given.', 'turtlereport' ), __( 'Sign in with Google again and tick the Search Console box on Google\'s consent screen.', 'turtlereport' ) ); } else { try { $sites = $services->search_console()->sites( 0 ); $add( 'gsc', __( 'Search Console', 'turtlereport' ), $sites ? true : false, /* translators: %d: number of properties. */ $sites ? sprintf( _n( 'Can see %d property.', 'Can see %d properties.', count( $sites ), 'turtlereport' ), count( $sites ) ) : __( 'Works, but can\'t see any property yet.', 'turtlereport' ), $sites ? '' : sprintf( /* translators: %s: email address. */ __( 'In Search Console, open the property → Settings → Users and permissions → Add user, and add %s (Restricted is enough). New users can take a few minutes to appear.', 'turtlereport' ), '' !== $email ? $email : __( 'this account', 'turtlereport' ) ) ); } catch ( ApiException $e ) { $add( 'gsc', __( 'Search Console', 'turtlereport' ), false, $e->getMessage(), self::advice( $e, 'Google Search Console API' ), $e->reason() ); } } // GA4: Admin API lists properties, Data API reads reports; both must be on. if ( ! $connection->has_scope( Scopes::ANALYTICS ) ) { $add( 'ga', __( 'Google Analytics 4', 'turtlereport' ), false, __( 'Permission not given.', 'turtlereport' ), __( 'Sign in with Google again and tick the Analytics box on Google\'s consent screen.', 'turtlereport' ) ); } else { try { $props = $services->analytics()->properties( 0 ); $add( 'ga', __( 'Google Analytics 4', 'turtlereport' ), $props ? true : false, /* translators: %d: number of properties. */ $props ? sprintf( _n( 'Can see %d property.', 'Can see %d properties.', count( $props ), 'turtlereport' ), count( $props ) ) : __( 'Works, but can\'t see any GA4 property yet.', 'turtlereport' ), $props ? '' : sprintf( /* translators: %s: email address. */ __( 'In GA4, open Admin → Property access management → Add users, and add %s with the Viewer role.', 'turtlereport' ), '' !== $email ? $email : __( 'this account', 'turtlereport' ) ) ); if ( $props ) { $property = $this->property_to_test( $connection, $props ); try { $services->analytics()->run_report( $property, array( 'dateRanges' => array( array( 'startDate' => '7daysAgo', 'endDate' => 'yesterday', ), ), 'metrics' => array( array( 'name' => 'sessions' ) ), 'limit' => 1, ), 0 ); $add( 'ga_data', __( 'GA4 reports', 'turtlereport' ), true, __( 'Report data can be read.', 'turtlereport' ) ); } catch ( ApiException $e ) { $add( 'ga_data', __( 'GA4 reports', 'turtlereport' ), false, $e->getMessage(), self::advice( $e, 'Google Analytics Data API' ), $e->reason() ); } } } catch ( ApiException $e ) { $add( 'ga', __( 'Google Analytics 4', 'turtlereport' ), false, $e->getMessage(), self::advice( $e, 'Google Analytics Admin API' ), $e->reason() ); } } // Business Profile. if ( $sa ) { $add( 'gbp', __( 'Business Profile', 'turtlereport' ), null, __( 'Not available with a service account.', 'turtlereport' ), __( 'Google only allows Business Profile through a Google sign-in connection. Add one and choose it for Business Profile under "Read with" in the site\'s settings.', 'turtlereport' ) ); } elseif ( ! $connection->has_scope( Scopes::BUSINESS_PROFILE ) ) { $add( 'gbp', __( 'Business Profile', 'turtlereport' ), null, __( 'Not requested (optional).', 'turtlereport' ), __( 'To report on Business Profile, sign in again with "Also read Business Profile" ticked, after Google approves your Cloud project.', 'turtlereport' ) ); } else { try { $locations = $services->business_profile()->locations( 0 ); $add( 'gbp', __( 'Business Profile', 'turtlereport' ), $locations ? true : false, /* translators: %d: number of locations. */ $locations ? sprintf( _n( 'Can see %d location.', 'Can see %d locations.', count( $locations ), 'turtlereport' ), count( $locations ) ) : __( 'Works, but can\'t see any location.', 'turtlereport' ), $locations ? '' : __( 'Sign in with a Google account that manages the Business Profile, or add this account as a manager of the profile.', 'turtlereport' ) ); } catch ( ApiException $e ) { $add( 'gbp', __( 'Business Profile', 'turtlereport' ), false, ApiException::QUOTA === $e->reason() ? __( 'Google hasn\'t approved Business Profile API access for this Cloud project yet.', 'turtlereport' ) : $e->getMessage(), ApiException::QUOTA === $e->reason() ? __( 'Apply with Google\'s Business Profile API contact form ("Application for Basic API Access"). Approval usually takes a few days; see step 5 of the setup guide.', 'turtlereport' ) : self::advice( $e, 'My Business Account Management API' ), $e->reason() ); } } // The working / not working status uses the same rule as the daily check. $this->check( $connection ); return $this->save_diagnosis( $connection, $checks ); } /** * Advice for an API error. * * @param ApiException $e Error. * @param string $api API that was called. * @return string */ private static function advice( ApiException $e, $api ) { switch ( $e->reason() ) { case ApiException::API_DISABLED: /* translators: %s: Google API name. */ return sprintf( __( 'In Google Cloud, open APIs & Services → Library, search for "%s", click Enable, wait a minute and test again.', 'turtlereport' ), $api ); case ApiException::PERMISSION: return __( 'Add this Google account (or service account email) as a user of the property in Google, then test again.', 'turtlereport' ); case ApiException::MISSING_SCOPE: return __( 'Sign in with Google again and tick every box on the consent screen.', 'turtlereport' ); case ApiException::RECONNECT: return __( 'Sign in with Google again, or upload a new key.', 'turtlereport' ); case ApiException::QUOTA: case ApiException::NETWORK: return __( 'Probably temporary. Test again in a few minutes.', 'turtlereport' ); } return ''; } /** * The GA4 property to test reading from: one a site uses, else the first visible. * * @param Connection $connection Connection. * @param array[] $props Visible properties. * @return string */ private function property_to_test( Connection $connection, array $props ) { $visible = array_column( $props, 'id' ); foreach ( $this->sites->using_connection( $connection->id ) as $site ) { if ( in_array( $site->ga_property, $visible, true ) && $site->connection_for( \TurtleReport\Profiles\Profile::SOURCE_ANALYTICS ) === $connection->id ) { return $site->ga_property; } } return (string) $visible[0]; } /** * Stores a diagnosis with the connection's status. * * @param Connection $connection Connection. * @param array[] $checks Checks. * @return array{checked:int,checks:array[]} */ private function save_diagnosis( Connection $connection, array $checks ) { $all = OptionUpdate::change( self::OPTION, static function ( array $all ) use ( $connection, $checks ) { $row = isset( $all[ $connection->id ] ) && is_array( $all[ $connection->id ] ) ? $all[ $connection->id ] : array(); $row['diagnosis'] = array( 'checked' => time(), 'checks' => $checks, ); $all[ $connection->id ] = array_merge( array( 'ok' => null, 'checked' => 0, 'message' => '', ), $row ); return $all; } ); return $all[ $connection->id ]['diagnosis']; } /** * The last stored diagnosis of a connection. * * @param string $connection_id Connection ID. * @return array|null */ public function diagnosis( $connection_id ) { $all = $this->all(); return isset( $all[ $connection_id ]['diagnosis'] ) && is_array( $all[ $connection_id ]['diagnosis'] ) ? $all[ $connection_id ]['diagnosis'] : null; } /** * Tests every finished connection. Runs daily from cron. * * @return void */ public function check_all() { foreach ( $this->connections->all() as $connection ) { if ( $connection->is_connected() ) { $this->check( $connection ); } } $this->prune(); } /** * Records a failure seen while loading a report. * * @param string $connection_id Connection ID. * @param string $message Error message. * @return void */ public function mark_failed( $connection_id, $message ) { $connection = $this->connections->find( $connection_id ); if ( $connection ) { $this->record( $connection, false, $message ); } } /** * Records that a connection works again, e.g. right after reconnecting. * * @param Connection $connection Connection. * @return void */ public function mark_ok( Connection $connection ) { $this->record( $connection, true, '' ); } /** * Connections currently failing. * * @return Connection[] */ public function failing() { $out = array(); foreach ( $this->connections->all() as $connection ) { if ( false === $this->status( $connection->id )['ok'] ) { $out[] = $connection; } } return $out; } /** * Saves a result and sends an alert when a connection breaks. * * @param Connection $connection Connection. * @param bool $ok Whether it works. * @param string $message Error message when broken. * @return void */ private function record( Connection $connection, $ok, $message ) { $before = null; OptionUpdate::change( self::OPTION, static function ( array $all ) use ( $connection, $ok, $message, &$before ) { $row = isset( $all[ $connection->id ] ) && is_array( $all[ $connection->id ] ) ? $all[ $connection->id ] : array(); $before = isset( $row['ok'] ) ? $row['ok'] : null; $all[ $connection->id ] = array_merge( $row, array( 'ok' => (bool) $ok, 'checked' => time(), 'message' => (string) $message, ) ); return $all; } ); if ( ! $ok && false !== $before ) { $this->alert( $connection, $message ); } } /** * Emails alert recipients about a broken connection. * * @param Connection $connection Connection. * @param string $message Error message. * @return void */ private function alert( Connection $connection, $message ) { if ( ! $this->settings->get( 'alerts_enabled' ) ) { return; } $names = array_map( static function ( $site ) { return '- ' . $site->display_name(); }, $this->sites->using_connection( $connection->id ) ); $blog = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); /* translators: 1: WordPress site name, 2: connection name. */ $subject = sprintf( __( '[%1$s] Google connection "%2$s" stopped working', 'turtlereport' ), $blog, $connection->display_name() ); $body = implode( "\n\n", array_filter( array( /* translators: %s: connection name. */ sprintf( __( 'TurtleReport can no longer read Google data through the connection "%s".', 'turtlereport' ), $connection->display_name() ), /* translators: %s: error message from Google. */ sprintf( __( 'Google said: %s', 'turtlereport' ), $message ), $names ? __( 'Sites affected:', 'turtlereport' ) . "\n" . implode( "\n", $names ) : '', /* translators: %s: URL of the Connections screen. */ sprintf( __( 'Reconnect it here: %s', 'turtlereport' ), AdminLinks::url( AdminLinks::CONNECTIONS ) ), __( 'You get one email when a connection breaks. You can change who receives these under TurtleReport → Settings.', 'turtlereport' ), ) ) ); wp_mail( $this->settings->alert_recipients(), $subject, $body ); } /** * Drops status rows for deleted connections. * * @return void */ private function prune() { OptionUpdate::change( self::OPTION, function ( array $all ) { wp_cache_delete( ConnectionRepository::OPTION, 'options' ); return array_intersect_key( $all, $this->connections->all() ); } ); } /** * Every status row. * * @return array */ private function all() { $all = get_option( self::OPTION, array() ); return is_array( $all ) ? $all : array(); } }