PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-stats / src / class-tracking-pixel.php

class-tracking-pixel.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-stats/src/class-tracking-pixel.php

500 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stats Tracking_Pixel
4 *
5 * @package automattic/jetpack-stats
6 */
7
8 namespace Automattic\Jetpack\Stats;
9
10 use Jetpack_Options;
11 use WP_Post;
12
13 /**
14 * Stats Tracking_Pixel class.
15 *
16 * Responsible for embedding the Stats tracking pixel.
17 *
18 * @since 0.1.0
19 */
20 class Tracking_Pixel {
21
22 /**
23 * Array name.
24 *
25 * @var string $array_name The 'stats' array name
26 */
27 const STATS_ARRAY_TO_STRING_FILTER = 'stats_array';
28
29 const TRACKED_UTM_PARAMETERS = array(
30 'utm_id',
31 'utm_source',
32 'utm_medium',
33 'utm_campaign',
34 'utm_term',
35 'utm_content',
36 'utm_source_platform',
37 'utm_creative_format',
38 'utm_marketing_tactic',
39 );
40
41 /**
42 * Stats Build View Data.
43 *
44 * @access public
45 * @return array
46 */
47 public static function build_view_data() {
48 global $wp_the_query;
49
50 $blog = Jetpack_Options::get_option( 'id' );
51 $tz = get_option( 'gmt_offset' );
52 $v = 'ext';
53 $blog_url = wp_parse_url( site_url() );
54 $srv = $blog_url['host'];
55 $is_not_post = false;
56 if ( $wp_the_query->is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
57 // Store and reset the queried_object and queried_object_id
58 // Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
59 // Repro:
60 // 1. Set home_url = https://ExamPle.com/
61 // 2. Set show_on_front = page
62 // 3. Set page_on_front = something
63 // 4. Visit https://example.com/ !
64 $queried_object = $wp_the_query->queried_object ?? null;
65 $queried_object_id = $wp_the_query->queried_object_id ?? null;
66 try {
67 $post_obj = $wp_the_query->get_queried_object();
68 $post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
69 } finally {
70 $wp_the_query->queried_object = $queried_object;
71 $wp_the_query->queried_object_id = $queried_object_id;
72 }
73 } else {
74 $post = '0';
75 $is_not_post = true;
76 }
77 $view_data = compact( 'v', 'blog', 'post', 'tz', 'srv' );
78 // Batcache removes some of the UTM params from $_GET, we need to extract them from uri directly instead.
79 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
80 $url_query = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_QUERY );
81 parse_str( (string) $url_query, $url_params );
82 foreach ( self::TRACKED_UTM_PARAMETERS as $utm_parameter ) {
83 if ( isset( $url_params[ $utm_parameter ] ) && is_scalar( $url_params[ $utm_parameter ] ) ) {
84 $view_data[ $utm_parameter ] = substr( sanitize_textarea_field( wp_unslash( $url_params[ $utm_parameter ] ) ), 0, 255 );
85 }
86 }
87
88 if ( $is_not_post ) {
89 if ( $wp_the_query->is_home() ) {
90 $view_data['arch_home'] = '1';
91 } elseif ( $wp_the_query->is_search() ) {
92 $search_term = $wp_the_query->query['s'] ?? $wp_the_query->query_vars['s'] ?? '';
93 $view_data['arch_search'] = sanitize_text_field( $search_term );
94 $view_data['arch_filters'] = sanitize_text_field( self::build_search_filters( $wp_the_query ) );
95 $view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
96 } elseif ( $wp_the_query->is_archive() ) {
97 if ( $wp_the_query->is_date ) {
98 $query = $wp_the_query->query;
99 $date_parts = array_filter( array( $query['year'] ?? null, $query['monthnum'] ?? null, $query['day'] ?? null ) );
100 $date = implode( '/', $date_parts );
101 $view_data['arch_date'] = $date;
102 }
103 if ( $wp_the_query->is_category ) {
104 $view_data['arch_cat'] = $wp_the_query->query['category_name'] ?? $wp_the_query->query_vars['category_name'] ?? '';
105 }
106 if ( $wp_the_query->is_tag ) {
107 $view_data['arch_tag'] = $wp_the_query->query['tag'] ?? $wp_the_query->query_vars['tag'] ?? '';
108 }
109 if ( $wp_the_query->is_author ) {
110 $view_data['arch_author'] = $wp_the_query->query['author_name'] ?? '';
111 }
112 if ( $wp_the_query->is_tax ) {
113 $query = $wp_the_query->query;
114 if ( is_array( $query ) && count( $query ) === 1 ) {
115 $view_data[ 'arch_tax_' . array_keys( $query )[0] ] = array_values( $query )[0];
116 }
117 }
118 $view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
119 } elseif ( $wp_the_query->is_404() ) {
120 $view_data['arch_err'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
121 } else {
122 $view_data['arch_other'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
123 }
124 }
125 return $view_data;
126 }
127
128 /**
129 * Collect the tracking data for a search page.
130 *
131 * @access private
132 * @param \WP_Query $query The WP_Query object to parse all the filters from.
133 * @return string The search filters in a URL query string format.
134 */
135 private static function build_search_filters( $query ) {
136 $data = array(
137 'posts_per_page' => $query->get( 'posts_per_page' ),
138 'paged' => ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1,
139 'orderby' => $query->get( 'orderby' ),
140 'order' => $query->get( 'order' ),
141 );
142
143 if ( $query->get( 'author_name' ) ) {
144 $data['author_name'] = $query->get( 'author_name' );
145 }
146 $filters = http_build_query( $data );
147
148 $the_tax_query = $query->tax_query;
149 $terms = array();
150 if ( ! empty( $the_tax_query->queried_terms ) && is_array( $the_tax_query->queried_terms ) ) {
151 foreach ( $the_tax_query->queries as $tax_query ) {
152 if ( ! is_array( $tax_query ) || ! isset( $tax_query['taxonomy'] ) ) {
153 continue;
154 }
155 $taxonomy = $tax_query['taxonomy'];
156 if ( ! isset( $terms[ $taxonomy ] ) || ! is_array( $terms[ $taxonomy ] ) ) {
157 $terms[ $taxonomy ] = array();
158 }
159 $terms[ $taxonomy ] = array_merge( $terms[ $taxonomy ], $tax_query['terms'] );
160 }
161 }
162 if ( ! empty( $terms ) ) {
163 $filters .= '&terms=' . wp_json_encode( $terms, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
164 }
165 return $filters;
166 }
167
168 /**
169 * Build the Stats tracking details.
170 *
171 * @since 0.6.0
172 *
173 * @access private
174 * @param array $data Array of options about the site and page for the inline (non-AMP) tracker.
175 * @return string
176 */
177 private static function build_stats_details( $data ) {
178 $data_stats_array = self::stats_array_to_string( $data );
179
180 $pushes = sprintf(
181 '_stq.push([ "view", %1$s ]);
182 _stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
183 $data_stats_array,
184 $data['blog'],
185 $data['post']
186 );
187
188 // OFF (default): byte-for-byte identical to the historical output.
189 if ( ! Options::get_option( 'honor_cookie_consent' ) ) {
190 return "_stq = window._stq || [];\n" . $pushes;
191 }
192
193 // Fail closed when the WP Consent API plugin is active (an unavailable client-side API
194 // means "wait", not "fire"); fail open otherwise to preserve historical tracking.
195 return self::build_consent_gate( $pushes, ! function_exists( 'wp_has_consent' ) );
196 }
197
198 /**
199 * Wrap the tracking pushes in a WP Consent API gate.
200 *
201 * The check runs in the browser because cached HTML is shared across visitors, deferred to
202 * DOMContentLoaded (and re-run on the `wp_consent_type_defined` readiness event) so a
203 * late-loading consent plugin is still honored. The check is idempotent.
204 *
205 * `_jpStatsFire.done` is set before the pushes, not after, so the gate is at-most-once even
206 * if a push throws. Retrying can't recover: the stats sender assigns the beacon `src` before
207 * any of its fallible DOM work, so a later exception means the view was already counted and
208 * a replay would double-count it.
209 *
210 * @access private
211 * @param string $pushes The `_stq.push(...)` statements to gate.
212 * @param bool $fail_open Whether to fire when the client-side WP Consent API is unavailable.
213 * @return string
214 */
215 private static function build_consent_gate( $pushes, $fail_open ) {
216 $fail_open_literal = $fail_open ? 'true' : 'false';
217
218 return sprintf(
219 '_stq = window._stq || [];
220 function _jpStatsFire() {
221 if ( _jpStatsFire.done ) { return; }
222 _jpStatsFire.done = true;
223 %1$s
224 }
225 function _jpStatsCheck() {
226 if ( typeof window.wp_has_consent === "function" ) {
227 var consented;
228 try {
229 consented = window.wp_has_consent( "statistics" );
230 } catch ( e ) {
231 consented = %2$s;
232 }
233 if ( consented ) { _jpStatsFire(); }
234 return;
235 }
236 if ( %2$s ) { _jpStatsFire(); }
237 }
238 document.addEventListener( "wp_listen_for_consent_change", function ( event ) {
239 if ( event && event.detail && event.detail.statistics === "allow" ) { _jpStatsFire(); }
240 } );
241 document.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
242 window.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
243 if ( document.readyState === "loading" ) {
244 document.addEventListener( "DOMContentLoaded", _jpStatsCheck, { once: true } );
245 } else {
246 _jpStatsCheck();
247 }',
248 $pushes,
249 $fail_open_literal
250 );
251 }
252
253 /**
254 * Add fetchpriority="low" to the Stats script attributes.
255 *
256 * Reduces network contention with resources in the critical rendering path (e.g., the LCP
257 * element image). This benefits Safari and Firefox, which don't automatically assign low
258 * priority to async/defer scripts (unlike Chrome).
259 *
260 * @since 0.19.5
261 *
262 * @param array $attributes Script tag attributes.
263 * @return array Modified attributes.
264 */
265 public static function add_low_fetchpriority( $attributes ) {
266 // WordPress derives the tag id from the enqueue handle as "{handle}-js", so the
267 // 'jetpack-stats' script (registered in enqueue_stats_script()) prints as
268 // 'jetpack-stats-js'. Keep this in sync if the handle is ever renamed.
269 if ( isset( $attributes['id'] ) && 'jetpack-stats-js' === $attributes['id'] ) {
270 $attributes['fetchpriority'] = 'low';
271 }
272 return $attributes;
273 }
274
275 /**
276 * Remove the dns-prefetch resource hint for stats.wp.com.
277 *
278 * WordPress automatically adds dns-prefetch hints for enqueued script hosts via
279 * wp_dependencies_unique_hosts(). Since we're deprioritizing the stats script,
280 * the dns-prefetch is counterproductive — it front-loads DNS resolution for a
281 * resource we're intentionally delaying.
282 *
283 * @since 0.19.5
284 *
285 * @param array $urls Array of resource hint URLs.
286 * @param string $relation_type The relation type (dns-prefetch, preconnect, etc.).
287 * @return array Filtered URLs.
288 */
289 public static function remove_stats_dns_prefetch( $urls, $relation_type ) {
290 if ( 'dns-prefetch' !== $relation_type ) {
291 return $urls;
292 }
293
294 return array_filter(
295 $urls,
296 static function ( $url ) {
297 // Resource hints can be arrays that carry the URL under an 'href' key.
298 if ( is_array( $url ) ) {
299 $candidate = ( isset( $url['href'] ) && is_string( $url['href'] ) ) ? $url['href'] : '';
300 } elseif ( is_string( $url ) ) {
301 $candidate = $url;
302 } else {
303 return true; // Unknown entry shape; leave it untouched.
304 }
305
306 // dns-prefetch entries arrive in several shapes: WordPress core emits bare
307 // hosts ('stats.wp.com') via wp_dependencies_unique_hosts(), while other
308 // filters may add scheme-relative ('//stats.wp.com') or full URLs. Normalize
309 // each to a host so we drop stats.wp.com exactly without removing look-alike
310 // hosts such as 'mystats.wp.com' or 'stats.wp.com.evil.tld'.
311 if ( str_starts_with( $candidate, '//' ) ) {
312 $host = wp_parse_url( 'https:' . $candidate, PHP_URL_HOST );
313 } elseif ( str_contains( $candidate, '://' ) ) {
314 $host = wp_parse_url( $candidate, PHP_URL_HOST );
315 } else {
316 $host = $candidate; // Bare host form, e.g. 'stats.wp.com'.
317 }
318
319 return ! is_string( $host ) || 'stats.wp.com' !== strtolower( $host );
320 }
321 );
322 }
323
324 /**
325 * Enqueue the Stats pixel.
326 * Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
327 *
328 * @access public
329 * @return void
330 */
331 public static function enqueue_stats_script() {
332 if ( self::is_amp_request() ) {
333 return;
334 }
335
336 wp_enqueue_script(
337 'jetpack-stats',
338 'https://stats.wp.com/e-' . gmdate( 'YW' ) . '.js',
339 array(),
340 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
341 array(
342 'in_footer' => true,
343 'strategy' => 'defer',
344 )
345 );
346 add_filter( 'wp_script_attributes', array( static::class, 'add_low_fetchpriority' ) );
347 add_filter( 'wp_resource_hints', array( static::class, 'remove_stats_dns_prefetch' ), 100, 2 );
348
349 $data = self::build_view_data();
350
351 /**
352 * Filter the parameters added to the JavaScript stats tracking code.
353 *
354 * @module stats
355 *
356 * @since-jetpack 10.9
357 *
358 * @param array $data Array of options about the site and page you're on.
359 */
360 $data = (array) apply_filters( 'jetpack_stats_footer_js_data', $data );
361
362 $triggers = self::build_stats_details( $data );
363 wp_add_inline_script(
364 'jetpack-stats',
365 $triggers,
366 'before'
367 );
368 }
369
370 /**
371 * Gets the tracking pixel URL for AMP output.
372 *
373 * @access private
374 * @param array $data Array of data for the AMP pixel tracker.
375 * @return string Returns the URL for the Stats tracker in an AMP scenario.
376 */
377 private static function get_amp_pixel_url( $data ) {
378 /**
379 * Filter the parameters added to the AMP pixel tracking code.
380 *
381 * @module stats
382 *
383 * @since-jetpack 10.9
384 *
385 * @param array $data Array of options about the site and page you're on.
386 */
387 $data = (array) apply_filters( 'jetpack_stats_footer_amp_data', $data );
388
389 $data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
390 $data['rand'] = 'RANDOM'; // AMP placeholder.
391 $data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
392 $data = array_map( 'rawurlencode', $data );
393 return add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
394 }
395
396 /**
397 * Build an AMP pixel.
398 * Do not use this function directly, it is hooked into `wp_footer`.
399 *
400 * @access public
401 * @return void
402 */
403 public static function add_amp_pixel() {
404 $data = self::build_view_data();
405 if ( ! self::is_amp_request() ) {
406 return;
407 }
408
409 printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
410 }
411
412 /**
413 * Stats Footer.
414 *
415 * @deprecated 0.6.0
416 *
417 * @access public
418 * @return void
419 */
420 public static function add_to_footer() {
421 _deprecated_function( __METHOD__, '0.6.0' );
422 }
423
424 /**
425 * Gets the footer to add for the Stats tracker.
426 *
427 * @deprecated 0.6.0
428 *
429 * @access public
430 * @param array $data Array of data for the JS stats tracker.
431 * @return void
432 */
433 public static function get_footer_to_add( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
434 _deprecated_function( __METHOD__, '0.6.0' );
435 }
436
437 /**
438 * Render the stats footer. Kept for backward compatibility on legacy AMF views.
439 *
440 * @deprecated 0.6.0
441 *
442 * @access public
443 * @param array $data Array of data for the JS stats tracker.
444 */
445 public static function render_footer( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
446 _deprecated_function( __METHOD__, '0.6.0' );
447 }
448
449 /**
450 * Render the stats footer for AMP output. Kept for backward compatibility.
451 *
452 * @access public
453 * @param array $data Array of data for the AMP pixel tracker.
454 */
455 public static function render_amp_footer( $data ) {
456 printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
457 }
458
459 /**
460 * Creates the "array" string used as part of the JS tracker.
461 *
462 * @access private
463 * @param array $kvs Array of options about the site and page you're on.
464 * @return string
465 */
466 private static function stats_array_to_string( $kvs ) {
467 /**
468 * Filters the options added to the JavaScript Stats tracking code.
469 *
470 * @since-jetpack 1.1.0
471 *
472 * @param array $kvs Array of options about the site and page you're on.
473 */
474 $kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
475 $kvs = array_map( 'strval', $kvs );
476
477 // Encode into JSON object for direct use in JS.
478 return wp_json_encode( $kvs, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
479 }
480
481 /**
482 * Does the page return AMP content.
483 *
484 * @return bool $is_amp_request Are we on AMP view.
485 */
486 private static function is_amp_request() {
487 $is_amp_request = ( function_exists( 'amp_is_request' ) && amp_is_request() );
488 $is_amp_request = $is_amp_request || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
489
490 /**
491 * Returns true if the current request should return valid AMP content.
492 *
493 * @since 6.2.0
494 *
495 * @param boolean $is_amp_request Is this request supposed to return valid AMP content?
496 */
497 return apply_filters( 'jetpack_is_amp_request', $is_amp_request );
498 }
499 }
500