events
1 month ago
class-wc-site-tracking.php
4 months ago
class-wc-tracks-client.php
5 months ago
class-wc-tracks-event.php
5 months ago
class-wc-tracks-footer-pixel.php
1 year ago
class-wc-tracks.php
5 months ago
class-wc-tracks-client.php
396 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Send Tracks events on behalf of a user. |
| 4 | * |
| 5 | * @package WooCommerce\Tracks |
| 6 | */ |
| 7 | |
| 8 | use Automattic\Jetpack\Constants; |
| 9 | use Automattic\WooCommerce\Utilities\NumberUtil; |
| 10 | |
| 11 | defined( 'ABSPATH' ) || exit; |
| 12 | |
| 13 | /** |
| 14 | * WC_Tracks_Client class. |
| 15 | */ |
| 16 | class WC_Tracks_Client { |
| 17 | |
| 18 | /** |
| 19 | * Pixel URL. |
| 20 | */ |
| 21 | const PIXEL = 'https://pixel.wp.com/t.gif'; |
| 22 | |
| 23 | /** |
| 24 | * Browser type. |
| 25 | */ |
| 26 | const BROWSER_TYPE = 'php-agent'; |
| 27 | |
| 28 | /** |
| 29 | * User agent. |
| 30 | */ |
| 31 | const USER_AGENT_SLUG = 'tracks-client'; |
| 32 | |
| 33 | /** |
| 34 | * Batch pixel queue for batched requests. |
| 35 | * |
| 36 | * @var array |
| 37 | */ |
| 38 | private static $pixel_batch_queue = array(); |
| 39 | |
| 40 | /** |
| 41 | * Whether the shutdown hook has been registered. |
| 42 | * |
| 43 | * @var bool |
| 44 | */ |
| 45 | private static $shutdown_hook_registered = false; |
| 46 | |
| 47 | /** |
| 48 | * Initialize tracks client class |
| 49 | * |
| 50 | * @return void |
| 51 | */ |
| 52 | public static function init() { |
| 53 | // Use wp hook for setting the identity cookie to avoid headers already sent warnings. |
| 54 | add_action( 'admin_init', array( __CLASS__, 'maybe_set_identity_cookie' ) ); |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Check if identity cookie is set, if not set it. |
| 59 | * |
| 60 | * @return void |
| 61 | */ |
| 62 | public static function maybe_set_identity_cookie() { |
| 63 | // Do not set on AJAX requests. |
| 64 | if ( Constants::is_true( 'DOING_AJAX' ) ) { |
| 65 | return; |
| 66 | } |
| 67 | |
| 68 | // Bail if cookie already set. |
| 69 | if ( isset( $_COOKIE['tk_ai'] ) ) { |
| 70 | return; |
| 71 | } |
| 72 | |
| 73 | $user = wp_get_current_user(); |
| 74 | |
| 75 | // We don't want to track user events during unit tests/CI runs. |
| 76 | if ( $user instanceof WP_User && 'wptests_capabilities' === $user->cap_key ) { |
| 77 | return false; |
| 78 | } |
| 79 | $user_id = $user->ID; |
| 80 | $anon_id = get_user_meta( $user_id, '_woocommerce_tracks_anon_id', true ); |
| 81 | |
| 82 | // If an id is still not found, create one and save it. |
| 83 | if ( ! $anon_id ) { |
| 84 | $anon_id = self::get_anon_id(); |
| 85 | update_user_meta( $user_id, '_woocommerce_tracks_anon_id', $anon_id ); |
| 86 | } |
| 87 | |
| 88 | // Don't set cookie on API requests. |
| 89 | if ( ! Constants::is_true( 'REST_REQUEST' ) && ! Constants::is_true( 'XMLRPC_REQUEST' ) ) { |
| 90 | WC_Site_Tracking::set_tracking_cookie( 'tk_ai', $anon_id ); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Record a Tracks event |
| 96 | * |
| 97 | * @param array $event Array of event properties. |
| 98 | * @return bool|WP_Error True on success, WP_Error on failure. |
| 99 | */ |
| 100 | public static function record_event( $event ) { |
| 101 | if ( ! $event instanceof WC_Tracks_Event ) { |
| 102 | $event = new WC_Tracks_Event( $event ); |
| 103 | } |
| 104 | |
| 105 | if ( is_wp_error( $event ) ) { |
| 106 | return $event; |
| 107 | } |
| 108 | |
| 109 | $pixel = $event->build_pixel_url( $event ); |
| 110 | |
| 111 | if ( ! $pixel ) { |
| 112 | return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 ); |
| 113 | } |
| 114 | |
| 115 | return self::record_pixel( $pixel ); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Record a Tracks event using batched requests for improved performance. |
| 120 | * Events are queued and sent together on the shutdown hook. |
| 121 | * |
| 122 | * @since 10.5.0 |
| 123 | * |
| 124 | * @param array $event Array of event properties. |
| 125 | * @return bool|WP_Error True on success, WP_Error on failure. |
| 126 | */ |
| 127 | public static function record_event_batched( $event ) { |
| 128 | if ( ! $event instanceof WC_Tracks_Event ) { |
| 129 | $event = new WC_Tracks_Event( $event ); |
| 130 | } |
| 131 | |
| 132 | if ( isset( $event->error ) && is_wp_error( $event->error ) ) { |
| 133 | return $event->error; |
| 134 | } |
| 135 | |
| 136 | $pixel = $event->build_pixel_url(); |
| 137 | |
| 138 | if ( ! $pixel ) { |
| 139 | return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 ); |
| 140 | } |
| 141 | |
| 142 | return self::record_pixel_batched( $pixel ); |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Synchronously request the pixel. |
| 147 | * |
| 148 | * @param string $pixel pixel url and query string. |
| 149 | * @return bool Always returns true. |
| 150 | */ |
| 151 | public static function record_pixel( $pixel ) { |
| 152 | // Add the Request Timestamp and no cache parameter just before the HTTP request. |
| 153 | $pixel = self::add_request_timestamp_and_nocache( $pixel ); |
| 154 | |
| 155 | wp_safe_remote_get( |
| 156 | $pixel, |
| 157 | array( |
| 158 | 'blocking' => false, |
| 159 | 'redirection' => 2, |
| 160 | 'httpversion' => '1.1', |
| 161 | 'timeout' => 1, |
| 162 | ) |
| 163 | ); |
| 164 | |
| 165 | return true; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Record a pixel using batched requests for improved performance. |
| 170 | * Pixels are queued and sent together on the shutdown hook. |
| 171 | * |
| 172 | * @since 10.5.0 |
| 173 | * |
| 174 | * @param string $pixel pixel url and query string. |
| 175 | * @return bool Always returns true. |
| 176 | */ |
| 177 | public static function record_pixel_batched( $pixel ) { |
| 178 | // Check if batching is enabled and supported. |
| 179 | $use_batching = self::can_use_batch_requests(); |
| 180 | |
| 181 | /** |
| 182 | * Filters whether to use batch requests for tracking pixels. |
| 183 | * |
| 184 | * @since 10.5.0 |
| 185 | * |
| 186 | * @param bool $use_batching Whether to use batch requests. Default true if supported. |
| 187 | */ |
| 188 | $use_batching = apply_filters( 'wc_tracks_use_batch_requests', $use_batching ); |
| 189 | |
| 190 | if ( $use_batching ) { |
| 191 | // Queue the pixel and send on shutdown. |
| 192 | self::queue_pixel_for_batch( $pixel ); |
| 193 | return true; |
| 194 | } |
| 195 | |
| 196 | // Fallback to immediate sending if batching is not supported or disabled. |
| 197 | return self::record_pixel( $pixel ); |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Create a timestamp representing milliseconds since 1970-01-01 |
| 202 | * |
| 203 | * @return string A string representing a timestamp. |
| 204 | */ |
| 205 | public static function build_timestamp() { |
| 206 | $ts = NumberUtil::round( microtime( true ) * 1000 ); |
| 207 | |
| 208 | return number_format( $ts, 0, '', '' ); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Add request timestamp and no cache parameter to pixel. |
| 213 | * Use this the latest possible before the HTTP request. |
| 214 | * |
| 215 | * @param string $pixel Pixel URL. |
| 216 | * @return string Pixel URL with request timestamp and URL terminator. |
| 217 | */ |
| 218 | public static function add_request_timestamp_and_nocache( $pixel ) { |
| 219 | $pixel .= '&_rt=' . self::build_timestamp() . '&_=_'; |
| 220 | |
| 221 | return $pixel; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Get a user's identity to send to Tracks. If Jetpack exists, default to its implementation. |
| 226 | * |
| 227 | * @param int $user_id User id. |
| 228 | * @return array Identity properties. |
| 229 | */ |
| 230 | public static function get_identity( $user_id ) { |
| 231 | $jetpack_lib = '/tracks/client.php'; |
| 232 | |
| 233 | if ( class_exists( 'Jetpack' ) && Constants::is_defined( 'JETPACK__VERSION' ) ) { |
| 234 | if ( version_compare( Constants::get_constant( 'JETPACK__VERSION' ), '7.5', '<' ) ) { |
| 235 | if ( file_exists( jetpack_require_lib_dir() . $jetpack_lib ) ) { |
| 236 | include_once jetpack_require_lib_dir() . $jetpack_lib; |
| 237 | if ( function_exists( 'jetpack_tracks_get_identity' ) ) { |
| 238 | return jetpack_tracks_get_identity( $user_id ); |
| 239 | } |
| 240 | } |
| 241 | } else { |
| 242 | $tracking = new Automattic\Jetpack\Tracking(); |
| 243 | return $tracking->tracks_get_identity( $user_id ); |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // Start with a previously set cookie. |
| 248 | $anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : false; |
| 249 | |
| 250 | // If there is no cookie, apply a saved id. |
| 251 | if ( ! $anon_id ) { |
| 252 | $anon_id = get_user_meta( $user_id, '_woocommerce_tracks_anon_id', true ); |
| 253 | } |
| 254 | |
| 255 | // If an id is still not found, create one and save it. |
| 256 | if ( ! $anon_id ) { |
| 257 | $anon_id = self::get_anon_id(); |
| 258 | |
| 259 | update_user_meta( $user_id, '_woocommerce_tracks_anon_id', $anon_id ); |
| 260 | } |
| 261 | |
| 262 | return array( |
| 263 | '_ut' => 'anon', |
| 264 | '_ui' => $anon_id, |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Grabs the user's anon id from cookies, or generates and sets a new one |
| 270 | * |
| 271 | * @return string An anon id for the user |
| 272 | */ |
| 273 | public static function get_anon_id() { |
| 274 | static $anon_id = null; |
| 275 | |
| 276 | if ( ! isset( $anon_id ) ) { |
| 277 | |
| 278 | // Did the browser send us a cookie? |
| 279 | if ( isset( $_COOKIE['tk_ai'] ) ) { |
| 280 | $anon_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ); |
| 281 | } else { |
| 282 | |
| 283 | $binary = ''; |
| 284 | |
| 285 | // Generate a new anonId and try to save it in the browser's cookies. |
| 286 | // Note that base64-encoding an 18 character string generates a 24-character anon id. |
| 287 | for ( $i = 0; $i < 18; ++$i ) { |
| 288 | $binary .= chr( wp_rand( 0, 255 ) ); |
| 289 | } |
| 290 | |
| 291 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 292 | $anon_id = 'woo:' . base64_encode( $binary ); |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | return $anon_id; |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Check if batch requests are supported. |
| 301 | * |
| 302 | * @since 10.5.0 |
| 303 | * |
| 304 | * @return bool Whether batch requests are supported. |
| 305 | */ |
| 306 | private static function can_use_batch_requests() { |
| 307 | // Check if the Requests library supports request_multiple(). |
| 308 | return ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) ) // @phpstan-ignore function.alreadyNarrowedType |
| 309 | || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) ); // @phpstan-ignore function.alreadyNarrowedType |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Queue a pixel URL for batch sending. |
| 314 | * |
| 315 | * @since 10.5.0 |
| 316 | * |
| 317 | * @param string $pixel The pixel URL to queue. |
| 318 | */ |
| 319 | private static function queue_pixel_for_batch( string $pixel ): void { |
| 320 | self::$pixel_batch_queue[] = $pixel; |
| 321 | |
| 322 | // Register shutdown hook once. |
| 323 | if ( ! self::$shutdown_hook_registered ) { |
| 324 | add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 ); |
| 325 | self::$shutdown_hook_registered = true; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Send all queued pixels using batched non-blocking requests. |
| 331 | * This runs on the shutdown hook to batch all requests together. |
| 332 | * |
| 333 | * Uses Requests library's request_multiple() for true parallel batching via curl_multi. |
| 334 | * |
| 335 | * @since 10.5.0 |
| 336 | */ |
| 337 | public static function send_batched_pixels(): void { |
| 338 | if ( empty( self::$pixel_batch_queue ) ) { |
| 339 | return; |
| 340 | } |
| 341 | |
| 342 | // Add request timestamp and nocache to all pixels. |
| 343 | $pixels_to_send = array(); |
| 344 | foreach ( self::$pixel_batch_queue as $pixel ) { |
| 345 | $pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel ); |
| 346 | } |
| 347 | |
| 348 | // Send with Requests library for true parallel batching. |
| 349 | self::send_with_requests_multiple( $pixels_to_send ); |
| 350 | |
| 351 | // Clear the queue. |
| 352 | self::$pixel_batch_queue = array(); |
| 353 | } |
| 354 | |
| 355 | /** |
| 356 | * Send pixels using Requests::request_multiple() for parallel non-blocking execution. |
| 357 | * Uses blocking => false for true non-blocking behavior via curl_multi. |
| 358 | * |
| 359 | * @since 10.5.0 |
| 360 | * |
| 361 | * @param array $pixels Array of pixel URLs to send. |
| 362 | */ |
| 363 | private static function send_with_requests_multiple( array $pixels ): void { |
| 364 | $requests = array(); |
| 365 | $options = array( |
| 366 | 'blocking' => false, // Non-blocking mode - returns immediately. |
| 367 | 'timeout' => 1, |
| 368 | ); |
| 369 | |
| 370 | foreach ( $pixels as $pixel ) { |
| 371 | $requests[] = array( |
| 372 | 'url' => $pixel, |
| 373 | 'headers' => array(), |
| 374 | 'data' => array(), |
| 375 | 'type' => 'GET', |
| 376 | ); |
| 377 | } |
| 378 | |
| 379 | try { |
| 380 | // Try modern namespaced version first. |
| 381 | if ( class_exists( 'WpOrg\Requests\Requests' ) ) { |
| 382 | \WpOrg\Requests\Requests::request_multiple( $requests, $options ); |
| 383 | } elseif ( class_exists( 'Requests' ) ) { |
| 384 | \Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated |
| 385 | } |
| 386 | } catch ( \Exception $e ) { |
| 387 | // Log error but don't break the site - tracking pixels should fail gracefully. |
| 388 | if ( function_exists( 'wc_get_logger' ) ) { |
| 389 | wc_get_logger()->error( 'WC_Tracks_Client: Batch pixel request failed - ' . $e->getMessage(), array( 'source' => 'wc-tracks' ) ); |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | WC_Tracks_Client::init(); |
| 396 |