'ok'|'info'|'warning'|'error', 'title' => string, 'detail' => string, * 'docs' => string, 'action' => array]. The action is the fix the dashboard offers as a button: * ['type' => 'sync'|'export'|'field', 'label' => string, 'target' => field ID for 'field'], or []. */ class HealthCheck { public const CONTAINER_HASH_OPTION = 'last_container_hash'; public const LAST_SYNC_OPTION = 'last_sync_at'; /** Plugin files of consent plugins the CMP bridge listens to. */ private const CONSENT_PLUGINS = [ 'complianz-gdpr/complianz-gpdr.php', 'complianz-gdpr-premium/complianz-gpdr-premium.php', 'cookiebot/cookiebot.php', 'borlabs-cookie/borlabs-cookie.php', 'cookie-law-info/cookie-law-info.php', 'wp-consent-api/wp-consent-api.php', ]; /** Hosts Zaraz calls for each tool, to name the tool behind a failed request. */ private const TOOL_HOSTS = [ 'graph.facebook.com' => 'Meta Conversions API', 'business-api.tiktok.com' => 'TikTok Events API', 'api.pinterest.com' => 'Pinterest Conversions API', 'www.google-analytics.com' => 'Google Analytics 4', 'region1.google-analytics.com' => 'Google Analytics 4', 'googleads.g.doubleclick.net' => 'Google Ads', 'bat.bing.com' => 'Microsoft Advertising (Bing)', 'tr.snapchat.com' => 'Snapchat', 'ct.pinterest.com' => 'Pinterest Tag', 'px.ads.linkedin.com' => 'LinkedIn', ]; private bool $canSync; /** * @param bool|null $canSync Whether the container can be synced through the Cloudflare API * (Pro with an API token and zone ID). Detected when null. */ public function __construct(?bool $canSync = null) { $this->canSync = $canSync ?? self::canSync(); } /** * @return bool */ public static function canSync(): bool { return class_exists('\WPZaraz\Licensing\LicenseManager') && \WPZaraz\Licensing\LicenseManager::isPro() && class_exists('\WPZaraz\Cloudflare\ApiClient') && Options::get('cf_api_token') && Options::get('cf_zone_id'); } /** * Zaraz activity and the live Zaraz configuration, when the Cloudflare API can be used. * Rejected requests are counted from the last sync, so fixed problems disappear after syncing. * * @param bool $fresh Skip the cached copies * @return array ['activity' => array|null, 'live_config' => array|null] */ public static function liveData(bool $fresh = false): array { $data = ['activity' => null, 'live_config' => null]; if (!self::canSync()) { return $data; } $client = new \WPZaraz\Cloudflare\ApiClient(); $data['activity'] = $client->getZarazActivity(24, self::lastSyncTime(), $fresh); $live = $fresh ? false : get_transient('wp_zaraz_live_config'); if (!is_array($live)) { $response = $client->getConfig(); $live = !empty($response['success']) ? (array) ($response['data']['result'] ?? []) : null; if ($live !== null) { set_transient('wp_zaraz_live_config', $live, 600); } } $data['live_config'] = $live; return $data; } /** * Fingerprint of a built container, stored after each export or sync. * * @param array $container * @return string */ public static function containerHash(array $container): string { return md5((string) wp_json_encode($container)); } /** * Records that the current container was exported or synced. * * @param array $container * @param bool $synced True when it was synced through the Cloudflare API */ public static function recordContainer(array $container, bool $synced = false): void { Options::set(self::CONTAINER_HASH_OPTION, self::containerHash($container)); if ($synced) { Options::set(self::LAST_SYNC_OPTION, time()); } } /** * @return int Unix time of the last sync through the Cloudflare API, 0 if never */ public static function lastSyncTime(): int { return (int) Options::get(self::LAST_SYNC_OPTION, 0); } /** * Runs every check. * * @param array|null $activity Zaraz activity from ApiClient::getZarazActivity(), when available * @param array|null $liveConfig Live Zaraz configuration from ApiClient::getConfig(), when available * @return array */ public function run(?array $activity = null, ?array $liveConfig = null): array { $results = []; $credentials = Options::getAll(); $registry = new RecipeRegistry(); $activeToolIds = AjaxHandler::activeToolIds($registry, $credentials); // 1. Tools configured if (empty($activeToolIds)) { $results[] = $this->result('error', __('No tools configured', 'zbridge'), __('Enter at least one tool ID under Zaraz Recipes, such as your GA4 Measurement ID.', 'zbridge'), 'getting-started/quickstart/', [], 'tools'); return $results; } // 2. Zaraz has the current container $built = (new ContainerBuilder($registry))->build($activeToolIds, $credentials); $stored = (string) Options::get(self::CONTAINER_HASH_OPTION, ''); $sendAction = $this->canSync ? ['type' => 'sync', 'label' => __('Sync to Zaraz', 'zbridge')] : ['type' => 'export', 'label' => __('Download container', 'zbridge')]; if ($stored === '') { $detail = $this->canSync ? __('Sync the container so Zaraz knows about your tools.', 'zbridge') : __('Download the container and import it in Cloudflare (Zaraz > Settings > Advanced > Import). Until then Zaraz does not know about your tools.', 'zbridge'); $results[] = $this->result('warning', __('Container not sent to Zaraz yet', 'zbridge'), $detail, 'cloudflare/export/', $sendAction, 'container'); } elseif ($stored !== self::containerHash($built)) { $detail = $this->canSync ? __('Your settings or the plugin changed since the container was last synced. Sync again so Zaraz gets the changes.', 'zbridge') : __('Your settings or the plugin changed since the container was last downloaded. Download it and import it in Cloudflare again so Zaraz gets the changes.', 'zbridge'); $results[] = $this->result('warning', __('Zaraz has an older container', 'zbridge'), $detail, 'cloudflare/export/', $sendAction, 'container'); } else { $results[] = $this->result('ok', __('Zaraz container is up to date', 'zbridge'), __('The last exported or synced container matches your current settings.', 'zbridge'), '', [], 'container'); } // 3. Tools that need an access token to send anything $tokenTools = [ 'meta' => ['recipes/meta/', 'meta_pixel_id', 'meta_capi_token', __('Meta needs a Conversions API access token', 'zbridge'), __('Zaraz sends Meta events from Cloudflare through the Conversions API, which rejects them without a token. Add it under Zaraz Recipes.', 'zbridge')], 'tiktok' => ['recipes/tiktok/', 'tiktok_pixel_id', 'tiktok_access_token', __('TikTok needs an Events API access token', 'zbridge'), __('Zaraz sends TikTok events through the Events API, which rejects them without a token. Add it under Zaraz Recipes.', 'zbridge')], ]; foreach ($tokenTools as $toolId => [$docsPath, $idKey, $tokenKey, $title, $detail]) { if (in_array($toolId, $activeToolIds, true) && !empty($credentials[$idKey]) && empty($credentials[$tokenKey])) { $results[] = $this->result('error', $title, $detail, $docsPath, ['type' => 'field', 'label' => __('Add the token', 'zbridge'), 'target' => $tokenKey]); } } // 4. Consent $bannerOn = (bool) Options::get('banner_enabled', false); $gateForCmp = (bool) Options::get('consent_gate_tools', false); if (!$bannerOn && $gateForCmp && !$this->consentPluginActive()) { $results[] = $this->result('error', __('Tools wait for consent nobody asks for', 'zbridge'), __('Tools are held until a consent plugin grants consent, but no supported consent plugin (Complianz, Cookiebot, CookieYes, Borlabs Cookie, WP Consent API) is active. Turn on the built-in banner or install a consent plugin.', 'zbridge'), 'consent/cmp-integrations/', [], 'consent'); } elseif (!ConsentManager::isToolGatingEnabled()) { $results[] = $this->result('info', __('Tools run without asking for consent', 'zbridge'), __('Neither the cookie banner nor consent gating is on, so Zaraz loads your tools for every visitor. Turn on the banner if your visitors need to consent first.', 'zbridge'), 'consent/cookie-banner/', [], 'consent'); } else { $results[] = $this->result('ok', __('Consent is collected', 'zbridge'), $bannerOn ? __('The built-in cookie banner collects consent and passes it to Zaraz.', 'zbridge') : __('Your consent plugin collects consent and WP Zbridge passes it to Zaraz.', 'zbridge'), '', [], 'consent'); } // 5. HTTP Events API path used by the server fallback (Pro, needs the live config) if ($liveConfig !== null) { $livePath = (string) ($liveConfig['settings']['eventsApiPath'] ?? ''); if ($livePath !== Options::eventsApiPath()) { $results[] = $this->result('error', __('Server fallback cannot reach Zaraz', 'zbridge'), __('The HTTP Events API path in Zaraz does not match this site, so server-sent purchases are not received. Sync the container again.', 'zbridge'), 'woocommerce/deduplication/', $this->canSync ? ['type' => 'sync', 'label' => __('Sync to Zaraz', 'zbridge')] : []); } } // 6. Requests Zaraz sent that the tools rejected (Pro, Zaraz Monitoring API) if ($activity !== null && !empty($activity['success'])) { $since = (int) ($activity['since'] ?? 0); foreach ((array) ($activity['failures'] ?? []) as $failure) { $tool = self::TOOL_HOSTS[$failure['host']] ?? $failure['host']; $hint = in_array((int) $failure['status'], [401, 403], true) ? __('The access token is missing, wrong or expired.', 'zbridge') : __('Check the tool ID and token under Zaraz Recipes.', 'zbridge'); $results[] = $this->result( 'warning', /* translators: 1: tool name, 2: number of requests, 3: HTTP status code */ sprintf(__('%1$s rejected %2$d requests (HTTP %3$d)', 'zbridge'), $tool, (int) $failure['count'], (int) $failure['status']), $since > 0 /* translators: 1: date and time of the last sync, 2: what to check */ ? sprintf(__('Since your last sync (%1$s). %2$s', 'zbridge'), self::formatTime($since), $hint) /* translators: 1: number of hours, 2: what to check */ : sprintf(__('In the last %1$d hours. %2$s', 'zbridge'), (int) ($activity['hours'] ?? 24), $hint), 'diagnostics/recovery-dashboard/' ); } } return $results; } /** * @return bool */ private function consentPluginActive(): bool { if (function_exists('wp_has_consent')) { return true; } if (!function_exists('is_plugin_active') && defined('ABSPATH') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if (function_exists('is_plugin_active')) { foreach (self::CONSENT_PLUGINS as $plugin) { if (is_plugin_active($plugin)) { return true; } } } return false; } /** * @param int $timestamp * @return string Date and time in the site's format and timezone */ private static function formatTime(int $timestamp): string { $format = (function_exists('get_option') ? get_option('date_format', 'Y-m-d') . ' ' . get_option('time_format', 'H:i') : 'Y-m-d H:i'); return function_exists('wp_date') ? (string) wp_date($format, $timestamp) : gmdate($format, $timestamp); } /** * @param string $level * @param string $title * @param string $detail * @param string $docsPath * @param array $action * @return array */ private function result(string $level, string $title, string $detail, string $docsPath = '', array $action = [], string $id = ''): array { return ['id' => $id, 'level' => $level, 'title' => $title, 'detail' => $detail, 'docs' => $docsPath !== '' ? Docs::url($docsPath) : '', 'action' => $action]; } /** * The three setup steps, read from Setup Health results: tool IDs entered, Zaraz has the * current container, consent decided (collected, or knowingly running without it). * * @param array $results From run() * @return array [['id' => string, 'label' => string, 'done' => bool], ...] */ public static function setupSteps(array $results): array { $byId = []; foreach ($results as $result) { if (!empty($result['id'])) { $byId[$result['id']] = $result['level']; } } $toolsDone = !isset($byId['tools']); return [ ['id' => 'tools', 'label' => __('Tool IDs entered', 'zbridge'), 'done' => $toolsDone], ['id' => 'container', 'label' => __('Container in Zaraz', 'zbridge'), 'done' => $toolsDone && ($byId['container'] ?? '') === 'ok'], ['id' => 'consent', 'label' => __('Consent set up', 'zbridge'), 'done' => $toolsDone && in_array($byId['consent'] ?? '', ['ok', 'info'], true)], ]; } }