<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hamdi Bk</title>
    <description>The latest articles on DEV Community by Hamdi Bk (@opti-behavior).</description>
    <link>https://dev.to/opti-behavior</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4058277%2Fec0380d9-d438-4b8c-9ec4-de36c714d1a5.jpg</url>
      <title>DEV Community: Hamdi Bk</title>
      <link>https://dev.to/opti-behavior</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/opti-behavior"/>
    <language>en</language>
    <item>
      <title>WooCommerce Conversion Tracking: Find the Step That Loses Sales</title>
      <dc:creator>Hamdi Bk</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:11:30 +0000</pubDate>
      <link>https://dev.to/opti-behavior/woocommerce-conversion-tracking-find-the-step-that-loses-sales-1dng</link>
      <guid>https://dev.to/opti-behavior/woocommerce-conversion-tracking-find-the-step-that-loses-sales-1dng</guid>
      <description>&lt;p&gt;Most WooCommerce stores know what sold. Few know where the buyers who did not buy stopped. Below is the guide we published on optiuser.com: the five steps to track, how to set them up with GA4 or inside WordPress, and the four leaks they usually reveal. Disclosure: we build Opti-Behavior, the plugin mentioned in some sections.&lt;/p&gt;

&lt;p&gt;WooCommerce conversion tracking means counting how many visitors reach each step of a purchase, from the product page to the paid order, so you can see where sales are lost. WooCommerce reports tell you what sold. They do not tell you who almost bought. This guide shows how to set up WooCommerce conversion tracking with the tools you already have, then the four problems it usually reveals and how to fix each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt;  track five steps (product page, add to cart, cart, checkout, order received), compare each step with the one before, and work on the biggest drop first. Google Analytics 4 can do it once your store sends ecommerce events; a WordPress funnel can do it from page visits. Then check the four usual leaks: products viewed but not added, checkouts started but not paid, payments that fail, and the product pages that lose the most sales.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WooCommerce reports show, and what they miss
&lt;/h2&gt;

&lt;p&gt;The WooCommerce Analytics menu lists revenue, orders, products, categories, coupons and stock. It answers “what did we sell?”. It does not count the visits that did not become an order, so it cannot give you a conversion rate per step.&lt;/p&gt;

&lt;p&gt;A store owner usually feels the problem as “traffic is fine, orders are not”. Conversion tracking turns that feeling into four precise questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which product pages are seen but rarely added to cart?&lt;/li&gt;
&lt;li&gt;At which step between cart and order do buyers leave?&lt;/li&gt;
&lt;li&gt;Do payments fail, with which payment method and which error?&lt;/li&gt;
&lt;li&gt;Which of these problems costs the most?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to set up WooCommerce conversion tracking
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Write down the steps
&lt;/h3&gt;

&lt;p&gt;Use the same five steps whatever the tool: product page, add to cart, cart page, checkout page, order received page. For the last step, use a rule like “URL contains order-received”, because the real confirmation address contains the order number.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. With Google Analytics 4
&lt;/h3&gt;

&lt;p&gt;GA4 needs the ecommerce events &lt;code&gt;view_item&lt;/code&gt;, &lt;code&gt;add_to_cart&lt;/code&gt;, &lt;code&gt;begin_checkout&lt;/code&gt; and &lt;code&gt;purchase&lt;/code&gt;. Google’s official WooCommerce integration sends them. Then build a Funnel exploration in the Explore section with those events as steps. If you also run ads, Google Ads and Meta each need their own purchase conversion, usually sent by their official WooCommerce extensions. Check how each one fits your consent setup before you turn it on.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Inside WordPress, from page visits
&lt;/h3&gt;

&lt;p&gt;A funnel built from page visits needs no event setup: it counts the visitors who reach each page in order. In &lt;a href="https://optiuser.com/blog/wordpress-conversion-funnel-plugin/" rel="noopener noreferrer"&gt;our guide to WordPress conversion funnels&lt;/a&gt; we show how it works. Opti-Behavior, our plugin, detects WooCommerce and suggests a Purchase Funnel (Shop, Product page, Cart, Checkout, Order received) with your real cart and checkout addresses filled in. This is in the free plugin.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Test the tracking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Place a test order on a phone and on a computer, and check that each step counts it once.&lt;/li&gt;
&lt;li&gt;Exclude your own visits and your team’s, or they will hide the real drop.&lt;/li&gt;
&lt;li&gt;Compare the order count of the tracking with WooCommerce &amp;gt; Orders for the same days. A large gap means a step is not measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Problem 1: products viewed but not added to cart
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sign:&lt;/strong&gt;  a product gets visits from search or ads, but sells little.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check by hand:&lt;/strong&gt;  for each product, divide adds to cart by product page views over the same period, then sort from the lowest. Open the worst pages on a phone: the price, the delivery cost, the stock and the add to cart button should be visible without scrolling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Opti-Behavior shows (Free):&lt;/strong&gt;  the Shop screen of our &lt;a href="https://optiuser.com/opti-behavior/ob-feature-shop/" rel="noopener noreferrer"&gt;WooCommerce analytics&lt;/a&gt; lists, for each product, product views, adds to cart, paid orders and lost visits (product page views that did not lead to an add to cart), worst product first. A product with adds to cart but no measured product view shows “Not measured” instead of 0. The click heatmap of the product page, also free, shows whether visitors click the gallery, the tabs or the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: checkouts started but not paid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sign:&lt;/strong&gt;  carts are filled, the checkout page gets visits, few orders arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check by hand:&lt;/strong&gt;  over the same days, compare visits to the checkout page with visits to the order received page. Then filter WooCommerce &amp;gt; Orders on Pending payment and Failed. A sudden drop after a plugin or theme update points to a broken checkout, not to buyers changing their mind. Our &lt;a href="https://optiuser.com/blog/woocommerce-abandoned-cart/" rel="noopener noreferrer"&gt;WooCommerce abandoned cart guide&lt;/a&gt; lists the settings to check first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Opti-Behavior shows:&lt;/strong&gt;  the funnel shows where visitors drop off between steps, with a device filter (Free). &lt;a href="https://optiuser.com/opti-behavior/ob-feature-smart-insights/" rel="noopener noreferrer"&gt;Smart Insights&lt;/a&gt; turns that funnel into a card that names the step where visitors leave, ranked by visitors lost (Free). Opti-Behavior Pro adds dedicated WooCommerce cards (product page to cart, cart to checkout, checkout to purchase), and &lt;a href="https://optiuser.com/opti-behavior/ob-feature-recordings/" rel="noopener noreferrer"&gt;session recordings&lt;/a&gt; show the shop events (add to cart, checkout, paid order, payment failure) in the list and the player, with a “Shop action” filter. Session-linked shop events are recorded only in Full tracking mode, after the visitor’s consent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfehofwo8xtx0aii65jn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfehofwo8xtx0aii65jn.png" alt="Smart Insights (Free) flags the funnel step where visitors drop off and says what to open next." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Smart Insights (Free) flags the funnel step where visitors drop off and says what to open next.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: payments that fail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sign:&lt;/strong&gt;  orders in Failed status, buyers who write “your payment does not work”, a payment method that suddenly sells nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check by hand:&lt;/strong&gt;  open WooCommerce &amp;gt; Status &amp;gt; Logs and read the log of each payment gateway for the days of the problem. Place a test order with each payment method, on a phone too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Opti-Behavior shows (Pro):&lt;/strong&gt;  on the Shop screen, payment errors grouped by error type and payment method, with the failed attempts, the orders concerned and their estimated cost (orders not paid later). In Full tracking mode, a recording of a failed payment can be watched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: which product loses the most sales
&lt;/h2&gt;

