PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
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.5.0, at includes/frontend/class-google-analytics-tracking-manager.php

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