Plugin Directory

Changeset 3742708


Ignore:
Timestamp:
10/13/2026 01:11:55 AM (3 weeks ago)
Author:
someguy9
Message:

Release 1.7.0

Location:
lightweight-google-analytics
Files:
6 edited
1 copied

Legend:

Unmodified
Added
Removed
  • lightweight-google-analytics/tags/1.7.0/lightweight-google-analytics.php

    r3689051 r3742708  
    44 * Plugin URI: https://wordpress.org/plugins/lightweight-google-analytics/
    55 * Description: Extremely simple plugin to add Google Analytics 4 to your WordPress site using your Measurement ID.
    6  * Version: 1.6.0
     6 * Version: 1.7.0
    77 * Requires at least: 5.7
    88 * Requires PHP: 7.0
    … …  
    1818}
    1919
    20 define( 'LIGHTWEIGHT_GA_VERSION', '1.6.0' );
     20define( 'LIGHTWEIGHT_GA_VERSION', '1.7.0' );
    2121
    2222// Saved settings with defaults filled in
    … …  
    3030                'tracking_code'            => 'analytics-js',
    3131                'consent_mode'             => '',
     32                'gpc'                      => '',
     33                'content_groups'           => '',
    3234                'debug_mode'               => '',
    3335        ) );
    … …  
    4648                $reason = 'preview';
    4749        } elseif ( is_user_logged_in() ) {
     50                $roles = explode( '-', $settings['tracking_level'] );
    4851                if ( 'logged-in' === $settings['tracking_level'] ) {
    4952                        $reason = 'logged-in';
    50                 } elseif ( 'all' !== $settings['tracking_level'] && array_intersect( explode( '-', $settings['tracking_level'] ), (array) wp_get_current_user()->roles ) ) {
     53                } elseif ( 'all' !== $settings['tracking_level'] && ( array_intersect( $roles, (array) wp_get_current_user()->roles ) || ( is_multisite() && is_super_admin() && in_array( 'administrator', $roles, true ) ) ) ) {
     54                        // Network super admins don't always have the administrator role on the site itself
    5155                        $reason = 'role';
    5256                }
    … …  
    5761}
    5862
    59 // Print the tracking code in the head or footer, whichever the settings say
     63// GA4 content group (the post's category, the post type, or the kind of archive) and author name for the current page
     64function lightweight_ga_page_dimensions() {
     65        $group  = '';
     66        $author = '';
     67        if ( is_front_page() ) {
     68                $group = __( 'Home', 'lightweight-google-analytics' );
     69        } elseif ( is_home() ) {
     70                $group = __( 'Blog', 'lightweight-google-analytics' );
     71        } elseif ( is_singular() && get_queried_object() instanceof WP_Post ) {
     72                $post = get_queried_object();
     73                if ( 'post' === $post->post_type ) {
     74                        $categories = get_the_category( $post->ID );
     75                        // Prefer the primary category picked in Yoast SEO or Rank Math
     76                        $primary = (int) get_post_meta( $post->ID, '_yoast_wpseo_primary_category', true ) ?: (int) get_post_meta( $post->ID, 'rank_math_primary_category', true );
     77                        foreach ( $categories as $category ) {
     78                                if ( $category->term_id === $primary ) {
     79                                        $group = $category->name;
     80                                }
     81                        }
     82                        if ( ! $group && $categories ) {
     83                                $group = $categories[0]->name;
     84                        }
     85                } else {
     86                        $type  = get_post_type_object( $post->post_type );
     87                        $group = $type ? $type->labels->singular_name : $post->post_type;
     88                }
     89                if ( post_type_supports( $post->post_type, 'author' ) ) {
     90                        $author = get_the_author_meta( 'display_name', $post->post_author );
     91                }
     92        } elseif ( is_category() || is_tag() || is_tax() ) {
     93                $group = single_term_title( '', false );
     94        } elseif ( is_post_type_archive() ) {
     95                $group = post_type_archive_title( '', false );
     96        } elseif ( is_author() ) {
     97                $group  = __( 'Author archive', 'lightweight-google-analytics' );
     98                $author = get_the_author_meta( 'display_name', get_queried_object_id() );
     99        } elseif ( is_date() ) {
     100                $group = __( 'Date archive', 'lightweight-google-analytics' );
     101        } elseif ( is_search() ) {
     102                $group = __( 'Search', 'lightweight-google-analytics' );
     103        } elseif ( is_404() ) {
     104                $group = __( 'Page not found', 'lightweight-google-analytics' );
     105        }
     106        // Change or add page-level parameters, e.g. a custom content group for a section of the site
     107        $dimensions = array_filter( (array) apply_filters( 'lightweight_ga_page_dimensions', array( 'content_group' => $group, 'author' => $author ) ), 'strlen' );
     108        // GA4 ignores parameter values longer than 100 characters
     109        return array_map( function ( $value ) {
     110                return function_exists( 'mb_substr' ) ? mb_substr( (string) $value, 0, 100 ) : substr( (string) $value, 0, 100 );
     111        }, $dimensions );
     112}
     113
     114// The WP Consent API (wordpress.org/plugins/wp-consent-api) lets consent banners share visitors' choices with other plugins.
     115// Its functions only exist after plugins_loaded, so this is checked at output time.
     116function lightweight_ga_has_consent_api() {
     117        return function_exists( 'wp_has_consent' );
     118}
     119
     120// Tell the WP Consent API (and its Site Health check) that tracking follows visitors' choices, which is true while Consent Mode is on
     121add_filter( 'wp_consent_api_registered_' . plugin_basename( __FILE__ ), function () {
     122        return (bool) lightweight_ga_settings()['consent_mode'];
     123} );
     124
     125// JS that waits for the WP Consent API to know the visitor's choices, then calls `callback` with wp_has_consent and again whenever they change.
     126// The API's script loads in the footer, so the first check runs once the page is parsed. Consent banners that set
     127// waitfor_consent_hook decide the consent type late; checking earlier would read "no banner" as consent.
     128function lightweight_ga_consent_api_js( $callback ) {
     129        return '(function(){var c=' . $callback . ',s=function(){if(typeof wp_has_consent==="function"){c();}},w=function(){window.waitfor_consent_hook?document.addEventListener("wp_consent_type_defined",s):s();};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",w):w();document.addEventListener("wp_listen_for_consent_change",s);})();';
     130}
     131
     132// Print the tracking code in the head or footer, whichever the settings say. Consent Mode defaults always go in the
     133// head, so they come before any consent banner's update even when the tracking code itself is in the footer.
    60134add_action( 'wp_head', 'lightweight_ga_display', 0 );
    61135add_action( 'wp_footer', 'lightweight_ga_display', 0 );
    62136function lightweight_ga_display() {
    63137        $settings = lightweight_ga_settings();
    64         $hook     = 'footer' === $settings['tracking_position'] ? 'wp_footer' : 'wp_head';
    65         if ( current_action() !== $hook || empty( $settings['enable_lightweight_ga'] ) || ! lightweight_ga_has_ga4_id() || lightweight_ga_excluded_reason() ) {
     138        if ( empty( $settings['enable_lightweight_ga'] ) || ! lightweight_ga_has_ga4_id() || lightweight_ga_excluded_reason() ) {
    66139                return;
    67140        }
    68         $id = $settings['tracking_id'];
     141        $id          = $settings['tracking_id'];
     142        $in_head     = 'wp_head' === current_action();
     143        $is_position = $in_head ? 'footer' !== $settings['tracking_position'] : 'footer' === $settings['tracking_position'];
    69144
    70145        if ( 'minimalanalytics-inline' === $settings['tracking_code'] ) {
    71                 $script = file_get_contents( __DIR__ . '/js/minimal-analytics-4.min.js' );
    72                 if ( ! $script ) {
    73                         return;
    74                 }
    75                 // The bundled script is configured by swapping the placeholder ID and the debug flag that sit at its top; check both anchors when updating the file
    76                 $script = str_replace(
    77                         array( 'tid:"G-XXXXXXXXXX"', ']},T=!1,' ),
    78                         array( 'tid:"' . esc_js( $id ) . '"', ']},T=' . ( $settings['debug_mode'] ? '!0' : '!1' ) . ',' ),
    79                         $script
    80                 );
    81                 echo "<!-- Minimal Analytics 4 - Lightweight Google Analytics -->\n";
    82                 wp_print_inline_script_tag( $script );
     146                if ( $is_position ) {
     147                        lightweight_ga_print_minimal_analytics( $settings );
     148                }
    83149                return;
    84150        }
    85151
    86         echo "<!-- Google tag (gtag.js) - Lightweight Google Analytics -->\n";
    87152        $gtag_stub = 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}';
    88 
    89         if ( $settings['consent_mode'] ) {
     153        // Google's documented opt-out flag stops GA4 sending anything for visitors whose browser sends Global Privacy Control.
     154        // It has to be set before any gtag() call, so it leads every inline script.
     155        if ( $settings['gpc'] ) {
     156                $gtag_stub = 'if(navigator.globalPrivacyControl){window["ga-disable-' . esc_js( $id ) . '"]=true;}' . $gtag_stub;
     157        }
     158
     159        if ( $in_head && $settings['consent_mode'] ) {
    90160                // Consent Mode v2 defaults have to be set before gtag.js loads; the consent banner then calls gtag("consent","update",...)
    91161                $consent = apply_filters( 'lightweight_ga_consent_defaults', array(
    … …  
    94164                        'ad_user_data'       => 'denied',
    95165                        'ad_personalization' => 'denied',
    96                         'wait_for_update'    => 500,
     166                        // How long Google waits for the consent update before sending anything; it carries on as soon as the update
     167                        // arrives. With the WP Consent API the update comes once the page is parsed (its script is in the footer), so
     168                        // allow for slow pages. Consent banners that call gtag themselves usually update early in the head.
     169                        'wait_for_update'    => lightweight_ga_has_consent_api() ? 2000 : 500,
    97170                ) );
    98                 wp_print_inline_script_tag( $gtag_stub . 'gtag("consent","default",' . wp_json_encode( $consent ) . ');' );
    99         }
    100 
     171                $update = '';
     172                if ( lightweight_ga_has_consent_api() ) {
     173                        // Pass the visitor's choices from any consent banner that supports the WP Consent API on to Google
     174                        // Banners such as Complianz change categories one at a time, so only send an update when the result changes
     175                        $update = lightweight_ga_consent_api_js( '(function(){var l;return function(){var s=wp_has_consent("statistics")?"granted":"denied",m=wp_has_consent("marketing")?"granted":"denied";if(s+m===l){return;}l=s+m;gtag("consent","update",{analytics_storage:s,ad_storage:m,ad_user_data:m,ad_personalization:m});};})()' );
     176                }
     177                echo "<!-- Google Consent Mode defaults - Lightweight Google Analytics -->\n";
     178                wp_print_inline_script_tag( $gtag_stub . 'gtag("consent","default",' . wp_json_encode( $consent ) . ');' . $update, array( 'data-lightweight-ga' => true ) );
     179        }
     180
     181        if ( ! $is_position ) {
     182                return;
     183        }
     184
     185        echo "<!-- Google tag (gtag.js) - Lightweight Google Analytics -->\n";
    101186        wp_print_script_tag( array(
    102                 'src'   => 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ),
    103                 'async' => true,
     187                'src'                 => 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ),
     188                'async'               => true,
     189                'data-lightweight-ga' => true, // marks this plugin's own tags for the duplicate tracking check
    104190        ) );
    105191
    … …  
    111197                $config['debug_mode'] = true;
    112198        }
     199        if ( $settings['content_groups'] ) {
     200                $config = array_merge( $config, lightweight_ga_page_dimensions() );
     201        }
    113202        // Add or change gtag config options, e.g. send_page_view, cookie_flags or custom dimensions
    114203        $config = apply_filters( 'lightweight_ga_gtag_config', $config, $id );
    115         wp_print_inline_script_tag( $gtag_stub . 'gtag("js",new Date());gtag("config","' . esc_js( $id ) . '"' . ( $config ? ',' . wp_json_encode( $config ) : '' ) . ');' );
     204        wp_print_inline_script_tag( $gtag_stub . 'gtag("js",new Date());gtag("config","' . esc_js( $id ) . '"' . ( $config ? ',' . wp_json_encode( $config ) : '' ) . ');', array( 'data-lightweight-ga' => true ) );
     205}
     206
     207// Minimal Analytics 4 is configured by swapping values in the bundled script; check all three anchors whenever the file is updated
     208function lightweight_ga_print_minimal_analytics( $settings ) {
     209        $script = file_get_contents( __DIR__ . '/js/minimal-analytics-4.min.js' );
     210        if ( ! $script ) {
     211                return;
     212        }
     213        // Content group and author ride along with every hit as event parameters
     214        $params = '';
     215        foreach ( $settings['content_groups'] ? lightweight_ga_page_dimensions() : array() as $key => $value ) {
     216                $params .= wp_json_encode( 'ep.' . $key ) . ':' . wp_json_encode( $value ) . ',';
     217        }
     218        $script = str_replace(
     219                array( 'tid:"G-XXXXXXXXXX"', ']},T=!1,', '_dbg:T?1:0[0]' ),
     220                array( 'tid:"' . esc_js( $settings['tracking_id'] ) . '"', ']},T=' . ( $settings['debug_mode'] ? '!0' : '!1' ) . ',', $params . '_dbg:T?1:0[0]' ),
     221                $script
     222        );
     223        // Run once the page is parsed: from the head it would otherwise send its page view before <title> exists and read a null document.body
     224        $run = 'document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();';
     225        if ( $settings['consent_mode'] && lightweight_ga_has_consent_api() ) {
     226                // Minimal Analytics has no Consent Mode, so with the WP Consent API it only starts once the visitor allows statistics
     227                $run = 'var d=0;' . lightweight_ga_consent_api_js( 'function(){if(!d&&wp_has_consent("statistics")){d=1;r();}}' );
     228        }
     229        if ( $settings['gpc'] ) {
     230                $run = 'if(!navigator.globalPrivacyControl){' . $run . '}';
     231        }
     232        echo "<!-- Minimal Analytics 4 - Lightweight Google Analytics -->\n";
     233        wp_print_inline_script_tag( '(function(){var r=function(){' . $script . "\n};" . $run . '})();', array( 'data-lightweight-ga' => true ) );
    116234}
    117235
    … …  
    121239                add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
    122240                add_action( 'admin_init', array( $this, 'init_settings' ) );
     241                add_action( 'admin_init', array( $this, 'maybe_upgrade' ) );
    123242                add_action( 'admin_notices', array( $this, 'admin_notices' ) );
     243                add_action( 'wp_ajax_lightweight_ga_scan', array( $this, 'ajax_scan' ) );
    124244                add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), array( $this, 'add_settings_link' ) );
    125245        }
    126246
     247        // Remembers the plugin version so an update can run one-off checks
     248        public function maybe_upgrade() {
     249                $state = (array) get_option( 'lightweight_ga_state', array() );
     250                if ( isset( $state['version'] ) && LIGHTWEIGHT_GA_VERSION === $state['version'] ) {
     251                        return;
     252                }
     253                if ( empty( $state['version'] ) && false !== get_option( 'lightweight_ga_settings' ) ) {
     254                        // 1.5.0 to 1.6.0 ticked every checkbox the first time the settings were saved, so ask the site owner to check them
     255                        $settings        = lightweight_ga_settings();
     256                        $state['review'] = $settings['consent_mode'] || $settings['debug_mode'] || $settings['disable_display_features'];
     257                }
     258                $state['version'] = LIGHTWEIGHT_GA_VERSION;
     259                update_option( 'lightweight_ga_state', $state );
     260        }
     261
    127262        public function add_settings_link( $links ) {
    128                 $links[] = '<a href="' . esc_url( menu_page_url( 'lightweight-google-analytics', false ) ) . '">' . esc_html__( 'Settings', 'lightweight-google-analytics' ) . '</a>';
     263                $links[] = '<a href="' . esc_url( admin_url( 'options-general.php?page=lightweight-google-analytics' ) ) . '">' . esc_html__( 'Settings', 'lightweight-google-analytics' ) . '</a>';
    129264                return $links;
    130265        }
    131266
    132267        public function admin_notices() {
    133                 $settings = lightweight_ga_settings();
    134                 $url      = esc_url( menu_page_url( 'lightweight-google-analytics', false ) );
     268                if ( ! current_user_can( 'manage_options' ) ) {
     269                        return;
     270                }
     271                $settings = lightweight_ga_settings();
     272                $screen   = function_exists( 'get_current_screen' ) && get_current_screen() ? get_current_screen()->id : '';
     273                $url      = esc_url( admin_url( 'options-general.php?page=lightweight-google-analytics' ) );
     274                $state    = (array) get_option( 'lightweight_ga_state', array() );
     275
     276                if ( ! empty( $state['review'] ) ) {
     277                        $on = array();
     278                        foreach ( array( 'consent_mode' => __( 'Consent Mode', 'lightweight-google-analytics' ), 'debug_mode' => __( 'Debug Mode', 'lightweight-google-analytics' ), 'disable_display_features' => __( 'Disable Display Features', 'lightweight-google-analytics' ) ) as $key => $label ) {
     279                                if ( $settings[ $key ] ) {
     280                                        $on[] = $label;
     281                                }
     282                        }
     283                        /* translators: 1: list of setting names, 2: link to the plugin settings page */
     284                        $message = sprintf( __( '<strong>Lightweight Google Analytics:</strong> a bug in versions 1.5.0 to 1.6.0 could switch settings on by themselves the first time they were saved. These are on for this site: %1$s. Consent Mode without a consent banner and Debug Mode can both reduce the data you see in Google Analytics. Please check them in the %2$s and save.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $on ) ), '<a href="' . $url . '">' . esc_html__( 'plugin settings', 'lightweight-google-analytics' ) . '</a>' );
     285                        printf( '<div class="notice notice-warning"><p>%s</p></div>', wp_kses_post( $message ) );
     286                }
     287
     288                // The settings page shows its own status message
     289                if ( 'settings_page_lightweight-google-analytics' === $screen ) {
     290                        return;
     291                }
    135292                if ( 0 === strpos( $settings['tracking_id'], 'UA-' ) ) {
    136293                        /* translators: %s: link to the plugin settings page */
    137294                        $message = sprintf( __( '<strong>Lightweight Google Analytics:</strong> Google shut down Universal Analytics in 2023 and UA- IDs are no longer supported, so no tracking code is being output. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) in the %s.', 'lightweight-google-analytics' ), '<a href="' . $url . '">' . esc_html__( 'plugin settings', 'lightweight-google-analytics' ) . '</a>' );
    138295                        printf( '<div class="notice notice-warning"><p>%s</p></div>', wp_kses_post( $message ) );
    139                 } elseif ( '' === $settings['tracking_id'] ) {
     296                } elseif ( '' === $settings['tracking_id'] && in_array( $screen, array( 'dashboard', 'plugins' ), true ) ) {
    140297                        /* translators: %s: link to the plugin settings page */
    141298                        $message = sprintf( __( 'Thank you for installing <strong>Lightweight Google Analytics</strong> - head to the %s to finish setting up Google Analytics.', 'lightweight-google-analytics' ), '<a href="' . $url . '">' . esc_html__( 'settings', 'lightweight-google-analytics' ) . '</a>' );
    … …  
    145302
    146303        public function sanitize_settings( $input ) {
    147                 $input     = (array) $input;
    148                 $sanitized = array( 'tracking_id' => '' );
    149 
    150                 $tracking_id = isset( $input['tracking_id'] ) ? strtoupper( trim( sanitize_text_field( $input['tracking_id'] ) ) ) : '';
    151                 if ( preg_match( '/^G-[A-Z0-9]+$/', $tracking_id ) ) {
    152                         $sanitized['tracking_id'] = $tracking_id;
    153                 } elseif ( 0 === strpos( $tracking_id, 'UA-' ) ) {
    154                         add_settings_error( 'lightweight_ga_settings', 'ua_tracking_id', __( 'Universal Analytics (UA-) IDs are no longer supported because Google shut Universal Analytics down in 2023. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) instead.', 'lightweight-google-analytics' ) );
    155                 } elseif ( '' !== $tracking_id ) {
    156                         add_settings_error( 'lightweight_ga_settings', 'invalid_tracking_id', __( 'Invalid Measurement ID. It should look like G-XXXXXXXX and can be found in Google Analytics under Admin > Data Streams.', 'lightweight-google-analytics' ) );
    157                 }
    158 
    159                 foreach ( array( 'enable_lightweight_ga', 'disable_display_features', 'consent_mode', 'debug_mode' ) as $checkbox ) {
    160                         $sanitized[ $checkbox ] = isset( $input[ $checkbox ] ) ? 'checked' : '';
     304                $input    = (array) $input;
     305                $previous = lightweight_ga_settings()['tracking_id'];
     306
     307                // Accept the ID itself, or a pasted gtag snippet or URL that contains it. The ID is looked for in the raw text because
     308                // sanitize_text_field() strips <script> blocks whole, URL included; only the matched letters and digits are ever kept.
     309                $raw = isset( $input['tracking_id'] ) && is_string( $input['tracking_id'] ) ? strtoupper( trim( $input['tracking_id'] ) ) : '';
     310                if ( preg_match( '/^G-[A-Z0-9]+$/', $raw ) ) {
     311                        $tracking_id = $raw;
     312                } elseif ( preg_match( '/(?<![A-Z0-9])G-[A-Z0-9]{4,}(?![A-Z0-9])/', $raw, $found ) ) {
     313                        $tracking_id = $found[0];
     314                        /* translators: %s: the Measurement ID found in the pasted text */
     315                        add_settings_error( 'lightweight_ga_settings', 'extracted_tracking_id', sprintf( __( 'Found the Measurement ID %s in the pasted text and saved it.', 'lightweight-google-analytics' ), $tracking_id ), 'info' );
     316                } else {
     317                        $tracking_id = sanitize_text_field( $raw );
     318                }
     319
     320                $sanitized = array( 'tracking_id' => $tracking_id );
     321                if ( '' !== $tracking_id && ! preg_match( '/^G-[A-Z0-9]+$/', $tracking_id ) ) {
     322                        // Keep the working ID instead of switching tracking off because of a typo
     323                        $sanitized['tracking_id'] = $previous;
     324                        if ( 0 === strpos( $tracking_id, 'UA-' ) ) {
     325                                add_settings_error( 'lightweight_ga_settings', 'ua_tracking_id', __( 'Universal Analytics (UA-) IDs are no longer supported because Google shut Universal Analytics down in 2023. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) instead.', 'lightweight-google-analytics' ) );
     326                        } else {
     327                                add_settings_error( 'lightweight_ga_settings', 'invalid_tracking_id', __( 'Invalid Measurement ID. It should look like G-XXXXXXXX and can be found in Google Analytics under Admin > Data Streams.', 'lightweight-google-analytics' ) . ( $previous ? ' ' . __( 'Your previous Measurement ID was kept.', 'lightweight-google-analytics' ) : '' ) );
     328                        }
     329                }
     330
     331                // ! empty() rather than isset(): on the very first save WordPress runs this twice, the second time on the already sanitized values
     332                foreach ( array( 'enable_lightweight_ga', 'disable_display_features', 'consent_mode', 'gpc', 'content_groups', 'debug_mode' ) as $checkbox ) {
     333                        $sanitized[ $checkbox ] = ! empty( $input[ $checkbox ] ) ? 'checked' : '';
    161334                }
    162335
    … …  
    167340
    168341                $sanitized['tracking_code'] = isset( $input['tracking_code'] ) && 'minimalanalytics-inline' === $input['tracking_code'] ? 'minimalanalytics-inline' : 'analytics-js';
     342
     343                // The duplicate tag check runs again with the new settings
     344                delete_transient( 'lightweight_ga_duplicate_scan' );
     345
     346                // Saving the settings answers the post-update request to review them
     347                $state = (array) get_option( 'lightweight_ga_state', array() );
     348                if ( ! empty( $state['review'] ) ) {
     349                        $state['review'] = false;
     350                        update_option( 'lightweight_ga_state', $state );
     351                }
    169352
    170353                return $sanitized;
    … …  
    201384                        'tracking_code'            => __( 'Tracking Code', 'lightweight-google-analytics' ),
    202385                        'consent_mode'             => __( 'Consent Mode', 'lightweight-google-analytics' ),
     386                        'gpc'                      => __( 'Global Privacy Control', 'lightweight-google-analytics' ),
     387                        'content_groups'           => __( 'Content Groups', 'lightweight-google-analytics' ),
    203388                        'debug_mode'               => __( 'Debug Mode', 'lightweight-google-analytics' ),
    204389                );
    205390                foreach ( $fields as $field => $label ) {
    206                         add_settings_field( $field, $label, array( $this, 'render_' . $field . '_field' ), 'lightweight_ga_settings', 'lightweight_ga_settings_section', array( 'class' => str_replace( '_', '-', $field ) ) );
    207                 }
    208         }
    209 
    210         // Tells the admin whether tracking is live, and why they personally will not show up in reports
     391                        // label_for ties each row's title to its input for screen readers
     392                        add_settings_field( $field, $label, array( $this, 'render_' . $field . '_field' ), 'lightweight_ga_settings', 'lightweight_ga_settings_section', array( 'class' => str_replace( '_', '-', $field ), 'label_for' => $field . '_field' ) );
     393                }
     394        }
     395
     396        // Tells the admin whether tracking is live, why they personally will not show up in reports, and what might need attention
    211397        public function status_notice() {
    212398                $settings = lightweight_ga_settings();
    213                 if ( empty( $settings['enable_lightweight_ga'] ) ) {
     399                if ( false === get_option( 'lightweight_ga_settings' ) ) {
     400                        $class   = 'warning';
     401                        $message = __( 'Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) and save to start tracking.', 'lightweight-google-analytics' );
     402                } elseif ( empty( $settings['enable_lightweight_ga'] ) ) {
    214403                        $class   = 'warning';
    215404                        $message = __( 'Tracking is turned off. Tick "Enable Google Analytics" and save to start tracking.', 'lightweight-google-analytics' );
    … …  
    228417                }
    229418                printf( '<div class="notice notice-%s inline" style="margin:15px 0 0"><p>%s</p></div>', esc_attr( $class ), esc_html( $message ) );
     419
     420                foreach ( $this->warnings( $settings ) as $warning ) {
     421                        printf( '<div class="notice notice-warning inline" style="margin:10px 0 0"><p>%s</p></div>', wp_kses_post( $warning ) );
     422                }
     423        }
     424
     425        // Settings combinations that don't do what the site owner probably expects
     426        private function warnings( $settings ) {
     427                $warnings = array();
     428                if ( 'minimalanalytics-inline' === $settings['tracking_code'] && $settings['consent_mode'] && ! lightweight_ga_has_consent_api() ) {
     429                        $warnings[] = __( 'Consent Mode only applies to Google\'s gtag.js. Minimal Analytics runs for every visitor whatever they choose in your consent banner, unless the <a href="https://wordpress.org/plugins/wp-consent-api/" target="_blank" rel="noopener">WP Consent API</a> plugin is active with a consent banner that supports it.', 'lightweight-google-analytics' );
     430                }
     431                if ( lightweight_ga_has_consent_api() && ! $settings['consent_mode'] ) {
     432                        $warnings[] = __( 'The WP Consent API is active, but Consent Mode is off, so Google Analytics ignores your visitors\' choices in your consent banner. Turn on Consent Mode to follow them.', 'lightweight-google-analytics' );
     433                }
     434                if ( $settings['enable_lightweight_ga'] && lightweight_ga_has_ga4_id() ) {
     435                        list( $ga, $gtm ) = $this->other_ga_plugins();
     436                        if ( $ga ) {
     437                                /* translators: %s: plugin name(s) */
     438                                $warnings[] = sprintf( __( '%s is also set up to add Google Analytics to your site, so your page views are probably counted twice. Turn off the tracking code in one of the plugins.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $ga ) ) );
     439                        }
     440                        if ( $gtm ) {
     441                                /* translators: %s: plugin name */
     442                                $warnings[] = sprintf( __( '%s adds a Google Tag Manager container to your site. If that container also sends GA4 page views, they are counted twice.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $gtm ) ) );
     443                        }
     444                }
     445                return $warnings;
     446        }
     447
     448        // Active plugins that are set up to print their own Google Analytics tag (and ones that print a Tag Manager container,
     449        // which may or may not include GA4). Settings outlive deactivated plugins, so each check also needs the plugin active.
     450        private function other_ga_plugins() {
     451                if ( ! function_exists( 'is_plugin_active' ) ) {
     452                        require_once ABSPATH . 'wp-admin/includes/plugin.php';
     453                }
     454                $option = function ( $name, $key ) {
     455                        $value = get_option( $name );
     456                        return is_array( $value ) && isset( $value[ $key ] ) ? $value[ $key ] : '';
     457                };
     458                $monsterinsights = function () {
     459                        return function_exists( 'monsterinsights_get_v4_id' ) && monsterinsights_get_v4_id();
     460                };
     461                $exactmetrics = function () {
     462                        return function_exists( 'exactmetrics_get_v4_id' ) && exactmetrics_get_v4_id();
     463                };
     464                $checks = array(
     465                        'google-site-kit/google-site-kit.php'                  => array( 'Site Kit by Google', function () use ( $option ) {
     466                                return in_array( 'analytics-4', (array) get_option( 'googlesitekit_active_modules' ), true ) && $option( 'googlesitekit_analytics-4_settings', 'useSnippet' ) && $option( 'googlesitekit_analytics-4_settings', 'measurementID' );
     467                        } ),
     468                        'google-analytics-for-wordpress/googleanalytics.php'   => array( 'MonsterInsights', $monsterinsights ),
     469                        'google-analytics-premium/googleanalytics-premium.php' => array( 'MonsterInsights Pro', $monsterinsights ),
     470                        'google-analytics-dashboard-for-wp/gadwp.php'          => array( 'ExactMetrics', $exactmetrics ),
     471                        'exactmetrics-premium/exactmetrics-premium.php'        => array( 'ExactMetrics Pro', $exactmetrics ),
     472                        'wp-analytify/wp-analytify.php'                        => array( 'Analytify', function () use ( $option ) {
     473                                return 'on' === $option( 'wp-analytify-profile', 'install_ga_code' ) && $option( 'wp-analytify-profile', 'profile_for_posts' );
     474                        } ),
     475                        'ga-google-analytics/ga-google-analytics.php'          => array( 'GA Google Analytics', function () use ( $option ) {
     476                                return $option( 'gap_options', 'gap_id' ) && $option( 'gap_options', 'gap_enable' );
     477                        } ),
     478                        'woocommerce-google-analytics-integration/woocommerce-google-analytics-integration.php' => array( 'Google Analytics for WooCommerce', function () use ( $option ) {
     479                                return (bool) $option( 'woocommerce_google_analytics_settings', 'ga_id' );
     480                        } ),
     481                        'host-analyticsjs-local/host-analyticsjs-local.php'    => array( 'CAOS', function () use ( $option ) {
     482                                return $option( 'caos_settings', 'measurement_id' ) && 'manual' !== $option( 'caos_settings', 'script_position' );
     483                        } ),
     484                        'seo-by-rank-math/rank-math.php'                       => array( 'Rank Math', function () use ( $option ) {
     485                                return in_array( 'analytics', (array) get_option( 'rank_math_modules' ), true ) && $option( 'rank_math_google_analytic_options', 'install_code' ) && $option( 'rank_math_google_analytic_options', 'measurement_id' );
     486                        } ),
     487                        'wp-seopress/seopress.php'                             => array( 'SEOPress', function () use ( $option ) {
     488                                return '1' === $option( 'seopress_toggle', 'toggle-google-analytics' ) && '1' === $option( 'seopress_google_analytics_option_name', 'seopress_google_analytics_enable' ) && $option( 'seopress_google_analytics_option_name', 'seopress_google_analytics_ga4' );
     489                        } ),
     490                        'complianz-gdpr/complianz-gpdr.php'                    => array( 'Complianz', function () use ( $option ) {
     491                                return 'google-analytics' === $option( 'cmplz_options', 'compile_statistics' ) && 'no' !== $option( 'cmplz_options', 'configuration_by_complianz' ) && $option( 'cmplz_options', 'ua_code' );
     492                        } ),
     493                );
     494                $ga = array();
     495                foreach ( $checks as $basename => $check ) {
     496                        if ( is_plugin_active( $basename ) && call_user_func( $check[1] ) ) {
     497                                $ga[] = $check[0];
     498                        }
     499                }
     500                $gtm = array();
     501                if ( is_plugin_active( 'duracelltomi-google-tag-manager/duracelltomi-google-tag-manager-for-wordpress.php' ) && $option( 'gtm4wp-options', 'gtm-code' ) ) {
     502                        $gtm[] = 'GTM4WP';
     503                }
     504                return array( array_unique( $ga ), $gtm );
     505        }
     506
     507        // Looks for other Google tags on the home page as a logged-out visitor sees it, which catches tracking code added by the
     508        // theme or a snippet plugin. Runs in the background from the settings page; cached for 12 hours and cleared on save.
     509        public function ajax_scan() {
     510                check_ajax_referer( 'lightweight_ga_scan' );
     511                if ( ! current_user_can( 'manage_options' ) ) {
     512                        wp_send_json( array() );
     513                }
     514                $ids = get_transient( 'lightweight_ga_duplicate_scan' );
     515                if ( ! is_array( $ids ) ) {
     516                        // A cache-busting parameter, never ?tagverify (Site Kit hides its own tag for that)
     517                        $url      = add_query_arg( 'lga_check', time(), home_url( '/' ) );
     518                        $response = wp_remote_get( $url, array(
     519                                'timeout'   => 10,
     520                                'headers'   => array( 'Cache-Control' => 'no-cache' ),
     521                                'sslverify' => apply_filters( 'https_local_ssl_verify', false, $url ),
     522                        ) );
     523                        // A blocked or failed request means "don't know", not "no duplicates"
     524                        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     525                                wp_send_json( array() );
     526                        }
     527                        // Drop this plugin's own tags, then collect the Google tag IDs that are left. IDs are matched rather than gtag.js
     528                        // URLs because some plugins serve gtag.js from the site itself.
     529                        $html = preg_replace( '#<script[^>]*data-lightweight-ga[^>]*>.*?</script>#is', '', wp_remote_retrieve_body( $response ) );
     530                        preg_match_all( '/(?:id=|["\'])((?:G|GT)-[A-Z0-9]{6,12}|GTM-[A-Z0-9]{4,10})(?![A-Z0-9])/', $html, $matches );
     531                        $ids = array_values( array_unique( $matches[1] ) );
     532                        set_transient( 'lightweight_ga_duplicate_scan', $ids, 12 * HOUR_IN_SECONDS );
     533                }
     534                $own      = lightweight_ga_settings()['tracking_id'];
     535                $messages = array();
     536                // A second copy of the site's own ID is the certain duplicate, so it goes first
     537                usort( $ids, function ( $a, $b ) use ( $own ) {
     538                        return ( $b === $own ) - ( $a === $own );
     539                } );
     540                foreach ( $ids as $tag_id ) {
     541                        if ( $tag_id === $own ) {
     542                                /* translators: %s: Measurement ID */
     543                                $messages[] = sprintf( __( 'Your Measurement ID (%s) is also added to your home page by your theme or another plugin, so your page views are counted twice. Remove the other copy of the tracking code.', 'lightweight-google-analytics' ), $tag_id );
     544                        } elseif ( 0 === strpos( $tag_id, 'GTM-' ) ) {
     545                                /* translators: %s: Google Tag Manager container ID */
     546                                $messages[] = sprintf( __( 'A Google Tag Manager container (%s) is on your home page. If it also sends GA4 page views, they are counted twice.', 'lightweight-google-analytics' ), $tag_id );
     547                        } else {
     548                                /* translators: %s: Google tag ID */
     549                                $messages[] = sprintf( __( 'Another Google tag (%s) is on your home page, added by your theme or another plugin. If it also sends page views to Google Analytics, they are counted twice.', 'lightweight-google-analytics' ), $tag_id );
     550                        }
     551                }
     552                wp_send_json( $messages );
    230553        }
    231554
    … …  
    239562                echo '  <h2>' . esc_html__( 'Options', 'lightweight-google-analytics' ) . '</h2>' . "\n";
    240563                $this->status_notice();
     564                $settings = lightweight_ga_settings();
     565                if ( $settings['enable_lightweight_ga'] && lightweight_ga_has_ga4_id() ) {
     566                        // Checks the home page for other Google tags in the background, so a slow loopback request doesn't hold up this page
     567                        echo '<div id="lightweight-ga-scan" aria-live="polite"></div>';
     568                        echo '<script>(function(){var b=new URLSearchParams({action:"lightweight_ga_scan",_ajax_nonce:' . wp_json_encode( wp_create_nonce( 'lightweight_ga_scan' ) ) . '});fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ) ) . ',{method:"POST",credentials:"same-origin",body:b}).then(function(r){return r.json();}).then(function(m){var w=document.getElementById("lightweight-ga-scan");(Array.isArray(m)?m:[]).forEach(function(t){var d=document.createElement("div"),p=document.createElement("p");d.className="notice notice-warning inline";d.style.margin="10px 0 0";p.textContent=t;d.appendChild(p);w.appendChild(d);});}).catch(function(){});})();</script>';
     569                }
    241570                echo '  <form action="options.php" method="post">' . "\n";
    242571
    … …  
    271600        public function render_tracking_id_field() {
    272601                $settings = lightweight_ga_settings();
    273                 echo '<input type="text" name="lightweight_ga_settings[tracking_id]" class="regular-text tracking_id_field" placeholder="G-XXXXXXXX" value="' . esc_attr( $settings['tracking_id'] ) . '">';
    274                 echo '<span class="tracking_id_field_validate" style="display:none;color:#27ae60;font-weight:500;margin-left:0.8em;vertical-align:middle;"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align:middle;"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg> ' . esc_html__( 'Google Analytics 4', 'lightweight-google-analytics' ) . '</span>';
    275                 echo '<p class="description">' . wp_kses_post( __( 'Your Google Analytics 4 Measurement ID, which starts with G-. Find it in <a href="https://analytics.google.com/" target="_blank" rel="noopener">Google Analytics</a> under Admin > Data Streams > your web stream.', 'lightweight-google-analytics' ) ) . '</p>';
     602                echo '<input type="text" name="lightweight_ga_settings[tracking_id]" id="tracking_id_field" class="regular-text tracking_id_field" placeholder="G-XXXXXXXX" value="' . esc_attr( $settings['tracking_id'] ) . '">';
     603                echo '<span class="tracking_id_field_validate" style="display:none;color:#27ae60;font-weight:500;margin-left:0.8em;vertical-align:middle;"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align:middle;" aria-hidden="true"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg> ' . esc_html__( 'Google Analytics 4', 'lightweight-google-analytics' ) . '</span>';
     604                echo '<p class="description">' . wp_kses_post( __( 'Your Google Analytics 4 Measurement ID, which starts with G-. Find it in <a href="https://analytics.google.com/" target="_blank" rel="noopener">Google Analytics</a> under Admin > Data Streams > your web stream. You can also paste Google\'s whole tag snippet and the ID will be picked out of it.', 'lightweight-google-analytics' ) ) . '</p>';
    276605        }
    277606
    278607        public function render_tracking_position_field() {
    279608                $settings = lightweight_ga_settings();
    280                 echo '<select name="lightweight_ga_settings[tracking_position]" class="tracking_position_field">';
     609                echo '<select name="lightweight_ga_settings[tracking_position]" id="tracking_position_field" class="tracking_position_field">';
    281610                echo '<option value="header" ' . selected( $settings['tracking_position'], 'header', false ) . '>' . esc_html__( 'Header (default, recommended)', 'lightweight-google-analytics' ) . '</option>';
    282611                echo '<option value="footer" ' . selected( $settings['tracking_position'], 'footer', false ) . '>' . esc_html__( 'Footer', 'lightweight-google-analytics' ) . '</option>';
    … …  
    298627                        'all'                         => __( 'Track all users (including logged in admins)', 'lightweight-google-analytics' ),
    299628                );
    300                 echo '<select name="lightweight_ga_settings[tracking_level]" class="tracking_level_field">';
     629                echo '<select name="lightweight_ga_settings[tracking_level]" id="tracking_level_field" class="tracking_level_field">';
    301630                foreach ( $levels as $value => $label ) {
    302631                        echo '<option value="' . esc_attr( $value ) . '" ' . selected( $settings['tracking_level'], $value, false ) . '>' . esc_html( $label ) . '</option>';
    … …  
    308637        public function render_tracking_code_field() {
    309638                $settings = lightweight_ga_settings();
    310                 echo '<select name="lightweight_ga_settings[tracking_code]" class="tracking_code_field">';
     639                echo '<select name="lightweight_ga_settings[tracking_code]" id="tracking_code_field" class="tracking_code_field">';
    311640                echo '<option value="analytics-js" ' . selected( $settings['tracking_code'], 'analytics-js', false ) . '>' . esc_html__( "Google's gtag.js (recommended)", 'lightweight-google-analytics' ) . '</option>';
    312641                echo '<option value="minimalanalytics-inline" ' . selected( $settings['tracking_code'], 'minimalanalytics-inline', false ) . '>' . esc_html__( 'Minimal Analytics 4 by idarek (inline, no external request, fewer features)', 'lightweight-google-analytics' ) . '</option>';
    313642                echo '</select>';
    314                 echo '<p class="description">' . wp_kses_post( __( 'Minimal Analytics 4 is a 3.5 KB inline script that sends page views, 90% scrolls, file downloads, outbound clicks and site searches straight to Google Analytics without loading gtag.js. It stores its client and session IDs in localStorage, so it is not affected by Consent Mode. <a href="https://github.com/idarek/minimal-analytics-4" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) ) . '</p>';
     643                echo '<p class="description">' . wp_kses_post( __( 'Minimal Analytics 4 is a 3.5 KB inline script that sends page views, 90% scrolls, file downloads, outbound clicks and site searches straight to Google Analytics without loading gtag.js. It stores its client and session IDs in localStorage, so Consent Mode does not apply to it; with the WP Consent API it waits for statistics consent instead. <a href="https://github.com/idarek/minimal-analytics-4" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) ) . '</p>';
    315644        }
    316645
    317646        public function render_consent_mode_field() {
    318                 $this->checkbox( 'consent_mode', __( 'Enable Google Consent Mode v2', 'lightweight-google-analytics' ), __( 'Analytics and ad storage are denied by default until a cookie consent plugin grants consent with <code>gtag("consent", "update", ...)</code>. Only applies to gtag.js. <a href="https://developers.google.com/tag-platform/security/guides/consent" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) );
     647                $this->checkbox( 'consent_mode', __( 'Enable Google Consent Mode v2', 'lightweight-google-analytics' ), __( 'Analytics and ad storage are denied by default until your cookie consent plugin grants consent. Works automatically with consent plugins that support the <a href="https://wordpress.org/plugins/wp-consent-api/" target="_blank" rel="noopener">WP Consent API</a> (such as Complianz and CookieYes), which also makes Minimal Analytics wait for consent. Other consent plugins need to call <code>gtag("consent", "update", ...)</code> themselves. <a href="https://developers.google.com/tag-platform/security/guides/consent" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) );
     648        }
     649
     650        public function render_gpc_field() {
     651                $this->checkbox( 'gpc', __( 'Honor Global Privacy Control', 'lightweight-google-analytics' ), __( 'Visitors whose browser sends a <a href="https://globalprivacycontrol.org/" target="_blank" rel="noopener">Global Privacy Control</a> signal (an opt-out built into Brave, DuckDuckGo and Firefox) are not measured. Recommended if your site has to follow California\'s CCPA or a similar law that treats the signal as an opt-out request.', 'lightweight-google-analytics' ) );
     652        }
     653
     654        public function render_content_groups_field() {
     655                $this->checkbox( 'content_groups', __( 'Send content groups and authors', 'lightweight-google-analytics' ), __( 'Sends the post\'s category (or the page type, such as Page or Search) as GA4\'s Content group, so you can compare sections of your site in reports. Also sends the author\'s name as an <code>author</code> parameter; to report on it, add an event-scoped custom dimension named <code>author</code> in Google Analytics under Admin > Custom definitions.', 'lightweight-google-analytics' ) );
    319656        }
    320657
  • lightweight-google-analytics/tags/1.7.0/readme.txt

    r3689051 r3742708  
    66Tested up to: 7.1
    77Requires PHP: 7.0
    8 Stable tag: 1.6.0
     8Stable tag: 1.7.0
    99License: GPLv3
    1010License URI: http://www.gnu.org/licenses/gpl.html
    … …  
    1919
    2020* Performance focused with no bloat and never any upsells
    21 * Only needs your GA4 Measurement ID (G-XXXXXXXX)
     21* Only needs your GA4 Measurement ID (G-XXXXXXXX), or paste Google's whole tag snippet and the ID is picked out of it
    2222* Load the tracking code in the header or footer
    2323* Exclude logged-in users by role (admins, editors and authors are excluded by default)
    2424* Post previews and the Customizer are never tracked
    2525* Option to disable Google's advertising features (Google signals)
    26 * Google Consent Mode v2 support for GDPR/CCPA compliance with cookie consent plugins
     26* Google Consent Mode v2 support for GDPR/CCPA compliance, working automatically with consent plugins that support the WP Consent API (such as Complianz and CookieYes)
     27* Option to honor Global Privacy Control, the opt-out signal sent by Brave, DuckDuckGo and Firefox
     28* Optional content groups: the post's category (or page type) as GA4's Content group, plus the author's name, so you can compare sections and authors in reports
     29* Warns you on the settings page when another plugin, your theme or a pasted snippet also adds Google Analytics, so page views aren't counted twice
    2730* Debug Mode for testing in GA4 DebugView
    2831* Optional Minimal Analytics 4 mode: a 3.5 KB inline script that reports page views, scrolls, downloads, outbound clicks and site searches without loading gtag.js at all
    2932* The settings page tells you whether tracking is live and why your own visits may not show up
    30 * Works with WP Rocket's local Google Analytics feature
    3133* Filters for developers to change the gtag config, the consent defaults or when tracking is skipped
    3234
    … …  
    5052        $defaults['region'] = array( 'AT', 'BE', 'DE', 'FR', 'IE', 'NL' );
    5153        return $defaults;
     54} );`
     55
     56Change the content group or author sent with each page (when Content Groups is on), or add more page-level parameters:
     57
     58`add_filter( 'lightweight_ga_page_dimensions', function ( $dimensions ) {
     59        if ( is_page( 'pricing' ) ) {
     60                $dimensions['content_group'] = 'Sales';
     61        }
     62        return $dimensions;
    5263} );`
    5364
    … …  
    7889Google Consent Mode v2 tells Google that analytics and advertising storage are denied until the visitor consents. Enable it if you use a cookie consent plugin that updates the consent state through `gtag("consent", "update", ...)` and you need to comply with GDPR or similar rules. It only applies to Google's gtag.js code.
    7990
     91= Which consent plugins work with Consent Mode? =
     92
     93Any consent plugin that supports the [WP Consent API](https://wordpress.org/plugins/wp-consent-api/), such as Complianz and CookieYes, works automatically: install and activate the WP Consent API plugin, then turn on Consent Mode. Statistics consent controls Google Analytics storage and marketing consent controls the advertising storage types, and both follow the visitor's choice when it changes. Note that the WP Consent API treats a site with no consent plugin as having consent. Other consent plugins need to call `gtag("consent", "update", ...)` themselves.
     94
     95= What does the Global Privacy Control option do? =
     96
     97Global Privacy Control is an opt-out signal built into Brave and DuckDuckGo and available in Firefox. California's CCPA and similar US state laws treat it as a request to opt out of the sale or sharing of personal information. When the option is on, visitors who send the signal are not measured: gtag.js gets Google's documented opt-out flag, and Minimal Analytics doesn't run at all.
     98
     99= How do I see content groups and authors in Google Analytics? =
     100
     101Turn on Content Groups in the plugin settings. The content group shows up under the built-in "Content group" dimension in reports such as Pages and screens. To report on authors, add an event-scoped custom dimension named `author` in Google Analytics under Admin > Custom definitions; it fills in from then on, usually within 48 hours.
     102
     103= Why does the settings page say my page views are counted twice? =
     104
     105The plugin checks whether another analytics plugin is set up to add Google Analytics too (such as Site Kit, MonsterInsights or Rank Math), and looks at your home page for other Google tags added by your theme or a code snippet plugin. Remove the extra copy so each visit is only counted once. Tags that only appear on some pages, or only after cookie consent, can't be detected.
     106
    80107= What is the Minimal Analytics 4 option? =
    81108
    82 It replaces Google's gtag.js with [Minimal Analytics 4](https://github.com/idarek/minimal-analytics-4) by Dariusz Wieckiewicz (MIT), a small inline script that talks to Google Analytics directly. It sends page views, session start and engagement events, 90% scrolls, file downloads, outbound link clicks and site searches, plus UTM campaign attribution. It does not support Consent Mode or Google's enhanced measurement, and it stores its client and session IDs in localStorage, so check your privacy requirements before using it.
     109It replaces Google's gtag.js with [Minimal Analytics 4](https://github.com/idarek/minimal-analytics-4) by Dariusz Wieckiewicz (MIT), a small inline script that talks to Google Analytics directly. It sends page views, session start and engagement events, 90% scrolls, file downloads, outbound link clicks and site searches, plus UTM campaign attribution. It does not support Consent Mode or Google's enhanced measurement, and it stores its client and session IDs in localStorage, so check your privacy requirements before using it. With the WP Consent API and Consent Mode turned on, it waits until the visitor allows statistics before it runs.
    83110
    84111= How do I test if tracking is working? =
    … …  
    95122
    96123== Changelog ==
     124
     125= 1.7.0 =
     126* Fixed: saving the settings for the first time on a new install switched on Consent Mode, Debug Mode and Disable Display Features by themselves (since 1.5.0). Sites updating from an affected version that have any of them on get a one-time notice asking to check them.
     127* Fixed: with Minimal Analytics in the header (the default), page views were sent before the page title existed, so GA4 showed "(not set)" titles, and scrolling at the top of the page caused a JavaScript error.
     128* Fixed: saving a mistyped or pasted Measurement ID cleared the working one and stopped tracking. The previous ID is now kept, and the ID is picked out of a pasted gtag snippet or URL.
     129* Fixed: with the tracking code in the footer, the Consent Mode defaults were printed in the footer too, after consent banners could already update them. They now always go in the head.
     130* Fixed: setup notices were shown to all logged-in users on every admin screen. They now only show to admins, and the setup reminder only on the Dashboard and Plugins screens.
     131* Fixed: the settings page told new installs to tick "Enable Google Analytics" although it was already ticked.
     132* New: works with consent plugins that support the WP Consent API when Consent Mode is on, for gtag.js and Minimal Analytics.
     133* New: option to honor Global Privacy Control.
     134* New: optional content groups (post category or page type) and author names for GA4 reports, with a `lightweight_ga_page_dimensions` filter.
     135* New: duplicate tracking warning when another plugin, the theme or a snippet also adds Google Analytics.
     136* New: a warning when Consent Mode is on but Minimal Analytics would ignore it, and a hint when the WP Consent API is active but Consent Mode is off.
     137* Network super admins are excluded along with administrators, and deleting the plugin on a multisite network removes its settings from every site.
     138* Settings fields are now properly labeled for screen readers.
    97139
    98140= 1.6.0 =
  • lightweight-google-analytics/tags/1.7.0/uninstall.php

    r3689051 r3742708  
    11<?php
    2 // Remove the plugin's only stored data
     2// Remove everything the plugin stores, on every site of a multisite network
    33if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    44        exit;
    55}
    6 delete_option( 'lightweight_ga_settings' );
     6
     7function lightweight_ga_uninstall_site() {
     8        delete_option( 'lightweight_ga_settings' );
     9        delete_option( 'lightweight_ga_state' );
     10        delete_transient( 'lightweight_ga_duplicate_scan' );
     11}
     12
     13if ( is_multisite() ) {
     14        foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
     15                switch_to_blog( $site_id );
     16                lightweight_ga_uninstall_site();
     17                restore_current_blog();
     18        }
     19} else {
     20        lightweight_ga_uninstall_site();
     21}
  • lightweight-google-analytics/trunk/lightweight-google-analytics.php

    r3689051 r3742708  
    44 * Plugin URI: https://wordpress.org/plugins/lightweight-google-analytics/
    55 * Description: Extremely simple plugin to add Google Analytics 4 to your WordPress site using your Measurement ID.
    6  * Version: 1.6.0
     6 * Version: 1.7.0
    77 * Requires at least: 5.7
    88 * Requires PHP: 7.0
    … …  
    1818}
    1919
    20 define( 'LIGHTWEIGHT_GA_VERSION', '1.6.0' );
     20define( 'LIGHTWEIGHT_GA_VERSION', '1.7.0' );
    2121
    2222// Saved settings with defaults filled in
    … …  
    3030                'tracking_code'            => 'analytics-js',
    3131                'consent_mode'             => '',
     32                'gpc'                      => '',
     33                'content_groups'           => '',
    3234                'debug_mode'               => '',
    3335        ) );
    … …  
    4648                $reason = 'preview';
    4749        } elseif ( is_user_logged_in() ) {
     50                $roles = explode( '-', $settings['tracking_level'] );
    4851                if ( 'logged-in' === $settings['tracking_level'] ) {
    4952                        $reason = 'logged-in';
    50                 } elseif ( 'all' !== $settings['tracking_level'] && array_intersect( explode( '-', $settings['tracking_level'] ), (array) wp_get_current_user()->roles ) ) {
     53                } elseif ( 'all' !== $settings['tracking_level'] && ( array_intersect( $roles, (array) wp_get_current_user()->roles ) || ( is_multisite() && is_super_admin() && in_array( 'administrator', $roles, true ) ) ) ) {
     54                        // Network super admins don't always have the administrator role on the site itself
    5155                        $reason = 'role';
    5256                }
    … …  
    5761}
    5862
    59 // Print the tracking code in the head or footer, whichever the settings say
     63// GA4 content group (the post's category, the post type, or the kind of archive) and author name for the current page
     64function lightweight_ga_page_dimensions() {
     65        $group  = '';
     66        $author = '';
     67        if ( is_front_page() ) {
     68                $group = __( 'Home', 'lightweight-google-analytics' );
     69        } elseif ( is_home() ) {
     70                $group = __( 'Blog', 'lightweight-google-analytics' );
     71        } elseif ( is_singular() && get_queried_object() instanceof WP_Post ) {
     72                $post = get_queried_object();
     73                if ( 'post' === $post->post_type ) {
     74                        $categories = get_the_category( $post->ID );
     75                        // Prefer the primary category picked in Yoast SEO or Rank Math
     76                        $primary = (int) get_post_meta( $post->ID, '_yoast_wpseo_primary_category', true ) ?: (int) get_post_meta( $post->ID, 'rank_math_primary_category', true );
     77                        foreach ( $categories as $category ) {
     78                                if ( $category->term_id === $primary ) {
     79                                        $group = $category->name;
     80                                }
     81                        }
     82                        if ( ! $group && $categories ) {
     83                                $group = $categories[0]->name;
     84                        }
     85                } else {
     86                        $type  = get_post_type_object( $post->post_type );
     87                        $group = $type ? $type->labels->singular_name : $post->post_type;
     88                }
     89                if ( post_type_supports( $post->post_type, 'author' ) ) {
     90                        $author = get_the_author_meta( 'display_name', $post->post_author );
     91                }
     92        } elseif ( is_category() || is_tag() || is_tax() ) {
     93                $group = single_term_title( '', false );
     94        } elseif ( is_post_type_archive() ) {
     95                $group = post_type_archive_title( '', false );
     96        } elseif ( is_author() ) {
     97                $group  = __( 'Author archive', 'lightweight-google-analytics' );
     98                $author = get_the_author_meta( 'display_name', get_queried_object_id() );
     99        } elseif ( is_date() ) {
     100                $group = __( 'Date archive', 'lightweight-google-analytics' );
     101        } elseif ( is_search() ) {
     102                $group = __( 'Search', 'lightweight-google-analytics' );
     103        } elseif ( is_404() ) {
     104                $group = __( 'Page not found', 'lightweight-google-analytics' );
     105        }
     106        // Change or add page-level parameters, e.g. a custom content group for a section of the site
     107        $dimensions = array_filter( (array) apply_filters( 'lightweight_ga_page_dimensions', array( 'content_group' => $group, 'author' => $author ) ), 'strlen' );
     108        // GA4 ignores parameter values longer than 100 characters
     109        return array_map( function ( $value ) {
     110                return function_exists( 'mb_substr' ) ? mb_substr( (string) $value, 0, 100 ) : substr( (string) $value, 0, 100 );
     111        }, $dimensions );
     112}
     113
     114// The WP Consent API (wordpress.org/plugins/wp-consent-api) lets consent banners share visitors' choices with other plugins.
     115// Its functions only exist after plugins_loaded, so this is checked at output time.
     116function lightweight_ga_has_consent_api() {
     117        return function_exists( 'wp_has_consent' );
     118}
     119
     120// Tell the WP Consent API (and its Site Health check) that tracking follows visitors' choices, which is true while Consent Mode is on
     121add_filter( 'wp_consent_api_registered_' . plugin_basename( __FILE__ ), function () {
     122        return (bool) lightweight_ga_settings()['consent_mode'];
     123} );
     124
     125// JS that waits for the WP Consent API to know the visitor's choices, then calls `callback` with wp_has_consent and again whenever they change.
     126// The API's script loads in the footer, so the first check runs once the page is parsed. Consent banners that set
     127// waitfor_consent_hook decide the consent type late; checking earlier would read "no banner" as consent.
     128function lightweight_ga_consent_api_js( $callback ) {
     129        return '(function(){var c=' . $callback . ',s=function(){if(typeof wp_has_consent==="function"){c();}},w=function(){window.waitfor_consent_hook?document.addEventListener("wp_consent_type_defined",s):s();};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",w):w();document.addEventListener("wp_listen_for_consent_change",s);})();';
     130}
     131
     132// Print the tracking code in the head or footer, whichever the settings say. Consent Mode defaults always go in the
     133// head, so they come before any consent banner's update even when the tracking code itself is in the footer.
    60134add_action( 'wp_head', 'lightweight_ga_display', 0 );
    61135add_action( 'wp_footer', 'lightweight_ga_display', 0 );
    62136function lightweight_ga_display() {
    63137        $settings = lightweight_ga_settings();
    64         $hook     = 'footer' === $settings['tracking_position'] ? 'wp_footer' : 'wp_head';
    65         if ( current_action() !== $hook || empty( $settings['enable_lightweight_ga'] ) || ! lightweight_ga_has_ga4_id() || lightweight_ga_excluded_reason() ) {
     138        if ( empty( $settings['enable_lightweight_ga'] ) || ! lightweight_ga_has_ga4_id() || lightweight_ga_excluded_reason() ) {
    66139                return;
    67140        }
    68         $id = $settings['tracking_id'];
     141        $id          = $settings['tracking_id'];
     142        $in_head     = 'wp_head' === current_action();
     143        $is_position = $in_head ? 'footer' !== $settings['tracking_position'] : 'footer' === $settings['tracking_position'];
    69144
    70145        if ( 'minimalanalytics-inline' === $settings['tracking_code'] ) {
    71                 $script = file_get_contents( __DIR__ . '/js/minimal-analytics-4.min.js' );
    72                 if ( ! $script ) {
    73                         return;
    74                 }
    75                 // The bundled script is configured by swapping the placeholder ID and the debug flag that sit at its top; check both anchors when updating the file
    76                 $script = str_replace(
    77                         array( 'tid:"G-XXXXXXXXXX"', ']},T=!1,' ),
    78                         array( 'tid:"' . esc_js( $id ) . '"', ']},T=' . ( $settings['debug_mode'] ? '!0' : '!1' ) . ',' ),
    79                         $script
    80                 );
    81                 echo "<!-- Minimal Analytics 4 - Lightweight Google Analytics -->\n";
    82                 wp_print_inline_script_tag( $script );
     146                if ( $is_position ) {
     147                        lightweight_ga_print_minimal_analytics( $settings );
     148                }
    83149                return;
    84150        }
    85151
    86         echo "<!-- Google tag (gtag.js) - Lightweight Google Analytics -->\n";
    87152        $gtag_stub = 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}';
    88 
    89         if ( $settings['consent_mode'] ) {
     153        // Google's documented opt-out flag stops GA4 sending anything for visitors whose browser sends Global Privacy Control.
     154        // It has to be set before any gtag() call, so it leads every inline script.
     155        if ( $settings['gpc'] ) {
     156                $gtag_stub = 'if(navigator.globalPrivacyControl){window["ga-disable-' . esc_js( $id ) . '"]=true;}' . $gtag_stub;
     157        }
     158
     159        if ( $in_head && $settings['consent_mode'] ) {
    90160                // Consent Mode v2 defaults have to be set before gtag.js loads; the consent banner then calls gtag("consent","update",...)
    91161                $consent = apply_filters( 'lightweight_ga_consent_defaults', array(
    … …  
    94164                        'ad_user_data'       => 'denied',
    95165                        'ad_personalization' => 'denied',
    96                         'wait_for_update'    => 500,
     166                        // How long Google waits for the consent update before sending anything; it carries on as soon as the update
     167                        // arrives. With the WP Consent API the update comes once the page is parsed (its script is in the footer), so
     168                        // allow for slow pages. Consent banners that call gtag themselves usually update early in the head.
     169                        'wait_for_update'    => lightweight_ga_has_consent_api() ? 2000 : 500,
    97170                ) );
    98                 wp_print_inline_script_tag( $gtag_stub . 'gtag("consent","default",' . wp_json_encode( $consent ) . ');' );
    99         }
    100 
     171                $update = '';
     172                if ( lightweight_ga_has_consent_api() ) {
     173                        // Pass the visitor's choices from any consent banner that supports the WP Consent API on to Google
     174                        // Banners such as Complianz change categories one at a time, so only send an update when the result changes
     175                        $update = lightweight_ga_consent_api_js( '(function(){var l;return function(){var s=wp_has_consent("statistics")?"granted":"denied",m=wp_has_consent("marketing")?"granted":"denied";if(s+m===l){return;}l=s+m;gtag("consent","update",{analytics_storage:s,ad_storage:m,ad_user_data:m,ad_personalization:m});};})()' );
     176                }
     177                echo "<!-- Google Consent Mode defaults - Lightweight Google Analytics -->\n";
     178                wp_print_inline_script_tag( $gtag_stub . 'gtag("consent","default",' . wp_json_encode( $consent ) . ');' . $update, array( 'data-lightweight-ga' => true ) );
     179        }
     180
     181        if ( ! $is_position ) {
     182                return;
     183        }
     184
     185        echo "<!-- Google tag (gtag.js) - Lightweight Google Analytics -->\n";
    101186        wp_print_script_tag( array(
    102                 'src'   => 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ),
    103                 'async' => true,
     187                'src'                 => 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ),
     188                'async'               => true,
     189                'data-lightweight-ga' => true, // marks this plugin's own tags for the duplicate tracking check
    104190        ) );
    105191
    … …  
    111197                $config['debug_mode'] = true;
    112198        }
     199        if ( $settings['content_groups'] ) {
     200                $config = array_merge( $config, lightweight_ga_page_dimensions() );
     201        }
    113202        // Add or change gtag config options, e.g. send_page_view, cookie_flags or custom dimensions
    114203        $config = apply_filters( 'lightweight_ga_gtag_config', $config, $id );
    115         wp_print_inline_script_tag( $gtag_stub . 'gtag("js",new Date());gtag("config","' . esc_js( $id ) . '"' . ( $config ? ',' . wp_json_encode( $config ) : '' ) . ');' );
     204        wp_print_inline_script_tag( $gtag_stub . 'gtag("js",new Date());gtag("config","' . esc_js( $id ) . '"' . ( $config ? ',' . wp_json_encode( $config ) : '' ) . ');', array( 'data-lightweight-ga' => true ) );
     205}
     206
     207// Minimal Analytics 4 is configured by swapping values in the bundled script; check all three anchors whenever the file is updated
     208function lightweight_ga_print_minimal_analytics( $settings ) {
     209        $script = file_get_contents( __DIR__ . '/js/minimal-analytics-4.min.js' );
     210        if ( ! $script ) {
     211                return;
     212        }
     213        // Content group and author ride along with every hit as event parameters
     214        $params = '';
     215        foreach ( $settings['content_groups'] ? lightweight_ga_page_dimensions() : array() as $key => $value ) {
     216                $params .= wp_json_encode( 'ep.' . $key ) . ':' . wp_json_encode( $value ) . ',';
     217        }
     218        $script = str_replace(
     219                array( 'tid:"G-XXXXXXXXXX"', ']},T=!1,', '_dbg:T?1:0[0]' ),
     220                array( 'tid:"' . esc_js( $settings['tracking_id'] ) . '"', ']},T=' . ( $settings['debug_mode'] ? '!0' : '!1' ) . ',', $params . '_dbg:T?1:0[0]' ),
     221                $script
     222        );
     223        // Run once the page is parsed: from the head it would otherwise send its page view before <title> exists and read a null document.body
     224        $run = 'document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();';
     225        if ( $settings['consent_mode'] && lightweight_ga_has_consent_api() ) {
     226                // Minimal Analytics has no Consent Mode, so with the WP Consent API it only starts once the visitor allows statistics
     227                $run = 'var d=0;' . lightweight_ga_consent_api_js( 'function(){if(!d&&wp_has_consent("statistics")){d=1;r();}}' );
     228        }
     229        if ( $settings['gpc'] ) {
     230                $run = 'if(!navigator.globalPrivacyControl){' . $run . '}';
     231        }
     232        echo "<!-- Minimal Analytics 4 - Lightweight Google Analytics -->\n";
     233        wp_print_inline_script_tag( '(function(){var r=function(){' . $script . "\n};" . $run . '})();', array( 'data-lightweight-ga' => true ) );
    116234}
    117235
    … …  
    121239                add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
    122240                add_action( 'admin_init', array( $this, 'init_settings' ) );
     241                add_action( 'admin_init', array( $this, 'maybe_upgrade' ) );
    123242                add_action( 'admin_notices', array( $this, 'admin_notices' ) );
     243                add_action( 'wp_ajax_lightweight_ga_scan', array( $this, 'ajax_scan' ) );
    124244                add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), array( $this, 'add_settings_link' ) );
    125245        }
    126246
     247        // Remembers the plugin version so an update can run one-off checks
     248        public function maybe_upgrade() {
     249                $state = (array) get_option( 'lightweight_ga_state', array() );
     250                if ( isset( $state['version'] ) && LIGHTWEIGHT_GA_VERSION === $state['version'] ) {
     251                        return;
     252                }
     253                if ( empty( $state['version'] ) && false !== get_option( 'lightweight_ga_settings' ) ) {
     254                        // 1.5.0 to 1.6.0 ticked every checkbox the first time the settings were saved, so ask the site owner to check them
     255                        $settings        = lightweight_ga_settings();
     256                        $state['review'] = $settings['consent_mode'] || $settings['debug_mode'] || $settings['disable_display_features'];
     257                }
     258                $state['version'] = LIGHTWEIGHT_GA_VERSION;
     259                update_option( 'lightweight_ga_state', $state );
     260        }
     261
    127262        public function add_settings_link( $links ) {
    128                 $links[] = '<a href="' . esc_url( menu_page_url( 'lightweight-google-analytics', false ) ) . '">' . esc_html__( 'Settings', 'lightweight-google-analytics' ) . '</a>';
     263                $links[] = '<a href="' . esc_url( admin_url( 'options-general.php?page=lightweight-google-analytics' ) ) . '">' . esc_html__( 'Settings', 'lightweight-google-analytics' ) . '</a>';
    129264                return $links;
    130265        }
    131266
    132267        public function admin_notices() {
    133                 $settings = lightweight_ga_settings();
    134                 $url      = esc_url( menu_page_url( 'lightweight-google-analytics', false ) );
     268                if ( ! current_user_can( 'manage_options' ) ) {
     269                        return;
     270                }
     271                $settings = lightweight_ga_settings();
     272                $screen   = function_exists( 'get_current_screen' ) && get_current_screen() ? get_current_screen()->id : '';
     273                $url      = esc_url( admin_url( 'options-general.php?page=lightweight-google-analytics' ) );
     274                $state    = (array) get_option( 'lightweight_ga_state', array() );
     275
     276                if ( ! empty( $state['review'] ) ) {
     277                        $on = array();
     278                        foreach ( array( 'consent_mode' => __( 'Consent Mode', 'lightweight-google-analytics' ), 'debug_mode' => __( 'Debug Mode', 'lightweight-google-analytics' ), 'disable_display_features' => __( 'Disable Display Features', 'lightweight-google-analytics' ) ) as $key => $label ) {
     279                                if ( $settings[ $key ] ) {
     280                                        $on[] = $label;
     281                                }
     282                        }
     283                        /* translators: 1: list of setting names, 2: link to the plugin settings page */
     284                        $message = sprintf( __( '<strong>Lightweight Google Analytics:</strong> a bug in versions 1.5.0 to 1.6.0 could switch settings on by themselves the first time they were saved. These are on for this site: %1$s. Consent Mode without a consent banner and Debug Mode can both reduce the data you see in Google Analytics. Please check them in the %2$s and save.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $on ) ), '<a href="' . $url . '">' . esc_html__( 'plugin settings', 'lightweight-google-analytics' ) . '</a>' );
     285                        printf( '<div class="notice notice-warning"><p>%s</p></div>', wp_kses_post( $message ) );
     286                }
     287
     288                // The settings page shows its own status message
     289                if ( 'settings_page_lightweight-google-analytics' === $screen ) {
     290                        return;
     291                }
    135292                if ( 0 === strpos( $settings['tracking_id'], 'UA-' ) ) {
    136293                        /* translators: %s: link to the plugin settings page */
    137294                        $message = sprintf( __( '<strong>Lightweight Google Analytics:</strong> Google shut down Universal Analytics in 2023 and UA- IDs are no longer supported, so no tracking code is being output. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) in the %s.', 'lightweight-google-analytics' ), '<a href="' . $url . '">' . esc_html__( 'plugin settings', 'lightweight-google-analytics' ) . '</a>' );
    138295                        printf( '<div class="notice notice-warning"><p>%s</p></div>', wp_kses_post( $message ) );
    139                 } elseif ( '' === $settings['tracking_id'] ) {
     296                } elseif ( '' === $settings['tracking_id'] && in_array( $screen, array( 'dashboard', 'plugins' ), true ) ) {
    140297                        /* translators: %s: link to the plugin settings page */
    141298                        $message = sprintf( __( 'Thank you for installing <strong>Lightweight Google Analytics</strong> - head to the %s to finish setting up Google Analytics.', 'lightweight-google-analytics' ), '<a href="' . $url . '">' . esc_html__( 'settings', 'lightweight-google-analytics' ) . '</a>' );
    … …  
    145302
    146303        public function sanitize_settings( $input ) {
    147                 $input     = (array) $input;
    148                 $sanitized = array( 'tracking_id' => '' );
    149 
    150                 $tracking_id = isset( $input['tracking_id'] ) ? strtoupper( trim( sanitize_text_field( $input['tracking_id'] ) ) ) : '';
    151                 if ( preg_match( '/^G-[A-Z0-9]+$/', $tracking_id ) ) {
    152                         $sanitized['tracking_id'] = $tracking_id;
    153                 } elseif ( 0 === strpos( $tracking_id, 'UA-' ) ) {
    154                         add_settings_error( 'lightweight_ga_settings', 'ua_tracking_id', __( 'Universal Analytics (UA-) IDs are no longer supported because Google shut Universal Analytics down in 2023. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) instead.', 'lightweight-google-analytics' ) );
    155                 } elseif ( '' !== $tracking_id ) {
    156                         add_settings_error( 'lightweight_ga_settings', 'invalid_tracking_id', __( 'Invalid Measurement ID. It should look like G-XXXXXXXX and can be found in Google Analytics under Admin > Data Streams.', 'lightweight-google-analytics' ) );
    157                 }
    158 
    159                 foreach ( array( 'enable_lightweight_ga', 'disable_display_features', 'consent_mode', 'debug_mode' ) as $checkbox ) {
    160                         $sanitized[ $checkbox ] = isset( $input[ $checkbox ] ) ? 'checked' : '';
     304                $input    = (array) $input;
     305                $previous = lightweight_ga_settings()['tracking_id'];
     306
     307                // Accept the ID itself, or a pasted gtag snippet or URL that contains it. The ID is looked for in the raw text because
     308                // sanitize_text_field() strips <script> blocks whole, URL included; only the matched letters and digits are ever kept.
     309                $raw = isset( $input['tracking_id'] ) && is_string( $input['tracking_id'] ) ? strtoupper( trim( $input['tracking_id'] ) ) : '';
     310                if ( preg_match( '/^G-[A-Z0-9]+$/', $raw ) ) {
     311                        $tracking_id = $raw;
     312                } elseif ( preg_match( '/(?<![A-Z0-9])G-[A-Z0-9]{4,}(?![A-Z0-9])/', $raw, $found ) ) {
     313                        $tracking_id = $found[0];
     314                        /* translators: %s: the Measurement ID found in the pasted text */
     315                        add_settings_error( 'lightweight_ga_settings', 'extracted_tracking_id', sprintf( __( 'Found the Measurement ID %s in the pasted text and saved it.', 'lightweight-google-analytics' ), $tracking_id ), 'info' );
     316                } else {
     317                        $tracking_id = sanitize_text_field( $raw );
     318                }
     319
     320                $sanitized = array( 'tracking_id' => $tracking_id );
     321                if ( '' !== $tracking_id && ! preg_match( '/^G-[A-Z0-9]+$/', $tracking_id ) ) {
     322                        // Keep the working ID instead of switching tracking off because of a typo
     323                        $sanitized['tracking_id'] = $previous;
     324                        if ( 0 === strpos( $tracking_id, 'UA-' ) ) {
     325                                add_settings_error( 'lightweight_ga_settings', 'ua_tracking_id', __( 'Universal Analytics (UA-) IDs are no longer supported because Google shut Universal Analytics down in 2023. Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) instead.', 'lightweight-google-analytics' ) );
     326                        } else {
     327                                add_settings_error( 'lightweight_ga_settings', 'invalid_tracking_id', __( 'Invalid Measurement ID. It should look like G-XXXXXXXX and can be found in Google Analytics under Admin > Data Streams.', 'lightweight-google-analytics' ) . ( $previous ? ' ' . __( 'Your previous Measurement ID was kept.', 'lightweight-google-analytics' ) : '' ) );
     328                        }
     329                }
     330
     331                // ! empty() rather than isset(): on the very first save WordPress runs this twice, the second time on the already sanitized values
     332                foreach ( array( 'enable_lightweight_ga', 'disable_display_features', 'consent_mode', 'gpc', 'content_groups', 'debug_mode' ) as $checkbox ) {
     333                        $sanitized[ $checkbox ] = ! empty( $input[ $checkbox ] ) ? 'checked' : '';
    161334                }
    162335
    … …  
    167340
    168341                $sanitized['tracking_code'] = isset( $input['tracking_code'] ) && 'minimalanalytics-inline' === $input['tracking_code'] ? 'minimalanalytics-inline' : 'analytics-js';
     342
     343                // The duplicate tag check runs again with the new settings
     344                delete_transient( 'lightweight_ga_duplicate_scan' );
     345
     346                // Saving the settings answers the post-update request to review them
     347                $state = (array) get_option( 'lightweight_ga_state', array() );
     348                if ( ! empty( $state['review'] ) ) {
     349                        $state['review'] = false;
     350                        update_option( 'lightweight_ga_state', $state );
     351                }
    169352
    170353                return $sanitized;
    … …  
    201384                        'tracking_code'            => __( 'Tracking Code', 'lightweight-google-analytics' ),
    202385                        'consent_mode'             => __( 'Consent Mode', 'lightweight-google-analytics' ),
     386                        'gpc'                      => __( 'Global Privacy Control', 'lightweight-google-analytics' ),
     387                        'content_groups'           => __( 'Content Groups', 'lightweight-google-analytics' ),
    203388                        'debug_mode'               => __( 'Debug Mode', 'lightweight-google-analytics' ),
    204389                );
    205390                foreach ( $fields as $field => $label ) {
    206                         add_settings_field( $field, $label, array( $this, 'render_' . $field . '_field' ), 'lightweight_ga_settings', 'lightweight_ga_settings_section', array( 'class' => str_replace( '_', '-', $field ) ) );
    207                 }
    208         }
    209 
    210         // Tells the admin whether tracking is live, and why they personally will not show up in reports
     391                        // label_for ties each row's title to its input for screen readers
     392                        add_settings_field( $field, $label, array( $this, 'render_' . $field . '_field' ), 'lightweight_ga_settings', 'lightweight_ga_settings_section', array( 'class' => str_replace( '_', '-', $field ), 'label_for' => $field . '_field' ) );
     393                }
     394        }
     395
     396        // Tells the admin whether tracking is live, why they personally will not show up in reports, and what might need attention
    211397        public function status_notice() {
    212398                $settings = lightweight_ga_settings();
    213                 if ( empty( $settings['enable_lightweight_ga'] ) ) {
     399                if ( false === get_option( 'lightweight_ga_settings' ) ) {
     400                        $class   = 'warning';
     401                        $message = __( 'Enter your Google Analytics 4 Measurement ID (G-XXXXXXXX) and save to start tracking.', 'lightweight-google-analytics' );
     402                } elseif ( empty( $settings['enable_lightweight_ga'] ) ) {
    214403                        $class   = 'warning';
    215404                        $message = __( 'Tracking is turned off. Tick "Enable Google Analytics" and save to start tracking.', 'lightweight-google-analytics' );
    … …  
    228417                }
    229418                printf( '<div class="notice notice-%s inline" style="margin:15px 0 0"><p>%s</p></div>', esc_attr( $class ), esc_html( $message ) );
     419
     420                foreach ( $this->warnings( $settings ) as $warning ) {
     421                        printf( '<div class="notice notice-warning inline" style="margin:10px 0 0"><p>%s</p></div>', wp_kses_post( $warning ) );
     422                }
     423        }
     424
     425        // Settings combinations that don't do what the site owner probably expects
     426        private function warnings( $settings ) {
     427                $warnings = array();
     428                if ( 'minimalanalytics-inline' === $settings['tracking_code'] && $settings['consent_mode'] && ! lightweight_ga_has_consent_api() ) {
     429                        $warnings[] = __( 'Consent Mode only applies to Google\'s gtag.js. Minimal Analytics runs for every visitor whatever they choose in your consent banner, unless the <a href="https://wordpress.org/plugins/wp-consent-api/" target="_blank" rel="noopener">WP Consent API</a> plugin is active with a consent banner that supports it.', 'lightweight-google-analytics' );
     430                }
     431                if ( lightweight_ga_has_consent_api() && ! $settings['consent_mode'] ) {
     432                        $warnings[] = __( 'The WP Consent API is active, but Consent Mode is off, so Google Analytics ignores your visitors\' choices in your consent banner. Turn on Consent Mode to follow them.', 'lightweight-google-analytics' );
     433                }
     434                if ( $settings['enable_lightweight_ga'] && lightweight_ga_has_ga4_id() ) {
     435                        list( $ga, $gtm ) = $this->other_ga_plugins();
     436                        if ( $ga ) {
     437                                /* translators: %s: plugin name(s) */
     438                                $warnings[] = sprintf( __( '%s is also set up to add Google Analytics to your site, so your page views are probably counted twice. Turn off the tracking code in one of the plugins.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $ga ) ) );
     439                        }
     440                        if ( $gtm ) {
     441                                /* translators: %s: plugin name */
     442                                $warnings[] = sprintf( __( '%s adds a Google Tag Manager container to your site. If that container also sends GA4 page views, they are counted twice.', 'lightweight-google-analytics' ), esc_html( implode( ', ', $gtm ) ) );
     443                        }
     444                }
     445                return $warnings;
     446        }
     447
     448        // Active plugins that are set up to print their own Google Analytics tag (and ones that print a Tag Manager container,
     449        // which may or may not include GA4). Settings outlive deactivated plugins, so each check also needs the plugin active.
     450        private function other_ga_plugins() {
     451                if ( ! function_exists( 'is_plugin_active' ) ) {
     452                        require_once ABSPATH . 'wp-admin/includes/plugin.php';
     453                }
     454                $option = function ( $name, $key ) {
     455                        $value = get_option( $name );
     456                        return is_array( $value ) && isset( $value[ $key ] ) ? $value[ $key ] : '';
     457                };
     458                $monsterinsights = function () {
     459                        return function_exists( 'monsterinsights_get_v4_id' ) && monsterinsights_get_v4_id();
     460                };
     461                $exactmetrics = function () {
     462                        return function_exists( 'exactmetrics_get_v4_id' ) && exactmetrics_get_v4_id();
     463                };
     464                $checks = array(
     465                        'google-site-kit/google-site-kit.php'                  => array( 'Site Kit by Google', function () use ( $option ) {
     466                                return in_array( 'analytics-4', (array) get_option( 'googlesitekit_active_modules' ), true ) && $option( 'googlesitekit_analytics-4_settings', 'useSnippet' ) && $option( 'googlesitekit_analytics-4_settings', 'measurementID' );
     467                        } ),
     468                        'google-analytics-for-wordpress/googleanalytics.php'   => array( 'MonsterInsights', $monsterinsights ),
     469                        'google-analytics-premium/googleanalytics-premium.php' => array( 'MonsterInsights Pro', $monsterinsights ),
     470                        'google-analytics-dashboard-for-wp/gadwp.php'          => array( 'ExactMetrics', $exactmetrics ),
     471                        'exactmetrics-premium/exactmetrics-premium.php'        => array( 'ExactMetrics Pro', $exactmetrics ),
     472                        'wp-analytify/wp-analytify.php'                        => array( 'Analytify', function () use ( $option ) {
     473                                return 'on' === $option( 'wp-analytify-profile', 'install_ga_code' ) && $option( 'wp-analytify-profile', 'profile_for_posts' );
     474                        } ),
     475                        'ga-google-analytics/ga-google-analytics.php'          => array( 'GA Google Analytics', function () use ( $option ) {
     476                                return $option( 'gap_options', 'gap_id' ) && $option( 'gap_options', 'gap_enable' );
     477                        } ),
     478                        'woocommerce-google-analytics-integration/woocommerce-google-analytics-integration.php' => array( 'Google Analytics for WooCommerce', function () use ( $option ) {
     479                                return (bool) $option( 'woocommerce_google_analytics_settings', 'ga_id' );
     480                        } ),
     481                        'host-analyticsjs-local/host-analyticsjs-local.php'    => array( 'CAOS', function () use ( $option ) {
     482                                return $option( 'caos_settings', 'measurement_id' ) && 'manual' !== $option( 'caos_settings', 'script_position' );
     483                        } ),
     484                        'seo-by-rank-math/rank-math.php'                       => array( 'Rank Math', function () use ( $option ) {
     485                                return in_array( 'analytics', (array) get_option( 'rank_math_modules' ), true ) && $option( 'rank_math_google_analytic_options', 'install_code' ) && $option( 'rank_math_google_analytic_options', 'measurement_id' );
     486                        } ),
     487                        'wp-seopress/seopress.php'                             => array( 'SEOPress', function () use ( $option ) {
     488                                return '1' === $option( 'seopress_toggle', 'toggle-google-analytics' ) && '1' === $option( 'seopress_google_analytics_option_name', 'seopress_google_analytics_enable' ) && $option( 'seopress_google_analytics_option_name', 'seopress_google_analytics_ga4' );
     489                        } ),
     490                        'complianz-gdpr/complianz-gpdr.php'                    => array( 'Complianz', function () use ( $option ) {
     491                                return 'google-analytics' === $option( 'cmplz_options', 'compile_statistics' ) && 'no' !== $option( 'cmplz_options', 'configuration_by_complianz' ) && $option( 'cmplz_options', 'ua_code' );
     492                        } ),
     493                );
     494                $ga = array();
     495                foreach ( $checks as $basename => $check ) {
     496                        if ( is_plugin_active( $basename ) && call_user_func( $check[1] ) ) {
     497                                $ga[] = $check[0];
     498                        }
     499                }
     500                $gtm = array();
     501                if ( is_plugin_active( 'duracelltomi-google-tag-manager/duracelltomi-google-tag-manager-for-wordpress.php' ) && $option( 'gtm4wp-options', 'gtm-code' ) ) {
     502                        $gtm[] = 'GTM4WP';
     503                }
     504                return array( array_unique( $ga ), $gtm );
     505        }
     506
     507        // Looks for other Google tags on the home page as a logged-out visitor sees it, which catches tracking code added by the
     508        // theme or a snippet plugin. Runs in the background from the settings page; cached for 12 hours and cleared on save.
     509        public function ajax_scan() {
     510                check_ajax_referer( 'lightweight_ga_scan' );
     511                if ( ! current_user_can( 'manage_options' ) ) {
     512                        wp_send_json( array() );
     513                }
     514                $ids = get_transient( 'lightweight_ga_duplicate_scan' );
     515                if ( ! is_array( $ids ) ) {
     516                        // A cache-busting parameter, never ?tagverify (Site Kit hides its own tag for that)
     517                        $url      = add_query_arg( 'lga_check', time(), home_url( '/' ) );
     518                        $response = wp_remote_get( $url, array(
     519                                'timeout'   => 10,
     520                                'headers'   => array( 'Cache-Control' => 'no-cache' ),
     521                                'sslverify' => apply_filters( 'https_local_ssl_verify', false, $url ),
     522                        ) );
     523                        // A blocked or failed request means "don't know", not "no duplicates"
     524                        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     525                                wp_send_json( array() );
     526                        }
     527                        // Drop this plugin's own tags, then collect the Google tag IDs that are left. IDs are matched rather than gtag.js
     528                        // URLs because some plugins serve gtag.js from the site itself.
     529                        $html = preg_replace( '#<script[^>]*data-lightweight-ga[^>]*>.*?</script>#is', '', wp_remote_retrieve_body( $response ) );
     530                        preg_match_all( '/(?:id=|["\'])((?:G|GT)-[A-Z0-9]{6,12}|GTM-[A-Z0-9]{4,10})(?![A-Z0-9])/', $html, $matches );
     531                        $ids = array_values( array_unique( $matches[1] ) );
     532                        set_transient( 'lightweight_ga_duplicate_scan', $ids, 12 * HOUR_IN_SECONDS );
     533                }
     534                $own      = lightweight_ga_settings()['tracking_id'];
     535                $messages = array();
     536                // A second copy of the site's own ID is the certain duplicate, so it goes first
     537                usort( $ids, function ( $a, $b ) use ( $own ) {
     538                        return ( $b === $own ) - ( $a === $own );
     539                } );
     540                foreach ( $ids as $tag_id ) {
     541                        if ( $tag_id === $own ) {
     542                                /* translators: %s: Measurement ID */
     543                                $messages[] = sprintf( __( 'Your Measurement ID (%s) is also added to your home page by your theme or another plugin, so your page views are counted twice. Remove the other copy of the tracking code.', 'lightweight-google-analytics' ), $tag_id );
     544                        } elseif ( 0 === strpos( $tag_id, 'GTM-' ) ) {
     545                                /* translators: %s: Google Tag Manager container ID */
     546                                $messages[] = sprintf( __( 'A Google Tag Manager container (%s) is on your home page. If it also sends GA4 page views, they are counted twice.', 'lightweight-google-analytics' ), $tag_id );
     547                        } else {
     548                                /* translators: %s: Google tag ID */
     549                                $messages[] = sprintf( __( 'Another Google tag (%s) is on your home page, added by your theme or another plugin. If it also sends page views to Google Analytics, they are counted twice.', 'lightweight-google-analytics' ), $tag_id );
     550                        }
     551                }
     552                wp_send_json( $messages );
    230553        }
    231554
    … …  
    239562                echo '  <h2>' . esc_html__( 'Options', 'lightweight-google-analytics' ) . '</h2>' . "\n";
    240563                $this->status_notice();
     564                $settings = lightweight_ga_settings();
     565                if ( $settings['enable_lightweight_ga'] && lightweight_ga_has_ga4_id() ) {
     566                        // Checks the home page for other Google tags in the background, so a slow loopback request doesn't hold up this page
     567                        echo '<div id="lightweight-ga-scan" aria-live="polite"></div>';
     568                        echo '<script>(function(){var b=new URLSearchParams({action:"lightweight_ga_scan",_ajax_nonce:' . wp_json_encode( wp_create_nonce( 'lightweight_ga_scan' ) ) . '});fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ) ) . ',{method:"POST",credentials:"same-origin",body:b}).then(function(r){return r.json();}).then(function(m){var w=document.getElementById("lightweight-ga-scan");(Array.isArray(m)?m:[]).forEach(function(t){var d=document.createElement("div"),p=document.createElement("p");d.className="notice notice-warning inline";d.style.margin="10px 0 0";p.textContent=t;d.appendChild(p);w.appendChild(d);});}).catch(function(){});})();</script>';
     569                }
    241570                echo '  <form action="options.php" method="post">' . "\n";
    242571
    … …  
    271600        public function render_tracking_id_field() {
    272601                $settings = lightweight_ga_settings();
    273                 echo '<input type="text" name="lightweight_ga_settings[tracking_id]" class="regular-text tracking_id_field" placeholder="G-XXXXXXXX" value="' . esc_attr( $settings['tracking_id'] ) . '">';
    274                 echo '<span class="tracking_id_field_validate" style="display:none;color:#27ae60;font-weight:500;margin-left:0.8em;vertical-align:middle;"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align:middle;"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg> ' . esc_html__( 'Google Analytics 4', 'lightweight-google-analytics' ) . '</span>';
    275                 echo '<p class="description">' . wp_kses_post( __( 'Your Google Analytics 4 Measurement ID, which starts with G-. Find it in <a href="https://analytics.google.com/" target="_blank" rel="noopener">Google Analytics</a> under Admin > Data Streams > your web stream.', 'lightweight-google-analytics' ) ) . '</p>';
     602                echo '<input type="text" name="lightweight_ga_settings[tracking_id]" id="tracking_id_field" class="regular-text tracking_id_field" placeholder="G-XXXXXXXX" value="' . esc_attr( $settings['tracking_id'] ) . '">';
     603                echo '<span class="tracking_id_field_validate" style="display:none;color:#27ae60;font-weight:500;margin-left:0.8em;vertical-align:middle;"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="vertical-align:middle;" aria-hidden="true"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg> ' . esc_html__( 'Google Analytics 4', 'lightweight-google-analytics' ) . '</span>';
     604                echo '<p class="description">' . wp_kses_post( __( 'Your Google Analytics 4 Measurement ID, which starts with G-. Find it in <a href="https://analytics.google.com/" target="_blank" rel="noopener">Google Analytics</a> under Admin > Data Streams > your web stream. You can also paste Google\'s whole tag snippet and the ID will be picked out of it.', 'lightweight-google-analytics' ) ) . '</p>';
    276605        }
    277606
    278607        public function render_tracking_position_field() {
    279608                $settings = lightweight_ga_settings();
    280                 echo '<select name="lightweight_ga_settings[tracking_position]" class="tracking_position_field">';
     609                echo '<select name="lightweight_ga_settings[tracking_position]" id="tracking_position_field" class="tracking_position_field">';
    281610                echo '<option value="header" ' . selected( $settings['tracking_position'], 'header', false ) . '>' . esc_html__( 'Header (default, recommended)', 'lightweight-google-analytics' ) . '</option>';
    282611                echo '<option value="footer" ' . selected( $settings['tracking_position'], 'footer', false ) . '>' . esc_html__( 'Footer', 'lightweight-google-analytics' ) . '</option>';
    … …  
    298627                        'all'                         => __( 'Track all users (including logged in admins)', 'lightweight-google-analytics' ),
    299628                );
    300                 echo '<select name="lightweight_ga_settings[tracking_level]" class="tracking_level_field">';
     629                echo '<select name="lightweight_ga_settings[tracking_level]" id="tracking_level_field" class="tracking_level_field">';
    301630                foreach ( $levels as $value => $label ) {
    302631                        echo '<option value="' . esc_attr( $value ) . '" ' . selected( $settings['tracking_level'], $value, false ) . '>' . esc_html( $label ) . '</option>';
    … …  
    308637        public function render_tracking_code_field() {
    309638                $settings = lightweight_ga_settings();
    310                 echo '<select name="lightweight_ga_settings[tracking_code]" class="tracking_code_field">';
     639                echo '<select name="lightweight_ga_settings[tracking_code]" id="tracking_code_field" class="tracking_code_field">';
    311640                echo '<option value="analytics-js" ' . selected( $settings['tracking_code'], 'analytics-js', false ) . '>' . esc_html__( "Google's gtag.js (recommended)", 'lightweight-google-analytics' ) . '</option>';
    312641                echo '<option value="minimalanalytics-inline" ' . selected( $settings['tracking_code'], 'minimalanalytics-inline', false ) . '>' . esc_html__( 'Minimal Analytics 4 by idarek (inline, no external request, fewer features)', 'lightweight-google-analytics' ) . '</option>';
    313642                echo '</select>';
    314                 echo '<p class="description">' . wp_kses_post( __( 'Minimal Analytics 4 is a 3.5 KB inline script that sends page views, 90% scrolls, file downloads, outbound clicks and site searches straight to Google Analytics without loading gtag.js. It stores its client and session IDs in localStorage, so it is not affected by Consent Mode. <a href="https://github.com/idarek/minimal-analytics-4" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) ) . '</p>';
     643                echo '<p class="description">' . wp_kses_post( __( 'Minimal Analytics 4 is a 3.5 KB inline script that sends page views, 90% scrolls, file downloads, outbound clicks and site searches straight to Google Analytics without loading gtag.js. It stores its client and session IDs in localStorage, so Consent Mode does not apply to it; with the WP Consent API it waits for statistics consent instead. <a href="https://github.com/idarek/minimal-analytics-4" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) ) . '</p>';
    315644        }
    316645
    317646        public function render_consent_mode_field() {
    318                 $this->checkbox( 'consent_mode', __( 'Enable Google Consent Mode v2', 'lightweight-google-analytics' ), __( 'Analytics and ad storage are denied by default until a cookie consent plugin grants consent with <code>gtag("consent", "update", ...)</code>. Only applies to gtag.js. <a href="https://developers.google.com/tag-platform/security/guides/consent" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) );
     647                $this->checkbox( 'consent_mode', __( 'Enable Google Consent Mode v2', 'lightweight-google-analytics' ), __( 'Analytics and ad storage are denied by default until your cookie consent plugin grants consent. Works automatically with consent plugins that support the <a href="https://wordpress.org/plugins/wp-consent-api/" target="_blank" rel="noopener">WP Consent API</a> (such as Complianz and CookieYes), which also makes Minimal Analytics wait for consent. Other consent plugins need to call <code>gtag("consent", "update", ...)</code> themselves. <a href="https://developers.google.com/tag-platform/security/guides/consent" target="_blank" rel="noopener">Learn more</a>', 'lightweight-google-analytics' ) );
     648        }
     649
     650        public function render_gpc_field() {
     651                $this->checkbox( 'gpc', __( 'Honor Global Privacy Control', 'lightweight-google-analytics' ), __( 'Visitors whose browser sends a <a href="https://globalprivacycontrol.org/" target="_blank" rel="noopener">Global Privacy Control</a> signal (an opt-out built into Brave, DuckDuckGo and Firefox) are not measured. Recommended if your site has to follow California\'s CCPA or a similar law that treats the signal as an opt-out request.', 'lightweight-google-analytics' ) );
     652        }
     653
     654        public function render_content_groups_field() {
     655                $this->checkbox( 'content_groups', __( 'Send content groups and authors', 'lightweight-google-analytics' ), __( 'Sends the post\'s category (or the page type, such as Page or Search) as GA4\'s Content group, so you can compare sections of your site in reports. Also sends the author\'s name as an <code>author</code> parameter; to report on it, add an event-scoped custom dimension named <code>author</code> in Google Analytics under Admin > Custom definitions.', 'lightweight-google-analytics' ) );
    319656        }
    320657
  • lightweight-google-analytics/trunk/readme.txt

    r3689051 r3742708  
    66Tested up to: 7.1
    77Requires PHP: 7.0
    8 Stable tag: 1.6.0
     8Stable tag: 1.7.0
    99License: GPLv3
    1010License URI: http://www.gnu.org/licenses/gpl.html
    … …  
    1919
    2020* Performance focused with no bloat and never any upsells
    21 * Only needs your GA4 Measurement ID (G-XXXXXXXX)
     21* Only needs your GA4 Measurement ID (G-XXXXXXXX), or paste Google's whole tag snippet and the ID is picked out of it
    2222* Load the tracking code in the header or footer
    2323* Exclude logged-in users by role (admins, editors and authors are excluded by default)
    2424* Post previews and the Customizer are never tracked
    2525* Option to disable Google's advertising features (Google signals)
    26 * Google Consent Mode v2 support for GDPR/CCPA compliance with cookie consent plugins
     26* Google Consent Mode v2 support for GDPR/CCPA compliance, working automatically with consent plugins that support the WP Consent API (such as Complianz and CookieYes)
     27* Option to honor Global Privacy Control, the opt-out signal sent by Brave, DuckDuckGo and Firefox
     28* Optional content groups: the post's category (or page type) as GA4's Content group, plus the author's name, so you can compare sections and authors in reports
     29* Warns you on the settings page when another plugin, your theme or a pasted snippet also adds Google Analytics, so page views aren't counted twice
    2730* Debug Mode for testing in GA4 DebugView
    2831* Optional Minimal Analytics 4 mode: a 3.5 KB inline script that reports page views, scrolls, downloads, outbound clicks and site searches without loading gtag.js at all
    2932* The settings page tells you whether tracking is live and why your own visits may not show up
    30 * Works with WP Rocket's local Google Analytics feature
    3133* Filters for developers to change the gtag config, the consent defaults or when tracking is skipped
    3234
    … …  
    5052        $defaults['region'] = array( 'AT', 'BE', 'DE', 'FR', 'IE', 'NL' );
    5153        return $defaults;
     54} );`
     55
     56Change the content group or author sent with each page (when Content Groups is on), or add more page-level parameters:
     57
     58`add_filter( 'lightweight_ga_page_dimensions', function ( $dimensions ) {
     59        if ( is_page( 'pricing' ) ) {
     60                $dimensions['content_group'] = 'Sales';
     61        }
     62        return $dimensions;
    5263} );`
    5364
    … …  
    7889Google Consent Mode v2 tells Google that analytics and advertising storage are denied until the visitor consents. Enable it if you use a cookie consent plugin that updates the consent state through `gtag("consent", "update", ...)` and you need to comply with GDPR or similar rules. It only applies to Google's gtag.js code.
    7990
     91= Which consent plugins work with Consent Mode? =
     92
     93Any consent plugin that supports the [WP Consent API](https://wordpress.org/plugins/wp-consent-api/), such as Complianz and CookieYes, works automatically: install and activate the WP Consent API plugin, then turn on Consent Mode. Statistics consent controls Google Analytics storage and marketing consent controls the advertising storage types, and both follow the visitor's choice when it changes. Note that the WP Consent API treats a site with no consent plugin as having consent. Other consent plugins need to call `gtag("consent", "update", ...)` themselves.
     94
     95= What does the Global Privacy Control option do? =
     96
     97Global Privacy Control is an opt-out signal built into Brave and DuckDuckGo and available in Firefox. California's CCPA and similar US state laws treat it as a request to opt out of the sale or sharing of personal information. When the option is on, visitors who send the signal are not measured: gtag.js gets Google's documented opt-out flag, and Minimal Analytics doesn't run at all.
     98
     99= How do I see content groups and authors in Google Analytics? =
     100
     101Turn on Content Groups in the plugin settings. The content group shows up under the built-in "Content group" dimension in reports such as Pages and screens. To report on authors, add an event-scoped custom dimension named `author` in Google Analytics under Admin > Custom definitions; it fills in from then on, usually within 48 hours.
     102
     103= Why does the settings page say my page views are counted twice? =
     104
     105The plugin checks whether another analytics plugin is set up to add Google Analytics too (such as Site Kit, MonsterInsights or Rank Math), and looks at your home page for other Google tags added by your theme or a code snippet plugin. Remove the extra copy so each visit is only counted once. Tags that only appear on some pages, or only after cookie consent, can't be detected.
     106
    80107= What is the Minimal Analytics 4 option? =
    81108
    82 It replaces Google's gtag.js with [Minimal Analytics 4](https://github.com/idarek/minimal-analytics-4) by Dariusz Wieckiewicz (MIT), a small inline script that talks to Google Analytics directly. It sends page views, session start and engagement events, 90% scrolls, file downloads, outbound link clicks and site searches, plus UTM campaign attribution. It does not support Consent Mode or Google's enhanced measurement, and it stores its client and session IDs in localStorage, so check your privacy requirements before using it.
     109It replaces Google's gtag.js with [Minimal Analytics 4](https://github.com/idarek/minimal-analytics-4) by Dariusz Wieckiewicz (MIT), a small inline script that talks to Google Analytics directly. It sends page views, session start and engagement events, 90% scrolls, file downloads, outbound link clicks and site searches, plus UTM campaign attribution. It does not support Consent Mode or Google's enhanced measurement, and it stores its client and session IDs in localStorage, so check your privacy requirements before using it. With the WP Consent API and Consent Mode turned on, it waits until the visitor allows statistics before it runs.
    83110
    84111= How do I test if tracking is working? =
    … …  
    95122
    96123== Changelog ==
     124
     125= 1.7.0 =
     126* Fixed: saving the settings for the first time on a new install switched on Consent Mode, Debug Mode and Disable Display Features by themselves (since 1.5.0). Sites updating from an affected version that have any of them on get a one-time notice asking to check them.
     127* Fixed: with Minimal Analytics in the header (the default), page views were sent before the page title existed, so GA4 showed "(not set)" titles, and scrolling at the top of the page caused a JavaScript error.
     128* Fixed: saving a mistyped or pasted Measurement ID cleared the working one and stopped tracking. The previous ID is now kept, and the ID is picked out of a pasted gtag snippet or URL.
     129* Fixed: with the tracking code in the footer, the Consent Mode defaults were printed in the footer too, after consent banners could already update them. They now always go in the head.
     130* Fixed: setup notices were shown to all logged-in users on every admin screen. They now only show to admins, and the setup reminder only on the Dashboard and Plugins screens.
     131* Fixed: the settings page told new installs to tick "Enable Google Analytics" although it was already ticked.
     132* New: works with consent plugins that support the WP Consent API when Consent Mode is on, for gtag.js and Minimal Analytics.
     133* New: option to honor Global Privacy Control.
     134* New: optional content groups (post category or page type) and author names for GA4 reports, with a `lightweight_ga_page_dimensions` filter.
     135* New: duplicate tracking warning when another plugin, the theme or a snippet also adds Google Analytics.
     136* New: a warning when Consent Mode is on but Minimal Analytics would ignore it, and a hint when the WP Consent API is active but Consent Mode is off.
     137* Network super admins are excluded along with administrators, and deleting the plugin on a multisite network removes its settings from every site.
     138* Settings fields are now properly labeled for screen readers.
    97139
    98140= 1.6.0 =
  • lightweight-google-analytics/trunk/uninstall.php

    r3689051 r3742708  
    11<?php
    2 // Remove the plugin's only stored data
     2// Remove everything the plugin stores, on every site of a multisite network
    33if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    44        exit;
    55}
    6 delete_option( 'lightweight_ga_settings' );
     6
     7function lightweight_ga_uninstall_site() {
     8        delete_option( 'lightweight_ga_settings' );
     9        delete_option( 'lightweight_ga_state' );
     10        delete_transient( 'lightweight_ga_duplicate_scan' );
     11}
     12
     13if ( is_multisite() ) {
     14        foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
     15                switch_to_blog( $site_id );
     16                lightweight_ga_uninstall_site();
     17                restore_current_blog();
     18        }
     19} else {
     20        lightweight_ga_uninstall_site();
     21}
Note: See TracChangeset for help on using the changeset viewer.