&lt;p&gt;A product with many lost visits is not always the most costly one: a cheap product with high traffic can cost less than an expensive one with fewer visits. Rank by money, not by visits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check by hand:&lt;/strong&gt;  lost visits × your conversion rate × your average order value. Example with round numbers: 400 lost visits × 2 % × 50 = about 400 in sales at stake for the period. It is an estimate, not a promise: use it to choose what to fix first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Opti-Behavior shows (Pro):&lt;/strong&gt;  the Shop screen gives the estimated lost sales, as an indicative range, per product and for the period, from the lost visits, your conversion rate and your average order. The Shop screen shows lost sales as a range, or "Not enough data" when there are too few orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  WooCommerce reports by e-mail
&lt;/h2&gt;

&lt;p&gt;Conversion tracking only helps if someone reads it. Instead of opening the admin every day, get the figures sent to you. In Opti-Behavior, the &lt;a href="https://optiuser.com/opti-behavior/ob-feature-reports/" rel="noopener noreferrer"&gt;scheduled e-mail report&lt;/a&gt; (daily, weekly or monthly) is free and includes a Shop block with the store’s figures. In Pro, the same block adds “Money at stake”, the estimated lost sales of the period, marked as an estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free or Pro: summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Products viewed but not added&lt;/td&gt;
&lt;td&gt;Shop screen: views, adds to cart, paid orders, lost visits per product&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where buyers leave&lt;/td&gt;
&lt;td&gt;Suggested purchase funnel, drop-off per step, click heatmaps&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A purchase step with a large drop&lt;/td&gt;
&lt;td&gt;Smart Insights card naming the step (Free); dedicated WooCommerce cards per purchase step (Pro)&lt;/td&gt;
&lt;td&gt;Free / Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why buyers leave&lt;/td&gt;
&lt;td&gt;Recordings with shop events (Full mode, after consent) and the “Shop action” filter&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments that fail&lt;/td&gt;
&lt;td&gt;Payment errors by type and payment method, with estimated cost&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What each product costs&lt;/td&gt;
&lt;td&gt;Estimated lost sales per product (indicative range)&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reports by e-mail&lt;/td&gt;
&lt;td&gt;Shop block with figures / “Money at stake”&lt;/td&gt;
&lt;td&gt;Free / Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full list of each version is on the &lt;a href="https://optiuser.com/opti-behavior/pricing/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check after a fix
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Change one thing at a time: the product page, the cart, the checkout or a payment method.&lt;/li&gt;
&lt;li&gt;Compare the same number of days before and after, on the same step of the funnel.&lt;/li&gt;
&lt;li&gt;Split by device: a fix on desktop can leave the problem on phones.&lt;/li&gt;
&lt;li&gt;Check WooCommerce &amp;gt; Orders too: more checkouts without more paid orders means the leak moved to the payment step.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does WooCommerce track conversions on its own?
&lt;/h3&gt;

&lt;p&gt;It records orders, revenue and products sold. It does not count the visitors who saw a product or started a checkout without buying, so it cannot show a conversion rate per step without another tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Google Analytics for WooCommerce conversion tracking?
&lt;/h3&gt;

&lt;p&gt;No. GA4 works once your store sends ecommerce events. A funnel built from page visits inside WordPress gives the step-by-step drop without that setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between conversion tracking and WooCommerce reports?
&lt;/h3&gt;

&lt;p&gt;WooCommerce reports count what was sold. Conversion tracking counts what happened before: visits, adds to cart, checkouts and payments, step by step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which WooCommerce analytics are free in Opti-Behavior?
&lt;/h3&gt;

&lt;p&gt;The Shop screen, the purchase funnel, click heatmaps, the Smart Insights card that names the funnel step where visitors leave, and the Shop block of the e-mail report. Dedicated WooCommerce Smart Insights cards, estimated lost sales, payment errors and recordings with shop events are in Pro.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the step where your store loses sales
&lt;/h3&gt;

&lt;p&gt;Install the free plugin, open Shop to see the products viewed but not added, then confirm the suggested purchase funnel.&lt;/p&gt;

