PluginProbe
Analytify – Google Analytics Dashboard For WordPress (GA4 analytics tracking) / trunk
Analytify – Google Analytics Dashboard For WordPress (GA4 analytics tracking) vtrunk
9.1.2 9.1.1 9.1.0 9.0.2 9.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 All 153 releases
wp-analytify / analytify-general.php

analytify-general.php in Analytify – Google Analytics Dashboard For WordPress (GA4 analytics tracking) trunk, at analytify-general.php

1,089 lines 34.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Analytify General Class
4 *
5 * This is the core class that sets the foundation for the Analytify plugin.
6 * It handles analytics wrappers, SDK calls to fetch data from Google Analytics,
7 * and provides the base functionality for all other plugin components.
8 *
9 * @package WP_Analytify
10 * @since 1.0.0
11 * @version 8.0.0
12 *
13 * @author Analytify Team
14 * @license GPL-2.0+
15 *
16 * @see https://analytify.io/
17 * @see https://wordpress.org/plugins/wp-analytify/
18 */
19
20
21 // Include core classes first.
22 require_once __DIR__ . '/classes/analytify-utils.php';
23 require_once __DIR__ . '/classes/analytify-settings.php';
24 require_once __DIR__ . '/classes/analytify-mp-ga4.php';
25
26 // Include all trait files.
27 require_once __DIR__ . '/inc/analytify-authentication.php';
28 require_once __DIR__ . '/inc/analytify-ga4-core.php';
29 require_once __DIR__ . '/inc/analytify-utilities.php';
30 require_once __DIR__ . '/inc/analytify-navigation.php';
31
32 if ( ! class_exists( 'Analytify_General' ) ) {
33
34 /**
35 * Analytify_General Class for Analytify.
36 */
37 class Analytify_General {
38
39 // Use all the traits.
40 use Analytify_Authentication;
41 use Analytify_GA4_Core;
42 use Analytify_General_Utilities;
43 use Analytify_Navigation;
44
45 /**
46 * Plugin settings object.
47 *
48 * @var object
49 */
50 public $settings;
51
52 /**
53 * Google Analytics service object.
54 *
55 * @var object
56 */
57 public $service;
58
59 /**
60 * Google Analytics client object.
61 *
62 * @var object
63 */
64 public $client;
65
66 /**
67 * Authentication token.
68 *
69 * @var string
70 */
71 public $token;
72
73 /**
74 * State data for authentication.
75 *
76 * @var array
77 */
78 protected $state_data;
79
80 /**
81 * Transient timeout duration.
82 *
83 * @var int
84 */
85 protected $transient_timeout;
86
87 /**
88 * Load settings flag.
89 *
90 * @var bool
91 */
92 protected $load_settings;
93
94 /**
95 * Plugin base URL.
96 *
97 * @var string
98 */
99 protected $plugin_base;
100
101 /**
102 * Plugin settings base URL.
103 *
104 * @var string
105 */
106 protected $plugin_settings_base;
107
108 /**
109 * Cache timeout duration.
110 *
111 * @var int
112 */
113 protected $cache_timeout;
114
115 /**
116 * Exception data.
117 *
118 * @var mixed
119 */
120 private $exception;
121
122 /**
123 * GA4 exception data.
124 *
125 * @var mixed
126 */
127 private $ga4_exception;
128
129 /**
130 * Available modules.
131 *
132 * @var array
133 */
134 private $modules;
135
136 /**
137 * GA4 reporting flag.
138 *
139 * @var bool
140 */
141 protected $is_reporting_in_ga4;
142
143 /**
144 * User added client ID.
145 *
146 * @var string
147 */
148 private $user_client_id;
149
150 /**
151 * User added client secret.
152 *
153 * @var string
154 */
155 private $user_client_secret;
156
157 /**
158 * Authentication date format.
159 *
160 * @var string
161 */
162 protected $auth_date_format;
163
164 /**
165 * Google token data.
166 *
167 * @var array|false
168 */
169 protected $google_token;
170
171 /**
172 * GA4 streams data.
173 *
174 * @var array
175 */
176 protected $ga4_streams;
177
178 /**
179 * Constructor of analytify-general class.
180 *
181 * Initializes the core plugin settings, authentication data, and prepares
182 * the environment for Google Analytics operations.
183 *
184 * @since 1.0.0
185 */
186 public function __construct() {
187 // Set cache timeout to 12 hours (60 * 60 * 12 seconds).
188 $this->transient_timeout = 60 * 60 * 12;
189
190 // Define admin page URLs for navigation.
191 $this->plugin_base = 'admin.php?page=analytify-dashboard';
192 $this->plugin_settings_base = 'admin.php?page=analytify-settings';
193
194 // Set authentication date format with timezone.
195 $this->auth_date_format = gmdate( 'l jS F Y h:i:s A' ) . ' ' . date_default_timezone_get();
196 // Sanitize page parameter for security.
197 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameter for display purposes
198 $current_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
199 if ( $current_page && strpos( $current_page, 'analytify-settings' ) === 0 ) {
200 $this->exception = get_option( 'analytify_profile_exception' );
201 $this->ga4_exception = get_option( 'analytify_ga4_exceptions' );
202 }
203 $this->modules = WPANALYTIFY_Utils::get_pro_modules();
204 // Setup Settings.
205 if ( class_exists( 'WP_Analytify_Settings' ) ) {
206 $this->settings = new WP_Analytify_Settings();
207 }
208
209 $this->is_reporting_in_ga4 = 'ga4' === WPANALYTIFY_Utils::get_ga_mode() ? true : false;
210
211 // Initialize connection on init hook to ensure themes are loaded.
212 add_action( 'init', array( $this, 'init_connection' ) );
213 }
214
215 /**
216 * Initialize connection to Google Analytics.
217 *
218 * This method is hooked to 'init' to ensure that themes (functions.php) are loaded
219 * before the connection is attempted. This allows custom hooks to fire correctly.
220 *
221 * @since 7.1.4
222 */
223 public function init_connection() {
224 if ( true === $this->is_reporting_in_ga4 ) {
225 // Rankmath Instant Indexing addon Compatibility.
226 // Sanitize page parameter for security.
227 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameter for display purposes
228 $current_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
229 if ( ( $current_page && 'instant-indexing' === $current_page ) || strpos( wp_get_referer(), 'instant-indexing' ) !== false ) {
230 return;
231 }
232
233 if ( 'on' === $this->settings->get_option( 'user_advanced_keys', 'wp-analytify-advanced', '' ) ) {
234 $this->user_client_id = $this->settings->get_option( 'client_id', 'wp-analytify-advanced' );
235 $this->user_client_secret = $this->settings->get_option( 'client_secret', 'wp-analytify-advanced' );
236 }
237
238 try {
239 $this->analytify_pa_connect_v2();
240 } catch ( Exception $e ) {
241 // Show error message only for logged in users.
242 if ( current_user_can( 'manage_options' ) ) {
243 // translators: Reset authentication error message.
244 printf( esc_html__( '%1$s Oops, Try to %2$s Reset %3$s Authentication. %4$s %7$s %4$s %5$s Don\'t worry, This error message is only visible to Administrators. %6$s %2$s', 'wp-analytify' ), '<br /><br />', '<a href=' . esc_url( admin_url( 'admin.php?page=analytify-settings&tab=authentication' ) ) . 'title="Reset">', '</a>', '<br />', '<i>', '</i>', esc_textarea( $e->getMessage() ) );
245 }
246 }
247 }
248
249 // Set cache time directly since after_setup_theme has already fired before init.
250 $this->set_cache_time();
251
252 $this->analytify_set_tracking_mode();
253 }
254
255 /**
256 * This function grabs the data from Google Analytics for individual posts/pages.
257 *
258 * @param string $metrics The metrics to retrieve.
259 * @param string $start_date The start date for the report.
260 * @param string $end_date The end date for the report.
261 * @param boolean $dimensions Optional dimensions for the report.
262 * @param boolean $sort Optional sorting for the report.
263 * @param boolean $filter Optional filters for the report.
264 * @param boolean $limit Optional limit for the report.
265 * @param string $name Optional name for caching.
266 * @return void
267 */
268 public function pa_get_analytics( $metrics, $start_date, $end_date, $dimensions = false, $sort = false, $filter = false, $limit = false, $name = '' ) {
269
270 if ( $this->is_reporting_in_ga4 ) {
271 return;
272 }
273
274 try {
275 $this->service = new Analytify_Google_Service_Analytics( $this->client );
276 $params = array();
277
278 if ( $dimensions ) {
279 $params['dimensions'] = $dimensions;
280 }
281
282 if ( $sort ) {
283 $params['sort'] = $sort;
284 }
285
286 if ( $filter ) {
287 $params['filters'] = $filter;
288 }
289
290 if ( $limit ) {
291 $params['max-results'] = $limit;
292 }
293
294 $profile_id = $this->settings->get_option( 'profile_for_posts', 'wp-analytify-profile' );
295
296 if ( ! $profile_id ) {
297 return false;
298 }
299
300 $transient_key = 'analytify_transient_';
301 $cache_result = get_transient( $transient_key . md5( $name . $profile_id . $start_date . $end_date . $filter ) );
302
303 // Note: This hard coded setting should be removed in future versions.
304
305 $is_custom_api = $this->settings->get_option( 'user_advanced_keys', 'wp-analytify-advanced' );
306
307 if ( 'on' !== $is_custom_api ) {
308 // If exception, return if the cache result else return the error.
309 $exception = get_transient( 'analytify_quota_exception' );
310 if ( $exception ) {
311 return $this->tackle_exception( $exception, $cache_result );
312 }
313 }
314
315 // If custom keys set. Fetch fresh result always.
316 if ( 'on' === $is_custom_api || false === $cache_result ) {
317 $result = $this->service->data_ga->get( 'ga:' . $profile_id, $start_date, $end_date, $metrics, $params );
318 set_transient( $transient_key . md5( $name . $profile_id . $start_date . $end_date . $filter ), $result, $this->get_cache_time() );
319 return $result;
320
321 } else {
322 return $cache_result;
323 }
324 } catch ( Analytify_Google_Service_Exception $e ) {
325 // Show error message only for logged in users.
326 if ( current_user_can( 'manage_options' ) ) {
327 echo "<div class='wp_analytify_error_msg'>";
328 // translators: Error message for logged in users.
329 printf( esc_html__( '%1$s Oops, Something went wrong. %2$s %5$s %2$s %3$s Don\'t worry, This error message is only visible to Administrators. %4$s %2$s ', 'wp-analytify' ), '<br /><br />', '<br />', '<i>', '</i>', esc_html( $e->getMessage() ) );
330 echo '</div>';
331 }
332 } catch ( Analytify_Google_Auth_Exception $e ) {
333 // Show error message only for logged in users.
334 if ( current_user_can( 'manage_options' ) ) {
335 echo "<div class='wp_analytify_error_msg'>";
336 // translators: Reset authentication error message.
337 printf( esc_html__( '%1$s Oops, Try to %3$s Reset %4$s Authentication. %2$s %7$s %2$s %5$s Don\'t worry, This error message is only visible to Administrators. %6$s %2$s', 'wp-analytify' ), '<br /><br />', '<br />', '<a href=' . esc_url( admin_url( 'admin.php?page=analytify-settings&tab=authentication' ) ) . ' title="Reset">', '</a>', '<i>', '</i>', esc_textarea( $e->getMessage() ) );
338 echo '</div>';
339 }
340 } catch ( Analytify_Google_IO_Exception $e ) {
341 // Show error message only for logged in users.
342 if ( current_user_can( 'manage_options' ) ) {
343 echo "<div class='wp_analytify_error_msg'>";
344 // translators: Error message.
345 printf( esc_html__( '%1$s Oops! %2$s %5$s %2$s %3$s Don\'t worry, This error message is only visible to Administrators. %4$s %2$s', 'wp-analytify' ), '<br /><br />', '<br />', '<i>', '</i>', esc_html( $e->getMessage() ) );
346 echo '</div>';
347 }
348 }
349 }
350
351 /**
352 * Mock Function to resist GA3 removal conflicts.
353 *
354 * @param string $metrics The metrics to retrieve.
355 * @param string $start_date The start date for the report.
356 * @param string $end_date The end date for the report.
357 * @param boolean $dimensions Optional dimensions for the report.
358 * @param boolean $sort Optional sorting for the report.
359 * @param boolean $filter Optional filters for the report.
360 * @param boolean $limit Optional limit for the report.
361 * @param string $name Optional name for caching.
362 * @return null|false
363 */
364 public function pa_get_analytics_dashboard( $metrics, $start_date, $end_date, $dimensions = false, $sort = false, $filter = false, $limit = false, $name = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
365 if ( $this->is_reporting_in_ga4 ) {
366 return null;
367 }
368 return false;
369 }
370
371 /**
372 * Mock Function to resist GA3 removal conflicts.
373 *
374 * @param string $metrics The metrics to retrieve.
375 * @param string $start_date The start date for the report.
376 * @param string $end_date The end date for the report.
377 * @param boolean $dimensions Optional dimensions for the report.
378 * @param boolean $sort Optional sorting for the report.
379 * @param boolean $filter Optional filters for the report.
380 * @param boolean $limit Optional limit for the report.
381 * @param string $name Optional name for caching.
382 * @return null|false
383 */
384 public function pa_get_analytics_dashboard_via_rest( $metrics, $start_date, $end_date, $dimensions = false, $sort = false, $filter = false, $limit = false, $name = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
385 if ( $this->is_reporting_in_ga4 ) {
386 return null;
387 }
388 return false;
389 }
390
391 /**
392 * This function grabs the data from Google Analytics For dashboard.
393 *
394 * @param string $profile Google Analytic Profile Id.
395 * @param string $metrics Metrics.
396 * @param string $start_date Start date of stats.
397 * @param string $end_date End date of stats.
398 * @param string $dimensions Dimensions.
399 * @param string $sort Sort.
400 * @param string $filter Filter.
401 * @param string $limit How many stats to show.
402 *
403 * @return array Return array of stats.
404 */
405 public function analytify_get_analytics( $profile, $metrics, $start_date, $end_date, $dimensions = false, $sort = false, $filter = false, $limit = false ) {
406
407 if ( $this->is_reporting_in_ga4 ) {
408 return null;
409 }
410 try {
411 if ( class_exists( 'Analytify_Google_Service_Analytics' ) ) {
412 $this->service = new Analytify_Google_Service_Analytics( $this->client );
413 }
414 $params = array();
415
416 if ( $dimensions ) {
417 $params['dimensions'] = $dimensions;
418 }
419 if ( $sort ) {
420 $params['sort'] = $sort;
421 }
422 if ( $filter ) {
423 $params['filters'] = $filter;
424 }
425 if ( $limit ) {
426 $params['max-results'] = $limit;
427 }
428
429 if ( 'single' === $profile ) {
430 $profile_id = $this->settings->get_option( 'profile_for_posts', 'wp-analytify-profile' );
431 } else {
432 $profile_id = $this->settings->get_option( 'profile_for_dashboard', 'wp-analytify-profile' );
433 }
434
435 if ( ! $profile_id ) {
436 return false;
437 }
438
439 return $this->service->data_ga->get( 'ga:' . $profile_id, $start_date, $end_date, $metrics, $params );
440 } catch ( Analytify_Google_Service_Exception $e ) {
441 // Show error message only for logged in users.
442 if ( current_user_can( 'manage_options' ) ) {
443 // translators: Error message.
444 printf( esc_html__( '%1$s Oops, Something went wrong. %2$s %5$s %2$s %3$s Don\'t worry, This error message is only visible to Administrators. %4$s %2$s ', 'wp-analytify' ), '<br /><br />', '<br />', '<i>', '</i>', esc_textarea( $e->getMessage() ) );
445 }
446 } catch ( Analytify_Google_Auth_Exception $e ) {
447 // Show error message only for logged in users.
448 if ( current_user_can( 'manage_options' ) ) {
449 // translators: Error message.
450 printf( esc_html__( '%1$s Oops, Try to %3$s Reset %4$s Authentication. %2$s %7$s %2$s %5$s Don\'t worry, This error message is only visible to Administrators. %6$s %2$s', 'wp-analytify' ), '<br /><br />', '<br />', '<a href=' . esc_url( admin_url( 'admin.php?page=analytify-settings&tab=authentication' ) ) . ' title="Reset">', '</a>', '<i>', '</i>', esc_textarea( $e->getMessage() ) );
451 }
452 } catch ( Analytify_Google_IO_Exception $e ) {
453 // Show error message only for logged in users.
454 if ( current_user_can( 'manage_options' ) ) {
455 // translators: Error message.
456 printf( esc_html__( '%1$s Oops! %2$s %5$s %2$s %3$s Don\'t worry, This error message is only visible to Administrators. %4$s %2$s', 'wp-analytify' ), '<br /><br />', '<br />', '<i>', '</i>', esc_html( $e->getMessage() ) );
457 echo '</div>';
458 }
459 }
460 }
461
462 /**
463 * Fetch reports from Google Analytics Data API.
464 *
465 * @param string $name 'test-report-name' Its the key used to store reports in transient as cache.
466 * @param array $metrics Array of metrics to fetch.
467 * @param array $date_range Date range for the report.
468 * @param array $dimensions Array of dimensions.
469 * @param array $order_by Sorting configuration.
470 * @param array $filters Filter configuration.
471 * @param integer array $limit Positive integer to limit report rows.
472 * @param boolean $cached Whether to use cached results.
473 *
474 * @return array {
475 * 'headers' => {
476 * ...
477 * },
478 * 'rows' => {
479 * ...
480 * }
481 * }
482 * @version 7.0.1
483 * @throws Exception When API request fails.
484 */
485 public function get_reports( $name, $metrics, $date_range, $dimensions = array(), $order_by = array(), $filters = array(), $limit = 0, $cached = true ) {
486 $logger = function_exists( 'analytify_get_logger' ) ? analytify_get_logger() : null;
487
488 $property_id = WPANALYTIFY_Utils::get_reporting_property();
489
490 // Don't use cache if custom API keys are in use.
491 if ( 'on' === $this->settings->get_option( 'user_advanced_keys', 'wp-analytify-advanced' ) ) {
492 $cached = false;
493 }
494
495 // To override the caching.
496 $cached = apply_filters( 'analytify_set_caching_to', $cached );
497
498 if ( $cached ) {
499 $cache_key = 'analytify_transient_' . md5( $name . $property_id . $date_range['start'] . $date_range['end'] );
500 $report_cache = get_transient( $cache_key );
501
502 if ( $report_cache ) {
503 return $report_cache;
504 }
505 }
506
507 $reports = array();
508 $dimension_filters = array();
509
510 // Default response array.
511 $default_response = array(
512 'headers' => array(),
513 'rows' => array(),
514 'error' => array(),
515 'aggregations' => array(),
516 );
517
518 try {
519 // Main request body for the report.
520 $request_body = array(
521 'dateRanges' => array(
522 array(
523 'startDate' => isset( $date_range['start'] ) ? $date_range['start'] : 'today',
524 'endDate' => isset( $date_range['end'] ) ? $date_range['end'] : 'today',
525 ),
526 ),
527 'metricAggregations' => array( 1 ), // TOTAL = 1; COUNT = 4; MINIMUM = 5; MAXIMUM = 6.
528 );
529
530 // Set metrics.
531 if ( $metrics ) {
532 $send_metrics = array();
533 foreach ( $metrics as $value ) {
534 $send_metrics[] = array( 'name' => $value );
535 }
536 $request_body['metrics'] = $send_metrics;
537 }
538
539 // Add dimensions.
540 if ( $dimensions ) {
541 $send_dimensions = array();
542 foreach ( $dimensions as $value ) {
543 $send_dimensions[] = array( 'name' => $value );
544 }
545 $request_body['dimensions'] = $send_dimensions;
546 }
547
548 // Order report by metric or dimension.
549 if ( $order_by ) {
550 $order_by_request = array();
551 $is_desc = ( empty( $order_by['order'] ) || 'desc' !== $order_by['order'] ) ? false : true;
552
553 if ( 'metric' === $order_by['type'] ) {
554 $order_by_request = array(
555 'metric' => array(
556 'metric_name' => isset( $order_by['name'] ) ? $order_by['name'] : '',
557 ),
558 'desc' => $is_desc,
559 );
560 } elseif ( 'dimension' === $order_by['type'] ) {
561 $order_by_request = array(
562 'dimension' => array(
563 'dimension_name' => $order_by['name'],
564 ),
565 'desc' => $is_desc,
566 );
567 }
568
569 $request_body['orderBys'] = array( $order_by_request );
570 }
571
572 // Filters for the report.
573 if ( $filters ) {
574 $dimension_filters = array(); // Initialize an empty array for filters.
575
576 foreach ( $filters['filters'] as $filter_data ) {
577 if ( 'dimension' === $filter_data['type'] ) {
578 if ( isset( $filter_data['not_expression'] ) && $filter_data['not_expression'] ) {
579 // Handle 'not_expression' logic.
580 $dimension_filters[] = array(
581 'not_expression' => array(
582 'filter' => array(
583 'field_name' => $filter_data['name'],
584 'string_filter' => array(
585 'match_type' => $filter_data['match_type'],
586 'value' => $filter_data['value'],
587 'case_sensitive' => true,
588 ),
589 ),
590 ),
591 );
592 } else {
593 // Standard dimension filter.
594 $dimension_filters[] = array(
595 'filter' => array(
596 'field_name' => $filter_data['name'],
597 'string_filter' => array(
598 'match_type' => $filter_data['match_type'],
599 'value' => $filter_data['value'],
600 'case_sensitive' => true,
601 ),
602 ),
603 );
604 }
605 } elseif ( 'metric' === $filter_data['type'] ) {
606 // Note: Add metric filter handling here.
607 // Currently no implementation for metric filters.
608 // This is intentionally left empty for future implementation.
609 // No action needed for metric filters at this time.
610 // Skip metric filters without affecting dimension_filters array.
611 continue;
612 }
613 }
614
615 if ( $dimension_filters ) {
616 $group_type = ( isset( $filters['logic'] ) && 'OR' === $filters['logic'] ) ? 'or_group' : 'and_group';
617
618 $dimension_filter_construct = array(
619 $group_type => array(
620 'expressions' => $dimension_filters,
621 ),
622 );
623
624 $request_body['dimensionFilter'] = $dimension_filter_construct;
625 }
626 }
627
628 // Set limit.
629 if ( 0 < $limit ) {
630 $request_body['limit'] = $limit;
631 }
632
633 // Get access token (this function should be implemented by you).
634 $token = $this->analytify_get_google_token();
635
636 // Validate that token is an array and has the expected structure.
637 if ( ! is_array( $token ) || ! isset( $token['access_token'] ) ) {
638 if ( $logger && method_exists( $logger, 'warning' ) ) {
639 $logger->warning(
640 'Invalid or missing Google Analytics token in get_reports.',
641 array(
642 'source' => 'get_reports',
643 'report_name' => $name,
644 'token_type' => gettype( $token ),
645 'has_access_token' => isset( $token['access_token'] ),
646 )
647 );
648 }
649 return array();
650 }
651
652 $access_token = $token['access_token'];
653
654 // Prepare the cURL request URL for GA4 API.
655 $url = 'https://analyticsdata.googleapis.com/v1beta/properties/' . $property_id . ':runReport';
656
657 // Send the request using wp_remote_post.
658 $response = wp_remote_post(
659 $url,
660 array(
661 'headers' => array(
662 'Authorization' => 'Bearer ' . $access_token,
663 'Content-Type' => 'application/json',
664 ),
665 'body' => wp_json_encode( $request_body ),
666 )
667 );
668
669 // Check for errors in the response.
670 if ( is_wp_error( $response ) ) {
671 throw new Exception( $response->get_error_message() );
672 }
673
674 // Parse the response body.
675 $reports = json_decode( wp_remote_retrieve_body( $response ), true );
676
677 // If the response doesn't contain rows, handle it accordingly.
678 if ( ! isset( $reports['rows'] ) ) {
679 return $default_response;
680 }
681 } catch ( \Throwable $th ) {
682 if ( method_exists( $th, 'getStatus' ) && method_exists( $th, 'getBasicMessage' ) ) {
683 $default_response['error'] = array(
684 'status' => $th->getStatus(),
685 'message' => $th->getBasicMessage(),
686 );
687 if ( $logger && method_exists( $logger, 'warning' ) ) {
688 $logger->warning(
689 'Exception in get_reports API call.',
690 array(
691 'source' => 'get_reports',
692 'report_name' => $name,
693 'status' => $th->getStatus(),
694 'message' => $th->getBasicMessage(),
695 'exception_type' => get_class( $th ),
696 )
697 );
698 }
699 } elseif ( method_exists( $th, 'getMessage' ) ) {
700 $default_response['error'] = array(
701 'status' => 'Token Expired',
702 'message' => $th->getMessage(),
703 );
704 if ( $logger && method_exists( $logger, 'warning' ) ) {
705 $logger->warning(
706 'Exception in get_reports API call - token expired.',
707 array(
708 'source' => 'get_reports',
709 'report_name' => $name,
710 'message' => $th->getMessage(),
711 'exception_type' => get_class( $th ),
712 )
713 );
714 }
715 }
716
717 return $default_response;
718 }
719
720 // Format the reports using your existing function.
721 $formatted_reports = $this->analytify_format_ga_reports( $reports );
722
723 if ( empty( $formatted_reports ) ) {
724 return $default_response;
725 }
726
727 // Cache the response if caching is enabled.
728 if ( $cached ) {
729 $this->analytify_handle_report_cache( $cache_key, $formatted_reports, $name, $cached );
730 }
731
732 return $formatted_reports;
733 }
734
735 /**
736 * Format reports data fetched from Google Analytics Data API.
737 *
738 * For references check folder for class definitions: lib\Google\vendor\google\analytics-data\src\V1beta.
739 *
740 * @param array $reports The reports data to format.
741 * @return array
742 */
743 public function analytify_format_ga_reports( $reports ) {
744 $metric_header_data = array();
745 $dimension_header_data = array();
746 $aggregations = array();
747 $rows = array();
748
749 // Get metric headers.
750 if ( isset( $reports['metricHeaders'] ) ) {
751 foreach ( $reports['metricHeaders'] as $metric_header ) {
752 $metric_header_data[] = $metric_header['name'];
753 }
754 }
755
756 // Get dimension headers.
757 if ( isset( $reports['dimensionHeaders'] ) ) {
758 foreach ( $reports['dimensionHeaders'] as $dimension_header ) {
759 $dimension_header_data[] = $dimension_header['name'];
760 }
761 }
762
763 $headers = array_merge( $metric_header_data, $dimension_header_data );
764
765 // Bind metrics and dimensions to rows.
766 if ( isset( $reports['rows'] ) ) {
767 foreach ( $reports['rows'] as $row ) {
768 $metric_data = array();
769 $dimension_data = array();
770
771 // Process metric values.
772 if ( isset( $row['metricValues'] ) ) {
773 $index_metric = 0;
774 foreach ( $row['metricValues'] as $value ) {
775 $metric_data[ $metric_header_data[ $index_metric ] ] = $value['value'];
776 ++$index_metric;
777 }
778 }
779
780 // Process dimension values.
781 if ( isset( $row['dimensionValues'] ) ) {
782 $index_dimension = 0;
783 foreach ( $row['dimensionValues'] as $value ) {
784 $dimension_data[ $dimension_header_data[ $index_dimension ] ] = $value['value'];
785 ++$index_dimension;
786 }
787 }
788
789 // Combine metric and dimension data.
790 $rows[] = array_merge( $metric_data, $dimension_data );
791 }
792 }
793
794 // Get metric aggregations (totals).
795 if ( isset( $reports['totals'] ) ) {
796 foreach ( $reports['totals'] as $total ) {
797 $index_metric = 0;
798
799 if ( isset( $total['metricValues'] ) ) {
800 foreach ( $total['metricValues'] as $value ) {
801 $aggregations[ $metric_header_data[ $index_metric ] ] = $value['value'];
802 ++$index_metric;
803 }
804 }
805 }
806 }
807
808 // Format and return the data.
809 $formatted_data = array(
810 'headers' => $headers,
811 'rows' => $rows,
812 'aggregations' => $aggregations,
813 );
814
815 return $formatted_data;
816 }
817
818
819 /**
820 * Query the search console api and return the response.
821 * Since SC can have two types of domain properties.
822 * We will first go with the sc-domain prefix with property
823 * if it fails we will use the second domain type using 'https://'
824 *
825 * @param string $transient_name The transient name for caching.
826 * @param array $dates The date range for the query.
827 * @param int $limit The limit for the results.
828 *
829 * @since 5.0.0
830 * @version 9.0.0
831 */
832 public function get_search_console_stats( $transient_name, $dates = array(), $limit = 10 ) {
833
834 $logger = function_exists( 'analytify_get_logger' ) ? analytify_get_logger() : null;
835
836 if ( class_exists( 'QM' ) ) {
837 QM::info( 'Analytify: Getting Google Analytics token for Search Console stats.' );
838 }
839
840 $token = $this->analytify_get_google_token();
841
842 if ( ! is_array( $token ) || ! isset( $token['access_token'] ) ) {
843 return array( 'error' => array( 'Invalid or missing Google Analytics token.' ) );
844 }
845
846 $access_token = $token['access_token'];
847
848 $tracking_stream_info = get_option( 'analytify_tracking_property_info' );
849
850 try {
851 $stream_url = ( isset( $tracking_stream_info['url'] ) && ! empty( $tracking_stream_info['url'] ) ) ? $tracking_stream_info['url'] : null;
852 } catch ( \Throwable $th ) {
853 if ( $logger && method_exists( $logger, 'warning' ) ) {
854 $logger->warning(
855 'Error fetching stream URL',
856 array(
857 'source' => 'analytify_fetch_stream_url',
858 'message' => $th->getMessage(),
859 )
860 );
861 }
862
863 if ( empty( $stream_url ) ) {
864 return array(
865 'error' => array(
866 'status' => 'No Stats Available',
867 'message' => __( 'No URL found for the selected stream', 'wp-analytify' ),
868 ),
869 );
870 }
871 }
872
873 // Validate stream URL.
874 if ( empty( $stream_url ) ) {
875 return array(
876 'error' => array(
877 'status' => 'No Stats Available',
878 'message' => __( 'No URL found for the selected stream', 'wp-analytify' ),
879 ),
880 );
881 }
882
883 // Sanitize URL.
884 $stream_url = trim( $stream_url );
885 $stream_url = esc_url_raw( $stream_url );
886
887 // Extract domain (handles ports and IPv6).
888 $domain_stream_url_filtered = preg_replace( '/^(https?:\/\/)?(www\.)?([^\/\s:]+(?::\d+)?|\[[^\]]+\])(\/.*)?$/i', '$3', $stream_url );
889 $domain_stream_url_filtered = preg_replace( '/:\d+$/', '', $domain_stream_url_filtered ); // Remove port.
890 $domain_stream_url_filtered = str_replace( array( '[', ']' ), '', $domain_stream_url_filtered ); // Remove IPv6 brackets.
891
892 // Build candidate URLs for Search Console API.
893 $urls = array(
894 'sc-domain:' . $domain_stream_url_filtered,
895 'https://' . $domain_stream_url_filtered,
896 'https://www.' . $domain_stream_url_filtered,
897 'http://' . $domain_stream_url_filtered,
898 'http://www.' . $domain_stream_url_filtered,
899 'https://' . rtrim( $domain_stream_url_filtered, '/' ) . '/', // URL-prefix format.
900 );
901
902 // Remove duplicates to avoid redundant API calls.
903 $urls = array_unique( $urls );
904
905 $base_url = ANALYTIFY_GOOGLE_SEARCH_CONSOLE_API_URL;
906 $start_date = $dates['start'] ?? 'yesterday';
907 $end_date = $dates['end'] ?? 'today';
908
909 // Track responses: prefer domains with data, fallback to any accepted domain.
910 $accepted_domains_with_data = array();
911 $accepted_domains_no_data = array();
912
913 foreach ( $urls as $url ) {
914 try {
915 $query_data = array(
916 'startDate' => $start_date,
917 'endDate' => $end_date,
918 'dimensions' => array( 'query' ),
919 'rowLimit' => $limit,
920 );
921
922 // Make request to Search Console API using WordPress HTTP API.
923 $http_response = wp_remote_post(
924 $base_url . rawurlencode( $url ) . '/searchAnalytics/query',
925 array(
926 'headers' => array(
927 'Authorization' => 'Bearer ' . $access_token,
928 'Content-Type' => 'application/json',
929 ),
930 'body' => wp_json_encode( $query_data ),
931 'timeout' => 30,
932 'sslverify' => true, // Explicitly ensure SSL verification.
933 )
934 );
935
936 if ( is_wp_error( $http_response ) ) {
937 if ( $logger && method_exists( $logger, 'warning' ) ) {
938 $logger->warning(
939 sprintf( 'HTTP request failed for domain "%s": %s', $url, $http_response->get_error_message() ),
940 array(
941 'source' => 'analytify_fetch_search_console_stats',
942 'domain' => $url,
943 )
944 );
945 }
946 continue; // Continue to next URL.
947 }
948
949 $http_code = wp_remote_retrieve_response_code( $http_response );
950 $response_body = wp_remote_retrieve_body( $http_response );
951
952 // Log all HTTP responses for debugging, but categorize them.
953 if ( 200 === $http_code ) {
954 $decoded = json_decode( $response_body, true );
955
956 // Validate JSON decode result.
957 if ( json_last_error() !== JSON_ERROR_NONE ) {
958 if ( $logger && method_exists( $logger, 'error' ) ) {
959 $logger->error(
960 sprintf( 'JSON decode failed for domain "%s": %s', $url, json_last_error_msg() ),
961 array(
962 'source' => 'analytify_fetch_search_console_stats',
963 'domain' => $url,
964 )
965 );
966 }
967 continue;
968 }
969
970 // Ensure decoded result is an array.
971 if ( ! is_array( $decoded ) ) {
972 if ( $logger && method_exists( $logger, 'error' ) ) {
973 $logger->error(
974 sprintf( 'Unexpected JSON response for domain "%s": not an array', $url ),
975 array(
976 'source' => 'analytify_fetch_search_console_stats',
977 'domain' => $url,
978 )
979 );
980 }
981 continue;
982 }
983
984 $row_count = count( $decoded['rows'] ?? array() );
985
986 // Log domain check result for debugging.
987 if ( $logger && method_exists( $logger, 'info' ) ) {
988 $logger->info(
989 sprintf( 'Domain "%s" - HTTP %d, %d rows found', $url, $http_code, $row_count ),
990 array(
991 'source' => 'analytify_fetch_search_console_stats',
992 'domain' => $url,
993 'http_code' => $http_code,
994 'row_count' => $row_count,
995 )
996 );
997 }
998
999 // Store this response - prefer domains with data.
1000 if ( $row_count > 0 ) {
1001 $accepted_domains_with_data[] = array(
1002 'url' => $url,
1003 'data' => $decoded,
1004 );
1005 if ( $logger && method_exists( $logger, 'info' ) ) {
1006 $logger->info(
1007 'Domain categorized as HAVING DATA',
1008 array(
1009 'source' => 'analytify_fetch_search_console_stats',
1010 'domain' => $url,
1011 'row_count' => $row_count,
1012 )
1013 );
1014 }
1015 } else {
1016 $accepted_domains_no_data[] = array(
1017 'url' => $url,
1018 'data' => $decoded,
1019 );
1020 if ( $logger && method_exists( $logger, 'info' ) ) {
1021 $logger->info(
1022 'Domain categorized as NO DATA',
1023 array(
1024 'source' => 'analytify_fetch_search_console_stats',
1025 'domain' => $url,
1026 'row_count' => $row_count,
1027 )
1028 );
1029 }
1030 }
1031 } elseif ( 200 !== $http_code ) {
1032 // Log non-200 with code only; do not log response body (may contain sensitive data).
1033 if ( $logger && method_exists( $logger, 'warning' ) ) {
1034 $logger->warning(
1035 sprintf( 'Domain "%s" returned HTTP %d', $url, $http_code ),
1036 array(
1037 'source' => 'analytify_fetch_search_console_stats',
1038 'domain' => $url,
1039 'http_code' => $http_code,
1040 )
1041 );
1042 }
1043 }
1044 } catch ( \Throwable $th ) {
1045 // Continue to next URL on exception.
1046 continue;
1047 }
1048 }
1049
1050 // Choose the best domain after checking ALL URLs.
1051 $chosen_domain = null;
1052
1053 // Priority 1: Any domain with actual keyword data (prefer first one found).
1054 if ( ! empty( $accepted_domains_with_data ) ) {
1055 $chosen_domain = $accepted_domains_with_data[0]; // Use first domain that has data.
1056 } // phpcs:ignore Squiz.ControlStructures.ControlSignature.SpaceAfterCloseBrace
1057 // Priority 2: If NO domains have data, use first accepted domain (fallback).
1058 elseif ( ! empty( $accepted_domains_no_data ) ) {
1059 $chosen_domain = $accepted_domains_no_data[0]; // Use first accepted domain as fallback.
1060 }
1061
1062 // Return the chosen domain's data.
1063 if ( $chosen_domain ) {
1064 return array(
1065 'response' => $chosen_domain['data'],
1066 );
1067 }
1068
1069 // No domains were accepted at all.
1070 if ( $logger && method_exists( $logger, 'warning' ) ) {
1071 $logger->warning(
1072 'FINAL FAILURE: No domain accepted',
1073 array(
1074 'source' => 'analytify_fetch_search_console_stats',
1075 'site' => $domain_stream_url_filtered,
1076 )
1077 );
1078 }
1079
1080 return array(
1081 'error' => array(
1082 'status' => "No Stats Available for $domain_stream_url_filtered",
1083 'message' => __( 'Analytify gets GA4 keyword stats from Search Console. Make sure the site is verified and you have owner access.', 'wp-analytify' ),
1084 ),
1085 );
1086 }
1087 } // End of class
1088 } // End of if class exists
1089