Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: update

Implement request batching for shopper tracking events.
34 changes: 32 additions & 2 deletions client/express-checkout/tracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,30 @@
import { debounce } from 'lodash';
import { recordUserEvent } from 'tracks';

/**
* Check if shopper tracking is enabled via wcpaySettings.
* This respects server-side filters and opt-out settings.
*
* @return {boolean} True if tracking is enabled.
*/
const isTrackingEnabled = () => {
// If wcpaySettings is not available, assume tracking is enabled (backward compatibility).
if ( typeof window.wcpaySettings === 'undefined' ) {
return true;
}

// Check if tracking has been explicitly disabled.
// This would be set by the server based on wcpay_enable_shopper_tracking filter
// and woocommerce_allow_tracking option.
return window.wcpaySettings.enableShopperTracking !== false;
};

// Track the button click event.
export const trackExpressCheckoutButtonClick = ( paymentMethod, source ) => {
if ( ! isTrackingEnabled() ) {
return;
}

const expressPaymentTypeEvents = {
google_pay: 'gpay_button_click',
apple_pay: 'applepay_button_click',
Expand All @@ -18,18 +40,26 @@ export const trackExpressCheckoutButtonClick = ( paymentMethod, source ) => {
};

const debouncedTrackApplePayButtonLoad = debounce( ( { source } ) => {
recordUserEvent( 'applepay_button_load', { source } );
if ( isTrackingEnabled() ) {
recordUserEvent( 'applepay_button_load', { source } );
}
}, 1000 );

const debouncedTrackGooglePayButtonLoad = debounce( ( { source } ) => {
recordUserEvent( 'gpay_button_load', { source } );
if ( isTrackingEnabled() ) {
recordUserEvent( 'gpay_button_load', { source } );
}
}, 1000 );

// Track the button load event.
export const trackExpressCheckoutButtonLoad = ( {
paymentMethods,
source,
} ) => {
if ( ! isTrackingEnabled() ) {
return;
}

const expressPaymentTypeEvents = {
googlePay: debouncedTrackGooglePayButtonLoad,
applePay: debouncedTrackApplePayButtonLoad,
Expand Down
84 changes: 70 additions & 14 deletions client/tracks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import domReady from '@wordpress/dom-ready';
import { debounce } from 'lodash';

/**
* Internal dependencies
Expand All @@ -10,6 +11,17 @@ import { MerchantEvent, ShopperEvent } from './event';
import { getConfig } from 'wcpay/utils/checkout';
import { getExpressCheckoutConfig } from 'wcpay/utils/express-checkout';

/**
* Event queue for batching.
*/
interface QueuedEvent {
event: string;
properties: Record< string, unknown >;
timestamp: number;
}

const eventQueue: QueuedEvent[] = [];

/**
* Checks if site tracking is enabled.
*
Expand Down Expand Up @@ -59,6 +71,56 @@ export const recordEvent = (
} );
};

/**
* Flush the event queue by sending all queued events in a single batch request.
*/
const flushEventQueue = (): void => {
if ( eventQueue.length === 0 ) {
return;
}

const nonce =
getConfig( 'platformTrackerNonce' ) ??
getExpressCheckoutConfig( 'nonce' )?.platform_tracker;
const ajaxUrl =
getConfig( 'ajaxUrl' ) ?? getExpressCheckoutConfig( 'ajax_url' );

// Create a copy of the queue and clear it immediately.
const eventsToSend = [ ...eventQueue ];
eventQueue.length = 0;

const body = new FormData();
body.append( 'tracksNonce', nonce );
body.append( 'action', 'platform_tracks_batch' );
body.append( 'tracksEvents', JSON.stringify( eventsToSend ) );

fetch( ajaxUrl, {
method: 'post',
body,
} ).catch( ( error ) => {
// Silently fail - tracking should not break the user experience.
// eslint-disable-next-line no-console
console.error( 'Failed to send tracking events:', error );
} );
};

/**
* Debounced flush function - waits 2 seconds of inactivity before flushing.
* This allows batching multiple rapid events together.
*/
const debouncedFlush = debounce( flushEventQueue, 2000 );

/**
* Flush events on page unload to ensure they're sent.
*/
if ( typeof window !== 'undefined' ) {
window.addEventListener( 'beforeunload', () => {
// Cancel debounced flush and flush immediately.
debouncedFlush.cancel();
flushEventQueue();
} );
}

/**
* Records events from buyers (aka shoppers).
*
Expand All @@ -71,21 +133,15 @@ export const recordUserEvent = (
eventName: ShopperEvent,
eventProperties: Record< string, unknown > = {}
): void => {
const nonce =
getConfig( 'platformTrackerNonce' ) ??
getExpressCheckoutConfig( 'nonce' )?.platform_tracker;
const ajaxUrl =
getConfig( 'ajaxUrl' ) ?? getExpressCheckoutConfig( 'ajax_url' );
const body = new FormData();
// Add event to queue with current timestamp.
eventQueue.push( {
event: eventName,
properties: eventProperties,
timestamp: Date.now(),
} );

body.append( 'tracksNonce', nonce );
body.append( 'action', 'platform_tracks' );
body.append( 'tracksEventName', eventName );
body.append( 'tracksEventProp', JSON.stringify( eventProperties ) );
fetch( ajaxUrl, {
method: 'post',
body,
} ).then( ( response ) => response.json() );
// Trigger debounced flush.
debouncedFlush();
};

/**
Expand Down
68 changes: 67 additions & 1 deletion includes/class-woopay-tracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public function __construct( $http ) {

add_action( 'wp_ajax_platform_tracks', [ $this, 'ajax_tracks' ] );
add_action( 'wp_ajax_nopriv_platform_tracks', [ $this, 'ajax_tracks' ] );
add_action( 'wp_ajax_platform_tracks_batch', [ $this, 'ajax_tracks_batch' ] );
add_action( 'wp_ajax_nopriv_platform_tracks_batch', [ $this, 'ajax_tracks_batch' ] );
add_action( 'wp_ajax_get_identity', [ $this, 'ajax_tracks_id' ] );
add_action( 'wp_ajax_nopriv_get_identity', [ $this, 'ajax_tracks_id' ] );

Expand Down Expand Up @@ -113,6 +115,64 @@ public function ajax_tracks() {
wp_send_json_success();
}

/**
* Handle batch tracking events from frontend.
* Processes multiple events in a single request to reduce server load.
*/
public function ajax_tracks_batch() {
// Check for nonce.
if (
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
empty( $_REQUEST['tracksNonce'] ) || ! wp_verify_nonce( $_REQUEST['tracksNonce'], 'platform_tracks_nonce' )
) {
wp_send_json_error(
__( 'You aren't authorized to do that.', 'woocommerce-payments' ),
403
);
}

if ( ! isset( $_REQUEST['tracksEvents'] ) ) {
wp_send_json_error(
__( 'No events provided.', 'woocommerce-payments' ),
400
);
}

// tracksEvents is a JSON-encoded array of events.
$events = json_decode( wc_clean( wp_unslash( $_REQUEST['tracksEvents'] ) ), true );
if ( ! is_array( $events ) ) {
wp_send_json_error(
__( 'Invalid events format.', 'woocommerce-payments' ),
400
);
}

$recorded_count = 0;
foreach ( $events as $event ) {
if ( ! isset( $event['event'] ) ) {
continue;
}

$event_name = sanitize_text_field( $event['event'] );
$event_properties = isset( $event['properties'] ) && is_array( $event['properties'] ) ? $event['properties'] : [];

// If client provided a timestamp, preserve it.
if ( isset( $event['timestamp'] ) ) {
$event_properties['_client_ts'] = absint( $event['timestamp'] );
}

$this->maybe_record_event( $event_name, $event_properties );
$recorded_count++;
}

wp_send_json_success(
[
'recorded' => $recorded_count,
'total' => count( $events ),
]
);
}

/**
* Get tracks ID of the current user
*/
Expand Down Expand Up @@ -368,7 +428,13 @@ private function tracks_build_event_obj( $user, $event_name, $properties = [] )
'blog_lang' => isset( $properties['blog_lang'] ) ? $properties['blog_lang'] : get_bloginfo( 'language' ),
];

$timestamp = round( microtime( true ) * 1000 );
// Use client-provided timestamp if available, otherwise use server time.
if ( isset( $properties['_client_ts'] ) ) {
$timestamp = $properties['_client_ts'];
unset( $properties['_client_ts'] ); // Remove from properties to avoid duplication.
} else {
$timestamp = round( microtime( true ) * 1000 );
}
$timestamp_string = is_string( $timestamp ) ? $timestamp : number_format( $timestamp, 0, '', '' );

/**
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test-class-woopay-tracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ public function test_filter_has_priority_over_woocommerce_setting(): void {
delete_option( 'woocommerce_allow_tracking' );
}

public function test_ajax_tracks_batch_validates_nonce(): void {
$_REQUEST['tracksNonce'] = 'invalid_nonce';
$_REQUEST['tracksEvents'] = '[]';

// Expect JSON error response.
$this->expectException( \WPDieException::class );
$this->tracker->ajax_tracks_batch();
}

public function test_ajax_tracks_batch_requires_events(): void {
$_REQUEST['tracksNonce'] = wp_create_nonce( 'platform_tracks_nonce' );
// Missing tracksEvents parameter.

$this->expectException( \WPDieException::class );
$this->tracker->ajax_tracks_batch();
}

public function test_ajax_tracks_batch_validates_events_format(): void {
$_REQUEST['tracksNonce'] = wp_create_nonce( 'platform_tracks_nonce' );
$_REQUEST['tracksEvents'] = 'not valid json';

$this->expectException( \WPDieException::class );
$this->tracker->ajax_tracks_batch();
}

public function test_tracks_build_event_obj_for_admin_events(): void {
$this->set_account_connected( true );
$event_name = 'wcadmin_test_event';
Expand Down
Loading