&lt;p&gt;Try it on your store: &lt;a href="https://optiuser.com/blog/woocommerce-conversion-tracking/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=oct26-problem-keyword&amp;amp;utm_content=woocommerce-conversion-tracking" rel="noopener noreferrer"&gt;https://optiuser.com/blog/woocommerce-conversion-tracking/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=oct26-problem-keyword&amp;amp;utm_content=woocommerce-conversion-tracking&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drafted with AI assistance, reviewed by the Opti-Behavior Team.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>wordpress</category>
      <category>ecommerce</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Heatmap Not Working After Installing a Cache Plugin? Here's Exactly What Breaks It</title>
      <dc:creator>Hamdi Bk</dc:creator>
      <pubDate>Tue, 13 Oct 2026 10:52:28 +0000</pubDate>
      <link>https://dev.to/opti-behavior/heatmap-not-working-after-installing-a-cache-plugin-heres-exactly-what-breaks-it-37ha</link>
      <guid>https://dev.to/opti-behavior/heatmap-not-working-after-installing-a-cache-plugin-heres-exactly-what-breaks-it-37ha</guid>
      <description>&lt;p&gt;&lt;em&gt;Last verified: August 2026. Filter names and behaviours below were checked against each optimizer’s own documentation or source. Re-check before relying on them — optimizers change these between releases.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Heatmap not working after installing a cache plugin? Your heatmap page is empty. The session counter says zero. There is nothing in the browser console, no failed request in the Network tab, no warning in your dashboard. As far as every screen you can look at is concerned, nothing is wrong.&lt;/p&gt;

&lt;p&gt;It isn’t your analytics tool. It’s your optimizer.&lt;/p&gt;

&lt;p&gt;Cache and performance plugins rewrite, merge, defer and delay JavaScript. Tracking scripts are JavaScript. When an optimizer decides your tracker is just another file to bundle, the tracker stops executing — and because nothing errors, nothing tells you. You keep browsing your own site, the pages load faster than ever, and you lose weeks of behavioural data before you happen to open the heatmap and find it blank.&lt;/p&gt;

&lt;p&gt;This applies to every tool in the category. Hotjar, Microsoft Clarity, Matomo, Opti-Behavior, your own custom beacon — same mechanism, same silence.&lt;/p&gt;

&lt;p&gt;Click around a real dashboard before you install anything&lt;/p&gt;

&lt;p&gt;A live WordPress site with real traffic, real heatmaps and real recordings. No signup, no credit card, no email.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://demo.optiuser.com/" rel="noopener noreferrer"&gt;Open the live demo →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Heatmap Not Working? The 60-Second Diagnostic
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dzrs9z92eb5c1infok5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dzrs9z92eb5c1infok5.gif" width="700" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A heatmap not working right after a cache plugin install is almost always a script problem, so start here. Open your site in a private window, as a logged-out visitor, and run these three checks in order. Each one narrows the failure to a different cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is the script in the HTML at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;View source (Ctrl+U — not the DevTools Elements tab, which shows the DOM after JavaScript has run). Search for your tracker’s filename.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not there&lt;/strong&gt; → the script was never enqueued. This is not an optimizer problem. Check that the plugin is active, that you aren’t excluded from tracking as a logged-in admin, and that consent (if required) has been granted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There, but the &lt;code&gt;src&lt;/code&gt; points at something like &lt;code&gt;/wp-content/cache/min/1/xxxx.js&lt;/code&gt;&lt;/strong&gt; → your tracker was merged into a combined bundle. Go to the Minify/Combine section below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There, but the &lt;code&gt;type&lt;/code&gt; attribute is not &lt;code&gt;text/javascript&lt;/code&gt;&lt;/strong&gt; — you see &lt;code&gt;type="rocketlazyloadscript"&lt;/code&gt;, &lt;code&gt;type="lazyload"&lt;/code&gt;, &lt;code&gt;data-src&lt;/code&gt; instead of &lt;code&gt;src&lt;/code&gt;, or similar → the script is being &lt;em&gt;delayed&lt;/em&gt;. Go to the Delay section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There, with &lt;code&gt;defer&lt;/code&gt; or &lt;code&gt;async&lt;/code&gt; added&lt;/strong&gt; → go to the Defer section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Does the request actually leave the browser?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DevTools → Network → filter by XHR/Fetch, then click around and scroll for ten seconds. You are looking for the POST that ships events back to your site (&lt;code&gt;admin-ajax.php&lt;/code&gt;, a REST route, or your vendor’s endpoint).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No request at all&lt;/strong&gt; → the tracker never initialised. Confirmed script-level problem, continue below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request fires but returns 4xx&lt;/strong&gt; → different problem: expired nonce or blocked REST route, usually caused by a &lt;em&gt;page&lt;/em&gt; cache serving stale HTML rather than a JS optimizer. See “The page-cache trap”.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request fires and returns 200&lt;/strong&gt; → your tracking works. Your problem is elsewhere: filtering, bot exclusion, or admin-user exclusion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Is the config object defined?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the console, type the name of the global config variable your tracker uses. Most tools localize their settings into a global (&lt;code&gt;window.hj&lt;/code&gt;, &lt;code&gt;window.clarity&lt;/code&gt;, &lt;code&gt;window.optiBehaviorHeatmapConfig&lt;/code&gt;, etc.).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;undefined&lt;/code&gt; while the script file itself is present&lt;/strong&gt; → this is the failure almost everyone misses. The external &lt;code&gt;.js&lt;/code&gt; file was correctly excluded from optimization, but the small &lt;strong&gt;inline&lt;/strong&gt; &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block containing its settings was not. The tracker loads, finds no configuration, and exits silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third case is worth stating plainly, because it wastes more hours than the other two combined: &lt;strong&gt;excluding your tracker’s file is only half the job. The inline config block has to be excluded too, and it is matched by a different rule.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways an optimizer kills a tracker
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Delay JavaScript until user interaction
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F95wntrfn638h0vwyo9j9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F95wntrfn638h0vwyo9j9.png" width="799" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most destructive one, and the most popular — it’s the single biggest PageSpeed win an optimizer can sell you. All non-essential JS is held back until the visitor moves the mouse, scrolls, or touches the screen.&lt;/p&gt;

&lt;p&gt;For a tracker, that is fatal by design: everything that happens &lt;em&gt;before&lt;/em&gt; the first interaction is exactly what you were trying to measure. Landing, above-the-fold reading time, immediate bounces, and any click that lands before the delay releases — all lost. A visitor who arrives and leaves in four seconds without touching anything is invisible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;defer&lt;/code&gt; moves execution to after HTML parsing. Less brutal than delay, but it inverts load order. If your tracker depends on another script (an ID broker, a consent layer, a queue stub) and only one of the two is deferred, the dependent one runs first and finds nothing.&lt;/p&gt;

