0, 'product' => 1, 'cart' => 2, 'checkout' => 3, 'submit' => 3, 'thanks' => 4, 'ordered' => 4, ); return isset( $ranks[ $stage ] ) ? $ranks[ $stage ] : 0; } /** * @param array> $rows Events, oldest first: session, agent, kind, stage, reason, message, value, ref. * @return array See summarize_sessions(). */ public static function summarize( array $rows ) { return self::summarize_sessions( self::sessions( $rows ) ); } /** * Events folded into one record per session. * * A session keeps the reason from the furthest step it reached, and the latest one at that * step: a captcha at checkout is the story, not a missing image fetched afterwards. * * @param array> $rows * @return array> */ public static function sessions( array $rows ) { $sessions = array(); foreach ( $rows as $row ) { $key = isset( $row['session'] ) ? (string) $row['session'] : ''; if ( '' === $key ) { continue; } $stage = isset( $row['stage'] ) ? (string) $row['stage'] : 'browse'; $value = isset( $row['value'] ) ? (float) $row['value'] : 0.0; $kind = isset( $row['kind'] ) ? (string) $row['kind'] : ''; $agent = isset( $row['agent'] ) ? (string) $row['agent'] : ''; if ( ! isset( $sessions[ $key ] ) ) { $sessions[ $key ] = self::session_record( $agent, $kind ); } $session =& $sessions[ $key ]; if ( 'signed' === $kind && 'signed' !== $session['kind'] ) { $session['agent'] = $agent; $session['kind'] = $kind; } $session['rank'] = max( $session['rank'], self::rank( $stage ) ); if ( in_array( $stage, array( 'cart', 'checkout', 'submit' ), true ) ) { $session['value'] = max( $session['value'], $value ); } if ( 'ordered' === $stage ) { $session['ordered'] = true; $ref = isset( $row['ref'] ) ? (string) $row['ref'] : ''; if ( '' === $ref || ! isset( $session['refs'][ $ref ] ) ) { $session['revenue'] += $value; $session['orders']++; if ( '' !== $ref ) { $session['refs'][ $ref ] = true; } } } $reason = isset( $row['reason'] ) ? (string) $row['reason'] : ''; if ( '' !== $reason ) { self::note_reason( $session, $reason, isset( $row['message'] ) ? (string) $row['message'] : '', self::rank( $stage ) ); } unset( $session ); } return $sessions; } /** * @param string $agent * @param string $kind * @return array */ public static function session_record( $agent, $kind ) { return array( 'agent' => $agent, 'kind' => $kind, 'rank' => 0, 'value' => 0.0, 'ordered' => false, 'orders' => 0, 'revenue' => 0.0, 'refs' => array(), 'reason' => '', 'message' => '', 'reason_rank' => -1, 'blocked' => false, 'ruled' => false, ); } /** * @param array $session * @param string $reason * @param string $message * @param int $rank */ public static function note_reason( array &$session, $reason, $message, $rank ) { if ( in_array( $reason, array( 'blocked', 'rate_limited' ), true ) ) { $session['blocked'] = true; } if ( 'policy' === $reason ) { $session['ruled'] = true; } if ( $rank >= $session['reason_rank'] ) { $session['reason'] = $reason; $session['message'] = $message; $session['reason_rank'] = $rank; } } /** * @param array> $sessions From sessions(), or built from the database by session. * @return array{sessions:int,product:int,cart:int,checkout:int,ordered:int,revenue:float,failed:int,lost_value:float,left:int,blocked:int,ruled:int,reasons:array,agents:array>} */ public static function summarize_sessions( array $sessions ) { $totals = self::empty_totals(); $agents = array(); $reasons = array(); foreach ( $sessions as $session ) { $label = '' !== $session['agent'] ? $session['agent'] : 'Unnamed'; if ( 'automation' === $session['kind'] ) { // Scripts are not shoppers. A card tester's declined payment is not a lost sale, // so they get their own line and stay out of the visits, the reasons and the money. $totals['scripts']++; $totals['script_failures'] += '' !== $session['reason'] ? 1 : 0; if ( ! isset( $agents[ $label ] ) ) { $agents[ $label ] = self::empty_totals(); $agents[ $label ]['agent'] = $label; $agents[ $label ]['kind'] = $session['kind']; } self::add( $agents[ $label ], $session ); continue; } self::add( $totals, $session ); if ( ! isset( $agents[ $label ] ) ) { $agents[ $label ] = self::empty_totals(); $agents[ $label ]['agent'] = $label; $agents[ $label ]['kind'] = $session['kind']; } self::add( $agents[ $label ], $session ); if ( $session['ordered'] ) { continue; } $reason = $session['reason']; if ( '' === $reason ) { if ( $session['rank'] < 2 ) { continue; } $reason = 'no_error'; } if ( ! isset( $reasons[ $reason ] ) ) { $reasons[ $reason ] = array( 'reason' => $reason, 'sessions' => 0, 'value' => 0.0, 'message' => '', ); } $reasons[ $reason ]['sessions']++; $reasons[ $reason ]['value'] += $session['rank'] >= 2 ? $session['value'] : 0.0; if ( '' === $reasons[ $reason ]['message'] && '' !== $session['message'] ) { $reasons[ $reason ]['message'] = $session['message']; } } $reasons = array_values( $reasons ); usort( $reasons, static function ( $a, $b ) { if ( $a['value'] === $b['value'] ) { return $b['sessions'] - $a['sessions']; } return $a['value'] < $b['value'] ? 1 : -1; } ); $agents = array_values( $agents ); usort( $agents, static function ( $a, $b ) { return $b['sessions'] - $a['sessions']; } ); $totals['reasons'] = $reasons; $totals['agents'] = $agents; return $totals; } /** * @return array */ private static function empty_totals() { return array( 'sessions' => 0, 'product' => 0, 'cart' => 0, 'checkout' => 0, 'ordered' => 0, 'orders' => 0, 'revenue' => 0.0, 'failed' => 0, 'lost_value' => 0.0, 'left' => 0, 'blocked' => 0, 'ruled' => 0, 'scripts' => 0, 'script_failures' => 0, ); } /** * @param array $totals * @param array $session */ private static function add( array &$totals, array $session ) { $rank = $session['ordered'] ? 4 : $session['rank']; $totals['sessions']++; $totals['product'] += $rank >= 1 ? 1 : 0; $totals['cart'] += $rank >= 2 ? 1 : 0; $totals['checkout'] += $rank >= 3 ? 1 : 0; $totals['blocked'] += $session['blocked'] ? 1 : 0; $totals['ruled'] += ! empty( $session['ruled'] ) ? 1 : 0; if ( $session['ordered'] ) { $totals['ordered']++; $totals['orders'] += isset( $session['orders'] ) ? max( 1, (int) $session['orders'] ) : 1; $totals['revenue'] += $session['revenue']; } elseif ( '' !== $session['reason'] ) { // Lost money counts only carts the store itself stopped. Agents also leave for their own reasons. $totals['failed']++; $totals['lost_value'] += $rank >= 2 ? $session['value'] : 0.0; } elseif ( $rank >= 2 ) { $totals['left']++; } } /** * Reason code for an error the store showed. Codes win over message text. * * @param string $message * @param string $code * @return string */ public static function reason_for( $message, $code = '' ) { $code = strtolower( (string) $code ); if ( '' !== $code ) { // A field check comes first: "shipping_address_1_required" is a field, not a shipping zone. if ( preg_match( '/_(required|validation)$/', $code ) || in_array( $code, array( 'rest_invalid_param', 'rest_missing_callback_param', 'validation' ), true ) ) { return 'invalid_field'; } if ( false !== strpos( $code, 'nonce' ) ) { return 'session_expired'; } if ( false !== strpos( $code, 'stock' ) ) { return 'out_of_stock'; } if ( false !== strpos( $code, 'payment' ) ) { return 'payment_failed'; } if ( false !== strpos( $code, 'shipping' ) ) { return 'shipping'; } if ( false !== strpos( $code, 'coupon' ) ) { return 'coupon'; } if ( 'terms' === $code ) { return 'terms'; } if ( 0 === strpos( $code, 'agent_stamp_' ) ) { return 'policy'; } } $plain = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( (string) $message ) : preg_replace( '/<[^>]*>/', '', (string) $message ); $text = strtolower( trim( (string) $plain ) ); $patterns = array( 'captcha' => '/captcha|turnstile|not a robot|are you (a )?human|bot (check|verification)|verify (that )?you are|verification token|anti-spam/', 'account_required' => '/logged in|log in to|login to|create an account|account to (check ?out|continue|purchase)|sign in to|registration is required/', 'out_of_stock' => '/out of stock|not enough stock|insufficient stock|no longer available|cannot be purchased|not purchasable|only \d+ (left|in stock)/', 'shipping' => '/no shipping|shipping method|does not ship|do not ship|cannot ship|shipping is not available|no delivery/', 'terms' => '/terms (and|&|&) conditions|accept (the|our) terms|read and accept/', 'coupon' => '/coupon/', 'payment_failed' => '/card|payment|declined|insufficient funds|transaction|cvc|cvv/', 'session_expired' => '/session (has )?expired|nonce|unable to process your order|refresh the page/', 'invalid_field' => '/required field|is (a )?required|not a valid|is invalid|invalid (postcode|zip|phone|email)|postcode|zip code|phone number|email address/', ); foreach ( $patterns as $reason => $pattern ) { if ( preg_match( $pattern, $text ) ) { return $reason; } } return '' === $text ? '' : 'other'; } /** * Reason code for an HTTP answer the store gave an agent, or empty when it is not a failure. * * @param int $status * @return string */ public static function reason_for_status( $status ) { $status = (int) $status; if ( 401 === $status || 403 === $status ) { return 'blocked'; } if ( 429 === $status ) { return 'rate_limited'; } if ( 404 === $status || 410 === $status ) { return 'not_found'; } if ( $status >= 500 ) { return 'unavailable'; } return ''; } /** * What a reason means and what to do about it. * * @param string $reason * @return array{title:string,fix:string} */ public static function explain( $reason ) { $catalog = array( 'blocked' => array( 'Blocked by a security plugin or firewall', 'Something in WordPress refused the agent, usually a security plugin or a firewall rule. Allow signed agents in it, or let signed agents skip the captcha under Rules.' ), 'rate_limited' => array( 'Rate limited (429)', 'A rate limit stopped the agent. Agents fetch several pages quickly. Raise the limit for signed agents.' ), 'captcha' => array( 'Stopped by a captcha', 'Under Rules, let signed agents skip a supported captcha. People still see it. Or set your captcha plugin to skip verified bots.' ), 'account_required' => array( 'Checkout needs an account', 'Turn on guest checkout. The Readiness tab has a button for it.' ), 'out_of_stock' => array( 'Out of stock', 'The agent wanted something you could not sell. Check stock on the products it looked at.' ), 'shipping' => array( 'No shipping for the address', 'No shipping method matched the address. Check your shipping zones.' ), 'terms' => array( 'Terms checkbox not ticked', 'Some agents do not tick a required terms box. A terms notice instead of a checkbox avoids it.' ), 'coupon' => array( 'Coupon rejected', 'A coupon the agent tried was refused. Agents often try codes they found on coupon sites.' ), 'payment_failed' => array( 'Payment failed', defined( 'AGENT_STAMP_PRO_VERSION' ) ? 'The payment did not go through. Many failures from one connection in a short time is card testing: turn on the card-testing guard under Rules.' : 'The payment did not go through. Many failures from one connection in a short time is card testing. Agent Stamp Pro adds a card-testing guard that stops it.' ), 'session_expired' => array( 'Checkout session expired', 'The checkout token did not match. Agents that pause between steps hit this, and so does a page cache on the cart or checkout page.' ), 'invalid_field' => array( 'A field was rejected', 'A checkout field failed validation. Strict phone or address formats trip agents. Read the example message.' ), 'not_found' => array( 'Page not found', 'The agent asked for a page that does not exist, often an old or guessed product link. Redirect old product URLs.' ), 'unavailable' => array( 'Server error', 'The store answered with an error or maintenance page.' ), 'policy' => array( 'Stopped by your rule', 'One of your Agent Stamp rules stopped it. Change it under Rules if this was a sale you wanted.' ), 'no_error' => array( 'Left with no error', 'The agent built a cart and left without an error we could see. Price, shipping cost, or delivery time may be why.' ), 'other' => array( 'Other error', 'The store showed an error. Read the example message.' ), ); $entry = isset( $catalog[ $reason ] ) ? $catalog[ $reason ] : $catalog['other']; return array( 'title' => $entry[0], 'fix' => $entry[1], ); } }