<?php
/**
* Pixel Builder for WooCommerce Analytics
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
use WP_Error;
/**
* Pixel Builder class - handles pixel URL construction.
*/
class Pixel_Builder {
/**
* Tracks pixel URL.
*
* @var string
*/
const TRACKS_PIXEL_URL = 'https://pixel.wp.com/t.gif';
/**
* ClickHouse pixel URL.
*
* @var string
*/
const CH_PIXEL_URL = 'https://pixel.wp.com/w.gif';
/**
* Browser type identifier for server-side tracking.
*
* @var string
*/
const BROWSER_TYPE = 'php-agent';
/**
* Event name regex pattern.
* Format: prefix_eventname (e.g., woocommerceanalytics_checkout_started)
*
* @var string
*/
const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){1}([a-z0-9_]+)$/';
/**
* Property name regex pattern.
* Format: lowercase letters/underscores, starting with letter or underscore.
*
* @var string
*/
const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/';
/**
* Build a timestamp representing milliseconds since 1970-01-01.
*
* @return string A string representing a timestamp.
*/
public static function build_timestamp() {
$ts = round( microtime( true ) * 1000 );
return number_format( $ts, 0, '', '' );
}
/**
* Add request timestamp and nocache parameter to pixel URL.
* Should be called just before the HTTP request.
*
* @param string $pixel Pixel URL.
* @return string Pixel URL with request timestamp and URL terminator.
*/
public static function add_request_timestamp_and_nocache( $pixel ) {
return $pixel . '&_rt=' . self::build_timestamp() . '&_=_';
}
/**
* Build a Tracks pixel URL from properties.
*
* @param array $properties Event properties.
* @return string|WP_Error Pixel URL on success, WP_Error on failure.
*/
public static function build_tracks_url( $properties ) {
$validated = self::validate_and_sanitize( $properties );
if ( is_wp_error( $validated ) ) {
return $validated;
}
return self::TRACKS_PIXEL_URL . '?' . http_build_query( $validated );
}
/**
* Build a ClickHouse pixel URL from properties.
*
* @param array $properties Event properties.
* @return string|WP_Error Pixel URL on success, WP_Error on failure.
*/
public static function build_ch_url( $properties ) {
$validated = self::validate_and_sanitize( $properties );
if ( is_wp_error( $validated ) ) {
return $validated;
}
return self::CH_PIXEL_URL . '?' . http_build_query( $validated );
}
/**
* Validate and sanitize event properties.
*
* @param array $properties Event properties.
* @return array|WP_Error Validated properties on success, WP_Error on failure.
*/
public static function validate_and_sanitize( $properties ) {
// Required: event name.
if ( empty( $properties['_en'] ) ) {
return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 );
}
// Validate event name format.
if ( ! self::event_name_is_valid( $properties['_en'] ) ) {
return new WP_Error( 'invalid_event_name', 'A valid event name must be specified.' );
}
// Delete non-routable IP addresses (geoip would discard these anyway).
if ( isset( $properties['_via_ip'] ) && preg_match( '/^192\.168|^10\./', $properties['_via_ip'] ) ) {
unset( $properties['_via_ip'] );
}
// Add browser type for server-side tracking.
$properties['browser_type'] = self::BROWSER_TYPE;
// Ensure timestamp exists.
if ( ! isset( $properties['_ts'] ) ) {
$properties['_ts'] = self::build_timestamp();
}
// Validate property names.
foreach ( array_keys( $properties ) as $key ) {
if ( '_en' === $key ) {
continue;
}
if ( ! self::prop_name_is_valid( $key ) ) {
return new WP_Error( 'invalid_prop_name', 'A valid prop name must be specified: ' . $key );
}
}
// Sanitize array values to prevent bracket notation in URL serialization.
return self::sanitize_property_values( $properties );
}
/**
* Check if event name is valid.
*
* @param string $name Event name.
* @return bool True if valid, false otherwise.
*/
public static function event_name_is_valid( $name ) {
return (bool) preg_match( self::EVENT_NAME_REGEX, $name );
}
/**
* Check if a property name is valid.
*
* @param string $name Property name.
* @return bool True if valid, false otherwise.
*/
public static function prop_name_is_valid( $name ) {
return (bool) preg_match( self::PROP_NAME_REGEX, $name );
}
/**
* Sanitize property values for URL serialization.
*
* Converts array values to appropriate formats to prevent http_build_query()
* from creating bracket notation (e.g., prop[0], prop[1]) which violates
* the property name regex.
*
* @param array $properties Event properties.
* @return array Sanitized properties.
*/
private static function sanitize_property_values( $properties ) {
foreach ( $properties as $key => $value ) {
if ( ! is_array( $value ) ) {
continue;
}
if ( empty( $value ) ) {
// Empty array becomes empty string.
$properties[ $key ] = '';
continue;
}
// Check if array is indexed (not associative) and contains only scalar values.
$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
$has_scalar_only = ! array_filter(
$value,
function ( $item ) {
return is_array( $item ) || is_object( $item );
}
);
if ( $is_indexed_array && $has_scalar_only ) {
// Indexed arrays with scalar values: join as comma string.
$properties[ $key ] = implode( ',', array_map( 'strval', $value ) );
continue;
}
// Associative arrays or nested arrays become JSON strings.
$encoded = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
$properties[ $key ] = ( false === $encoded ) ? '' : $encoded;
}
return $properties;
}
/**
* Check if a SOCKS proxy is configured.
*
* The Requests library doesn't support SOCKS proxies, so we need to fall back
* to wp_remote_get() which respects WordPress proxy settings.
*
* @return bool True if a SOCKS proxy is configured.
*/
private static function is_socks_proxy_configured() {
if ( ! defined( 'WP_PROXY_HOST' ) || ! is_string( WP_PROXY_HOST ) || '' === WP_PROXY_HOST ) {
return false;
}
return self::is_socks_proxy_host( (string) WP_PROXY_HOST );
}
/**
* Check if a proxy host string indicates a SOCKS proxy.
*
* @param string $proxy_host The proxy host value.
* @return bool True if the host indicates a SOCKS proxy.
*/
public static function is_socks_proxy_host( $proxy_host ) {
$proxy_host = strtolower( $proxy_host );
return 0 === strpos( $proxy_host, 'socks5://' )
|| 0 === strpos( $proxy_host, 'socks4://' )
|| 0 === strpos( $proxy_host, 'socks://' );
}
/**
* Send pixel requests using batched non-blocking HTTP calls.
*
* Uses Requests library's request_multiple() for parallel execution via curl_multi.
*
* @param array $pixels Array of pixel URLs to send.
* @return bool True on success.
*/
public static function send_pixels_batched( $pixels ) {
if ( empty( $pixels ) ) {
return true;
}
// Check if batching is supported.
// Note: WpOrg\Requests\Requests doesn't support SOCKS proxies, so we fall back
// to individual wp_remote_get() requests which respect WP_PROXY_* settings.
$can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
|| ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
$can_batch = $can_batch && ! self::is_socks_proxy_configured();
if ( ! $can_batch ) {
// Fallback to individual requests.
foreach ( $pixels as $pixel ) {
self::send_pixel( $pixel );
}
return true;
}
// Add timestamp and nocache to all pixels.
$pixels_to_send = array();
foreach ( $pixels as $pixel ) {
$pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel );
}
// Build request array for batch sending.
$requests = array();
$options = array(
'blocking' => false, // Non-blocking mode.
'timeout' => 1,
);
foreach ( $pixels_to_send as $pixel ) {
$requests[] = array(
'url' => $pixel,
'headers' => array(),
'data' => array(),
'type' => 'GET',
);
}
try {
if ( class_exists( 'WpOrg\Requests\Requests' ) ) {
\WpOrg\Requests\Requests::request_multiple( $requests, $options );
} elseif ( class_exists( 'Requests' ) ) {
\Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated
}
} catch ( \Exception $e ) {
// Log error but don't break the site - tracking pixels should fail gracefully.
$error_message = 'WooCommerce Analytics: Batch pixel request failed - ' . $e->getMessage();
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( $error_message, array( 'source' => 'woocommerce-analytics' ) );
} else {
// Fallback for MU-plugin stage when WooCommerce logger is not available.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $error_message );
}
return false;
}
return true;
}
/**
* Send a single pixel request.
*
* @param string $pixel Pixel URL.
* @return bool True on success.
*/
public static function send_pixel( $pixel ) {
$pixel = self::add_request_timestamp_and_nocache( $pixel );
wp_remote_get(
$pixel,
array(
'blocking' => false,
'redirection' => 2,
'httpversion' => '1.1',
'timeout' => 1,
)
);
return true;
}
}