&lt;p&gt;The characteristic symptom is not empty data — it’s &lt;em&gt;wrong&lt;/em&gt; data. A tracker whose session-ID source isn’t ready when it runs will usually fall back to minting a fresh random ID. You get one brand-new “session” per page view: inflated visitor counts, average session duration collapsing toward zero, funnels that never link two steps together. Everything looks alive, and everything is wrong.&lt;/p&gt;

&lt;p&gt;Empty heatmaps get noticed. This doesn’t. Check for it specifically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minify and combine
&lt;/h3&gt;

&lt;p&gt;Several JS files are concatenated into one bundle and rewritten. Trackers break here for two reasons: the rewrite can mangle the code, and the file the browser now loads has a different URL — so any exclusion rule you wrote based on the original filename no longer matches anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concatenate / lazy-load / render-blocking removal
&lt;/h3&gt;

&lt;p&gt;Feature names differ, mechanism is the same as the two above. Jetpack Boost concatenates. Cloudflare Rocket Loader defers everything through its own scheduler. NitroPack does the work on an external service, so no local rule can reach it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The page-cache trap (bonus, and it’s a real one)
&lt;/h3&gt;

&lt;p&gt;Full-page caches — WP Super Cache, Cache Enabler, and the page-cache half of &lt;a href="https://wp-rocket.me/" rel="noopener noreferrer"&gt;WP Rocket&lt;/a&gt; or LiteSpeed — don’t touch your JavaScript at all. They cause a different failure: &lt;strong&gt;stale HTML with an expired security nonce&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your tracker’s POST includes a nonce that WordPress generated when the page was rendered. If that page has been sitting in the cache for two days, the nonce is dead and the server rejects every event with a 403. Same silence, completely different cause. If diagnostic step 2 showed a 4xx, this is your problem — not JS optimization.&lt;/p&gt;

&lt;p&gt;The same trap ruins &lt;a href="https://optiuser.com/blog/self-hosted-ab-testing-wordpress-technical-guide/" rel="noopener noreferrer"&gt;A/B tests&lt;/a&gt;. A page cached while showing variant B gets served to every subsequent visitor, including the ones assigned variant A. Your test results become noise. The fix is a cache-vary cookie (below), not an exclusion rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes, plugin by plugin
&lt;/h2&gt;

&lt;p&gt;Add these to your child theme’s &lt;code&gt;functions.php&lt;/code&gt; or a small mu-plugin. Replace &lt;code&gt;your-tracker.js&lt;/code&gt; and &lt;code&gt;yourTrackerConfig&lt;/code&gt; with your actual filename and inline config variable name.&lt;/p&gt;

&lt;p&gt;If you’d rather not write code: every one of these plugins exposes the same lists in its settings UI (usually under &lt;em&gt;File Optimization&lt;/em&gt; → &lt;em&gt;Excluded files&lt;/em&gt; or similar). The code path is more reliable because a plugin update or a settings reset can’t quietly wipe it.&lt;/p&gt;

&lt;h3&gt;
  
  
  WP Rocket
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Minify/Combine JS and Defer JS — regex fragments matched against the URL.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_exclude_js'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'my_tracker_excludes'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_exclude_defer_js'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'my_tracker_excludes'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;my_tracker_excludes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$excluded&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$excluded&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'(.*)your-tracker\.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$excluded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Delay JavaScript execution — plain strings, matched against BOTH&lt;/span&gt;
&lt;span class="c1"&gt;// external script URLs and inline script content.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_delay_js_exclusions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$excluded&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$excluded&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$excluded&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'yourTrackerConfig'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the inline config block&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$excluded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Inline config blocks, for Defer and Combine separately.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_defer_inline_exclusions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'yourTrackerConfig'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_excluded_inline_js_content'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'yourTrackerConfig'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The trap that costs people weeks:&lt;/strong&gt; &lt;code&gt;rocket_exclude_js&lt;/code&gt; and &lt;code&gt;rocket_exclude_defer_js&lt;/code&gt; entries are &lt;strong&gt;not&lt;/strong&gt; self-delimited regex patterns. WP Rocket joins your entries with &lt;code&gt;|&lt;/code&gt; and wraps the whole set in its own &lt;code&gt;#(...)#&lt;/code&gt; delimiters before matching. Write &lt;code&gt;/my-tracker\.js/&lt;/code&gt; with slashes, as you would for &lt;code&gt;preg_match()&lt;/code&gt;, and those slashes become &lt;strong&gt;literal characters the URL must contain&lt;/strong&gt;. A real enqueued URL ends in &lt;code&gt;.js?ver=1.2.3&lt;/code&gt;, never in a literal &lt;code&gt;/&lt;/code&gt;, so the pattern silently matches nothing — and you will swear you excluded the file, because you did.&lt;/p&gt;

&lt;p&gt;Emit bare fragments. No delimiters.&lt;/p&gt;

&lt;h3&gt;
  
  
  LiteSpeed Cache
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'litespeed_optimize_js_excludes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Defer exclusion — matched against inline content too, so feed both.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'litespeed_optm_js_defer_exc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'yourTrackerConfig'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SiteGround Optimizer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sgo_javascript_combine_exclude'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'my_tracker_handles'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sgo_js_minify_exclude'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="s1"&gt;'my_tracker_handles'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sgo_js_async_exclude'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="s1"&gt;'my_tracker_handles'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;my_tracker_handles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$exclude_list&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$exclude_list&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker-handle'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// enqueue HANDLE, not filename&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$exclude_list&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sgo_javascript_combine_excluded_inline_content'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'yourTrackerConfig'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; all three SG Optimizer filters compare against the &lt;strong&gt;&lt;code&gt;wp_enqueue_script&lt;/code&gt; handle&lt;/strong&gt;, not the filename. Their own documentation shows &lt;code&gt;$exclude_list[] = 'script-handle'&lt;/code&gt;. Pass &lt;code&gt;your-tracker.js&lt;/code&gt; and the filter runs, returns cleanly, and excludes precisely nothing. This one is invisible in testing because the code &lt;em&gt;looks&lt;/em&gt; correct.&lt;/p&gt;

