settings = Settings::instance(); $this->init(); } /** * Initialize tracking manager * Following ThinkRank initialization patterns * * @since 1.0.0 * @return void */ public function init(): void { // Always add the hook, but check conditions during injection add_action('wp_head', [$this, 'inject_ga4_tracking'], 1); } /** * Determine if tracking should be injected * Following ThinkRank conditional logic patterns * * @since 1.0.0 * @return bool Whether to inject tracking */ private function should_inject_tracking(): bool { // Don't inject in admin area if (is_admin()) { return false; } $measurement_id = $this->get_setting('ga4_measurement_id'); $auto_inject = $this->get_setting('ga4_auto_inject'); // Basic requirements if (empty($measurement_id) || !$auto_inject) { return false; } // Exclude admin users if configured $exclude_admin = (bool) $this->get_setting('ga4_exclude_admin'); if ($exclude_admin && current_user_can('manage_options')) { return false; } return true; } /** * Inject GA4 tracking code * Following WordPress script enqueuing standards * * @since 1.0.0 * @return void */ public function inject_ga4_tracking(): void { // Check if we should inject tracking at runtime if (!$this->should_inject_tracking()) { return; } $measurement_id = $this->get_setting('ga4_measurement_id'); if (empty($measurement_id)) { return; } // Sanitize and validate measurement ID $measurement_id = sanitize_text_field($measurement_id); if (!preg_match('/^G-[A-Z0-9]{10}$/i', $measurement_id)) { echo "\n"; return; } $this->enqueue_ga4_scripts($measurement_id); } /** * Enqueue GA4 tracking scripts using WordPress standards * Following WordPress script enqueuing patterns * * @since 1.0.0 * @param string $measurement_id GA4 Measurement ID * @return void */ private function enqueue_ga4_scripts(string $measurement_id): void { $anonymize_ip = (bool) $this->get_setting('ga4_anonymize_ip'); // Enqueue external Google Analytics script wp_enqueue_script( 'google-analytics-gtag', "https://www.googletagmanager.com/gtag/js?id=" . esc_attr($measurement_id), [], // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Google's gtag.js is versioned by Google; a ?ver= would only bust their cache. null, // Use null for external scripts to avoid version query parameter false // Load in head for proper GA4 initialization ); // Add async attribute to the external script add_filter('script_loader_tag', [$this, 'add_async_attribute'], 10, 2); // Generate inline configuration script $config_options = []; if ($anonymize_ip) { $config_options[] = "'anonymize_ip': true"; } $config_string = !empty($config_options) ? ', {' . implode(', ', $config_options) . '}' : ''; $inline_script = " window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '" . esc_js($measurement_id) . "'" . $config_string . "); "; // Add inline script after the external script wp_add_inline_script('google-analytics-gtag', $inline_script); // Add HTML comment for identification add_action('wp_head', function() use ($measurement_id) { echo "\n"; }, 0); } /** * Add async attribute to Google Analytics script * Following WordPress script attribute patterns * * @since 1.0.0 * @param string $tag Script tag HTML * @param string $handle Script handle * @return string Modified script tag */ public function add_async_attribute(string $tag, string $handle): string { if ('google-analytics-gtag' === $handle && strpos($tag, ' async') === false) { return preg_replace('/( false, 'message' => __('Invalid GA4 Measurement ID format. Expected format: G-XXXXXXXXXX', 'thinkrank') ]; } // Check homepage for tracking code $home_url = home_url(); $response = wp_remote_get($home_url, [ 'timeout' => 15, 'user-agent' => 'ThinkRank/' . THINKRANK_VERSION . ' Verification Bot' ]); if (is_wp_error($response)) { // Unreachable homepage is not evidence the tag works — don't leave // a previous pass asserted. $this->mark_unverified(); return [ 'success' => false, 'message' => sprintf( /* translators: %s: Error message from WordPress HTTP request */ __('Could not verify tracking: %s', 'thinkrank'), $response->get_error_message() ) ]; } $body = wp_remote_retrieve_body($response); $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { $this->mark_unverified(); return [ 'success' => false, 'message' => sprintf( /* translators: %d: HTTP status code number (e.g., 404, 500) */ __('Could not verify tracking: HTTP %d response', 'thinkrank'), $status_code ) ]; } $discovered = ''; if ($discover) { // Read the ID off the live page. Whatever is actually serving GA4 // — our own tag, another plugin, the theme, GTM — the measurement // ID appears in the markup, so this reports the site's real state // rather than only what ThinkRank was told. if (preg_match('/\bG-[A-Z0-9]{10}\b/', $body, $m)) { $measurement_id = $m[0]; $discovered = $measurement_id; } else { $this->mark_unverified(); return [ 'success' => false, 'message' => __('No GA4 Measurement ID found on your homepage. Add your ID above, or check that your GA4 tag is installed.', 'thinkrank') ]; } } // Check for GA4 script presence $has_measurement_id = strpos($body, $measurement_id) !== false; $has_gtag = strpos($body, 'gtag') !== false; $has_ga4_script = strpos($body, 'googletagmanager.com/gtag/js') !== false; if ($has_measurement_id && $has_gtag && $has_ga4_script) { // Update verification status $this->settings->set('ga4_tracking_verified', true); $this->settings->set('ga4_last_verification', current_time('mysql')); // Persist a discovered ID so the status ability, the settings // screen and a later re-verify all agree with the live page. // // Deliberately does NOT touch `ga4_auto_inject` (default false): // our own tag is gated on measurement_id AND auto_inject, so // storing the ID alone cannot start emitting a second GA4 tag on // a site that already has one. Keep those two independent. if ('' !== $discovered) { $this->settings->set('ga4_measurement_id', $discovered); } return [ 'success' => true, 'measurement_id' => $measurement_id, 'discovered' => '' !== $discovered, 'message' => '' !== $discovered ? sprintf( /* translators: %s: GA4 measurement ID found on the site. */ __('✅ GA4 tracking detected and working — found %s on your site.', 'thinkrank'), $measurement_id ) : __('✅ GA4 tracking code detected and working correctly!', 'thinkrank') ]; } // Verification failed: the flag must not keep asserting a pass from // some earlier run (it was previously only ever set to true, so it // survived the tag being removed entirely). $this->mark_unverified(); // Provide specific feedback if (!$has_ga4_script) { return [ 'success' => false, 'message' => __('⚠️ GA4 script not detected. Please check your configuration or enable auto-inject.', 'thinkrank') ]; } if (!$has_measurement_id) { return [ 'success' => false, 'message' => __('⚠️ Measurement ID not found in tracking code. Please verify your Measurement ID.', 'thinkrank') ]; } return [ 'success' => false, 'message' => __('⚠️ GA4 tracking code not properly configured. Please check your setup.', 'thinkrank') ]; } /** * Record that the most recent verification did NOT pass. * * `ga4_tracking_verified` used to be written only on success, so once true * it stayed true forever — surviving the GA4 tag being removed, and * meaning "someone once clicked Verify and it passed" rather than "GA4 is * working now". Every failure path now clears it, so the flag describes * the latest check. The timestamp is kept current either way, so the UI * can say when the check ran regardless of outcome. * * @since 1.27.0 * @return void */ private function mark_unverified(): void { $this->settings->set('ga4_tracking_verified', false); $this->settings->set('ga4_last_verification', current_time('mysql')); } /** * Detect existing GA4 implementations * Following ThinkRank conflict detection patterns * * @since 1.0.0 * @return array Array of detected conflicts */ public function detect_existing_tracking(): array { $conflicts = []; // Check for common GA4 plugins $ga4_plugins = [ 'google-analytics-for-wordpress/googleanalytics.php' => 'MonsterInsights', 'ga-google-analytics/ga-google-analytics.php' => 'GA Google Analytics', 'google-analytics-dashboard-for-wp/gadwp.php' => 'ExactMetrics', 'gtag/gtag.php' => 'Gtag Plugin', 'google-site-kit/google-site-kit.php' => 'Site Kit by Google' ]; foreach ($ga4_plugins as $plugin_file => $plugin_name) { if (is_plugin_active($plugin_file)) { $conflicts[] = [ 'type' => 'plugin', 'name' => $plugin_name, 'file' => $plugin_file, 'recommendation' => 'disable_auto_inject' ]; } } // Check for manual GA4 in active theme global $wp_filesystem; if (!function_exists('WP_Filesystem')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); $theme_files = [ get_template_directory() . '/header.php', get_template_directory() . '/functions.php' ]; foreach ($theme_files as $file) { if ($wp_filesystem && $wp_filesystem->exists($file) && $wp_filesystem->is_readable($file)) { $content = $wp_filesystem->get_contents($file); if ($content && (strpos($content, 'gtag') !== false || strpos($content, 'G-') !== false)) { $conflicts[] = [ 'type' => 'theme', 'name' => get_template(), 'file' => basename($file), 'recommendation' => 'verify_manual' ]; } } } return $conflicts; } /** * Get setting value with proper fallback * Following ThinkRank settings access patterns * * @since 1.0.0 * @param string $key Setting key * @param mixed $fallback Default value * @return mixed Setting value */ private function get_setting(string $key, $fallback = '') { return $this->settings->get($key, $fallback); } }