* Get shipping address from customer data.
* Collects shipping address fields from WC_Customer object with graceful degradation.
* Returns array with 6 address fields, sanitized with sanitize_text_field().
* @return array Shipping address array with 6 keys.
private function get_shipping_address(): array {
if ( WC()->customer instanceof \WC_Customer ) {
$shipping_data = array_merge(
'first_name' => \sanitize_text_field( WC()->customer->get_shipping_first_name() ),
'last_name' => \sanitize_text_field( WC()->customer->get_shipping_last_name() ),
'address_1' => \sanitize_text_field( WC()->customer->get_shipping_address_1() ),
'address_2' => \sanitize_text_field( WC()->customer->get_shipping_address_2() ),
'city' => \sanitize_text_field( WC()->customer->get_shipping_city() ),
'state' => \sanitize_text_field( WC()->customer->get_shipping_state() ),
'postcode' => \sanitize_text_field( WC()->customer->get_shipping_postcode() ),
'country' => \sanitize_text_field( WC()->customer->get_shipping_country() ),
} elseif ( WC()->session instanceof \WC_Session ) {
// Fallback to session customer data if WC_Customer not available.
$customer_data = WC()->session->get( 'customer' );
if ( is_array( $customer_data ) ) {
$shipping_data = array_merge(
'first_name' => \sanitize_text_field( $customer_data['shipping_first_name'] ?? null ),
'last_name' => \sanitize_text_field( $customer_data['shipping_last_name'] ?? null ),
'address_1' => \sanitize_text_field( $customer_data['shipping_address_1'] ?? null ),
'address_2' => \sanitize_text_field( $customer_data['shipping_address_2'] ?? null ),
'city' => \sanitize_text_field( $customer_data['shipping_city'] ?? null ),
'state' => \sanitize_text_field( $customer_data['shipping_state'] ?? null ),
'postcode' => \sanitize_text_field( $customer_data['shipping_postcode'] ?? null ),
'country' => \sanitize_text_field( $customer_data['shipping_country'] ?? null ),
} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Graceful degradation - returns as much data as possible.
* Get client IP address using WooCommerce geolocation utility.
* @return string|null IP address or null if not available.
private function get_ip_address(): ?string {
if ( class_exists( 'WC_Geolocation' ) ) {
$ip = \WC_Geolocation::get_ip_address();
* Get customer email with fallback chain.
* Tries logged-in user email first, then WC_Customer billing email,
* then session customer data as fallback.
* @return string|null Email address or null if not available.
private function get_email(): ?string {
// Try logged-in user first.
if ( \is_user_logged_in() ) {
$user = \wp_get_current_user();
if ( $user && $user->user_email ) {
return \sanitize_email( $user->user_email );
// Try WC_Customer object.
if ( WC()->customer instanceof \WC_Customer ) {
$email = WC()->customer->get_billing_email();
return \sanitize_email( $email );
// Fallback to session customer data if WC_Customer not available.
if ( WC()->session instanceof \WC_Session ) {
$customer_data = WC()->session->get( 'customer' );
if ( is_array( $customer_data ) && ! empty( $customer_data['email'] ) ) {
return \sanitize_email( $customer_data['email'] );
* Get user agent string from HTTP headers.
* @return string|null User agent or null if not available.
private function get_user_agent(): ?string {
if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
* Get product category names as comma-separated list.
* Uses WooCommerce helper with caching for better performance.
* Returns all categories for the product, not just the primary one.
* @param \WC_Product $product The product object.
* @return string|null Comma-separated category names or null if none.
private function get_product_category_names( \WC_Product $product ): ?string {
$terms = WC()->call_function( 'wc_get_product_terms', $product->get_id(), 'product_cat' );
if ( empty( $terms ) || ! is_array( $terms ) ) {
$category_names = array_map(
return implode( ', ', $category_names );
* Trim collected data array to ensure it stays within 1 MB size limit.
* Removes oldest entries from the array until the serialized size is under the limit.
* Always keeps at least one entry (the most recent).
* @param array $data Array of collected event data.
* @param int $base_size Size in bytes of additional data that will be combined with this array.
* @return array Trimmed array that fits within the size limit.
private function trim_to_max_size( array $data, int $base_size = 0 ): array {
$max_size_bytes = 1 * 1024 * 1024 - $base_size; // 1 MB minus base data size.
$data_count = count( $data );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Used for size calculation only.
$data_size = strlen( serialize( $data ) );
while ( $data_count > 1 && $data_size > $max_size_bytes ) {
$data_count = count( $data );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Used for size calculation only.
$data_size = strlen( serialize( $data ) );