&lt;p&gt;If you don’t know your handle: search the plugin’s source for &lt;code&gt;wp_enqueue_script(&lt;/code&gt; — it’s the first argument.&lt;/p&gt;

&lt;h3&gt;
  
  
  Autoptimize
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'autoptimize_filter_js_exclude'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$exclude&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$exclude&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;', your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the format: a &lt;strong&gt;comma-separated string&lt;/strong&gt;, not an array. Returning an array here breaks the filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Perfmatters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'delay'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'defer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'minify'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$stage&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"perfmatters_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_js_exclusions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  WP-Optimize
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp-optimize-minify-default-exclusions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; matched case-insensitively as a substring &lt;strong&gt;against the full script URL&lt;/strong&gt;. Enqueue handles never appear in a URL, so passing a handle here silently does nothing. The mirror image of the SiteGround mistake — which is exactly why people who fix one site break the next.&lt;/p&gt;

&lt;h3&gt;
  
  
  W3 Total Cache
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'w3tc_minify_js_do_tag_minification'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$do&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$do&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Breeze
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'breeze_js_dontmove'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker.js'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Jetpack Boost
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'js_do_concat'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$do_concat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker-handle'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$do_concat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concatenation is filterable. &lt;strong&gt;Render-blocking/defer is not&lt;/strong&gt; — Boost’s only documented exclusion mechanism there is the &lt;code&gt;data-jetpack-boost="ignore"&lt;/code&gt; tag attribute. See below.&lt;/p&gt;

&lt;h3&gt;
  
  
  NitroPack, WP Fastest Cache, FlyingPress, Hummingbird, Swift Performance
&lt;/h3&gt;

&lt;p&gt;No reliable PHP exclusion filter is publicly documented for these. NitroPack in particular optimizes on its own external service, so no local hook can intercept it. Use the settings UI, and rely on tag attributes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The universal fallback: tag attributes
&lt;/h3&gt;

&lt;p&gt;When no filter exists, mark the script tag itself. Six attributes cover most of the ecosystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'script_loader_tag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'your-tracker-handle'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'&amp;lt;script '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'&amp;lt;script nowprocket data-cfasync="false" data-noptimize="1" '&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'data-no-optimize="1" data-no-defer="1" data-jetpack-boost="ignore" '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$tag&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nowprocket&lt;/code&gt; — WP Rocket&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-cfasync="false"&lt;/code&gt; — Cloudflare Rocket Loader&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-noptimize="1"&lt;/code&gt; — Autoptimize, Breeze (no hyphen)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-no-optimize="1"&lt;/code&gt; — LiteSpeed and others (with hyphen — yes, you need both)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-no-defer="1"&lt;/code&gt; — generic&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-jetpack-boost="ignore"&lt;/code&gt; — Jetpack Boost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important limitation:&lt;/strong&gt; attributes only protect optimizations that inspect the rendered tag. Optimizations that match against the &lt;strong&gt;URL&lt;/strong&gt; — WP Rocket’s minify and defer among them — never see these attributes. Attributes are a safety net, not a substitute for the filters above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the page-cache trap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WP Rocket: vary the page cache by a cookie your tracker sets.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rocket_cache_dynamic_cookies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cookies&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cookies&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_ab_variant'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cookies&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// LiteSpeed equivalent.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'litespeed_vary_cookies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cookies&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cookies&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_ab_variant'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cookies&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For expired nonces on long-cached pages, the durable fix belongs in your tracker: retry once on a 403 after re-reading a fresh nonce. If you’re using a hosted tool, you can only shorten the cache lifetime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix properly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzwb809se864723oy098.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzwb809se864723oy098.png" alt="Heatmap not working after installing a cache plugin" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before you trust any fix for a heatmap not working, verify it properly. Applying an exclusion and seeing data appear proves nothing on its own — you may simply have generated traffic while logged in, or the page you tested may not have been cached yet. Do this instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Purge every cache.&lt;/strong&gt; Plugin cache, host cache (SiteGround, Kinsta, WP Engine all have their own), and Cloudflare if it’s in front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reload as a logged-out visitor&lt;/strong&gt; in a private window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View source and confirm three things:&lt;/strong&gt; your tracker’s &lt;code&gt;src&lt;/code&gt; points at the original file, not a &lt;code&gt;/cache/min/&lt;/code&gt; bundle; the &lt;code&gt;type&lt;/code&gt; is &lt;code&gt;text/javascript&lt;/code&gt;; the inline config block is present and unmangled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the console, check the config global is defined.&lt;/strong&gt; Not &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In the Network tab, confirm the event POST fires and returns 200.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then check the dashboard&lt;/strong&gt; — with the same date filter as your test, and with any “exclude admins” setting in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-test on the pages that matter&lt;/strong&gt;, not just the homepage. Checkout, product, and landing pages often have different scripts and different cache rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-test after your next optimizer update.&lt;/strong&gt; Exclusion lists get reset by major version upgrades more often than you’d expect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Choosing a tool? See our &lt;a href="https://optiuser.com/blog/wordpress-heatmap-plugins-comparison/" rel="noopener noreferrer"&gt;WordPress heatmap plugin comparison&lt;/a&gt; of 8 options.&lt;/p&gt;

&lt;h2&gt;
  
  
  You should never have had to read this
&lt;/h2&gt;

&lt;p&gt;A heatmap not working for weeks with no warning is the real failure here. Every step above is diagnosis you performed because your tool stayed quiet. That’s the real defect. Not the optimizer — optimizers are doing exactly what you installed them to do. The defect is that a tracking tool can lose 100% of its data for six weeks and report nothing but an empty chart.&lt;/p&gt;

&lt;p&gt;There’s no technical reason for that silence. The tool knows the site had traffic. It knows which of its modules are enabled. If traffic is arriving and a module’s event table stays at zero, it has everything it needs to raise its hand.&lt;/p&gt;

&lt;p&gt;That’s the check we built into Opti-Behavior in 1.7.1. Every six hours it compares “the site received page views in the last 24 hours” against “this module recorded at least one event”, per module. If traffic is flowing and events aren’t, it shows an admin notice, names which modules went dark, and identifies the active cache or optimization plugin most likely responsible — from a list of nineteen. Sites with no traffic never alert, so a quiet site can’t trigger a false alarm. It reads nothing but &lt;code&gt;COUNT(*)&lt;/code&gt; aggregates, adds no frontend code, and runs at most once every six hours.&lt;/p&gt;

&lt;p&gt;Click around a real dashboard before you install anything&lt;/p&gt;

&lt;p&gt;A live WordPress site with real traffic, real heatmaps and real recordings. No signup, no credit card, no email.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://demo.optiuser.com/" rel="noopener noreferrer"&gt;Open the live demo →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Alongside it, the exclusion rules on this page ship pre-registered for all the optimizers listed above — including the handle-versus-filename distinction that makes the SiteGround and WP-Optimize filters no-ops when you get them backwards. We had both of those backwards ourselves until July 2026. That’s how we know how invisible it is.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://optiuser.com/" rel="noopener noreferrer"&gt;Opti-Behavior&lt;/a&gt; is a self-hosted WordPress plugin: heatmaps, funnels, session recordings, A/B testing and form analytics, with the data staying in your own database. &lt;a href="https://wordpress.org/plugins/opti-behavior/" rel="noopener noreferrer"&gt;See the plugin on WordPress.org →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Whatever you use, apply the exclusions, and then go check the last thirty days of data you assumed was being collected. Using Opti-Behavior? Run &lt;a href="https://optiuser.com/docs/verify-tracking-first-10-minutes/" rel="noopener noreferrer"&gt;First 10 minutes: verify tracking is working&lt;/a&gt;, then see &lt;a href="https://optiuser.com/docs/cache-plugin-compatibility/" rel="noopener noreferrer"&gt;Cache plugin compatibility&lt;/a&gt; for what is handled automatically.&lt;/p&gt;

&lt;p&gt;This article was drafted with the help of AI tools and reviewed by The Opti-Behavior Team.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cookieless Analytics for WordPress: How Session-Based Tracking Works, and How to Prove a Plugin Sets Zero Cookie</title>
      <dc:creator>Hamdi Bk</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:38:02 +0000</pubDate>
      <link>https://dev.to/opti-behavior/cookieless-analytics-for-wordpress-how-session-based-tracking-works-and-how-to-prove-a-plugin-16go</link>
      <guid>https://dev.to/opti-behavior/cookieless-analytics-for-wordpress-how-session-based-tracking-works-and-how-to-prove-a-plugin-16go</guid>
      <description>&lt;p&gt;Every WordPress analytics plugin says "GDPR compliant" on its page. Most of them still leave a cookie. Some leave nothing in the cookie jar and hide a note in &lt;code&gt;localStorage&lt;/code&gt; instead. A few build a fingerprint of your device.&lt;/p&gt;

&lt;p&gt;This post explains the one design that really needs no consent banner, and shows you how to test any plugin yourself in one minute.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbvcv04n5jly0xz7i7q7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbvcv04n5jly0xz7i7q7.png" alt=" wp Plugin Sets Zero Cookie" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie is the problem, not the counting
&lt;/h2&gt;

&lt;p&gt;Europe's privacy rule (ePrivacy, Art. 5(3)) is simple: if you &lt;strong&gt;write or read something on the visitor's device&lt;/strong&gt;, you must ask first. Counting pageviews is not the problem. Leaving a note in the browser is.&lt;/p&gt;

&lt;p&gt;Three ways to leave a note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a cookie (&lt;code&gt;document.cookie&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;other browser storage: &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt;, IndexedDB&lt;/li&gt;
&lt;li&gt;a fingerprint: nothing is written, but the script reads your screen size, fonts and browser details and mixes them into a secret ID. Privacy authorities treat this as worse than a cookie, because you can't see it or delete it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plugin that does none of the three can count visitors without a banner. France's privacy authority (CNIL) already allows this kind of audience measurement without consent, under conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How session-based tracking works
&lt;/h2&gt;

&lt;p&gt;The idea: the visitor's ID lives only in the page's memory, while the tab is open. It is never saved anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified. Runs once per page load.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// exists only in this variable&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wp-json/analytics/v1/event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pageview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageY&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Close the tab, the ID disappears. There is nothing left to ask permission for.&lt;/p&gt;

&lt;p&gt;On the server, WordPress writes the data into its own database. Before saving, it cuts the visitor's internet address (IP) short, so nobody can be identified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified. IPv4: zero the last number. IPv6: keep the first 48 bits.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;ob_anonymize_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_VALIDATE_IP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_FLAG_IPV4&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\.\d+$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;inet_pton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;inet_ntop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still know which country the visitor is from. You can't find the person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you lose:&lt;/strong&gt; the plugin can't be sure that today's visitor is the same person as yesterday. It makes a good guess. For traffic, funnels and heatmaps, that's enough. For following one person over months, it isn't, and you shouldn't do that without asking anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you gain:&lt;/strong&gt; the 35–45% of European visitors who click "Reject" are now counted. And they're not just anyone: Firefox and Brave users, people with ad blockers, developers. If your visitors are technical, cookie-based analytics is measuring the wrong crowd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test any plugin in 60 seconds
&lt;/h2&gt;

&lt;p&gt;Open your site in a private window, press F12, and paste this in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run before and after the analytics script loads&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dbs&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IndexedDB:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dbs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open the Network tab and look at what the plugin's script sends out. If you see scrambled &lt;code&gt;navigator&lt;/code&gt; or canvas data leaving, that's a fingerprint.&lt;/p&gt;

&lt;p&gt;Try it on Google Analytics, Hotjar or Clarity: you'll see &lt;code&gt;_ga&lt;/code&gt;, &lt;code&gt;_hjSession*&lt;/code&gt;, &lt;code&gt;_clck&lt;/code&gt;. Try it on a real cookieless plugin: every list is empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I land
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://optiuser.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cookieless-session" rel="noopener noreferrer"&gt;Opti-Behavior&lt;/a&gt;) on exactly this model: ID in memory only, no &lt;code&gt;localStorage&lt;/code&gt;, no fingerprint, IP cut short before saving, everything stored in your WordPress database with no outside server, a ~15 KB script that never slows the page, robot filtering. Heatmaps and funnels are free. Session recordings (replaying one visitor) are Pro and run on a separate track, because replaying a person does need a "yes", cookies or not.&lt;/p&gt;

&lt;p&gt;Full write-up, including what the CNIL says and why fingerprinting is a trap: &lt;a href="https://optiuser.com/blog/wordpress-visitor-tracking-without-cookies/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cookieless-session" rel="noopener noreferrer"&gt;How to Track WordPress Visitors Without Cookies (GDPR Compliant)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Run the 60-second test on your current setup and post what comes back. I'm collecting examples of "cookieless" plugins that aren't.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>php</category>
    </item>
    <item>
      <title>Your A/B test has three goals and they disagree. Now what?</title>
      <dc:creator>Hamdi Bk</dc:creator>
      <pubDate>Sat, 01 Aug 2026 18:37:51 +0000</pubDate>
      <link>https://dev.to/opti-behavior/your-ab-test-has-three-goals-and-they-disagree-now-what-4hj4</link>
      <guid>https://dev.to/opti-behavior/your-ab-test-has-three-goals-and-they-disagree-now-what-4hj4</guid>
      <description>&lt;p&gt;Every A/B testing tutorial ends the same way: run the test, wait for significance, ship the winner.&lt;/p&gt;

&lt;p&gt;Then you run a real test and variant B converts 12% better on newsletter signups, brings in 4% less revenue per visitor, and bounce is flat. Nothing is significant except the signups. Ship it?&lt;/p&gt;

&lt;p&gt;I spent an embarrassing amount of time on this question while building an A/B engine, and most of what I read online didn't help, because most of it assumes one metric. This post is what I ended up with. It's not novel — the statistics are decades old — but I couldn't find it written down in one place with working code, so here it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the p-value doesn't answer the question you're asking
&lt;/h2&gt;

&lt;p&gt;Two problems, and the second one is the bad one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple comparisons.&lt;/strong&gt; Three metrics at α = 0.05 means roughly a 14% chance of at least one false positive if nothing is actually different. Bonferroni fixes this, but now you need α = 0.017 per metric and your test needs to run three times as long. On a site doing 300 conversions a month that's not a fix, it's a refusal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The p-value is answering a different question.&lt;/strong&gt; It tells you the probability of your data assuming no difference exists. What you actually want to know is: if I ship B, how much do I expect to lose if I'm wrong? Those are not the same question and no amount of Bonferroni turns one into the other.&lt;/p&gt;

&lt;p&gt;There's also the peeking problem — everyone checks the dashboard daily and stops when it goes green, which quietly inflates the false positive rate well past whatever α you wrote down. I'll come back to that, because Bayesian methods do &lt;em&gt;not&lt;/em&gt; magically solve it, whatever you may have read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Posterior first, decision second
&lt;/h2&gt;

&lt;p&gt;For a conversion rate, the Beta-Binomial conjugate pair gives you the posterior in one line. With a uniform prior, after &lt;code&gt;c&lt;/code&gt; conversions out of &lt;code&gt;n&lt;/code&gt; visitors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Beta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No closed-form comparison between two Betas that's worth implementing, so sample.&lt;/p&gt;

&lt;p&gt;PHP has no Beta sampler in core, and no Gamma sampler either, so you build one. Marsaglia–Tsang, which needs a normal sampler underneath:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;normal_sample&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Box–Muller&lt;/span&gt;
    &lt;span class="nv"&gt;$u1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;mt_getrandmax&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$u2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;mt_getrandmax&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;M_PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$u2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;gamma_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$shape&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shape&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;mt_getrandmax&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;gamma_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shape&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$shape&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$shape&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;9.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normal_sample&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;mt_getrandmax&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.0331&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gamma_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gamma_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the single-metric comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$draws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$loss_if_ship_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$cA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nA&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$cA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$pB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$cB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$cB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pB&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$wins&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$loss_if_ship_b&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pA&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$pB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$prob_b_better&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wins&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$expected_loss&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$loss_if_ship_b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers instead of one. &lt;code&gt;$prob_b_better&lt;/code&gt; is the intuitive one everybody quotes. &lt;strong&gt;&lt;code&gt;$expected_loss&lt;/code&gt; is the one that should drive the decision&lt;/strong&gt;: it's the average amount of conversion rate you give up, across the whole posterior, in the worlds where B is actually worse. If that number is 0.0004 and you genuinely don't care about four hundredths of a percentage point, ship B and stop thinking about it, even at 88% probability.&lt;/p&gt;

&lt;p&gt;Pick that threshold &lt;em&gt;before&lt;/em&gt; the test. Write it in the test config. It's the smallest effect you'd bother shipping for, and it forces a conversation about what the test is actually for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revenue per visitor is not a Beta
&lt;/h2&gt;

&lt;p&gt;This is where I initially got it wrong. I treated revenue per visitor as a conversion-like quantity and got posteriors that were far too confident.&lt;/p&gt;

&lt;p&gt;Revenue per visitor is zero-inflated (most visitors buy nothing) and heavy-tailed (one enterprise order distorts everything). A Beta is wrong, a Normal is wrong, a log-normal is closer but still assumes away the zeros.&lt;/p&gt;

&lt;p&gt;I use a bootstrap instead. Resample the observed per-visitor revenue values with replacement, &lt;code&gt;$draws&lt;/code&gt; times, take the mean of each resample. That's your posterior-ish distribution, and it inherits whatever ugly shape your real data has without you having to name it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bootstrap_means&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's O(draws × n) and it will hurt on large samples. Precompute a cumulative array and sample indices in blocks, or bootstrap on a fixed random subsample of 5,000 visitors per arm — the extra Monte Carlo noise is small compared to the sampling noise you're already living with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining metrics without lying to yourself
&lt;/h2&gt;

&lt;p&gt;Now the actual question. Three metrics, three posteriors, one decision.&lt;/p&gt;

&lt;p&gt;The standard answer is an OEC — Overall Evaluation Criterion, from Kohavi's work at Microsoft. One weighted composite, agreed in advance, that the test is scored against. The two things people get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Weight relative uplift, not raw values.&lt;/strong&gt; A conversion rate lives in [0,1], revenue per visitor might be €3.40, bounce rate is a percentage. Summing those with weights is meaningless. Convert each to relative uplift first, so everything is dimensionless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Compute the composite inside the loop.&lt;/strong&gt; This one matters and it's easy to miss. If you compute &lt;code&gt;P(conv_B &amp;gt; conv_A)&lt;/code&gt;, &lt;code&gt;P(rev_B &amp;gt; rev_A)&lt;/code&gt; separately and then combine the summary numbers, you've thrown away the correlation between metrics. The draws are paired: draw &lt;code&gt;i&lt;/code&gt; represents one coherent hypothetical world. Score the composite in that world, then aggregate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$draws&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$w&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'conv'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rev'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bounce'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// must sum to 1&lt;/span&gt;
&lt;span class="nv"&gt;$rev_A&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_means&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$revenue_A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$rev_B&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_means&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$revenue_B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$oec_positive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$oec_loss&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$cA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nA&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$cA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$pB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$cB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$cB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$bA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$bounceA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nA&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$bounceA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$bB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;beta_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$bounceB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$nB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$bounceB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$u_conv&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$pA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$pA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$u_rev&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rev_B&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$rev_A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$rev_A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$u_bounce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$bB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$bA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$bA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// lower bounce is better, so flip&lt;/span&gt;

    &lt;span class="nv"&gt;$oec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'conv'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$u_conv&lt;/span&gt;
         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rev'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$u_rev&lt;/span&gt;
         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'bounce'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$u_bounce&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$oec&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$oec_positive&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$oec_loss&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;$oec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$prob_b_better&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$oec_positive&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$expected_loss&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$oec_loss&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$draws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// in relative-uplift units&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship B when &lt;code&gt;$expected_loss&lt;/code&gt; is below your threshold of caring. Not when &lt;code&gt;$prob_b_better&lt;/code&gt; crosses 95%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix the weights before you look at any data.&lt;/strong&gt; Otherwise you will — I promise you will — nudge them until your preferred variant wins. If you can't decide the weights before the test, you don't have a hypothesis, you have a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails are not goals
&lt;/h2&gt;

&lt;p&gt;Some metrics don't belong in the composite at all. Page load time, JS error rate, checkout failures — these are guardrails, and no amount of signup uplift should be allowed to buy a regression in them.&lt;/p&gt;

&lt;p&gt;They get a veto, not a weight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p95_load_B&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$p95_load_A&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// no composite, no discussion&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mixing a guardrail into the OEC is how you ship a variant that converts 8% better and breaks checkout for Safari.&lt;/p&gt;

&lt;h2&gt;
  
  
  The peeking problem is still there
&lt;/h2&gt;

&lt;p&gt;You will read that Bayesian methods let you monitor continuously without penalty. That's half true and the half that's false will burn you.&lt;/p&gt;

&lt;p&gt;Expected loss is a decision-theoretic quantity, so stopping when it falls under your threshold isn't the same statistical sin as stopping when a p-value dips below 0.05. But with tiny samples the posterior is wide and jumpy, and "expected loss happens to be low right now" is a thing that occurs by chance early in a test.&lt;/p&gt;

&lt;p&gt;The pragmatic fix I settled on: a hard minimum before any decision is offered at all. Both arms need a floor of visitors &lt;em&gt;and&lt;/em&gt; a floor of conversions — conversions are what actually carry the information, and 50,000 visitors with 11 conversions tells you almost nothing. Below the floor the UI shows the posterior and refuses to say anything else.&lt;/p&gt;

&lt;p&gt;Not elegant. Works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance, since this runs on shared hosting
&lt;/h2&gt;

&lt;p&gt;20,000 draws × 4 Beta samples × 2 arms is a lot of &lt;code&gt;mt_rand()&lt;/code&gt; calls in PHP, and it is nowhere near free on a €4/month host.&lt;/p&gt;

&lt;p&gt;What made it survivable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run it on a scheduled job, not on dashboard load. Cache the decision payload.&lt;/li&gt;
&lt;li&gt;Aggregate counts (&lt;code&gt;$nA&lt;/code&gt;, &lt;code&gt;$cA&lt;/code&gt;, …) in a summary table updated incrementally. Never scan the raw events table to compute a posterior.&lt;/li&gt;
&lt;li&gt;Drop to 10,000 draws. The Monte Carlo error on &lt;code&gt;$prob_b_better&lt;/code&gt; at 10k draws is about ±0.5 percentage points, which is invisible next to the uncertainty in your data.&lt;/li&gt;
&lt;li&gt;Bail out early when the result isn't close. If the two 99% intervals don't overlap, you don't need the simulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On my setup, the full three-metric run at 20k draws takes &lt;strong&gt;[X] ms&lt;/strong&gt; for &lt;strong&gt;[N]&lt;/strong&gt; visitors per arm, down from &lt;strong&gt;[Y] ms&lt;/strong&gt; before the summary table. Replace these with your real numbers or delete the paragraph — made-up benchmarks are worse than no benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;The statistics were the easy part. The hard part was accepting that "which variant won?" is a badly formed question, and that the honest output of an A/B test is a distribution plus a decision rule you committed to in advance — not a green checkmark.&lt;/p&gt;

&lt;p&gt;The engine got simpler once I stopped trying to produce certainty and started producing an expected cost of being wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Opti-Behavior, a self-hosted analytics plugin, and the decision engine described here is what's running inside its A/B testing module — so take my enthusiasm for this approach with the appropriate grain of salt. The maths is standard and framework-agnostic; port it wherever you like. Happy to argue about the weighting scheme in the comments, it's the part I'm least sure about.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>privacy</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
  </channel>
</rss>
