Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.04% covered (warning)
88.04%
81 / 92
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pixel_Builder
88.04% covered (warning)
88.04%
81 / 92
80.00% covered (warning)
80.00%
8 / 10
41.60
0.00% covered (danger)
0.00%
0 / 1
 build_timestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_request_timestamp_and_nocache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_tracks_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 build_ch_url
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 validate_and_sanitize
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 event_name_is_valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prop_name_is_valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_property_values
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 send_pixels_batched
70.59% covered (warning)
70.59%
24 / 34
0.00% covered (danger)
0.00%
0 / 1
17.30
 send_pixel
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Pixel Builder for WooCommerce Analytics
4 *
5 * @package automattic/woocommerce-analytics
6 */
7
8namespace Automattic\Woocommerce_Analytics;
9
10use WP_Error;
11
12/**
13 * Pixel Builder class - handles pixel URL construction.
14 */
15class Pixel_Builder {
16
17    /**
18     * Tracks pixel URL.
19     *
20     * @var string
21     */
22    const TRACKS_PIXEL_URL = 'https://pixel.wp.com/t.gif';
23
24    /**
25     * ClickHouse pixel URL.
26     *
27     * @var string
28     */
29    const CH_PIXEL_URL = 'https://pixel.wp.com/w.gif';
30
31    /**
32     * Browser type identifier for server-side tracking.
33     *
34     * @var string
35     */
36    const BROWSER_TYPE = 'php-agent';
37
38    /**
39     * Event name regex pattern.
40     * Format: prefix_eventname (e.g., woocommerceanalytics_checkout_started)
41     *
42     * @var string
43     */
44    const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){1}([a-z0-9_]+)$/';
45
46    /**
47     * Property name regex pattern.
48     * Format: lowercase letters/underscores, starting with letter or underscore.
49     *
50     * @var string
51     */
52    const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/';
53
54    /**
55     * Build a timestamp representing milliseconds since 1970-01-01.
56     *
57     * @return string A string representing a timestamp.
58     */
59    public static function build_timestamp() {
60        $ts = round( microtime( true ) * 1000 );
61        return number_format( $ts, 0, '', '' );
62    }
63
64    /**
65     * Add request timestamp and nocache parameter to pixel URL.
66     * Should be called just before the HTTP request.
67     *
68     * @param string $pixel Pixel URL.
69     * @return string Pixel URL with request timestamp and URL terminator.
70     */
71    public static function add_request_timestamp_and_nocache( $pixel ) {
72        return $pixel . '&_rt=' . self::build_timestamp() . '&_=_';
73    }
74
75    /**
76     * Build a Tracks pixel URL from properties.
77     *
78     * @param array $properties Event properties.
79     * @return string|WP_Error Pixel URL on success, WP_Error on failure.
80     */
81    public static function build_tracks_url( $properties ) {
82        $validated = self::validate_and_sanitize( $properties );
83
84        if ( is_wp_error( $validated ) ) {
85            return $validated;
86        }
87
88        return self::TRACKS_PIXEL_URL . '?' . http_build_query( $validated );
89    }
90
91    /**
92     * Build a ClickHouse pixel URL from properties.
93     *
94     * @param array $properties Event properties.
95     * @return string|WP_Error Pixel URL on success, WP_Error on failure.
96     */
97    public static function build_ch_url( $properties ) {
98        $validated = self::validate_and_sanitize( $properties );
99
100        if ( is_wp_error( $validated ) ) {
101            return $validated;
102        }
103
104        return self::CH_PIXEL_URL . '?' . http_build_query( $validated );
105    }
106
107    /**
108     * Validate and sanitize event properties.
109     *
110     * @param array $properties Event properties.
111     * @return array|WP_Error Validated properties on success, WP_Error on failure.
112     */
113    public static function validate_and_sanitize( $properties ) {
114        // Required: event name.
115        if ( empty( $properties['_en'] ) ) {
116            return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 );
117        }
118
119        // Validate event name format.
120        if ( ! self::event_name_is_valid( $properties['_en'] ) ) {
121            return new WP_Error( 'invalid_event_name', 'A valid event name must be specified.' );
122        }
123
124        // Delete non-routable IP addresses (geoip would discard these anyway).
125        if ( isset( $properties['_via_ip'] ) && preg_match( '/^192\.168|^10\./', $properties['_via_ip'] ) ) {
126            unset( $properties['_via_ip'] );
127        }
128
129        // Add browser type for server-side tracking.
130        $properties['browser_type'] = self::BROWSER_TYPE;
131
132        // Ensure timestamp exists.
133        if ( ! isset( $properties['_ts'] ) ) {
134            $properties['_ts'] = self::build_timestamp();
135        }
136
137        // Validate property names.
138        foreach ( array_keys( $properties ) as $key ) {
139            if ( '_en' === $key ) {
140                continue;
141            }
142            if ( ! self::prop_name_is_valid( $key ) ) {
143                return new WP_Error( 'invalid_prop_name', 'A valid prop name must be specified: ' . $key );
144            }
145        }
146
147        // Sanitize array values to prevent bracket notation in URL serialization.
148        return self::sanitize_property_values( $properties );
149    }
150
151    /**
152     * Check if event name is valid.
153     *
154     * @param string $name Event name.
155     * @return bool True if valid, false otherwise.
156     */
157    public static function event_name_is_valid( $name ) {
158        return (bool) preg_match( self::EVENT_NAME_REGEX, $name );
159    }
160
161    /**
162     * Check if a property name is valid.
163     *
164     * @param string $name Property name.
165     * @return bool True if valid, false otherwise.
166     */
167    public static function prop_name_is_valid( $name ) {
168        return (bool) preg_match( self::PROP_NAME_REGEX, $name );
169    }
170
171    /**
172     * Sanitize property values for URL serialization.
173     *
174     * Converts array values to appropriate formats to prevent http_build_query()
175     * from creating bracket notation (e.g., prop[0], prop[1]) which violates
176     * the property name regex.
177     *
178     * @param array $properties Event properties.
179     * @return array Sanitized properties.
180     */
181    private static function sanitize_property_values( $properties ) {
182        foreach ( $properties as $key => $value ) {
183            if ( ! is_array( $value ) ) {
184                continue;
185            }
186
187            if ( empty( $value ) ) {
188                // Empty array becomes empty string.
189                $properties[ $key ] = '';
190                continue;
191            }
192
193            // Check if array is indexed (not associative) and contains only scalar values.
194            $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
195            $has_scalar_only  = ! array_filter(
196                $value,
197                function ( $item ) {
198                    return is_array( $item ) || is_object( $item );
199                }
200            );
201
202            if ( $is_indexed_array && $has_scalar_only ) {
203                // Indexed arrays with scalar values: join as comma string.
204                $properties[ $key ] = implode( ',', array_map( 'strval', $value ) );
205                continue;
206            }
207
208            // Associative arrays or nested arrays become JSON strings.
209            $encoded            = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
210            $properties[ $key ] = ( false === $encoded ) ? '' : $encoded;
211        }
212
213        return $properties;
214    }
215
216    /**
217     * Send pixel requests using batched non-blocking HTTP calls.
218     *
219     * Uses Requests library's request_multiple() for parallel execution via curl_multi.
220     *
221     * @param array $pixels Array of pixel URLs to send.
222     * @return bool True on success.
223     */
224    public static function send_pixels_batched( $pixels ) {
225        if ( empty( $pixels ) ) {
226            return true;
227        }
228
229        // Check if batching is supported.
230        $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
231            || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
232
233        if ( ! $can_batch ) {
234            // Fallback to individual requests.
235            foreach ( $pixels as $pixel ) {
236                self::send_pixel( $pixel );
237            }
238            return true;
239        }
240
241        // Add timestamp and nocache to all pixels.
242        $pixels_to_send = array();
243        foreach ( $pixels as $pixel ) {
244            $pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel );
245        }
246
247        // Build request array for batch sending.
248        $requests = array();
249        $options  = array(
250            'blocking' => false, // Non-blocking mode.
251            'timeout'  => 1,
252        );
253
254        foreach ( $pixels_to_send as $pixel ) {
255            $requests[] = array(
256                'url'     => $pixel,
257                'headers' => array(),
258                'data'    => array(),
259                'type'    => 'GET',
260            );
261        }
262
263        try {
264            if ( class_exists( 'WpOrg\Requests\Requests' ) ) {
265                \WpOrg\Requests\Requests::request_multiple( $requests, $options );
266            } elseif ( class_exists( 'Requests' ) ) {
267                \Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated
268            }
269        } catch ( \Exception $e ) {
270            // Log error but don't break the site - tracking pixels should fail gracefully.
271            $error_message = 'WooCommerce Analytics: Batch pixel request failed - ' . $e->getMessage();
272            if ( function_exists( 'wc_get_logger' ) ) {
273                wc_get_logger()->error( $error_message, array( 'source' => 'woocommerce-analytics' ) );
274            } else {
275                // Fallback for MU-plugin stage when WooCommerce logger is not available.
276                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
277                error_log( $error_message );
278            }
279            return false;
280        }
281
282        return true;
283    }
284
285    /**
286     * Send a single pixel request.
287     *
288     * @param string $pixel Pixel URL.
289     * @return bool True on success.
290     */
291    public static function send_pixel( $pixel ) {
292        $pixel = self::add_request_timestamp_and_nocache( $pixel );
293
294        wp_remote_get(
295            $pixel,
296            array(
297                'blocking'    => false,
298                'redirection' => 2,
299                'httpversion' => '1.1',
300                'timeout'     => 1,
301            )
302        );
303
304        return true;
305    }
306}