false, 'ga4_measurement_id' => '', 'ga4_api_secret' => '', 'ga4_region' => 'global', 'ga4_export_crawlers' => true, 'ga4_export_referrals' => true, 'ga4_export_guidance' => true, ); } /** * Export a logged row to GA4 when the feature is enabled and configured. * * @param array $row Logged hit row. * @return void */ public static function maybe_export( $row ) { $settings = wp_parse_args( get_option( 'ai_o_optimiser_settings', array() ), self::default_settings() ); if ( empty( $settings['ga4_enabled'] ) || ! self::has_credentials( $settings ) ) { return; } $source_type = isset( $row['source_type'] ) ? sanitize_key( $row['source_type'] ) : ''; if ( ! self::should_export_source_type( $source_type, $settings ) ) { return; } $event_name = self::event_name_for_source_type( $source_type ); if ( empty( $event_name ) ) { return; } self::send_event( $settings, $event_name, self::build_params( $row ), self::build_client_id( $row ), false, self::timestamp_micros( $row ) ); } /** * Validate and queue a saved-settings test event to GA4. * * @return array Result summary for admin display. */ public static function send_test_event() { $settings = wp_parse_args( get_option( 'ai_o_optimiser_settings', array() ), self::default_settings() ); if ( ! self::has_credentials( $settings ) ) { return array( 'success' => false, 'message' => __( 'GA4 Measurement ID and API secret are required before sending a test event.', 'ai-o-optimiser' ), ); } $params = array( 'engagement_time_msec' => 1, 'session_id' => time(), 'ai_o_optimiser_source_type' => 'test', 'ai_o_optimiser_source_slug' => 'test', 'ai_o_optimiser_source_label' => 'AI Tracker GA4 Test', 'ai_o_optimiser_request_path' => '/', 'ai_o_optimiser_site_host' => self::host_from_url( home_url( '/' ) ), 'debug_mode' => 1, ); $validation = self::send_event( $settings, 'ai_tracker_test', $params, self::build_test_client_id(), true, 0 ); if ( empty( $validation['success'] ) ) { return $validation; } self::send_event( $settings, 'ai_tracker_test', $params, self::build_test_client_id(), false, 0 ); return array( 'success' => true, 'message' => __( 'GA4 validation passed and a live test event was queued. Check GA4 Realtime or DebugView for ai_tracker_test.', 'ai-o-optimiser' ), ); } /** * Sanitise settings submitted from the Settings form. * * @param array $settings Existing merged settings. * @return array Updated settings. */ public static function sanitize_submitted_settings( $settings ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Called only from AI_O_Optimiser_Admin::handle_save_settings(), which verifies the nonce with check_admin_referer() before this runs; PHPCS cannot follow the call. $settings['ga4_enabled'] = isset( $_POST['ga4_enabled'] ); $settings['ga4_measurement_id'] = isset( $_POST['ga4_measurement_id'] ) ? self::sanitize_measurement_id( sanitize_text_field( wp_unslash( $_POST['ga4_measurement_id'] ) ) ) : ''; $settings['ga4_region'] = isset( $_POST['ga4_region'] ) && 'eu' === sanitize_key( wp_unslash( $_POST['ga4_region'] ) ) ? 'eu' : 'global'; $settings['ga4_export_crawlers'] = isset( $_POST['ga4_export_crawlers'] ); $settings['ga4_export_referrals'] = isset( $_POST['ga4_export_referrals'] ); $settings['ga4_export_guidance'] = isset( $_POST['ga4_export_guidance'] ); if ( isset( $_POST['ga4_clear_api_secret'] ) ) { $settings['ga4_api_secret'] = ''; } elseif ( isset( $_POST['ga4_api_secret'] ) ) { $posted_secret = sanitize_text_field( wp_unslash( $_POST['ga4_api_secret'] ) ); if ( '' !== $posted_secret ) { $encrypted = self::encrypt_secret( $posted_secret ); if ( '' !== $encrypted ) { $settings['ga4_api_secret'] = $encrypted; } } } return $settings; // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Validate the Measurement ID shape used by GA4 web streams. * * @param string $measurement_id Raw Measurement ID. * @return string Sanitised Measurement ID or empty string. */ public static function sanitize_measurement_id( $measurement_id ) { $measurement_id = strtoupper( sanitize_text_field( trim( (string) $measurement_id ) ) ); return preg_match( '/^G-[A-Z0-9]+$/', $measurement_id ) ? $measurement_id : ''; } /** * Does the settings array contain enough credential material? * * @param array $settings Settings. * @return bool */ private static function has_credentials( $settings ) { return ! empty( $settings['ga4_measurement_id'] ) && ! empty( $settings['ga4_api_secret'] ); } /** * Source-type specific export controls. * * @param string $source_type Source type. * @param array $settings Settings. * @return bool */ private static function should_export_source_type( $source_type, $settings ) { switch ( $source_type ) { case 'crawler': return ! empty( $settings['ga4_export_crawlers'] ); case 'referral': return ! empty( $settings['ga4_export_referrals'] ); case 'guidance': return ! empty( $settings['ga4_export_guidance'] ); default: return false; } } /** * Custom GA4 event name for a source type. * * @param string $source_type Source type. * @return string */ private static function event_name_for_source_type( $source_type ) { $map = array( 'crawler' => 'ai_crawler_hit', 'referral' => 'ai_referral_visit', 'guidance' => 'ai_guidance_file_request', ); return isset( $map[ $source_type ] ) ? $map[ $source_type ] : ''; } /** * Build GA4 event parameters, capped to GA4-safe names and values. * * @param array $row Logged hit row. * @return array */ private static function build_params( $row ) { $request_path = isset( $row['request_uri'] ) ? self::safe_path_only( $row['request_uri'] ) : '/'; $params = array( 'engagement_time_msec' => 1, 'session_id' => self::session_id( $row ), 'ai_o_optimiser_source_type' => isset( $row['source_type'] ) ? sanitize_key( $row['source_type'] ) : '', 'ai_o_optimiser_source_slug' => isset( $row['source_slug'] ) ? sanitize_key( $row['source_slug'] ) : '', 'ai_o_optimiser_source_label' => isset( $row['source_label'] ) ? self::truncate_param_value( $row['source_label'], 100 ) : '', 'ai_o_optimiser_bot_slug' => isset( $row['bot_slug'] ) ? sanitize_key( $row['bot_slug'] ) : '', 'ai_o_optimiser_bot_label' => isset( $row['bot_label'] ) ? self::truncate_param_value( $row['bot_label'], 100 ) : '', 'ai_o_optimiser_request_path' => self::truncate_param_value( $request_path, 250 ), 'ai_o_optimiser_site_host' => self::host_from_url( home_url( '/' ) ), ); if ( ! empty( $row['referrer'] ) ) { $params['ai_o_optimiser_referrer_domain'] = self::host_from_url( $row['referrer'] ); } // Remove empty values so GA4 receives concise custom-event data only. return array_filter( $params, function( $value ) { return '' !== $value && null !== $value; } ); } /** * Send a Measurement Protocol event. * * @param array $settings Settings. * @param string $event_name Event name. * @param array $params Event params. * @param string $client_id Client ID. * @param bool $validate_only Use debug validation endpoint. * @param int $timestamp_micros Optional event timestamp. * @return array Result summary. */ private static function send_event( $settings, $event_name, $params, $client_id, $validate_only = false, $timestamp_micros = 0 ) { $query_args = array( 'measurement_id' => $settings['ga4_measurement_id'], 'api_secret' => self::decrypt_secret( $settings['ga4_api_secret'] ), ); $payload = array( 'client_id' => $client_id, 'non_personalized_ads' => true, 'events' => array( array( 'name' => $event_name, 'params' => $params, ), ), ); if ( $timestamp_micros > 0 ) { $payload['timestamp_micros'] = $timestamp_micros; } $endpoint = self::endpoint_url( $settings, $validate_only ); $url = add_query_arg( $query_args, $endpoint ); $args = array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), 'timeout' => $validate_only ? 8 : 1.5, 'blocking' => $validate_only, 'data_format' => 'body', ); $response = wp_remote_post( $url, $args ); if ( ! $validate_only ) { return array( 'success' => true, 'message' => __( 'GA4 event queued.', 'ai-o-optimiser' ), ); } if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => $response->get_error_message(), ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { return array( 'success' => false, 'message' => sprintf( /* translators: Placeholder values are replaced with contextual values shown in this message. */ __( 'GA4 validation returned HTTP %d.', 'ai-o-optimiser' ), $code ), ); } $messages = isset( $body['validationMessages'] ) && is_array( $body['validationMessages'] ) ? $body['validationMessages'] : array(); if ( empty( $messages ) ) { return array( 'success' => true, 'message' => __( 'GA4 validation passed. Check GA4 Realtime / DebugView after sending live events.', 'ai-o-optimiser' ), ); } $first_message = isset( $messages[0]['description'] ) ? $messages[0]['description'] : __( 'GA4 returned validation messages.', 'ai-o-optimiser' ); return array( 'success' => false, 'message' => $first_message, ); } /** * GA4 collection or validation endpoint. * * @param array $settings Settings. * @param bool $validate_only Use debug validation endpoint. * @return string */ private static function endpoint_url( $settings, $validate_only = false ) { $host = ( isset( $settings['ga4_region'] ) && 'eu' === $settings['ga4_region'] ) ? 'https://region1.google-analytics.com' : 'https://www.google-analytics.com'; return $host . ( $validate_only ? '/debug/mp/collect' : '/mp/collect' ); } /** * Create a pseudonymous GA4 client_id without sending raw IP/user-agent data. * * @param array $row Logged hit row. * @return string */ private static function build_client_id( $row ) { $seed = home_url( '/' ) . '|'; $seed .= isset( $row['ip_hash'] ) && '' !== $row['ip_hash'] ? $row['ip_hash'] : 'no-ip'; $seed .= '|' . ( isset( $row['source_type'] ) ? sanitize_key( $row['source_type'] ) : '' ); $seed .= '|' . ( isset( $row['source_slug'] ) ? sanitize_key( $row['source_slug'] ) : '' ); $hash = hash( 'sha256', $seed ); return substr( $hash, 0, 16 ) . '.' . substr( $hash, 16, 16 ); } /** * Stable pseudonymous client ID for the validation test. * * @return string */ private static function build_test_client_id() { $hash = hash( 'sha256', home_url( '/' ) . '|ga4-test' ); return substr( $hash, 0, 16 ) . '.' . substr( $hash, 16, 16 ); } /** * Session ID used so GA4 can place server-side events in Realtime reports. * * @param array $row Logged hit row. * @return int */ private static function session_id( $row ) { if ( ! empty( $row['hit_time'] ) ) { $timestamp = strtotime( $row['hit_time'] . ' UTC' ); if ( $timestamp ) { return (int) $timestamp; } } return time(); } /** * Convert the stored UTC hit time to GA4 timestamp_micros. * * @param array $row Logged hit row. * @return int */ private static function timestamp_micros( $row ) { if ( empty( $row['hit_time'] ) ) { return 0; } $timestamp = strtotime( $row['hit_time'] . ' UTC' ); return $timestamp ? (int) $timestamp * 1000000 : 0; } /** * Strip query strings/fragments from a request URI before GA4 export. * * @param string $request_uri Request URI. * @return string */ private static function safe_path_only( $request_uri ) { $path = wp_parse_url( (string) $request_uri, PHP_URL_PATH ); if ( empty( $path ) ) { $path = strtok( (string) $request_uri, '?' ); } $path = '/' . ltrim( (string) $path, '/' ); return sanitize_text_field( $path ); } /** * Hostname helper used instead of exporting full referrer URLs. * * @param string $url URL. * @return string */ private static function host_from_url( $url ) { $host = wp_parse_url( (string) $url, PHP_URL_HOST ); return $host ? self::truncate_param_value( strtolower( sanitize_text_field( $host ) ), 100 ) : ''; } /** * Limit string values to safe event parameter lengths. * * @param string $value Value. * @param int $length Max length. * @return string */ private static function truncate_param_value( $value, $length = 100 ) { $value = sanitize_text_field( (string) $value ); return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, max( 1, (int) $length ) ) : substr( $value, 0, max( 1, (int) $length ) ); } }