Skip to main content
  1. Blog Post/

Send Google Analytics 4 [GA4] events to multiple Measurement IDs

5 min · 2218 words
#GA4
Table of Contents

I must admit it, I miss the flexibility the customTasks give to Universal Analytics, and I really hope someone takes a step forward at some point by adding that feature to Google Analytics 4.

In the meanwhile, I was in the need of doing parallel tracking, ie: sending the data to two different Measurement IDs and that would mean duplicating all the event tags on the client account. If it only was this I'd even accept it, but having the setup splitter un duplicated event would mean needing to have the setup synced in the future ( which all of us know how that would likely end )

How are we doing it

While this is in any case is a recommended practice, we can do a trick to forward a copy of the GA4 beacons with a modified Measurement ID . It's based on a technique named "Monkey Patching", we already used this one for our Google Analytics 4 PII Redacting post, but this time we change the logic slightly.

In case you don't know "Monkey Patching" it's a technique that will modify/update the behavior of the previously defined function/method at runtime, without needing the change the original code.

Google Analytics 4, relies on the navigator.sendBeacon browser's API for sending the data, and we're going to intercept that calls to that API in order to be able to capture the current GA4 Hits Payloads and sending a copy.

Setting up Google Tag Manager


There's one thing we need to have in mind and is that this code MUST be run before Google Analytics 4 Config Tag, and for achieving this we'll use the Tag Sequencing on Google Tag Manager.

Custom HTML Tag

But before anything, we need to create a new CUSTOM HTML tag. This is where ALL the stuff is really happening.

(function() {
    // Add your secondary measurement ID(s) here
    var measurementIds = ["G-THYNGSTER-2"];

    // We should not run this twice, if the sendBeacon has been already modified, abort
    if(navigator.sendBeacon && navigator.sendBeacon.toString().indexOf('native code') !== -1){               
        // Helper Convert QueryString to an Object 
        var queryString2Object = function queryString2Object(str) {
            return (str || document.location.search).replace(/(^\?)/, "").split("&").map(function(n) {
                return n = n.split("="),
                this[n[0]] = decodeURIComponent(n[1]),
                this;
            }
            .bind({}))[0];
        };
        // Helper Convert an Object to a QueryString
        var Object2QueryString = function Object2QueryString(obj) {
            return Object.keys(obj).map(function(key) {
                return key + '=' + encodeURIComponent(obj[key]);
            }).join('&');
        };        
        try {
            // Monkey Patch, sendBeacon 
            var proxied = window.navigator.sendBeacon;            
            window.navigator.sendBeacon = function() {
                // Make an arguments copy and modify the Measurement ID
                var args = Array.prototype.slice.call(arguments);
                var _this = this;
                if (args && args[0].match(/analytics\.google\.com|google-analytics\.com.*v\=2\&/)) {
                    var payload = queryString2Object(args[0]);
                    measurementIds.forEach(function(id){
                        payload.tid = id;
                        args[0] = Object2QueryString(payload);
                        proxied.apply(_this, args);                          
                    });                                  
                }
                return proxied.apply(this, arguments);
            }
            ;
        } catch (e) {
            // In case something goes wrong, let's apply back the arguments to the original function
            return proxied.apply(this, arguments);
        }        
    }
}
)();
                                

The trigger

We could be using an "All Pages" trigger, but since Tags are injected asynchronously by GTM into the page, it's safer to use the Tag Sequencing. We only have to link the previously create tag within our GA4 Configuration Tag

Disclaimer

Before going further on this post, I want to say again, while this is a working workaround but a specific need is not, actually, covered by GTAG / Google Analytics 4 . You should run this at your own risk, and I recommend you to follow some people like Simo or Charles which are both on top of all new GA4/GTM related features to be notified if at some point some official functionality comes to GTAG.

How the code works

This snippet code is pretty straightforward, there's only one thing we need to configure, and it's the first variable with the Measurement IDs to where we want to send the data.

I'm trying a new blogging approach for this blog, and on this post and doing a deep walkthrough over the code, I feel this can be of interest to people wanting to learn rather than just wanting to copy and paste the code. At this point, if you're in the last one's group you can skip the rest of the post otherwise I hope I'm doing any good work explaining the code :)

Note: You should only add the secondary account, the main Measurement ID on the GA4 Config that doesn't need to be added here.

var measurementIds = ["G-MEASUREMENTID-1", "G-MEASUREMENTID-2"];
                                

One problem with Monkey Patching functions is that they may have been already modified by some other scripts... So in order to be safe on our side, we're aborting the patching if the current navigator.sendBeacon has been already modified.

if(navigator.sendBeacon && navigator.sendBeacon.toString().indexOf('native code') !== -1){
                                

Next in line are 2 helper functions, queryString2Object and Object2QueryString , these are not needed since we could use a replace or a regex to do the work, but this way everything is cleaner. First5 one takes a query string:

v=2&tid=G-THYNGSTER
                                

And converts it to an Object

{
   v: "1",
   tid: "G-THYNGSTER"
}
                                

Now we can easily update any payload values with no risk of writing a wrong regex or doing a bad text replace. The second function does the inverse task, converts the object back to a QueryString

Now, we'll be wrapping everything between a try && catch statement, if for ANY reason anything fails, we'll send the hit back to the original function. We really want to have the original request to be sent despite the duplicate ones that may fail at some point.

Let's now check how the Monkey Patching is done, first of all, since we're going to modify the original function, we need to save a reference to the original function:

var proxied = window.navigator.sendBeacon; 
                                

In the first place, we want to keep the current call arguments intact, that's why we're doing a copy of them, and then we'll use this copy rather than the original ones.

window.navigator.sendBeacon = function() {
    var args = Array.prototype.slice.call(arguments);
    var _this = this;
                                

Our next check is verifying that the current beacon is for GA4, we don't really want or need to mess around with other hits (again, let's stay in the safe place :) )

if (args && args[0].match(/analytics\.google\.com|google-analytics\.com.*v\=2\&/)) {
                                

Once, we know that the current hit is a GA4 Hit, we'll convert the payload to an object

var payload = queryString2Object(args[0]);
                                

And the last thing we're doing is looping across our secondary measurement IDs while updating the &tid parameter, then finally we send the hit to Google Analytics 4 Endpoint using for that the "original" reference we saved at the start.

measurementIds.forEach(function(id){
    payload.tid = id;
    args[0] = Object2QueryString(payload);
    proxied.apply(_this, args);                          
});   
                                

The last line will take care of sending the original hit ( this is why we don't need to add the main Measurement ID into the configuration )

return proxied.apply(this, arguments);
                                

Well, there's still a final one, the one within the catch statement, as we mentioned before if ANYTHING goes wrong we'll still send back the original hit, this assures that despite the code fails, we'll have our main configured id recording the data.