PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / frontend / class-google-analytics-tracking-manager.php

class-google-analytics-tracking-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/frontend/class-google-analytics-tracking-manager.php

427 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Google Analytics Tracking Manager Class
4 *
5 * Handles GA4 tracking code injection, verification, and conflict detection.
6 * Follows ThinkRank patterns for frontend integration and security standards.
7 *
8 * @package ThinkRank\Frontend
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Frontend;
15
16 use ThinkRank\Core\Settings;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Google Analytics Tracking Manager Class
25 *
26 * Single Responsibility: Manage GA4 tracking code injection and verification
27 * Following ThinkRank frontend patterns from SEO_Manager
28 *
29 * @since 1.0.0
30 */
31 class Google_Analytics_Tracking_Manager {
32
33 /**
34 * Settings instance
35 *
36 * @var Settings
37 */
38 private Settings $settings;
39
40 /**
41 * Constructor
42 *
43 * @since 1.0.0
44 */
45 public function __construct() {
46 $this->settings = Settings::instance();
47 $this->init();
48 }
49
50 /**
51 * Initialize tracking manager
52 * Following ThinkRank initialization patterns
53 *
54 * @since 1.0.0
55 * @return void
56 */
57 public function init(): void {
58 // Always add the hook, but check conditions during injection
59 add_action('wp_head', [$this, 'inject_ga4_tracking'], 1);
60 }
61
62 /**
63 * Determine if tracking should be injected
64 * Following ThinkRank conditional logic patterns
65 *
66 * @since 1.0.0
67 * @return bool Whether to inject tracking
68 */
69 private function should_inject_tracking(): bool {
70 // Don't inject in admin area
71 if (is_admin()) {
72 return false;
73 }
74
75 $measurement_id = $this->get_setting('ga4_measurement_id');
76 $auto_inject = $this->get_setting('ga4_auto_inject');
77
78 // Basic requirements
79 if (empty($measurement_id) || !$auto_inject) {
80 return false;
81 }
82
83 // Exclude admin users if configured
84 $exclude_admin = (bool) $this->get_setting('ga4_exclude_admin');
85 if ($exclude_admin && current_user_can('manage_options')) {
86 return false;
87 }
88
89 return true;
90 }
91
92 /**
93 * Inject GA4 tracking code
94 * Following WordPress script enqueuing standards
95 *
96 * @since 1.0.0
97 * @return void
98 */
99 public function inject_ga4_tracking(): void {
100 // Check if we should inject tracking at runtime
101 if (!$this->should_inject_tracking()) {
102 return;
103 }
104
105 $measurement_id = $this->get_setting('ga4_measurement_id');
106
107 if (empty($measurement_id)) {
108 return;
109 }
110
111 // Sanitize and validate measurement ID
112 $measurement_id = sanitize_text_field($measurement_id);
113
114 if (!preg_match('/^G-[A-Z0-9]{10}$/i', $measurement_id)) {
115 echo "<!-- Invalid GA4 Measurement ID format -->\n";
116 return;
117 }
118
119 $this->enqueue_ga4_scripts($measurement_id);
120 }
121
122 /**
123 * Enqueue GA4 tracking scripts using WordPress standards
124 * Following WordPress script enqueuing patterns
125 *
126 * @since 1.0.0
127 * @param string $measurement_id GA4 Measurement ID
128 * @return void
129 */
130 private function enqueue_ga4_scripts(string $measurement_id): void {
131 $anonymize_ip = (bool) $this->get_setting('ga4_anonymize_ip');
132
133 // Enqueue external Google Analytics script
134 wp_enqueue_script(
135 'google-analytics-gtag',
136 "https://www.googletagmanager.com/gtag/js?id=" . esc_attr($measurement_id),
137 [],
138 // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Google's gtag.js is versioned by Google; a ?ver= would only bust their cache.
139 null, // Use null for external scripts to avoid version query parameter
140 false // Load in head for proper GA4 initialization
141 );
142
143 // Add async attribute to the external script
144 add_filter('script_loader_tag', [$this, 'add_async_attribute'], 10, 2);
145
146 // Generate inline configuration script
147 $config_options = [];
148 if ($anonymize_ip) {
149 $config_options[] = "'anonymize_ip': true";
150 }
151 $config_string = !empty($config_options) ? ', {' . implode(', ', $config_options) . '}' : '';
152
153 $inline_script = "
154 window.dataLayer = window.dataLayer || [];
155 function gtag(){dataLayer.push(arguments);}
156 gtag('js', new Date());
157 gtag('config', '" . esc_js($measurement_id) . "'" . $config_string . ");
158 ";
159
160 // Add inline script after the external script
161 wp_add_inline_script('google-analytics-gtag', $inline_script);
162
163 // Add HTML comment for identification
164 add_action('wp_head', function() use ($measurement_id) {
165 echo "<!-- ThinkRank SEO: Google Analytics 4 Tracking (Measurement ID: " . esc_attr($measurement_id) . ") -->\n";
166 }, 0);
167 }
168
169 /**
170 * Add async attribute to Google Analytics script
171 * Following WordPress script attribute patterns
172 *
173 * @since 1.0.0
174 * @param string $tag Script tag HTML
175 * @param string $handle Script handle
176 * @return string Modified script tag
177 */
178 public function add_async_attribute(string $tag, string $handle): string {
179 if ('google-analytics-gtag' === $handle && strpos($tag, ' async') === false) {
180 return preg_replace('/(<script\b)/i', '$1 async', $tag, 1);
181 }
182 return $tag;
183 }
184
185
186
187
188
189
190
191
192
193 /**
194 * Verify tracking is working
195 * Following ThinkRank verification patterns
196 *
197 * @since 1.0.0
198 * @param string $measurement_id GA4 Measurement ID to verify
199 * @return array Verification results
200 */
201 public function verify_tracking(string $measurement_id): array {
202 // Sanitize and validate measurement ID
203 $measurement_id = sanitize_text_field($measurement_id);
204
205 // An empty ID is not an error — it is the normal state of an
206 // OAuth-connected site that never used ThinkRank's tag injection. We
207 // are about to fetch the homepage anyway, so discover the ID from the
208 // live page instead of refusing to look. Anything non-empty but
209 // malformed IS a user mistake and still gets told so.
210 $discover = '' === $measurement_id;
211 if (!$discover && !preg_match('/^G-[A-Z0-9]{10}$/i', $measurement_id)) {
212 return [
213 'success' => false,
214 'message' => __('Invalid GA4 Measurement ID format. Expected format: G-XXXXXXXXXX', 'thinkrank')
215 ];
216 }
217
218 // Check homepage for tracking code
219 $home_url = home_url();
220 $response = wp_remote_get($home_url, [
221 'timeout' => 15,
222 'user-agent' => 'ThinkRank/' . THINKRANK_VERSION . ' Verification Bot'
223 ]);
224
225 if (is_wp_error($response)) {
226 // Unreachable homepage is not evidence the tag works — don't leave
227 // a previous pass asserted.
228 $this->mark_unverified();
229 return [
230 'success' => false,
231 'message' => sprintf(
232 /* translators: %s: Error message from WordPress HTTP request */
233 __('Could not verify tracking: %s', 'thinkrank'),
234 $response->get_error_message()
235 )
236 ];
237 }
238
239 $body = wp_remote_retrieve_body($response);
240 $status_code = wp_remote_retrieve_response_code($response);
241
242 if ($status_code !== 200) {
243 $this->mark_unverified();
244 return [
245 'success' => false,
246 'message' => sprintf(
247 /* translators: %d: HTTP status code number (e.g., 404, 500) */
248 __('Could not verify tracking: HTTP %d response', 'thinkrank'),
249 $status_code
250 )
251 ];
252 }
253
254 $discovered = '';
255 if ($discover) {
256 // Read the ID off the live page. Whatever is actually serving GA4
257 // — our own tag, another plugin, the theme, GTM — the measurement
258 // ID appears in the markup, so this reports the site's real state
259 // rather than only what ThinkRank was told.
260 if (preg_match('/\bG-[A-Z0-9]{10}\b/', $body, $m)) {
261 $measurement_id = $m[0];
262 $discovered = $measurement_id;
263 } else {
264 $this->mark_unverified();
265 return [
266 'success' => false,
267 'message' => __('No GA4 Measurement ID found on your homepage. Add your ID above, or check that your GA4 tag is installed.', 'thinkrank')
268 ];
269 }
270 }
271
272 // Check for GA4 script presence
273 $has_measurement_id = strpos($body, $measurement_id) !== false;
274 $has_gtag = strpos($body, 'gtag') !== false;
275 $has_ga4_script = strpos($body, 'googletagmanager.com/gtag/js') !== false;
276
277 if ($has_measurement_id && $has_gtag && $has_ga4_script) {
278 // Update verification status
279 $this->settings->set('ga4_tracking_verified', true);
280 $this->settings->set('ga4_last_verification', current_time('mysql'));
281
282 // Persist a discovered ID so the status ability, the settings
283 // screen and a later re-verify all agree with the live page.
284 //
285 // Deliberately does NOT touch `ga4_auto_inject` (default false):
286 // our own tag is gated on measurement_id AND auto_inject, so
287 // storing the ID alone cannot start emitting a second GA4 tag on
288 // a site that already has one. Keep those two independent.
289 if ('' !== $discovered) {
290 $this->settings->set('ga4_measurement_id', $discovered);
291 }
292
293 return [
294 'success' => true,
295 'measurement_id' => $measurement_id,
296 'discovered' => '' !== $discovered,
297 'message' => '' !== $discovered
298 ? sprintf(
299 /* translators: %s: GA4 measurement ID found on the site. */
300 __('�
301 GA4 tracking detected and working found %s on your site.', 'thinkrank'),
302 $measurement_id
303 )
304 : __('�
305 GA4 tracking code detected and working correctly!', 'thinkrank')
306 ];
307 }
308
309 // Verification failed: the flag must not keep asserting a pass from
310 // some earlier run (it was previously only ever set to true, so it
311 // survived the tag being removed entirely).
312 $this->mark_unverified();
313
314 // Provide specific feedback
315 if (!$has_ga4_script) {
316 return [
317 'success' => false,
318 'message' => __('⚠️ GA4 script not detected. Please check your configuration or enable auto-inject.', 'thinkrank')
319 ];
320 }
321
322 if (!$has_measurement_id) {
323 return [
324 'success' => false,
325 'message' => __('⚠️ Measurement ID not found in tracking code. Please verify your Measurement ID.', 'thinkrank')
326 ];
327 }
328
329 return [
330 'success' => false,
331 'message' => __('⚠️ GA4 tracking code not properly configured. Please check your setup.', 'thinkrank')
332 ];
333 }
334
335 /**
336 * Record that the most recent verification did NOT pass.
337 *
338 * `ga4_tracking_verified` used to be written only on success, so once true
339 * it stayed true forever — surviving the GA4 tag being removed, and
340 * meaning "someone once clicked Verify and it passed" rather than "GA4 is
341 * working now". Every failure path now clears it, so the flag describes
342 * the latest check. The timestamp is kept current either way, so the UI
343 * can say when the check ran regardless of outcome.
344 *
345 * @since 1.27.0
346 * @return void
347 */
348 private function mark_unverified(): void {
349 $this->settings->set('ga4_tracking_verified', false);
350 $this->settings->set('ga4_last_verification', current_time('mysql'));
351 }
352
353 /**
354 * Detect existing GA4 implementations
355 * Following ThinkRank conflict detection patterns
356 *
357 * @since 1.0.0
358 * @return array Array of detected conflicts
359 */
360 public function detect_existing_tracking(): array {
361 $conflicts = [];
362
363 // Check for common GA4 plugins
364 $ga4_plugins = [
365 'google-analytics-for-wordpress/googleanalytics.php' => 'MonsterInsights',
366 'ga-google-analytics/ga-google-analytics.php' => 'GA Google Analytics',
367 'google-analytics-dashboard-for-wp/gadwp.php' => 'ExactMetrics',
368 'gtag/gtag.php' => 'Gtag Plugin',
369 'google-site-kit/google-site-kit.php' => 'Site Kit by Google'
370 ];
371
372 foreach ($ga4_plugins as $plugin_file => $plugin_name) {
373 if (is_plugin_active($plugin_file)) {
374 $conflicts[] = [
375 'type' => 'plugin',
376 'name' => $plugin_name,
377 'file' => $plugin_file,
378 'recommendation' => 'disable_auto_inject'
379 ];
380 }
381 }
382
383 // Check for manual GA4 in active theme
384 global $wp_filesystem;
385 if (!function_exists('WP_Filesystem')) {
386 require_once ABSPATH . 'wp-admin/includes/file.php';
387 }
388 WP_Filesystem();
389
390 $theme_files = [
391 get_template_directory() . '/header.php',
392 get_template_directory() . '/functions.php'
393 ];
394
395 foreach ($theme_files as $file) {
396 if ($wp_filesystem && $wp_filesystem->exists($file) && $wp_filesystem->is_readable($file)) {
397 $content = $wp_filesystem->get_contents($file);
398 if ($content && (strpos($content, 'gtag') !== false || strpos($content, 'G-') !== false)) {
399 $conflicts[] = [
400 'type' => 'theme',
401 'name' => get_template(),
402 'file' => basename($file),
403 'recommendation' => 'verify_manual'
404 ];
405 }
406 }
407 }
408
409 return $conflicts;
410 }
411
412
413
414 /**
415 * Get setting value with proper fallback
416 * Following ThinkRank settings access patterns
417 *
418 * @since 1.0.0
419 * @param string $key Setting key
420 * @param mixed $fallback Default value
421 * @return mixed Setting value
422 */
423 private function get_setting(string $key, $fallback = '') {
424 return $this->settings->get($key, $fallback);
425 }
426 }
427