> */ private static $events = array(); /** @var int */ private static $status = 200; /** @var string */ private static $stage = ''; /** @var array{kind:string,slug:string}|null The code that refused this request, found when it set the status. */ private static $culprit = null; /** @var bool Errors for this request were already recorded, by code or by a rule, so their notices are not read again. */ private static $recorded = false; public static function init() { add_action( 'template_redirect', array( __CLASS__, 'page_view' ), 1 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'rest_request' ), 10, 3 ); add_filter( 'rest_post_dispatch', array( __CLASS__, 'rest_response' ), 10, 3 ); add_action( 'woocommerce_add_to_cart', array( __CLASS__, 'added_to_cart' ), 10, 0 ); add_action( 'woocommerce_checkout_process', array( __CLASS__, 'checkout_submit' ), 1 ); add_action( 'woocommerce_after_checkout_validation', array( __CLASS__, 'checkout_errors' ), 999, 2 ); add_filter( 'woocommerce_add_error', array( __CLASS__, 'store_error' ) ); add_filter( 'status_header', array( __CLASS__, 'status' ), 10, 2 ); add_action( 'woocommerce_order_status_failed', array( __CLASS__, 'payment_failed' ), 10, 2 ); add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'order_paid' ), 10, 4 ); add_action( 'shutdown', array( __CLASS__, 'flush' ), 1 ); } /** * @return AgentStamp_Request */ public static function request() { if ( null === self::$request ) { self::$request = AgentStamp_Request::from_globals(); } return self::$request; } /** * @return bool */ public static function has_signature() { $headers = self::request()->headers; return ( isset( $headers['signature-input'] ) && '' !== trim( $headers['signature-input'] ) ) || ( isset( $headers['signature'] ) && '' !== trim( $headers['signature'] ) ); } /** * The classifier's answer for this request, worked out once. * A signature nonce can be claimed only once, so every caller shares this. * * @return array{verdict:string,name:string,confidence:string,detail:string} */ public static function classification() { if ( null === self::$classification ) { $classifier = new AgentStamp_Classifier( new AgentStamp_Signature( new AgentStamp_Directory(), new AgentStamp_Wp_Nonces() ) ); self::$classification = $classifier->classify( self::request(), time() ); } return self::$classification; } /** * Who this request is, when it is not a person: kind and a display name. * * A page view is never verified: checking a signature costs a key fetch and * a nonce, so that happens only at checkout and when a signed agent skips the captcha. Until then * a signed request is named from its Signature-Agent when that is a known operator. * * @return array{kind:string,agent:string}|null */ public static function identity() { if ( false !== self::$identity ) { return self::$identity; } $signer = self::has_signature() ? self::signer() : null; // When the captcha skip already checked the signature, use that answer. $checked = null !== $signer && null !== self::$classification ? self::$classification : null; self::$identity = self::identify( self::request()->user_agent, $checked, $signer ); return self::$identity; } /** * The operator a signed request claims, by its Signature-Agent host: a known name, or empty. * * @return string */ public static function signer() { $header = isset( self::request()->headers['signature-agent'] ) ? (string) self::request()->headers['signature-agent'] : ''; $directory = AgentStamp_Directory::directory_url_from_agent_header( $header ); if ( ! is_array( $directory ) ) { return ''; } $name = AgentStamp_Agents::name_for_host( $directory['host'] ); return strtolower( $name ) === strtolower( $directory['host'] ) ? '' : $name; } /** * Pure: kind and name from a user-agent, the classifier result when the signature was checked, * or the claimed signer when a signature arrived and was not checked yet. * * @param string $ua * @param array|null $signed Classifier result for a signed request. * @param string|null $signer Known operator name from Signature-Agent, '' for an unknown one, null when unsigned. * @return array{kind:string,agent:string}|null */ public static function identify( $ua, $signed, $signer = null ) { if ( is_array( $signed ) ) { if ( 'agent' === $signed['verdict'] ) { return array( 'kind' => 'signed' === $signed['confidence'] ? 'signed' : 'declared', 'agent' => AgentStamp_Classifier::label( $signed ), ); } return array( 'kind' => 'unverified', 'agent' => 'Unverified signature', ); } if ( is_string( $signer ) && '' !== $signer ) { return array( 'kind' => 'declared', 'agent' => $signer . ' (declared)', ); } $named = AgentStamp_Agents::match_ai_ua( $ua ); if ( '' !== $named ) { return array( 'kind' => 'declared', 'agent' => $named . ' (declared)', ); } if ( '' === $signer ) { return array( 'kind' => 'unverified', 'agent' => 'Unchecked signature', ); } if ( '' !== AgentStamp_Agents::match_ai_crawler( $ua ) || AgentStamp_Agents::is_other_bot( $ua ) || self::looks_like_browser( $ua ) ) { return null; } return array( 'kind' => 'automation', 'agent' => self::automation_label( $ua ), ); } /** * A person's browser, including in-app browsers (Instagram, Facebook) that leave out "Safari/". * A person must never be recorded. * * @param string $ua * @return bool */ public static function looks_like_browser( $ua ) { if ( AgentStamp_Agents::is_human_browser( $ua ) ) { return true; } $ua = (string) $ua; if ( 0 !== strpos( $ua, 'Mozilla/5.0' ) || ! preg_match( '/AppleWebKit\/|Gecko\/|Trident\//', $ua ) ) { return false; } foreach ( array( 'Headless', 'PhantomJS', 'Selenium', 'Puppeteer', 'Playwright' ) as $marker ) { if ( false !== stripos( $ua, $marker ) ) { return false; } } return true; } /** * @param string $ua * @return string */ private static function automation_label( $ua ) { $ua = trim( (string) $ua ); if ( '' === $ua ) { return 'Script (no user-agent)'; } foreach ( array( 'HeadlessChrome', 'Headless', 'PhantomJS', 'Selenium', 'Puppeteer', 'Playwright' ) as $marker ) { if ( false !== stripos( $ua, $marker ) ) { return 'Headless browser'; } } $first = strtok( $ua, ' ' ); if ( false !== $first && 0 !== strpos( $first, 'Mozilla/' ) && preg_match( '/^([A-Za-z][A-Za-z0-9._-]{0,40})(?:\/|$)/', $first, $match ) ) { return 'Script (' . $match[1] . ')'; } return 'Script'; } /** * Is a stage worth writing for this kind? Unnamed automation only counts once it shops. * * @param string $kind * @param string $stage * @return bool */ public static function records( $kind, $stage ) { if ( 'automation' !== $kind ) { return true; } return AgentStamp_Funnel::rank( $stage ) >= 2; } /** * One session per agent visit: the visit cookie when the agent kept it, * else a keyed hash of the day, the network and the user-agent. The name is left * out, so a visit named from its user-agent and checked at checkout stays one session. * * @return string */ private static function session() { if ( '' !== self::$session ) { return self::$session; } $cookie = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_key( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; if ( preg_match( '/^[a-f0-9]{16}$/', $cookie ) ) { self::$session = $cookie; return self::$session; } self::$session = self::session_key( gmdate( 'Ymd' ), self::network( AgentStamp_Policy::client_ip() ), self::request()->user_agent, wp_salt( 'nonce' ) ); if ( ! headers_sent() ) { setcookie( self::COOKIE, self::$session, time() + DAY_IN_SECONDS, COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN, is_ssl(), true ); } return self::$session; } /** * @return string 16 hex characters. */ public static function session_key( $day, $network, $ua, $secret ) { return substr( hash_hmac( 'sha256', $day . '|' . $network . '|' . $ua, $secret ), 0, 16 ); } /** * The /24 of an IPv4 address or the /48 of an IPv6 one. Agents move between nearby addresses. * * @param string $ip * @return string */ public static function network( $ip ) { $ip = (string) $ip; if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return implode( '.', array_slice( explode( '.', $ip ), 0, 3 ) ) . '.0/24'; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $packed = inet_pton( $ip ); return bin2hex( substr( $packed, 0, 6 ) ) . '/48'; } return ''; } /** * @param string $stage * @param string $reason * @param string $message * @param float $value * @param int $ref * @param array|null $identity Overrides who this request is. * @param string $session Overrides the session. */ public static function push( $stage, $reason = '', $message = '', $value = 0.0, $ref = 0, $identity = null, $session = '' ) { $identity = null === $identity ? self::identity() : $identity; if ( null === $identity || ! self::records( $identity['kind'], $stage ) ) { return; } self::$stage = $stage; self::$events[] = array( 'created_at' => time(), 'session' => '' !== $session ? $session : self::session(), 'agent' => $identity['agent'], 'kind' => $identity['kind'], 'stage' => $stage, 'reason' => $reason, 'message' => self::clean( $message ), 'status' => 0, 'value' => (float) $value, 'ref' => (int) $ref, ); } /** * @param string $message * @return string */ private static function clean( $message ) { $message = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( html_entity_decode( (string) $message, ENT_QUOTES, 'UTF-8' ) ) ) ); // Error text can carry what the shopper typed. Email addresses and long numbers are dropped. $message = preg_replace( '/[^\s@]+@[^\s@]+/', '[email]', $message ); return preg_replace( '/\d[\d\s-]{6,}\d/', '[number]', $message ); } public static function page_view() { if ( is_admin() || wp_doing_ajax() || null === self::identity() ) { return; } // robots.txt, llms.txt and /.well-known/ are read by agents on every site. They are not a visit to the shop. if ( ( function_exists( 'is_robots' ) && is_robots() ) || ( is_404() && self::is_probe( self::request()->url ) ) ) { return; } $stage = 'browse'; $value = 0.0; $ref = 0; if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) { $stage = 'thanks'; } elseif ( function_exists( 'is_checkout' ) && is_checkout() ) { $stage = 'checkout'; } elseif ( function_exists( 'is_cart' ) && is_cart() ) { $stage = function_exists( 'WC' ) && isset( WC()->cart ) && is_object( WC()->cart ) && ! WC()->cart->is_empty() ? 'cart' : 'browse'; } elseif ( function_exists( 'is_product' ) && is_product() ) { $stage = 'product'; $ref = (int) get_queried_object_id(); $product = function_exists( 'wc_get_product' ) ? wc_get_product( $ref ) : null; if ( $product ) { $value = (float) $product->get_price(); if ( ! $product->is_in_stock() ) { self::push( $stage, 'out_of_stock', 'The product page shows the item out of stock.', $value, $ref ); return; } } } self::push( $stage, '', '', $value, $ref ); } /** * The funnel stage of a WooCommerce REST route, or empty when it is not a shopping route. * * @param string $route * @param string $method * @return string */ public static function rest_stage( $route, $method ) { $method = strtoupper( (string) $method ); if ( preg_match( '#^/wc/v\d+/orders/?$#', (string) $route ) ) { return 'POST' === $method ? 'submit' : ''; } if ( ! preg_match( '#^/wc/store(?:/v\d+)?/(.*)$#', (string) $route, $match ) ) { return ''; } $path = $match[1]; if ( 0 === strpos( $path, 'checkout' ) ) { return 'POST' === $method ? 'submit' : 'checkout'; } if ( 0 === strpos( $path, 'cart' ) || 0 === strpos( $path, 'batch' ) ) { // Reading an empty cart is not building one. return 'GET' === $method ? 'browse' : 'cart'; } if ( preg_match( '#^products/\d+#', $path ) ) { return 'product'; } if ( 0 === strpos( $path, 'products' ) ) { return 'browse'; } return ''; } /** * @param mixed $result * @param WP_REST_Server $server * @param WP_REST_Request $request * @return mixed */ public static function rest_request( $result, $server, $request ) { $stage = is_object( $request ) && method_exists( $request, 'get_route' ) ? self::rest_stage( $request->get_route(), $request->get_method() ) : ''; if ( '' === $stage ) { return $result; } if ( 0 === strpos( (string) $request->get_route(), '/wc/v' ) ) { // The admin REST API: only an agent that signed or named itself, not a store integration. $identity = self::identity(); if ( null === $identity || self::is_integration( $identity, 'rest-api' ) ) { return $result; } } self::push( $stage ); return $result; } /** * @param WP_REST_Response $response * @param WP_REST_Server $server * @param WP_REST_Request $request * @return WP_REST_Response */ public static function rest_response( $response, $server, $request ) { if ( ! is_object( $response ) || ! method_exists( $response, 'get_status' ) || ! is_object( $request ) || ! method_exists( $request, 'get_route' ) ) { return $response; } $stage = self::rest_stage( $request->get_route(), $request->get_method() ); $status = (int) $response->get_status(); if ( '' === $stage || $status < 400 ) { return $response; } if ( 0 === strpos( (string) $request->get_route(), '/wc/v' ) ) { // The admin REST API: a store integration's failed call is not an agent visit. $identity = self::identity(); if ( null === $identity || self::is_integration( $identity, 'rest-api' ) ) { return $response; } } $data = $response->get_data(); $code = is_array( $data ) && isset( $data['code'] ) ? (string) $data['code'] : ''; $message = is_array( $data ) && isset( $data['message'] ) ? (string) $data['message'] : ''; if ( is_array( $data ) && isset( $data['data']['params'] ) && is_array( $data['data']['params'] ) ) { // "Invalid parameter(s)" says little. The field messages say which field and why. $message = implode( ' ', array_map( 'strval', array_values( $data['data']['params'] ) ) ); } $reason = AgentStamp_Funnel::reason_for( $message, $code ); if ( '' === $reason || 'other' === $reason ) { $from_status = AgentStamp_Funnel::reason_for_status( $status ); $reason = '' !== $from_status ? $from_status : ( '' !== $reason ? $reason : 'other' ); } self::push( $stage, $reason, $message ); return $response; } public static function added_to_cart() { self::push( 'cart' ); } public static function checkout_submit() { self::push( 'submit' ); } /** * Classic checkout validation errors, read by code first so a translated store still sorts them. * * @param array $data * @param WP_Error $errors */ public static function checkout_errors( $data, $errors ) { if ( ! is_object( $errors ) || ! method_exists( $errors, 'get_error_codes' ) || null === self::identity() ) { return; } foreach ( $errors->get_error_codes() as $code ) { $message = (string) $errors->get_error_message( $code ); $reason = AgentStamp_Funnel::reason_for( $message, (string) $code ); if ( 'other' === $reason && preg_match( '/_required$|_validation$|^validation$/', (string) $code ) ) { $reason = 'invalid_field'; } if ( '' !== $reason ) { self::$recorded = true; self::push( 'submit', $reason, $message ); } } } /** * A WooCommerce error notice shown to an agent. * * @param string $message * @return string */ public static function store_error( $message ) { if ( ! self::$recorded && null !== self::identity() ) { $reason = AgentStamp_Funnel::reason_for( $message ); if ( '' !== $reason ) { $stage = self::$stage; if ( '' === $stage ) { // A classic add-to-cart link fails before the page is known. It never reached checkout. $ajax = isset( $_REQUEST['wc-ajax'] ) && 'add_to_cart' === sanitize_key( wp_unslash( $_REQUEST['wc-ajax'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read only, WooCommerce handles the request. $stage = isset( $_REQUEST['add-to-cart'] ) || $ajax ? 'cart' : 'checkout'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read only, WooCommerce handles the request. } self::push( $stage, $reason, $message ); } } return $message; } /** * A checkout that was not recognised as an agent until the checkout page signals came in. * * @param array $result The merged stamp. * @param string $reason * @param string $message */ public static function note_checkout( array $result, $reason, $message ) { $identity = self::identity_for_result( $result ); if ( null === $identity ) { return; } // The notice that follows says the same thing in words. It is not a second failure. self::$recorded = true; self::push( 'submit', $reason, $message, 0.0, 0, $identity ); } /** * Kind and name for an order's stamp. * * @param array $result * @return array{kind:string,agent:string}|null */ public static function identity_for_result( array $result ) { $verdict = isset( $result['verdict'] ) ? $result['verdict'] : ''; if ( 'agent' === $verdict ) { return array( 'kind' => 'signed' === $result['confidence'] ? 'signed' : 'declared', 'agent' => AgentStamp_Classifier::label( $result ), ); } if ( in_array( $verdict, array( 'likely', 'unattended', 'api' ), true ) ) { return array( 'kind' => $verdict, 'agent' => AgentStamp_Classifier::label( $result ), ); } if ( 'unknown' === $verdict ) { // Unknown is not a verdict on a person. Only a request that is itself automation is recorded. return self::identity(); } return null; } /** * Called when an order is stamped: the order is a checkout this session reached. * * @param WC_Order $order * @param array $result * @param string $via */ public static function stamped( $order, array $result, $via ) { $identity = self::identity_for_result( $result ); if ( null === $identity ) { return; } if ( self::is_integration( $identity, $via ) ) { return; } $session = self::session(); $order->update_meta_data( '_agent_stamp_session', $session ); $order->update_meta_data( '_agent_stamp_kind', $identity['kind'] ); $order->update_meta_data( '_agent_stamp_agent', $identity['agent'] ); self::push( 'submit', '', '', (float) $order->get_total(), (int) $order->get_id(), $identity, $session ); } /** * A store integration posting orders (an ERP, a marketplace sync, a script) is not an AI agent. * Through the plain REST API only a signed or named agent counts. * * @param array{kind:string} $identity * @param string $via * @return bool */ public static function is_integration( array $identity, $via ) { $via = (string) $via; if ( in_array( $identity['kind'], array( 'signed', 'declared' ), true ) ) { return false; } if ( 'rest-api' === $via ) { return true; } return 'api' === $identity['kind'] && ! AgentStamp_Classifier::is_api_channel( $via ); } /** * @param WC_Order $order * @return array{kind:string,agent:string}|null */ private static function order_identity( $order ) { $kind = (string) $order->get_meta( '_agent_stamp_kind' ); if ( '' === $kind || '' === (string) $order->get_meta( '_agent_stamp_session' ) ) { return null; } $agent = (string) $order->get_meta( '_agent_stamp_agent' ); return array( 'kind' => $kind, 'agent' => '' !== $agent ? $agent : AgentStamp_Plugin::order_label( $order ), ); } /** * Payment failures land on the session that placed the order, even from a webhook. * * @param int $order_id * @param WC_Order|null $order */ public static function payment_failed( $order_id, $order = null ) { if ( ! is_object( $order ) && function_exists( 'wc_get_order' ) ) { $order = wc_get_order( $order_id ); } if ( ! $order ) { return; } $identity = self::order_identity( $order ); if ( null === $identity ) { return; } self::push( 'submit', 'payment_failed', 'The payment for order ' . $order->get_order_number() . ' failed.', (float) $order->get_total(), (int) $order->get_id(), $identity, (string) $order->get_meta( '_agent_stamp_session' ) ); } /** * @param int $order_id * @param string $from * @param string $to * @param WC_Order $order */ public static function order_paid( $order_id, $from, $to, $order = null ) { if ( ! in_array( $to, array( 'processing', 'completed', 'on-hold' ), true ) || ! is_object( $order ) ) { return; } if ( '' !== (string) $order->get_meta( '_agent_stamp_funnel_paid' ) ) { return; } // On-hold also means "waiting for a bank transfer or cheque". That is not paid yet. if ( 'on-hold' === $to && ! $order->get_date_paid() && '' === (string) $order->get_meta( '_agent_stamp_held_paid' ) ) { return; } $identity = self::order_identity( $order ); if ( null === $identity ) { return; } $order->update_meta_data( '_agent_stamp_funnel_paid', '1' ); $order->save_meta_data(); self::push( 'ordered', '', '', (float) $order->get_total(), (int) $order->get_id(), $identity, (string) $order->get_meta( '_agent_stamp_session' ) ); } /** * A missing file an agent checks for on every site (robots.txt, llms.txt, /.well-known/, an image) is not a lost visit. * * @param string $url * @return bool */ public static function is_probe( $url ) { $path = (string) wp_parse_url( (string) $url, PHP_URL_PATH ); if ( 0 === strpos( $path, '/.well-known/' ) || 0 === strpos( $path, '/wp-json/' ) ) { return true; } return 1 === preg_match( '/\.(?!html?$|php$)[a-z0-9]{1,5}$/i', $path ); } /** * @param string $header * @param int $code * @return string */ public static function status( $header, $code ) { self::$status = (int) $code; // A refusal sets its status while the plugin that refused is still on the call stack. if ( null === self::$culprit && in_array( self::$status, array( 401, 403, 429 ), true ) && null !== self::identity() ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Only on a refusal to a known agent, to name the plugin that refused. $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); self::$culprit = self::culprit( array_column( $frames, 'file' ), self::code_roots() ); } return $header; } /** * The plugin, must-use plugin, or theme that refused a request, from the files on the call * stack, innermost first. Agent Stamp, WooCommerce, and WordPress itself only pass a status on. * * @param string[] $files * @param array $roots Folders for plugin, mu and theme code. * @return array{kind:string,slug:string}|null */ public static function culprit( array $files, array $roots ) { $skip = array( 'agent-stamp', 'agent-stamp-pro', 'woocommerce' ); foreach ( $files as $file ) { $file = str_replace( '\\', '/', (string) $file ); foreach ( array( 'plugin', 'mu', 'theme' ) as $kind ) { $root = isset( $roots[ $kind ] ) ? rtrim( str_replace( '\\', '/', (string) $roots[ $kind ] ), '/' ) : ''; if ( '' === $root || 0 !== strpos( $file, $root . '/' ) ) { continue; } $slug = strtok( substr( $file, strlen( $root ) + 1 ), '/' ); if ( 'plugin' === $kind && in_array( $slug, $skip, true ) ) { continue 2; } return array( 'kind' => $kind, 'slug' => $slug, ); } } return null; } /** * @return array */ private static function code_roots() { return array( 'plugin' => defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : '', 'mu' => defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '', 'theme' => function_exists( 'get_theme_root' ) ? get_theme_root() : '', ); } /** * A readable name for the code that refused a request. * * @param array{kind:string,slug:string} $culprit * @return string */ private static function culprit_name( array $culprit ) { if ( 'theme' === $culprit['kind'] ) { $theme = function_exists( 'wp_get_theme' ) ? wp_get_theme( $culprit['slug'] ) : null; return 'the ' . ( $theme && $theme->exists() ? $theme->get( 'Name' ) : $culprit['slug'] ) . ' theme'; } if ( 'mu' === $culprit['kind'] ) { return 'the must-use plugin ' . $culprit['slug']; } foreach ( (array) get_option( 'active_plugins', array() ) as $file ) { if ( 0 === strpos( (string) $file, $culprit['slug'] . '/' ) && function_exists( 'get_file_data' ) ) { $data = get_file_data( WP_PLUGIN_DIR . '/' . $file, array( 'name' => 'Plugin Name' ) ); if ( '' !== $data['name'] ) { return $data['name']; } } } return $culprit['slug']; } /** * Write this request's events. A refusal from a security plugin lands here even when no page ran. */ public static function flush() { if ( self::$status >= 400 && ! is_admin() ) { $has_reason = false; foreach ( self::$events as $event ) { $has_reason = $has_reason || '' !== $event['reason']; } if ( ! $has_reason ) { $reason = AgentStamp_Funnel::reason_for_status( self::$status ); if ( 'not_found' === $reason && self::is_probe( self::request()->url ) ) { $reason = ''; } if ( '' !== $reason && null !== self::identity() ) { $message = 'The store answered HTTP ' . self::$status . '.'; if ( null !== self::$culprit ) { $message .= ' Sent by ' . self::culprit_name( self::$culprit ) . '.'; } self::push( '' !== self::$stage ? self::$stage : 'browse', $reason, $message ); } } } if ( empty( self::$events ) ) { return; } $cart = 0.0; if ( function_exists( 'WC' ) && isset( WC()->cart ) && is_object( WC()->cart ) ) { $cart = (float) WC()->cart->get_total( 'edit' ); } $rows = array(); $seen = array(); foreach ( self::$events as $event ) { if ( 0.0 === (float) $event['value'] && $cart > 0 && AgentStamp_Funnel::rank( $event['stage'] ) >= 2 ) { $event['value'] = $cart; } $event['status'] = self::$status; $key = $event['session'] . '|' . $event['stage'] . '|' . $event['reason'] . '|' . $event['ref']; if ( isset( $seen[ $key ] ) ) { // The same step twice in one request keeps the larger value: the classic checkout // records the submit before the order total is known. $rows[ $seen[ $key ] ]['value'] = max( (float) $rows[ $seen[ $key ] ]['value'], (float) $event['value'] ); continue; } $seen[ $key ] = count( $rows ); $rows[] = $event; } self::$events = array(); AgentStamp_Events::insert( $rows ); } /** * Funnel summary for the last days, cached for ten minutes. * * @param int $days * @return array */ public static function summary( $days ) { $key = 'agent_stamp_funnel_' . (int) $days; $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } $summary = AgentStamp_Funnel::summarize_sessions( AgentStamp_Events::sessions( time() - (int) $days * DAY_IN_SECONDS ) ); // An empty week is not cached, so the first agent shows up as soon as it visits. if ( ! empty( $summary['sessions'] ) || ! empty( $summary['scripts'] ) ) { set_transient( $key, $summary, 10 * MINUTE_IN_SECONDS ); } return $summary; } }