Health tab (via the /health REST route) and WordPress's own * Site Health screen. * * @package Tagsmith */ declare(strict_types=1); namespace Tagsmith\Health; use Tagsmith\Auth\Connection; use Tagsmith\Auth\OAuthFlow; use Tagsmith\Clarity\ClarityError; use Tagsmith\Google\AdminApi; use Tagsmith\Google\ApiError; use Tagsmith\Google\SearchConsoleApi; use Tagsmith\Pixels\Platforms; use Tagsmith\Pixels\ServerConsent; use Tagsmith\Plugin; use Tagsmith\Settings; use Tagsmith\Tracking\Gate; defined( 'ABSPATH' ) || exit; /** * Runs a fixed set of read-only checks and reports each as good, recommended * or critical, or as inactive when the feature it looks at is switched off * on purpose (the Health tab says "Not in use"; WordPress's Site Health is * told "good", which is all it understands for "nothing to fix"). * OAuthFlow, AdminApi and SearchConsoleApi may be given as * closures so boot() -> register_site_health() never has to construct them * (OAuthFlow needs TokenStore, which needs the auth salts). */ class HealthCheck { /** * Conflicting analytics/tag-manager plugins, main file (relative to * WP_PLUGIN_DIR) => display name. * * @var array */ private const CONFLICT_PLUGINS = array( 'google-site-kit/google-site-kit.php' => 'Site Kit', 'google-analytics-for-wordpress/googleanalytics.php' => 'MonsterInsights', 'google-analytics-dashboard-for-wp/gadwp.php' => 'ExactMetrics', 'ga-google-analytics/ga-google-analytics.php' => 'GA Google Analytics', 'duracelltomi-google-tag-manager/duracelltomi-google-tag-manager-for-wordpress.php' => 'GTM4WP', 'wp-analytify/wp-analytify.php' => 'Analytify', ); /** * Microsoft's own Clarity plugin, main file relative to WP_PLUGIN_DIR. * * @var string */ private const MICROSOFT_CLARITY_PLUGIN = 'microsoft-clarity/clarity.php'; /** * Option holding the encrypted Clarity Data Export token (written by * the Clarity sync; only its presence is read here). * * @var string */ private const CLARITY_TOKEN_OPTION = 'tagsmith_clarity_token'; /** * Option holding the Clarity sync status (last_success, error, ...). * * @var string */ private const CLARITY_STATUS_OPTION = 'tagsmith_clarity_status'; /** * Option holding the result of the last ad pixel scan of the homepage: * `checked_at` (Unix time) and, per platform, its `state`, the `id` it was * checked for and any `others` found. The settings screen shows a stored * state only while the platform's saved ID is still that `id`. Read in the * admin only, so it is not autoloaded. * * @var string */ public const PIXEL_SCAN_OPTION = 'tagsmith_pixel_scan'; /** * The last ad pixel scan check_pixels() stored, or null when there is none * or what is stored is not shaped like one. The settings screen is given * it with the page, and /health hands over a new one. * * @return array{checked_at: int, platforms: array}|null */ public static function stored_pixel_scan(): ?array { $scan = get_option( self::PIXEL_SCAN_OPTION, null ); if ( ! is_array( $scan ) || ! is_int( $scan['checked_at'] ?? null ) || ! is_array( $scan['platforms'] ?? null ) ) { return null; } return $scan; } /** * The ad pixel scan the latest run() made, or null when it made none. The * Ads & pixels tab asks for it when the person checks again, so an old * scan is never mistaken for a new one. * * @return array{checked_at: int, platforms: array}|null */ public function last_pixel_scan(): ?array { return $this->pixel_scan; } /** * Severity order of check statuses, mildest first. "inactive" is below * "good": it only ever stands for a feature that is off, so any finding on * the same check outranks it. It is also the list of statuses a filter may * return (see filter_result()). * * @var array */ private const SEVERITY = array( 'inactive' => 0, 'good' => 1, 'recommended' => 2, 'critical' => 3, ); /** * The ad pixel scan the latest run() made and stored, or null when it * made none (no pixel on, or the homepage could not be read). * * @var array{checked_at: int, platforms: array}|null */ private ?array $pixel_scan = null; /** * Settings store. * * @var Settings */ private Settings $settings; /** * The homepage as fetched during the current run (HTML, or why it could * not be loaded), shared by the tag and Clarity checks; null when not * fetched yet. * * @var string|\WP_Error|null */ private $homepage = null; /** * Connection state. * * @var Connection */ private Connection $connection; /** * OAuth flow, or a closure that builds one. * * @var OAuthFlow|\Closure */ private $oauth; /** * Admin API client, or a closure that builds one. * * @var AdminApi|\Closure */ private $admin_api; /** * Search Console API client, or a closure that builds one. * * @var SearchConsoleApi|\Closure */ private $search_console; /** * The shared tracking gate, which says whether tags are added in this * environment. * * @var Gate */ private Gate $gate; /** * What the server knows about marketing consent (is a banner set up). * * @var ServerConsent */ private ServerConsent $consent; /** * Constructor. * * @param Settings $settings Settings store. * @param Connection $connection Connection state. * @param OAuthFlow|\Closure $oauth OAuth flow, or a closure returning one. * @param AdminApi|\Closure $admin_api Admin API client, or a closure returning one. * @param SearchConsoleApi|\Closure $search_console Search Console client, or a closure returning one. * @param Gate|null $gate The shared tracking gate; built from the settings when not given. * @param ServerConsent|null $consent What the server knows about consent; built from the settings when not given. */ public function __construct( Settings $settings, Connection $connection, OAuthFlow|\Closure $oauth, AdminApi|\Closure $admin_api, SearchConsoleApi|\Closure $search_console, ?Gate $gate = null, ?ServerConsent $consent = null ) { $this->settings = $settings; $this->connection = $connection; $this->oauth = $oauth; $this->admin_api = $admin_api; $this->search_console = $search_console; $this->gate = $gate ?? new Gate( $settings ); $this->consent = $consent ?? new ServerConsent( $settings ); } /** * Runs every check and returns the ones that apply. The homepage is * fetched at most once per run. * * @return array */ public function run(): array { $this->homepage = null; $this->pixel_scan = null; $checks = array( $this->check_connection(), $this->check_search_console(), $this->check_tag(), $this->check_clarity(), $this->check_pixels(), $this->check_conflicts(), $this->check_environment(), $this->check_consent(), ); return array_map( array( $this, 'filter_result' ), array_values( array_filter( $checks ) ) ); } /** * Passes one check result through the `tagsmith_health_check_result` * filter. A callback that returns something that is not a well-formed * result (the same id, a label, a known status and a description) is * ignored, so the Health tab never receives a broken row. * * @param array{id: string, label: string, status: string, description: string, action_url?: string, detected_ids?: string[]} $result One check result. * @return array{id: string, label: string, status: string, description: string, action_url?: string, detected_ids?: string[]} */ private function filter_result( array $result ): array { /** * Filters the result of each check on the Settings, Health tab. * * It is meant for an environment that cannot meet a check for * reasons outside the site owner's control. The demo * (demo/tagsmith-demo.php) is one: its Playground runtime has no * sodium extension, so it reports the Environment check as passed; * and it holds its Google tag, and any Microsoft Clarity tag, back * on purpose, so it reports the tag check, and a Clarity tag that is * not found, as "inactive" (Not in use). A real site is not changed * by it. WordPress's own Site Health tests are not filtered. * * @since 2.0.0 * * @param array $result One check result: id, label, status ('good', 'recommended', 'critical' or, since 2.1.0, 'inactive', which the Health tab shows as "Not in use" for a feature that is switched off), description, and for some checks action_url and detected_ids. * @param string $id The check's id: 'connection', 'search_console', 'tag', 'clarity', 'pixels' (since 2.1.0), 'conflicts', 'environment' or 'consent'. */ $filtered = apply_filters( 'tagsmith_health_check_result', $result, $result['id'] ); if ( ! is_array( $filtered ) || ( $filtered['id'] ?? null ) !== $result['id'] || ! is_string( $filtered['label'] ?? null ) || ! is_string( $filtered['description'] ?? null ) || ! is_string( $filtered['status'] ?? null ) || ! isset( self::SEVERITY[ $filtered['status'] ] ) ) { return $result; } return $filtered; } /** * Registers the connection and tag checks with WordPress's Site Health * (Tools -> Site Health -> Info/Status), as `site_status_tests` direct * tests. Only hooks a filter; nothing is resolved eagerly. */ public function register_site_health(): void { add_filter( 'site_status_tests', array( $this, 'add_site_health_tests' ) ); } /** * The `site_status_tests` filter callback. Both checks are registered as * async tests (backed by the /site-health/{test} REST route), so the * Site Health page never blocks its initial render on the homepage * loopback or a Google API call. * * @param array $tests The existing direct/async tests. * @return array */ public function add_site_health_tests( array $tests ): array { $tests['async']['tagsmith_connection'] = array( 'label' => __( 'Tagsmith: connection to Google', 'tagsmith' ), 'test' => rest_url( 'tagsmith/v1/site-health/connection' ), 'has_rest' => true, 'async_direct_test' => array( $this, 'site_health_connection' ), ); $tests['async']['tagsmith_tag'] = array( 'label' => __( 'Tagsmith: tracking tag', 'tagsmith' ), 'test' => rest_url( 'tagsmith/v1/site-health/tag' ), 'has_rest' => true, 'async_direct_test' => array( $this, 'site_health_tag' ), ); return $tests; } /** * Site Health direct test callback for the connection check. * * @return array */ public function site_health_connection(): array { return $this->to_site_health( $this->check_connection() ); } /** * Site Health direct test callback for the tag check. * * @return array */ public function site_health_tag(): array { return $this->to_site_health( $this->check_tag() ); } /** * Maps one of our check results to the shape Site Health's direct tests * expect: label, status, badge, description (HTML), actions (HTML), test. * Site Health knows only good, recommended and critical, so "inactive" (a * feature switched off on purpose) is handed over as "good". * * @param array{id: string, label: string, status: string, description: string, action_url?: string, detected_ids?: string[]} $item One check result. * @return array */ private function to_site_health( array $item ): array { $actions = ''; if ( isset( $item['action_url'] ) ) { $actions = sprintf( '

%2$s

', esc_url( $item['action_url'] ), esc_html__( 'Go to Tagsmith Settings', 'tagsmith' ) ); } return array( 'label' => $item['label'], 'status' => 'inactive' === $item['status'] ? 'good' : $item['status'], 'badge' => array( 'label' => 'Tagsmith', 'color' => 'blue', ), 'description' => '

' . esc_html( $item['description'] ) . '

', 'actions' => $actions, 'test' => 'tagsmith_' . $item['id'], ); } /** * Connection: is a Google account connected, and (only when a property * is already chosen, to keep this cheap) is it actually reachable. * * @return array{id: string, label: string, status: string, description: string, action_url?: string} */ private function check_connection(): array { $label = __( 'Connection to Google', 'tagsmith' ); if ( $this->connection->reconnect_required() ) { return $this->result( 'connection', $label, 'critical', ( new ApiError( 'reconnect_required' ) )->user_message(), $this->settings_url() ); } if ( ! $this->connection->is_connected() ) { return $this->result( 'connection', $label, 'recommended', __( 'Not connected to Google. Tracking still works if a Measurement ID is set.', 'tagsmith' ), $this->settings_url() ); } $property_id = (string) $this->settings->get( 'property_id' ); if ( '' === $property_id ) { return $this->result( 'connection', $label, 'recommended', __( 'Connected to Google, but no property has been selected yet.', 'tagsmith' ), $this->settings_url() ); } try { $this->oauth()->access_token(); $this->admin_api()->web_streams( $property_id ); return $this->result( 'connection', $label, 'good', __( 'Connected to Google Analytics, and the selected property is accessible.', 'tagsmith' ) ); } catch ( ApiError $e ) { return $this->result( 'connection', $label, 'critical', $e->user_message() ); } } /** * Search Console: only checked when enabled in Settings. The configured * site must appear in the connected account's site list. * * @return array{id: string, label: string, status: string, description: string, action_url?: string}|null */ private function check_search_console(): ?array { if ( ! $this->settings->get( 'gsc_enabled' ) ) { return null; } $label = __( 'Search Console', 'tagsmith' ); $gsc_site = (string) $this->settings->get( 'gsc_site' ); try { $sites = $this->search_console()->sites(); foreach ( $sites as $site ) { if ( $site['site_url'] === $gsc_site ) { return $this->result( 'search_console', $label, 'good', __( 'Search Console is connected, and the selected site is accessible.', 'tagsmith' ) ); } } return $this->result( 'search_console', $label, 'recommended', __( 'The selected Search Console site was not found in this Google account. Reselect it in Settings.', 'tagsmith' ), $this->settings_url() ); } catch ( ApiError $e ) { return $this->result( 'search_console', $label, 'critical', $e->user_message() ); } } /** * Tag: without a Measurement ID, recommends setting one up, except on * Clarity-only sites (Microsoft Clarity set up here), where it only * notes, as not in use, that the Google tag is not used. Otherwise it * fetches the homepage as a logged-out visitor and looks for every Google * tag on it (see TagScanner), then reports, in order: * - the homepage could not be loaded: not in use when the tag is off on * purpose, else why and how to check by hand; * - another GA4 id is on the page: Tagsmith may report on the wrong * property (the dashboard shows 0 visits); * - the configured id is loaded more than once: double counting; * - the configured id is present: good; * - it is missing: not in use when the tag is off on purpose, else * critical. * A Tag Manager container adds a note (it can load GA tags the page * source does not show). The result carries detected_ids: every Google * tag id found (empty when the page was not scanned). * * @return array{id: string, label: string, status: string, description: string, action_url?: string, detected_ids: string[]} */ private function check_tag(): array { $label = __( 'Tracking tag on the homepage', 'tagsmith' ); $measurement_id = (string) $this->settings->get( 'measurement_id' ); if ( '' === $measurement_id ) { if ( $this->clarity_configured() ) { return $this->result( 'tag', $label, 'inactive', __( 'The Google tag is not set up; only Microsoft Clarity is added to your site.', 'tagsmith' ) ) + array( 'detected_ids' => array() ); } return $this->result( 'tag', $label, 'recommended', __( 'No Measurement ID is configured yet. Connect Google Analytics or enter one manually in Settings.', 'tagsmith' ), $this->settings_url() ) + array( 'detected_ids' => array() ); } $expected = $this->tag_expected(); $html = $this->homepage(); if ( ! is_string( $html ) ) { if ( ! $expected ) { return $this->result( 'tag', $label, 'inactive', $this->tag_off_reason() ) + array( 'detected_ids' => array() ); } return $this->result( 'tag', $label, 'recommended', sprintf( /* translators: 1: why the request failed (an error message or "HTTP 401"). 2: the GA4 measurement id. */ __( 'Could not load the homepage to check for the tag (%1$s). Many hosts block a site from requesting its own pages. To check by hand, open your homepage, view the page source and search for gtag/js?id=%2$s.', 'tagsmith' ), $html->get_error_message(), $measurement_id ) ) + array( 'detected_ids' => array() ); } $scan = TagScanner::scan( $html ); $ours = $scan['ga4'][ $measurement_id ] ?? null; $copies = null === $ours ? 0 : max( $ours['loaders'], $ours['configs'] ); $others = implode( ', ', array_values( array_diff( array_keys( $scan['ga4'] ), array( $measurement_id ) ) ) ); if ( '' !== $others ) { /* translators: 1: other GA4 measurement id(s) found on the homepage. */ $switch = sprintf( __( 'If your visits are recorded under %1$s, choose that property under Connection > Change property.', 'tagsmith' ), $others ); if ( $copies > 0 ) { $status = 'recommended'; $description = sprintf( /* translators: 1: other GA4 measurement id(s) found on the homepage. 2: the configured GA4 measurement id. */ __( 'Your homepage also sends data to %1$s, which is not the property Tagsmith reports on (%2$s).', 'tagsmith' ), $others, $measurement_id ) . ' ' . $switch; } elseif ( $expected ) { $status = 'critical'; $description = sprintf( /* translators: 1: the configured GA4 measurement id. 2: other GA4 measurement id(s) found on the homepage. */ __( 'Your Tagsmith tag (%1$s) was not found on the homepage, but the homepage sends data to %2$s, which is not the property Tagsmith reports on.', 'tagsmith' ), $measurement_id, $others ) . ' ' . $switch . ' ' . __( 'Otherwise check for a caching plugin serving an old page.', 'tagsmith' ); } else { $status = 'recommended'; $description = $this->tag_off_reason() . ' ' . sprintf( /* translators: 1: other GA4 measurement id(s) found on the homepage. 2: the configured GA4 measurement id. */ __( 'Your homepage sends data to %1$s, which is not the property Tagsmith reports on (%2$s).', 'tagsmith' ), $others, $measurement_id ) . ' ' . $switch; } } elseif ( $copies > 1 ) { $status = 'recommended'; $description = sprintf( /* translators: 1: the GA4 measurement id. 2: number of times it was found loaded on the homepage. */ __( 'Possible double counting: the Google tag %1$s is loaded %2$d times on the homepage, for example by Tagsmith and by your theme or another plugin. Remove the extra copy so each visit is counted once.', 'tagsmith' ), $measurement_id, $copies ); } elseif ( $copies > 0 ) { $status = 'good'; $description = __( 'The tracking tag was found on the homepage.', 'tagsmith' ); } elseif ( ! $expected ) { $status = 'inactive'; $description = $this->tag_off_reason(); } else { $status = 'critical'; $description = __( 'The configured Measurement ID was not found on the homepage. Check that tracking is enabled and no caching or optimization plugin is stripping the tag.', 'tagsmith' ); } if ( array() !== $scan['containers'] ) { $description .= ' ' . sprintf( /* translators: 1: Tag Manager container id(s), e.g. GTM-ABC123. 2: the configured GA4 measurement id. */ __( 'A Google Tag Manager container (%1$s) is also on the homepage. It may load more Google Analytics tags that do not appear in the page source; check in Tag Manager that it does not also send page views to %2$s, or visits may be counted twice.', 'tagsmith' ), implode( ', ', $scan['containers'] ), $measurement_id ); } return $this->result( 'tag', $label, $status, $description ) + array( 'detected_ids' => $scan['ids'] ); } /** * Microsoft Clarity: looks for Clarity tags on the homepage (see * TagScanner) and reports, when Clarity is not set up here (not in use, or * recommended when switched on without a Project ID), any Clarity tag * another plugin or the theme adds, which could be managed here instead. * When it is set up: * - the homepage could not be loaded: not in use when the tag is off on * purpose, else how to check by hand; * - our project is missing: critical when the tag should print, else * the same "intentionally off" note as the tag check (not in use); * - another project is on the page, or ours is loaded more than once: * recommended; * - otherwise good. * Microsoft's own Clarity plugin being active is at least recommended * (it would load the tag a second time). With a Data Export token, the * last sync time or error is added; a token Clarity rejected is * recommended. The result carries detected_ids: the Clarity project * ids found (empty when the page was not scanned). * * @return array{id: string, label: string, status: string, description: string, action_url?: string, detected_ids: string[]} */ private function check_clarity(): array { $label = __( 'Microsoft Clarity', 'tagsmith' ); $enabled = (bool) $this->settings->get( 'clarity_enabled' ); $project_id = (string) $this->settings->get( 'clarity_project_id' ); $plugin = $this->microsoft_clarity_active(); $html = $this->homepage(); $scan = is_string( $html ) ? TagScanner::scan( $html ) : null; $found = null === $scan ? array() : $scan['clarity']; $action = null; if ( ! $enabled || '' === $project_id ) { $plugin_id = $plugin ? Settings::microsoft_clarity_project_id() : ''; $elsewhere = array_values( array_diff( $found, array( $plugin_id ) ) ); if ( $enabled ) { $status = 'recommended'; $parts = array( __( 'Microsoft Clarity is turned on, but no Project ID is set, so its tag is not added. Enter your Clarity Project ID on the Tracking tab.', 'tagsmith' ) ); $action = $this->settings_url( 'tracking' ); } else { $status = 'inactive'; $parts = array( __( 'Microsoft Clarity is not turned on.', 'tagsmith' ) ); } if ( array() !== $elsewhere ) { $parts[] = sprintf( /* translators: %s: Microsoft Clarity project id(s) found on the homepage. */ __( 'Your homepage already loads Microsoft Clarity (%s), from another plugin or your theme. You can manage it here instead, with the same consent and exclusion rules as Google Analytics: turn on Microsoft Clarity on the Tracking tab with its Project ID, then remove the other tag.', 'tagsmith' ), implode( ', ', $elsewhere ) ); } if ( $plugin ) { $parts[] = '' !== $plugin_id ? sprintf( /* translators: %s: the Microsoft Clarity project id Microsoft's Clarity plugin is set up with. */ __( "Microsoft's Clarity plugin is active, with Project ID %s. You can set up Microsoft Clarity on the Tracking tab with the same Project ID, then switch off Microsoft's plugin so the tag is not loaded twice.", 'tagsmith' ), $plugin_id ) : __( "Microsoft's Clarity plugin is active. You can set up Microsoft Clarity on the Tracking tab instead, then switch off Microsoft's plugin so the tag is not loaded twice.", 'tagsmith' ); } } else { $expected = $this->gate->environment_allows(); $copies = null === $scan ? 0 : (int) ( $scan['clarity_counts'][ $project_id ] ?? 0 ); $others = implode( ', ', array_diff( $found, array( $project_id ) ) ); if ( ! is_string( $html ) ) { $status = $expected ? 'recommended' : 'inactive'; $parts = array( $expected ? sprintf( /* translators: 1: why the request failed (an error message or "HTTP 401"). 2: the Microsoft Clarity project id. */ __( 'Could not load the homepage to check for the Clarity tag (%1$s). Many hosts block a site from requesting its own pages. To check by hand, open your homepage, view the page source and search for your Project ID, %2$s.', 'tagsmith' ), $html->get_error_message(), $project_id ) : $this->environment_off_reason(), ); } elseif ( 0 === $copies && $expected ) { $status = 'critical'; $parts = array( sprintf( /* translators: %s: the Microsoft Clarity project id. */ __( 'Your Microsoft Clarity tag (%s) was not found on the homepage. Check that no caching or optimization plugin is stripping it.', 'tagsmith' ), $project_id ), ); if ( '' !== $others ) { $parts[] = sprintf( /* translators: %s: other Microsoft Clarity project id(s) found on the homepage. */ __( 'The homepage loads a different Clarity project (%s): if your recordings are there, use its Project ID on the Tracking tab.', 'tagsmith' ), $others ); $action = $this->settings_url( 'tracking' ); } } elseif ( 0 === $copies ) { $status = 'inactive'; $parts = array( $this->environment_off_reason() ); if ( '' !== $others ) { $status = 'recommended'; $parts[] = sprintf( /* translators: 1: other Microsoft Clarity project id(s) found on the homepage. 2: the configured Microsoft Clarity project id. */ __( 'Your homepage loads Clarity project %1$s, which is not the project set in Tagsmith (%2$s).', 'tagsmith' ), $others, $project_id ); $action = $this->settings_url( 'tracking' ); } } else { $status = 'good'; $parts = array(); if ( '' !== $others ) { $status = 'recommended'; $parts[] = sprintf( /* translators: 1: other Microsoft Clarity project id(s) found on the homepage. 2: the configured Microsoft Clarity project id. */ __( 'Your homepage also loads Clarity project %1$s, which is not the project set in Tagsmith (%2$s), so recordings may be split between two projects. Remove the other tag, or use its Project ID here.', 'tagsmith' ), $others, $project_id ); $action = $this->settings_url( 'tracking' ); } if ( $copies > 1 ) { $status = 'recommended'; $parts[] = sprintf( /* translators: 1: the Microsoft Clarity project id. 2: number of times it was found loaded on the homepage. */ __( 'Microsoft Clarity project %1$s is loaded %2$d times on the homepage, for example by Tagsmith and by another plugin or your theme. Remove the extra copy: it loads Clarity again, without your Tagsmith consent and exclusion settings.', 'tagsmith' ), $project_id, $copies ); } if ( array() === $parts ) { $parts[] = __( 'The Microsoft Clarity tag was found on the homepage.', 'tagsmith' ); } } if ( $plugin ) { $status = self::worse( $status, 'recommended' ); $parts[] = __( "Microsoft's Clarity plugin is also active. Since Clarity is turned on here, you can switch off Microsoft's plugin so the tag is not loaded twice.", 'tagsmith' ); $action = $action ?? admin_url( 'plugins.php' ); } } $export = $this->clarity_export_note(); if ( null !== $export ) { $parts[] = $export['note']; if ( $export['rejected'] ) { $status = self::worse( $status, 'recommended' ); $action = $action ?? $this->settings_url( 'tracking' ); } } return $this->result( 'clarity', $label, $status, implode( ' ', $parts ), $action ) + array( 'detected_ids' => $found ); } /** * The Clarity Data Export line, when a token is saved: the error of the * latest failed sync (when newer than the last success), else the last * sync time, else that it has not synced yet. The error's message is * derived from its slug with ClarityError::message_for(), so it is in * the viewer's language, not the locale of whatever request ran the * sync; like ClaritySync::status(), an error without a slug is ignored. * 'rejected' is true when Clarity refused the token, which needs a new * one. * * @return array{note: string, rejected: bool}|null Null without a token. */ private function clarity_export_note(): ?array { if ( empty( get_option( self::CLARITY_TOKEN_OPTION, '' ) ) ) { return null; } $status = get_option( self::CLARITY_STATUS_OPTION, array() ); $status = is_array( $status ) ? $status : array(); $error = is_array( $status['error'] ?? null ) ? $status['error'] : array(); $slug = is_string( $error['slug'] ?? null ) ? $error['slug'] : ''; $success = (int) ( $status['last_success'] ?? 0 ); if ( '' !== $slug && (int) ( $error['at'] ?? 0 ) >= $success ) { return array( /* translators: %s: why the last Clarity Data Export sync failed. */ 'note' => sprintf( __( 'Clarity Data Export: %s', 'tagsmith' ), ClarityError::message_for( $slug ) ), 'rejected' => 'clarity_unauthorized' === $slug, ); } if ( $success > 0 ) { $format = get_option( 'date_format', 'F j, Y' ) . ' ' . get_option( 'time_format', 'g:i a' ); return array( /* translators: %s: date and time of the last successful Clarity Data Export sync. */ 'note' => sprintf( __( 'Clarity Data Export: last synced %s.', 'tagsmith' ), (string) wp_date( $format, $success ) ), 'rejected' => false, ); } return array( 'note' => __( 'Clarity Data Export: not synced yet.', 'tagsmith' ), 'rejected' => false, ); } /** * Whether Microsoft Clarity is set up here: switched on, with a * Project ID (the same rule TagRenderer uses to print its tag). */ private function clarity_configured(): bool { return (bool) $this->settings->get( 'clarity_enabled' ) && '' !== (string) $this->settings->get( 'clarity_project_id' ); } /** * Whether Microsoft's own Clarity plugin is active. */ private function microsoft_clarity_active(): bool { $this->load_plugin_functions(); return is_plugin_active( self::MICROSOFT_CLARITY_PLUGIN ); } /** * Loads is_plugin_active() outside wp-admin (REST requests). */ private function load_plugin_functions(): void { if ( ! function_exists( 'is_plugin_active' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } } /** * Ad pixels: looks on the homepage for the pixels Tagsmith prints, and for * other copies of the same base code. It says nothing (no row: an off * feature is neutral) while no platform is switched on. Per platform that * is on: * - no ID yet: recommended, nothing is sent; * - not added on this site on purpose (a staging site): not in use, with * why; * - the homepage could not be loaded: recommended, as for the tag check; * - Tagsmith's config is not on the homepage: critical; * - the same ID is also added by the theme or another plugin: recommended * (every visit would count twice); * - a different ID of the same platform is also there: recommended; * - otherwise good. * Pixels Tagsmith does not manage that are on the homepage are mentioned, * and a consent banner is recommended when the consent settings need one * and none is set up (a banner that decides only in the browser is named, * with what that costs, and is still recommended). * * A scan that could read the homepage is stored (see PIXEL_SCAN_OPTION) * for the settings screen. * * @return array{id: string, label: string, status: string, description: string, action_url?: string}|null */ private function check_pixels(): ?array { $rows = $this->settings->get( 'pixels' ); $rows = is_array( $rows ) ? $rows : array(); $on = array(); foreach ( array_keys( Platforms::all() ) as $key ) { if ( is_array( $rows[ $key ] ?? null ) && ! empty( $rows[ $key ]['enabled'] ) ) { $on[ $key ] = (string) Platforms::normalize_id( $key, $rows[ $key ]['id'] ?? '' ); } } if ( array() === $on ) { return null; } $label = __( 'Ad pixels on the homepage', 'tagsmith' ); $expected = $this->pixels_expected(); $html = $expected ? $this->homepage() : null; $scan = is_string( $html ) ? TagScanner::scan_pixels( $html ) : null; $states = array(); $notes = array(); $found = array(); $status = $expected ? 'good' : 'inactive'; $settings = false; foreach ( $on as $key => $id ) { $name = Platforms::get( $key )['label']; $state = $this->pixel_state( $key, $id, $expected, $scan ); $states[ $key ] = array( 'state' => $state['state'], 'id' => $id, 'others' => $state['others'], ); if ( 'found' === $state['state'] ) { $found[] = $name; } $sentence = $this->pixel_sentence( $name, $id, $state ); if ( null !== $sentence ) { $notes[] = $sentence; $status = self::worse( $status, $state['status'] ); $settings = $settings || 'good' !== $state['status']; } } if ( ! $expected ) { $notes[] = $this->pixels_off_reason(); } elseif ( null === $scan ) { $notes[] = sprintf( /* translators: %s: why the request failed (an error message or "HTTP 401"). */ __( 'Could not load the homepage to check your ad pixels (%s). Many hosts block a site from requesting its own pages. To check by hand, open your homepage and view its page source.', 'tagsmith' ), $html instanceof \WP_Error ? $html->get_error_message() : '' ); $status = self::worse( $status, 'recommended' ); } if ( array() !== $found ) { array_unshift( $notes, sprintf( /* translators: %s: names of the ad pixels, separated by commas. */ __( 'Found on the homepage: %s.', 'tagsmith' ), implode( ', ', $found ) ) ); } if ( null !== $scan ) { foreach ( $scan['others'] as $key => $copies ) { if ( isset( $on[ $key ] ) ) { continue; } $ids = array_map( 'strval', array_keys( $copies ) ); $states[ $key ] = array( 'state' => 'elsewhere', 'id' => '', 'others' => $ids, ); $notes[] = sprintf( /* translators: 1: name of an ad pixel, e.g. "Snap Pixel". 2: its ID, or IDs separated by commas. */ __( 'Your homepage loads %1$s (%2$s), which Tagsmith does not manage.', 'tagsmith' ), Platforms::get( $key )['label'], implode( ', ', $ids ) ); } $this->pixel_scan = array( 'checked_at' => time(), 'platforms' => $states, ); update_option( self::PIXEL_SCAN_OPTION, $this->pixel_scan, false ); } if ( $this->consent->banner_needed() && ! $this->consent->banner_active() ) { $banner = $this->consent->banner(); if ( 'browser' === $banner['type'] ) { // A banner that calls the WP Consent API only in the browser: the server cannot hand a lead to the next page. $notes[] = sprintf( /* translators: %s: name of the consent banner plugin, e.g. CookieYes. */ __( 'Consent banner found: %s. It decides consent in the browser, so leads from forms that send visitors to another page are not counted; forms that submit in the background are.', 'tagsmith' ), $banner['name'] ); } else { $notes[] = __( 'Ad pixels load after a visitor agrees, as your consent settings ask, but no consent banner that works with the WP Consent API was found. Until one is installed, visitors who are asked first will not trigger your pixels, so you will record fewer conversions.', 'tagsmith' ); } $status = self::worse( $status, 'recommended' ); } return $this->result( 'pixels', $label, $status, implode( ' ', $notes ), $settings ? $this->settings_url( 'pixels' ) : null ); } /** * One platform's state on the homepage. * * @param string $key Platform key. * @param string $id The ID saved for it ('' when there is none). * @param bool $expected Whether pixels are added on this site. * @param array{ours: array, others: array>}|null $scan The homepage scan, or null when it was not loaded. * @return array{state: string, status: string, others: string[]} */ private function pixel_state( string $key, string $id, bool $expected, ?array $scan ): array { if ( '' === $id ) { return array( 'state' => 'needs_id', 'status' => 'recommended', 'others' => array(), ); } if ( ! $expected ) { return array( 'state' => 'off_here', 'status' => 'inactive', 'others' => array(), ); } if ( null === $scan ) { return array( 'state' => 'unknown', 'status' => 'good', 'others' => array(), ); } $copies = $scan['others'][ $key ] ?? array(); $others = array_map( 'strval', array_keys( array_diff_key( $copies, array( $id => 1 ) ) ) ); if ( ( $scan['ours'][ $key ] ?? '' ) !== $id ) { return array( 'state' => 'missing', 'status' => 'critical', 'others' => $others, ); } if ( ( $copies[ $id ] ?? 0 ) > 0 ) { return array( 'state' => 'duplicate', 'status' => 'recommended', 'others' => $others, ); } return array( 'state' => array() === $others ? 'found' : 'other', 'status' => array() === $others ? 'good' : 'recommended', 'others' => $others, ); } /** * What to tell the owner about one platform's state, or null when there * is nothing to add (found, or not looked for). * * @param string $name The platform's name. * @param string $id The ID saved for it. * @param array{state: string, status: string, others: string[]} $state Its state. */ private function pixel_sentence( string $name, string $id, array $state ): ?string { switch ( $state['state'] ) { case 'needs_id': /* translators: %s: name of an ad pixel, e.g. "Meta Pixel". */ return sprintf( __( '%s is on but has no ID yet, so nothing is sent. Add its ID, or turn it off.', 'tagsmith' ), $name ); case 'missing': return sprintf( /* translators: 1: name of an ad pixel, e.g. "Meta Pixel". 2: its ID. */ __( '%1$s (%2$s) is on, but it was not found on the homepage. Check that no caching or optimization plugin is stripping it.', 'tagsmith' ), $name, $id ); case 'duplicate': return sprintf( /* translators: 1: name of an ad pixel, e.g. "Meta Pixel". 2: its ID. */ __( '%1$s %2$s is also added by your theme or another plugin, so every visit would be counted twice. Remove the extra copy so each visit counts once.', 'tagsmith' ), $name, $id ); case 'other': return sprintf( /* translators: 1: name of an ad pixel, e.g. "Meta Pixel". 2: another ID, or IDs separated by commas. */ __( 'Your homepage also loads %1$s with a different ID (%2$s) from your theme or another plugin. Tagsmith does not manage that one.', 'tagsmith' ), $name, implode( ', ', $state['others'] ) ); } return null; } /** * Whether ad pixels are expected on the homepage now: the environment is * production, or non-production tracking is allowed, and this is not the * demo (which never loads a pixel). */ private function pixels_expected(): bool { return $this->gate->environment_allows() && ! Plugin::is_demo(); } /** * Why ad pixels are intentionally not on the homepage, naming the setting * to change. */ private function pixels_off_reason(): string { if ( Plugin::is_demo() ) { return __( 'Ad pixels are never added in the demo.', 'tagsmith' ); } return sprintf( /* translators: %s: the current WordPress environment type (e.g. "staging"). */ __( "Ad pixels are not added on %s sites. Turn on 'Also track staging and development sites' on the Tracking tab to test them.", 'tagsmith' ), wp_get_environment_type() ); } /** * The more severe of two check statuses. * * @param string $a 'inactive'|'good'|'recommended'|'critical'. * @param string $b 'inactive'|'good'|'recommended'|'critical'. */ private static function worse( string $a, string $b ): string { return ( self::SEVERITY[ $b ] ?? 0 ) > ( self::SEVERITY[ $a ] ?? 0 ) ? $b : $a; } /** * The homepage for this run: fetched on first use, then reused, so the * tag and Clarity checks share one request. * * @return string|\WP_Error */ private function homepage() { if ( null === $this->homepage ) { $this->homepage = $this->fetch_homepage(); } return $this->homepage; } /** * The homepage HTML as a logged-out visitor sees it, or why it could not * be loaded (a transport error, or an HTTP error status such as 401 on a * password-protected staging site). * * @return string|\WP_Error */ private function fetch_homepage() { $response = wp_remote_get( home_url( '/' ), array( 'timeout' => 10, 'cookies' => array(), 'headers' => array( 'Cache-Control' => 'no-cache' ), // The tags live in ; never read more than 1 MB of the page. 'limit_response_size' => 1048576, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own loopback-request filter, not ours. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 400 ) { return new \WP_Error( 'tagsmith_homepage_status', 'HTTP ' . $code ); } return (string) wp_remote_retrieve_body( $response ); } /** * Why the Tagsmith tag is intentionally not on the homepage (only * meaningful when tag_expected() is false), naming the setting to change. */ private function tag_off_reason(): string { if ( ! $this->settings->get( 'tracking_enabled' ) ) { return __( "Tracking is turned off ('Add the Google tag to my site' on the Tracking tab), so the Tagsmith tag is intentionally not on the homepage.", 'tagsmith' ); } return $this->environment_off_reason(); } /** * Why tags are intentionally not added in this (non-production) * environment, naming the setting to change. Shared by the tag and * Clarity checks. */ private function environment_off_reason(): string { return sprintf( /* translators: %s: the current WordPress environment type (e.g. "staging"). */ __( "The tag is not added on %s sites. Turn on 'Also track staging and development sites' on the Tracking tab to test it.", 'tagsmith' ), wp_get_environment_type() ); } /** * Whether the tag is expected to be present on the homepage right now: * tracking is turned on, and either the environment is production or * non-production tracking is explicitly allowed. */ private function tag_expected(): bool { if ( ! $this->settings->get( 'tracking_enabled' ) ) { return false; } return $this->gate->environment_allows(); } /** * Conflicts: other plugins that also load their own Google Analytics tag. * * @return array{id: string, label: string, status: string, description: string, action_url?: string} */ private function check_conflicts(): array { $label = __( 'Conflicting plugins', 'tagsmith' ); $this->load_plugin_functions(); $active = array(); foreach ( self::CONFLICT_PLUGINS as $file => $name ) { if ( is_plugin_active( $file ) ) { $active[] = $name; } } if ( array() === $active ) { return $this->result( 'conflicts', $label, 'good', __( 'No conflicting analytics plugins were detected.', 'tagsmith' ) ); } return $this->result( 'conflicts', $label, 'recommended', sprintf( /* translators: %s: comma-separated list of active conflicting plugin names. */ __( 'Possible double tracking: %s is also active and may load its own Google Analytics tag. Disable its GA tag to avoid duplicate data.', 'tagsmith' ), implode( ', ', $active ) ) ); } /** * The URL probed for outbound HTTPS to Google's APIs. Any HTTP answer * (even a 404) proves the connection works; only a transport error * (DNS, firewall, TLS, timeout) fails the check. * * @var string */ private const GOOGLE_PROBE_URL = 'https://analyticsdata.googleapis.com/'; /** * Environment: the sodium functions (needed to encrypt tokens at rest), * outbound HTTPS to googleapis.com (probed only while a Google account is * connected), the environment type and whether WP-Cron is disabled. * Missing sodium functions are critical. Without the native extension, * WordPress's bundled fallback supplies them and tokens are still * encrypted, so that and an unreachable googleapis.com are recommended; * the rest is informational. * * @return array{id: string, label: string, status: string, description: string, action_url?: string} */ private function check_environment(): array { $label = __( 'Environment', 'tagsmith' ); if ( ! $this->has_sodium() ) { return $this->result( 'environment', $label, 'critical', __( 'The PHP sodium extension is not available, so tokens cannot be encrypted at rest. Ask your host to enable it.', 'tagsmith' ) ); } $status = 'good'; $notes = array( sprintf( /* translators: %s: the current WordPress environment type (e.g. "production"). */ __( 'Environment type: %s.', 'tagsmith' ), wp_get_environment_type() ), ); if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { $notes[] = __( 'WP-Cron is disabled (DISABLE_WP_CRON); make sure a system cron job is configured instead.', 'tagsmith' ); } if ( ! $this->has_native_sodium() ) { $status = 'recommended'; array_unshift( $notes, __( 'The native PHP sodium extension is not loaded, so the fallback bundled with WordPress encrypts your tokens at rest. It works, but the extension is faster. Ask your host to enable it.', 'tagsmith' ) ); } // Only a site that talks to Google needs to reach it. In tracking-only // mode the plugin sends Google nothing, and the check does not either. if ( $this->connection->is_connected() && ! $this->can_reach_google() ) { $status = 'recommended'; array_unshift( $notes, __( "This site could not reach Google's APIs over HTTPS (analyticsdata.googleapis.com), so reports cannot load. Ask your host whether outgoing HTTPS connections are blocked.", 'tagsmith' ) ); } return $this->result( 'environment', $label, $status, implode( ' ', $notes ) ); } /** * Whether an outbound HTTPS request to Google's APIs gets any HTTP * answer. A cheap HEAD request with a 5 second timeout. */ protected function can_reach_google(): bool { $response = wp_remote_head( self::GOOGLE_PROBE_URL, array( 'timeout' => 5 ) ); return ! is_wp_error( $response ); } /** * Whether the sodium functions TokenStore calls exist, whether they come * from the PHP extension or from the fallback WordPress bundles when the * extension is not loaded. Behind a protected method so tests can * simulate a host without them via a subclass. */ protected function has_sodium(): bool { foreach ( array( 'sodium_crypto_generichash', 'sodium_crypto_secretbox', 'sodium_crypto_secretbox_open' ) as $function ) { if ( ! function_exists( $function ) ) { return false; } } return true; } /** * Whether the native PHP sodium extension is loaded. Behind a protected * method (rather than a direct extension_loaded() call) so tests can * simulate a host without it via a subclass. */ protected function has_native_sodium(): bool { return extension_loaded( 'sodium' ); } /** * Consent: the consent mode and region default are internally * consistent, and, when the default denies EEA visitors, a WP Consent * API compatible banner is present to let them grant it back. * * @return array{id: string, label: string, status: string, description: string, action_url?: string} */ private function check_consent(): array { $label = __( 'Consent', 'tagsmith' ); $consent_mode = (string) $this->settings->get( 'consent_mode' ); $region_default = (string) $this->settings->get( 'consent_region_default' ); if ( 'off' === $consent_mode && 'all_granted' !== $region_default ) { return $this->result( 'consent', $label, 'recommended', __( 'Consent Mode is off, but the default region setting is not "all granted". Turn on Consent Mode, or set the region default to "all granted".', 'tagsmith' ), $this->settings_url() ); } if ( 'eea_denied' === $region_default && ! function_exists( 'wp_has_consent' ) ) { return $this->result( 'consent', $label, 'recommended', __( 'Install a consent banner that supports the WP Consent API, e.g. Complianz or CookieYes, so EU visitors can grant consent.', 'tagsmith' ) ); } return $this->result( 'consent', $label, 'good', __( 'The consent settings look consistent.', 'tagsmith' ) ); } /** * Builds one check result. * * @param string $id Stable check id. * @param string $label Human-readable label. * @param string $status 'inactive'|'good'|'recommended'|'critical'. * @param string $description Plain-text description. * @param string|null $action_url Optional URL for a suggested next step. * @return array{id: string, label: string, status: string, description: string, action_url?: string} */ private function result( string $id, string $label, string $status, string $description, ?string $action_url = null ): array { $item = array( 'id' => $id, 'label' => $label, 'status' => $status, 'description' => $description, ); if ( null !== $action_url ) { $item['action_url'] = $action_url; } return $item; } /** * Resolves the OAuth flow, building it from the closure on first use. */ private function oauth(): OAuthFlow { if ( $this->oauth instanceof \Closure ) { $this->oauth = ( $this->oauth )(); } return $this->oauth; } /** * Resolves the Admin API client, building it from the closure on first use. */ private function admin_api(): AdminApi { if ( $this->admin_api instanceof \Closure ) { $this->admin_api = ( $this->admin_api )(); } return $this->admin_api; } /** * Resolves the Search Console client, building it from the closure on first use. */ private function search_console(): SearchConsoleApi { if ( $this->search_console instanceof \Closure ) { $this->search_console = ( $this->search_console )(); } return $this->search_console; } /** * The Settings admin screen URL, used as the action_url for checks that * point the user there, optionally opening one of its tabs. * * @param string $tab Tab name (e.g. 'tracking'), or '' for the default tab. */ private function settings_url( string $tab = '' ): string { return admin_url( 'admin.php?page=tagsmith-settings' ) . ( '' === $tab ? '' : '#tab=' . $tab ); } }