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

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