From 0c7ae3971962f18fa61573a128ff0ca4f717eab7 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:35:14 +0200 Subject: [PATCH 1/4] Don't load tracking code for user roles that aren't tracked For an untracked role (administrators by default), the tracker is registered but not enqueued, and neither is its plausible.init() call. FormSubmit::add_js() and the cloaked affiliate links script only checked whether it was registered, and enqueued scripts that depend on it, which pulled the tracker onto the page anyway. The tracker then left window.plausible an object, so every call to it threw "window.plausible is not a function": the form and affiliate link scripts, and the 404, search and query parameter events attached to the tracker. The WooCommerce checkout and WooCommerce/EDD purchase events were echoed for those roles too. Add Helpers::main_script_is_enqueued() and only add tracking code when the tracker is enqueued. --- readme.txt | 3 ++ src/Assets.php | 8 +-- src/Helpers.php | 19 +++++++ src/Integrations/EDD.php | 6 +++ src/Integrations/FormSubmit.php | 2 +- src/Integrations/WooCommerce.php | 11 ++++ tests/TestCase.php | 28 ++++++++++ tests/integration/AssetsTest.php | 54 +++++++++++++++++++ tests/integration/Integrations/EDDTest.php | 16 ++++++ .../Integrations/WooCommerceTest.php | 39 ++++++++++++++ 10 files changed, 181 insertions(+), 5 deletions(-) diff --git a/readme.txt b/readme.txt index 7b6cf192..bd632d83 100644 --- a/readme.txt +++ b/readme.txt @@ -278,6 +278,9 @@ Please make sure you make a backup of your database before updating any version == Changelog == += 2.6.4 = +* Fixed: for user roles that aren't tracked (administrators by default), some integrations still loaded the tracker without initializing it, which caused a "window.plausible is not a function" error, e.g. on search results, 404 and checkout pages. They no longer load anything for those roles. + = 2.6.3 = * Improved: the tracker script now loads asynchronously (like Plausible Analytics' own snippet) instead of deferred, so it can no longer hold up the rest of the page when it can't be reached. * Added: Hummingbird compatibility. Its Asset Optimization and Delay JavaScript no longer touch the plugin's scripts, which could cause a "plausible is not a function" error (e.g. on search results pages). diff --git a/src/Assets.php b/src/Assets.php index 5e1b6c5f..dfb02c58 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -34,7 +34,7 @@ private function init() { * @return void */ public function maybe_enqueue_cloaked_affiliate_links_assets() { - if ( Helpers::is_cloaked_affiliate_links_enabled() && Helpers::main_script_is_registered() ) { + if ( Helpers::is_cloaked_affiliate_links_enabled() && Helpers::main_script_is_enqueued() ) { wp_enqueue_script( 'plausible-affiliate-links', PLAUSIBLE_ANALYTICS_PLUGIN_URL . 'assets/dist/js/plausible-affiliate-links.js', @@ -57,7 +57,7 @@ public function maybe_enqueue_cloaked_affiliate_links_assets() { public function maybe_enqueue_four_o_four_script() { $is_404 = apply_filters( 'plausible_analytics_is_404', is_404() ); - if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::FOUR_O_FOUR ) && $is_404 && Helpers::main_script_is_registered() ) { + if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::FOUR_O_FOUR ) && $is_404 && Helpers::main_script_is_enqueued() ) { $data = wp_json_encode( [ 'props' => [ @@ -148,7 +148,7 @@ protected function get_js_url( bool $local = false ) { * @return void */ public function maybe_enqueue_query_params_script() { - if ( Helpers::is_query_params_enabled() && Helpers::main_script_is_registered() ) { + if ( Helpers::is_query_params_enabled() && Helpers::main_script_is_enqueued() ) { $props = []; $query_params = Helpers::get_settings()['query_params'] ?? []; @@ -183,7 +183,7 @@ public function maybe_enqueue_query_params_script() { public function maybe_enqueue_search_queries_script() { $is_search = apply_filters( 'plausible_analytics_is_search', is_search() ); - if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::SEARCH_QUERIES ) && $is_search && Helpers::main_script_is_registered() ) { + if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::SEARCH_QUERIES ) && $is_search && Helpers::main_script_is_enqueued() ) { global $wp_query; $search_source = isset( $_REQUEST['search_source'] ) ? sanitize_text_field( $_REQUEST['search_source'] ) : wp_get_referer(); diff --git a/src/Helpers.php b/src/Helpers.php index 4e8d3638..7b47ac19 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -886,12 +886,31 @@ public static function is_query_params_enabled( $settings = [] ) { /** * Checks if the main Plausible Analytics script is registered. * + * The script is registered for every visitor, also for user roles that aren't tracked, so to decide whether to add + * tracking code, use @see self::main_script_is_enqueued(). + * * @return bool */ public static function main_script_is_registered() { return wp_script_is( 'plausible-analytics', 'registered' ); } + /** + * Checks if the main Plausible Analytics script is enqueued, i.e. the current visitor is tracked. + * + * Tracking code must only be added when it is: for a user role that isn't tracked, the script is registered but not + * enqueued, and neither is its inline plausible.init() call. A script that depends on it would still pull the tracker + * onto the page, which then leaves window.plausible an object instead of a function, so any call to it throws + * "window.plausible is not a function". + * + * @since 2.6.4 + * + * @return bool + */ + public static function main_script_is_enqueued() { + return wp_script_is( 'plausible-analytics', 'enqueued' ); + } + /** * Translates a URL slug, e.g., WooCommerce's product base, to the given language. * diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index 954c4ed9..5387b03c 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -10,6 +10,7 @@ namespace Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\EnhancedMeasurements; +use Plausible\Analytics\WP\Helpers; use Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\Proxy; @@ -203,6 +204,11 @@ public function track_purchase() { return; // @codeCoverageIgnore } + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() + if ( ! Helpers::main_script_is_enqueued() ) { + return; + } + $session = edd_get_purchase_session(); $order = null; diff --git a/src/Integrations/FormSubmit.php b/src/Integrations/FormSubmit.php index 61b1d9fb..88c1fe7f 100644 --- a/src/Integrations/FormSubmit.php +++ b/src/Integrations/FormSubmit.php @@ -50,7 +50,7 @@ private function init() { * @codeCoverageIgnore because there's nothing to test here. */ public function add_js() { - if ( ! Helpers::main_script_is_registered() ) { + if ( ! Helpers::main_script_is_enqueued() ) { return; } diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index cf46e5cc..e75584c0 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -11,6 +11,7 @@ use Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\EnhancedMeasurements; +use Plausible\Analytics\WP\Helpers; use Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\Proxy; use WC_Cart; @@ -273,6 +274,11 @@ public function track_entered_checkout() { return; // @codeCoverageIgnore } + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() + if ( ! Helpers::main_script_is_enqueued() ) { + return; + } + $cart = $this->get_wc_cart(); $props = apply_filters( 'plausible_analytics_woocommerce_entered_checkout_custom_properties', @@ -301,6 +307,11 @@ public function track_entered_checkout() { * @return void */ public function track_purchase( $order_id ) { + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() + if ( ! Helpers::main_script_is_enqueued() ) { + return; + } + $order = wc_get_order( $order_id ); $is_tracked = $order->get_meta( Integrations::PURCHASE_TRACKED_META_KEY ); diff --git a/tests/TestCase.php b/tests/TestCase.php index fc415bbd..dec198f5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -42,6 +42,34 @@ function ( $caps ) use ( $cap ) { ); } + /** + * Enqueue the main script, like Assets::maybe_enqueue_main_script() does for a visitor that's tracked. + * + * @return void + */ + public function enqueueMainScript() { + wp_register_script( 'plausible-analytics', 'https://plausible.test/js/pa-test.js', [], null ); + wp_enqueue_script( 'plausible-analytics' ); + } + + /** + * Leave the main script registered but not enqueued, like Assets::maybe_enqueue_main_script() does for a user role + * that isn't tracked. + * + * @return void + */ + public function dequeueMainScript() { + wp_register_script( 'plausible-analytics', 'https://plausible.test/js/pa-test.js', [], null ); + wp_dequeue_script( 'plausible-analytics' ); + + // A script counts as enqueued while an enqueued script depends on it, e.g. one left behind by another test. + foreach ( wp_scripts()->queue as $handle ) { + if ( in_array( 'plausible-analytics', (array) ( wp_scripts()->registered[ $handle ]->deps ?? [] ), true ) ) { + wp_dequeue_script( $handle ); + } + } + } + /** * Checks an array for a (partial) match with $string. * diff --git a/tests/integration/AssetsTest.php b/tests/integration/AssetsTest.php index 32944fa5..f513176a 100644 --- a/tests/integration/AssetsTest.php +++ b/tests/integration/AssetsTest.php @@ -7,6 +7,7 @@ use Plausible\Analytics\Tests\TestCase; use Plausible\Analytics\WP\Assets; +use Plausible\Analytics\WP\Integrations\FormSubmit; class AssetsTest extends TestCase { /** @@ -175,4 +176,57 @@ public function testEnqueueSearchQueriesScript() { remove_filter( 'plausible_analytics_is_search', '__return_true' ); } } + + /** + * For a user role that isn't tracked (administrators by default), the tracker is registered but not enqueued, and + * neither is its plausible.init() call. Scripts that depend on it mustn't pull it onto the page anyway: the tracker + * then leaves window.plausible an object, so their calls throw "window.plausible is not a function". + * + * @see Assets::maybe_enqueue_cloaked_affiliate_links_assets() + * @see FormSubmit::add_js() + * @return void + * @throws \Exception + */ + public function testIntegrationsDontLoadTheTrackerForUntrackedRoles() { + $user_id = wp_insert_user( [ 'user_login' => 'pa_untracked_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'administrator' ] ); + + wp_set_current_user( $user_id ); + add_filter( 'plausible_analytics_settings', [ $this, 'enableCloakedAffiliateLinks' ] ); + add_filter( 'plausible_analytics_settings', [ $this, 'enableFormCompletions' ] ); + + $assets = $this->getMockBuilder( Assets::class )->disableOriginalConstructor()->onlyMethods( [ 'get_js_url' ] )->getMock(); + $assets->method( 'get_js_url' )->willReturn( 'https://plausible.test/js/plausible.js' ); + $form_submit = $this->getMockBuilder( FormSubmit::class )->disableOriginalConstructor()->onlyMethods( [] )->getMock(); + + try { + $this->dequeueMainScript(); + + $assets->maybe_enqueue_main_script(); + $form_submit->add_js(); + $assets->maybe_enqueue_cloaked_affiliate_links_assets(); + + $this->assertTrue( wp_script_is( 'plausible-analytics', 'registered' ) ); + $this->assertFalse( wp_script_is( 'plausible-analytics', 'enqueued' ) ); + $this->assertFalse( wp_script_is( 'plausible-form-submit-integration', 'enqueued' ) ); + $this->assertFalse( wp_script_is( 'plausible-affiliate-links', 'enqueued' ) ); + + // Once administrators are tracked, everything is loaded. + add_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + + $assets->maybe_enqueue_main_script(); + $form_submit->add_js(); + $assets->maybe_enqueue_cloaked_affiliate_links_assets(); + + $this->assertTrue( wp_script_is( 'plausible-analytics', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'plausible-form-submit-integration', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'plausible-affiliate-links', 'enqueued' ) ); + } finally { + remove_filter( 'plausible_analytics_settings', [ $this, 'enableCloakedAffiliateLinks' ] ); + remove_filter( 'plausible_analytics_settings', [ $this, 'enableFormCompletions' ] ); + remove_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + $this->dequeueMainScript(); + wp_set_current_user( 0 ); + wp_delete_user( $user_id ); + } + } } diff --git a/tests/integration/Integrations/EDDTest.php b/tests/integration/Integrations/EDDTest.php index 7227fbc3..03270941 100644 --- a/tests/integration/Integrations/EDDTest.php +++ b/tests/integration/Integrations/EDDTest.php @@ -15,6 +15,7 @@ class EDDTest extends TestCase { * @see EDD::track_purchase() */ public function testTrackPurchase() { + $this->enqueueMainScript(); when( 'edd_is_success_page' )->justReturn( true ); when( 'edd_get_purchase_session' )->justReturn( [ 'purchase_key' => 'test-key' @@ -34,4 +35,19 @@ public function testTrackPurchase() { $this->assertStringContainsString( $class->event_goals['purchase'], $output ); $this->assertStringContainsString( '{"revenue":{"amount":10,"currency":"EUR"}}', $output ); } + + /** + * For a user role that isn't tracked, window.plausible isn't initialized, so no purchase should be sent. + * + * @see EDD::track_purchase() + * @return void + */ + public function testTrackPurchaseSkipsUntrackedRoles() { + $this->dequeueMainScript(); + when( 'edd_is_success_page' )->justReturn( true ); + + ob_start(); + ( new EDD( false ) )->track_purchase(); + $this->assertEquals( '', ob_get_clean() ); + } } diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index 85a736f9..459fbce4 100644 --- a/tests/integration/Integrations/WooCommerceTest.php +++ b/tests/integration/Integrations/WooCommerceTest.php @@ -48,6 +48,7 @@ public function testEventGoalsAreNotTranslated() { * @return void */ public function testTrackEnteredCheckout() { + $this->enqueueMainScript(); when( 'is_checkout' )->justReturn( true ); when( 'is_wc_endpoint_url' )->justReturn( false ); when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); @@ -83,6 +84,7 @@ public function testTrackEnteredCheckout() { * @return void */ public function testTrackPurchase() { + $this->enqueueMainScript(); when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); $class = new WooCommerce( false ); @@ -105,4 +107,41 @@ public function testTrackPurchase() { $class->track_purchase( 1 ); } + + /** + * For a user role that isn't tracked, window.plausible isn't initialized, so no purchase should be sent (and the + * order shouldn't be marked as tracked). + * + * @see WooCommerce::track_purchase() + * @return void + */ + public function testTrackPurchaseSkipsUntrackedRoles() { + $this->dequeueMainScript(); + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + + $class = new WooCommerce( false ); + $mock = $this->getMockBuilder( 'WC_Order' )->setMethods( [ 'get_meta', 'add_meta_data', 'save' ] )->getMock(); + $mock->expects( $this->never() )->method( 'add_meta_data' ); + + when( 'wc_get_order' )->justReturn( $mock ); + + ob_start(); + $class->track_purchase( 1 ); + $this->assertEquals( '', ob_get_clean() ); + } + + /** + * @see WooCommerce::track_entered_checkout() + * @return void + */ + public function testTrackEnteredCheckoutSkipsUntrackedRoles() { + $this->dequeueMainScript(); + when( 'is_checkout' )->justReturn( true ); + when( 'is_wc_endpoint_url' )->justReturn( false ); + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + + ob_start(); + ( new WooCommerce( false ) )->track_entered_checkout(); + $this->assertEquals( '', ob_get_clean() ); + } } From c6fc1f2f2a336cba7bcc1cfc5ab0dc9f94d15da2 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:41:30 +0200 Subject: [PATCH 2/4] Don't let a test's user leak into the tests that follow InitOptionsTest::testTrackLoggedInUsers() set the global current user to a user with an untracked role and never reset it. Depending on the order the test files run in (in CI it ran before AssetsTest), the Assets tests then ran as that user, so the tracker wasn't enqueued. Reset it afterwards, and have AssetsTest start every test as a logged-out (tracked) visitor. --- tests/integration/AssetsTest.php | 11 +++++++++++ tests/integration/InitOptionsTest.php | 2 ++ 2 files changed, 13 insertions(+) diff --git a/tests/integration/AssetsTest.php b/tests/integration/AssetsTest.php index f513176a..4b758307 100644 --- a/tests/integration/AssetsTest.php +++ b/tests/integration/AssetsTest.php @@ -10,6 +10,17 @@ use Plausible\Analytics\WP\Integrations\FormSubmit; class AssetsTest extends TestCase { + /** + * These tests act as a visitor that's tracked (logged out), whatever user a previous test left logged in. + * + * @return void + */ + protected function set_up() { + parent::set_up(); + + wp_set_current_user( 0 ); + } + /** * @see Assets::maybe_enqueue_cloaked_affiliate_links_assets() * @return void diff --git a/tests/integration/InitOptionsTest.php b/tests/integration/InitOptionsTest.php index e477bd3d..6413038b 100644 --- a/tests/integration/InitOptionsTest.php +++ b/tests/integration/InitOptionsTest.php @@ -172,6 +172,8 @@ public function testTrackLoggedInUsers() { $this->assertEquals( 'test', $params['customProperties']['user_logged_in'] ); } finally { remove_filter( 'plausible_analytics_settings', [ $this, 'enablePageviewProps' ] ); + // Don't leave the test user logged in for the tests that follow. + wp_set_current_user( 0 ); } } } From 28fab39d8f2e8c5b3d6b67acb77a2ad389440620 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:50:43 +0200 Subject: [PATCH 3/4] Don't send server-side events for user roles that aren't tracked The events sent through the proxy from the server ignored the "Track analytics for user roles" setting, so e.g. an administrator's add to cart and remove from cart were tracked, while their pageviews weren't: the WooCommerce and EDD add to cart and remove from cart events, EDD's start checkout event, and Contact Form 7 and Gravity Forms submissions. Move the role check from Assets::maybe_enqueue_main_script() into Helpers::is_current_user_tracked() and use it for all of them. The proxy itself doesn't check it, as the proxy health check in the admin uses it. get_user_role() now uses wp_get_current_user(), so the current user is determined in requests where the global isn't set yet. --- readme.txt | 1 + src/Assets.php | 9 +- src/Helpers.php | 25 +++- src/Integrations/EDD.php | 15 +++ src/Integrations/FormSubmit.php | 5 + src/Integrations/WooCommerce.php | 10 ++ tests/integration/UntrackedRolesTest.php | 138 +++++++++++++++++++++++ 7 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 tests/integration/UntrackedRolesTest.php diff --git a/readme.txt b/readme.txt index bd632d83..541910eb 100644 --- a/readme.txt +++ b/readme.txt @@ -280,6 +280,7 @@ Please make sure you make a backup of your database before updating any version = 2.6.4 = * Fixed: for user roles that aren't tracked (administrators by default), some integrations still loaded the tracker without initializing it, which caused a "window.plausible is not a function" error, e.g. on search results, 404 and checkout pages. They no longer load anything for those roles. +* Fixed: events sent from the server (WooCommerce and Easy Digital Downloads add to cart, remove from cart and EDD checkout, and Contact Form 7 and Gravity Forms submissions) were also sent for user roles that aren't tracked. = 2.6.3 = * Improved: the tracker script now loads asynchronously (like Plausible Analytics' own snippet) instead of deferred, so it can no longer hold up the rest of the page when it can't be reached. diff --git a/src/Assets.php b/src/Assets.php index dfb02c58..0012e1bf 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -88,9 +88,8 @@ public function maybe_enqueue_four_o_four_script() { * @throws \Exception */ public function maybe_enqueue_main_script() { - $settings = Helpers::get_settings(); - $user_role = Helpers::get_user_role(); - $url = $this->get_js_url( true ); + $settings = Helpers::get_settings(); + $url = $this->get_js_url( true ); if ( ! $url ) { echo '\n"; @@ -104,9 +103,9 @@ public function maybe_enqueue_main_script() { wp_register_script( 'plausible-analytics', $url, [], null, apply_filters( 'plausible_load_js_in_footer', false ) ); /** - * Bail if tracked_user_roles is empty (which means no roles should be tracked) or if the current role should not be tracked. + * Bail if the current user's role should not be tracked. */ - if ( ( ! empty( $user_role ) && ! isset( $settings['tracked_user_roles'] ) ) || ( ! empty( $user_role ) && ! in_array( $user_role, $settings['tracked_user_roles'], true ) ) ) { + if ( ! Helpers::is_current_user_tracked() ) { return; // @codeCoverageIgnore } diff --git a/src/Helpers.php b/src/Helpers.php index 7b47ac19..7e661315 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -827,13 +827,32 @@ public static function get_multilang_plugin_name() { * @return string */ public static function get_user_role() { - global $current_user; - - $user_roles = $current_user->roles; + // wp_get_current_user() makes sure the current user is determined, e.g. in REST API requests. + $user_roles = (array) wp_get_current_user()->roles; return array_shift( $user_roles ); } + /** + * Checks whether the current visitor should be tracked: logged-out visitors always are, logged-in users only when + * their role is enabled under "Track analytics for user roles". + * + * @since 2.6.4 + * + * @return bool + */ + public static function is_current_user_tracked() { + $user_role = static::get_user_role(); + + if ( empty( $user_role ) ) { + return true; + } + + $tracked_user_roles = static::get_settings()['tracked_user_roles'] ?? []; + + return in_array( $user_role, (array) $tracked_user_roles, true ); + } + /** * Wrapper to check if the Cloaked Affiliate Links option contains any values. * diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index 5387b03c..1bd79997 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -85,6 +85,11 @@ private function init( $init ) { * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_add_to_cart( $download_id, $options, $items ) { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + $download = new \EDD_Download( $download_id ); if ( $download->ID === 0 ) { @@ -127,6 +132,11 @@ function ( $item ) use ( $download_id ) { * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_remove_cart_item( $key ) { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + $cart_contents = edd_get_cart_contents(); $item_removed_from_cart = $cart_contents[ $key ] ?? []; $product = null; @@ -171,6 +181,11 @@ public function track_remove_cart_item( $key ) { * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_entered_checkout() { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + // Just to make sure we're where we're supposed to be. if ( ! edd_is_checkout() ) { return; diff --git a/src/Integrations/FormSubmit.php b/src/Integrations/FormSubmit.php index 88c1fe7f..86cd5333 100644 --- a/src/Integrations/FormSubmit.php +++ b/src/Integrations/FormSubmit.php @@ -107,6 +107,11 @@ public function maybe_track_submission( $result, $tags ) { * @codeCoverageIgnore because we can't test XHR requests here. */ private function track_submission( $uri ) { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + $proxy = new Proxy( false ); $proxy->do_request( diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index e75584c0..70dcf47d 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -163,6 +163,11 @@ public function track_ajax_add_to_cart( $product_id ) { * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_add_to_cart( $product, $add_to_cart_data ) { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + if ( ! $product ) { return; } @@ -348,6 +353,11 @@ public function track_purchase( $order_id ) { * @codeCoverageIgnore because we can't test XHR requests here. */ public function track_remove_cart_item( $cart_item_key, $cart ) { + // Not for user roles that aren't tracked. @see Helpers::is_current_user_tracked() + if ( ! Helpers::is_current_user_tracked() ) { + return; + } + $cart_contents = $cart->get_cart_contents(); $item_removed_from_cart = $this->clean_data( $cart_contents[ $cart_item_key ] ?? [] ); $product = null; diff --git a/tests/integration/UntrackedRolesTest.php b/tests/integration/UntrackedRolesTest.php new file mode 100644 index 00000000..68af0b09 --- /dev/null +++ b/tests/integration/UntrackedRolesTest.php @@ -0,0 +1,138 @@ +user_id = wp_insert_user( [ 'user_login' => 'pa_role_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'administrator' ] ); + $this->sent = []; + + add_filter( 'pre_http_request', [ $this, 'capture_event' ], 10, 3 ); + } + + /** + * @return void + */ + protected function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'capture_event' ] ); + remove_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + wp_set_current_user( 0 ); + wp_delete_user( $this->user_id ); + + parent::tear_down(); + } + + /** + * Records events instead of sending them. + * + * @param false|array $response + * @param array $args + * @param string $url + * + * @return false|array + */ + public function capture_event( $response, $args, $url ) { + if ( strpos( $url, '/api/event' ) === false ) { + return $response; + } + + $this->sent[] = json_decode( $args['body'], true )['n'] ?? ''; + + return [ 'headers' => [], 'body' => '', 'response' => [ 'code' => 202, 'message' => 'Accepted' ], 'cookies' => [], 'filename' => null ]; + } + + /** + * @see Helpers::is_current_user_tracked() + * @return void + */ + public function testIsCurrentUserTracked() { + wp_set_current_user( 0 ); + $this->assertTrue( Helpers::is_current_user_tracked() ); + + wp_set_current_user( $this->user_id ); + $this->assertFalse( Helpers::is_current_user_tracked() ); + + add_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + $this->assertTrue( Helpers::is_current_user_tracked() ); + } + + /** + * @see WooCommerce::track_add_to_cart() + * @see WooCommerce::track_remove_cart_item() + * @return void + */ + public function testWooCommerceCartEventsSkipUntrackedRoles() { + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + wp_set_current_user( $this->user_id ); + + $class = new WooCommerce( false ); + $class->track_add_to_cart( $this->getMockBuilder( 'WC_Product' )->getMock(), [ 'id' => 1, 'quantity' => 1 ] ); + $class->track_remove_cart_item( 'key', $this->getMockBuilder( 'WC_Cart' )->getMock() ); + + $this->assertEquals( [], $this->sent ); + } + + /** + * @see EDD::track_add_to_cart() + * @see EDD::track_remove_cart_item() + * @see EDD::track_entered_checkout() + * @return void + */ + public function testEDDEventsSkipUntrackedRoles() { + wp_set_current_user( $this->user_id ); + + $class = new EDD( false ); + $class->track_add_to_cart( 1, [], [] ); + $class->track_remove_cart_item( 0 ); + $class->track_entered_checkout(); + + $this->assertEquals( [], $this->sent ); + } + + /** + * @see FormSubmit::track_gravity_forms_submission() + * @return void + */ + public function testFormSubmissionsRespectTrackedRoles() { + $class = $this->getMockBuilder( FormSubmit::class )->disableOriginalConstructor()->onlyMethods( [] )->getMock(); + $form = [ 'source_url' => home_url( '/contact/' ) ]; + + wp_set_current_user( $this->user_id ); + $class->track_gravity_forms_submission( $form ); + $this->assertEquals( [], $this->sent ); + + // Once administrators are tracked, the submission is sent. + add_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + $class->track_gravity_forms_submission( $form ); + $this->assertEquals( [ 'WP Form Completions' ], $this->sent ); + } +} From 167afde728f9e6fae445a689ecb0c7fc59fa424a Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:21:37 +0200 Subject: [PATCH 4/4] Check that the tracker is initialized, and don't track roleless users wp_script_is( 'plausible-analytics', 'enqueued' ) is also true while any enqueued script depends on it, e.g. another plugin's, so for an untracked role the integrations could still add tracking code without plausible.init() (and mark purchases as tracked). Replace main_script_is_enqueued() with main_script_is_initialized(), which checks that the tracker is in the queue itself and that its plausible.init() call was added, which only happens for visitors that are tracked. A logged-in user without a role on the site (e.g. a multisite super admin on a subsite) was treated like a logged-out visitor, so tracked. None of their roles is enabled under "Track analytics for user roles", so they no longer are. --- readme.txt | 1 + src/Assets.php | 8 ++++---- src/Helpers.php | 25 +++++++++++++++--------- src/Integrations/EDD.php | 4 ++-- src/Integrations/FormSubmit.php | 2 +- src/Integrations/WooCommerce.php | 8 ++++---- tests/TestCase.php | 3 ++- tests/integration/AssetsTest.php | 9 +++++++-- tests/integration/UntrackedRolesTest.php | 18 +++++++++++++++++ 9 files changed, 55 insertions(+), 23 deletions(-) diff --git a/readme.txt b/readme.txt index 541910eb..7fd9b18c 100644 --- a/readme.txt +++ b/readme.txt @@ -281,6 +281,7 @@ Please make sure you make a backup of your database before updating any version = 2.6.4 = * Fixed: for user roles that aren't tracked (administrators by default), some integrations still loaded the tracker without initializing it, which caused a "window.plausible is not a function" error, e.g. on search results, 404 and checkout pages. They no longer load anything for those roles. * Fixed: events sent from the server (WooCommerce and Easy Digital Downloads add to cart, remove from cart and EDD checkout, and Contact Form 7 and Gravity Forms submissions) were also sent for user roles that aren't tracked. +* Changed: logged-in users without a role on the site (e.g. multisite super admins visiting a subsite) are no longer tracked, as none of their roles is enabled under "Track analytics for user roles". Previously they were tracked like logged-out visitors. = 2.6.3 = * Improved: the tracker script now loads asynchronously (like Plausible Analytics' own snippet) instead of deferred, so it can no longer hold up the rest of the page when it can't be reached. diff --git a/src/Assets.php b/src/Assets.php index 0012e1bf..793c5907 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -34,7 +34,7 @@ private function init() { * @return void */ public function maybe_enqueue_cloaked_affiliate_links_assets() { - if ( Helpers::is_cloaked_affiliate_links_enabled() && Helpers::main_script_is_enqueued() ) { + if ( Helpers::is_cloaked_affiliate_links_enabled() && Helpers::main_script_is_initialized() ) { wp_enqueue_script( 'plausible-affiliate-links', PLAUSIBLE_ANALYTICS_PLUGIN_URL . 'assets/dist/js/plausible-affiliate-links.js', @@ -57,7 +57,7 @@ public function maybe_enqueue_cloaked_affiliate_links_assets() { public function maybe_enqueue_four_o_four_script() { $is_404 = apply_filters( 'plausible_analytics_is_404', is_404() ); - if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::FOUR_O_FOUR ) && $is_404 && Helpers::main_script_is_enqueued() ) { + if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::FOUR_O_FOUR ) && $is_404 && Helpers::main_script_is_initialized() ) { $data = wp_json_encode( [ 'props' => [ @@ -147,7 +147,7 @@ protected function get_js_url( bool $local = false ) { * @return void */ public function maybe_enqueue_query_params_script() { - if ( Helpers::is_query_params_enabled() && Helpers::main_script_is_enqueued() ) { + if ( Helpers::is_query_params_enabled() && Helpers::main_script_is_initialized() ) { $props = []; $query_params = Helpers::get_settings()['query_params'] ?? []; @@ -182,7 +182,7 @@ public function maybe_enqueue_query_params_script() { public function maybe_enqueue_search_queries_script() { $is_search = apply_filters( 'plausible_analytics_is_search', is_search() ); - if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::SEARCH_QUERIES ) && $is_search && Helpers::main_script_is_enqueued() ) { + if ( EnhancedMeasurements::is_enabled( EnhancedMeasurements::SEARCH_QUERIES ) && $is_search && Helpers::main_script_is_initialized() ) { global $wp_query; $search_source = isset( $_REQUEST['search_source'] ) ? sanitize_text_field( $_REQUEST['search_source'] ) : wp_get_referer(); diff --git a/src/Helpers.php b/src/Helpers.php index 7e661315..59778f99 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -835,22 +835,22 @@ public static function get_user_role() { /** * Checks whether the current visitor should be tracked: logged-out visitors always are, logged-in users only when - * their role is enabled under "Track analytics for user roles". + * their role is enabled under "Track analytics for user roles". A logged-in user without a role on this site (e.g. a + * multisite super admin visiting a subsite) has no role that's enabled, so isn't tracked. * * @since 2.6.4 * * @return bool */ public static function is_current_user_tracked() { - $user_role = static::get_user_role(); - - if ( empty( $user_role ) ) { + if ( ! is_user_logged_in() ) { return true; } + $user_role = static::get_user_role(); $tracked_user_roles = static::get_settings()['tracked_user_roles'] ?? []; - return in_array( $user_role, (array) $tracked_user_roles, true ); + return ! empty( $user_role ) && in_array( $user_role, (array) $tracked_user_roles, true ); } /** @@ -906,7 +906,7 @@ public static function is_query_params_enabled( $settings = [] ) { * Checks if the main Plausible Analytics script is registered. * * The script is registered for every visitor, also for user roles that aren't tracked, so to decide whether to add - * tracking code, use @see self::main_script_is_enqueued(). + * tracking code, use @see self::main_script_is_initialized(). * * @return bool */ @@ -915,19 +915,26 @@ public static function main_script_is_registered() { } /** - * Checks if the main Plausible Analytics script is enqueued, i.e. the current visitor is tracked. + * Checks if the main Plausible Analytics script is enqueued and initialized, i.e. the current visitor is tracked. * * Tracking code must only be added when it is: for a user role that isn't tracked, the script is registered but not * enqueued, and neither is its inline plausible.init() call. A script that depends on it would still pull the tracker * onto the page, which then leaves window.plausible an object instead of a function, so any call to it throws * "window.plausible is not a function". * + * wp_script_is( 'plausible-analytics', 'enqueued' ) isn't enough: it's also true while any enqueued script depends on + * it, e.g. another plugin's. So check that it's in the queue itself and that @see Assets::maybe_enqueue_main_script() + * added the plausible.init() call, which it only does for visitors that are tracked. + * * @since 2.6.4 * * @return bool */ - public static function main_script_is_enqueued() { - return wp_script_is( 'plausible-analytics', 'enqueued' ); + public static function main_script_is_initialized() { + $scripts = wp_scripts(); + $after = (array) $scripts->get_data( 'plausible-analytics', 'after' ); + + return in_array( 'plausible-analytics', $scripts->queue, true ) && strpos( implode( "\n", $after ), 'plausible.init(' ) !== false; } /** diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index 1bd79997..5835b810 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -219,8 +219,8 @@ public function track_purchase() { return; // @codeCoverageIgnore } - // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() - if ( ! Helpers::main_script_is_enqueued() ) { + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_initialized() + if ( ! Helpers::main_script_is_initialized() ) { return; } diff --git a/src/Integrations/FormSubmit.php b/src/Integrations/FormSubmit.php index 86cd5333..40d8f00a 100644 --- a/src/Integrations/FormSubmit.php +++ b/src/Integrations/FormSubmit.php @@ -50,7 +50,7 @@ private function init() { * @codeCoverageIgnore because there's nothing to test here. */ public function add_js() { - if ( ! Helpers::main_script_is_enqueued() ) { + if ( ! Helpers::main_script_is_initialized() ) { return; } diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index 70dcf47d..dc5e7f52 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -279,8 +279,8 @@ public function track_entered_checkout() { return; // @codeCoverageIgnore } - // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() - if ( ! Helpers::main_script_is_enqueued() ) { + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_initialized() + if ( ! Helpers::main_script_is_initialized() ) { return; } @@ -312,8 +312,8 @@ public function track_entered_checkout() { * @return void */ public function track_purchase( $order_id ) { - // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_enqueued() - if ( ! Helpers::main_script_is_enqueued() ) { + // Not for user roles that aren't tracked: window.plausible isn't initialized for them. @see Helpers::main_script_is_initialized() + if ( ! Helpers::main_script_is_initialized() ) { return; } diff --git a/tests/TestCase.php b/tests/TestCase.php index dec198f5..49e2b12a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -43,13 +43,14 @@ function ( $caps ) use ( $cap ) { } /** - * Enqueue the main script, like Assets::maybe_enqueue_main_script() does for a visitor that's tracked. + * Enqueue and initialize the main script, like Assets::maybe_enqueue_main_script() does for a visitor that's tracked. * * @return void */ public function enqueueMainScript() { wp_register_script( 'plausible-analytics', 'https://plausible.test/js/pa-test.js', [], null ); wp_enqueue_script( 'plausible-analytics' ); + wp_add_inline_script( 'plausible-analytics', 'plausible.init({});' ); } /** diff --git a/tests/integration/AssetsTest.php b/tests/integration/AssetsTest.php index 4b758307..d9e6060c 100644 --- a/tests/integration/AssetsTest.php +++ b/tests/integration/AssetsTest.php @@ -213,11 +213,15 @@ public function testIntegrationsDontLoadTheTrackerForUntrackedRoles() { $this->dequeueMainScript(); $assets->maybe_enqueue_main_script(); + + // Another plugin's script that depends on the tracker makes WordPress report the tracker as enqueued. + wp_enqueue_script( 'pa-test-other-plugin', 'https://example.org/other-plugin.js', [ 'plausible-analytics' ], null ); + $form_submit->add_js(); $assets->maybe_enqueue_cloaked_affiliate_links_assets(); $this->assertTrue( wp_script_is( 'plausible-analytics', 'registered' ) ); - $this->assertFalse( wp_script_is( 'plausible-analytics', 'enqueued' ) ); + $this->assertFalse( \Plausible\Analytics\WP\Helpers::main_script_is_initialized() ); $this->assertFalse( wp_script_is( 'plausible-form-submit-integration', 'enqueued' ) ); $this->assertFalse( wp_script_is( 'plausible-affiliate-links', 'enqueued' ) ); @@ -228,13 +232,14 @@ public function testIntegrationsDontLoadTheTrackerForUntrackedRoles() { $form_submit->add_js(); $assets->maybe_enqueue_cloaked_affiliate_links_assets(); - $this->assertTrue( wp_script_is( 'plausible-analytics', 'enqueued' ) ); + $this->assertTrue( \Plausible\Analytics\WP\Helpers::main_script_is_initialized() ); $this->assertTrue( wp_script_is( 'plausible-form-submit-integration', 'enqueued' ) ); $this->assertTrue( wp_script_is( 'plausible-affiliate-links', 'enqueued' ) ); } finally { remove_filter( 'plausible_analytics_settings', [ $this, 'enableCloakedAffiliateLinks' ] ); remove_filter( 'plausible_analytics_settings', [ $this, 'enableFormCompletions' ] ); remove_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + wp_dequeue_script( 'pa-test-other-plugin' ); $this->dequeueMainScript(); wp_set_current_user( 0 ); wp_delete_user( $user_id ); diff --git a/tests/integration/UntrackedRolesTest.php b/tests/integration/UntrackedRolesTest.php index 68af0b09..9978462a 100644 --- a/tests/integration/UntrackedRolesTest.php +++ b/tests/integration/UntrackedRolesTest.php @@ -85,6 +85,24 @@ public function testIsCurrentUserTracked() { $this->assertTrue( Helpers::is_current_user_tracked() ); } + /** + * A logged-in user without a role on this site (e.g. a multisite super admin visiting a subsite) isn't a logged-out + * visitor: no role of theirs is enabled, so they aren't tracked. + * + * @see Helpers::is_current_user_tracked() + * @return void + */ + public function testLoggedInUserWithoutRoleIsNotTracked() { + $user = new \WP_User( $this->user_id ); + $user->set_role( '' ); + + wp_set_current_user( $this->user_id ); + + $this->assertTrue( is_user_logged_in() ); + $this->assertEmpty( Helpers::get_user_role() ); + $this->assertFalse( Helpers::is_current_user_tracked() ); + } + /** * @see WooCommerce::track_add_to_cart() * @see WooCommerce::track_remove_cart_item()