Send Google Analytics 4 [GA4] events to multiple Measurement IDs
UPDATE JULY 2024
Google Updated their GTAG library to relay on fetch instead of sendBeacon, making the old script to stop working. In any case sendBeacon
uses fetch API
under the hoods ( it's a fetch request that won't expected any return response from Google Analytics Endpoint ). We may expect at some poing Google returning some data back to the browser and that's being the reason behind this update.
In any case there's a new code snippet for Monkey Patching the Fetch function allowing you to send duplicate hits to secondary accounts, and not only this, it's an improved code that will allow you to control which measurementId you want to duplicate (in case you are using more than one)
This is way now you can define the Measure IDs mapping:
// All requests to G-DEBUGEMALL will get a copy to G-REDIRECT-1 and G-REDIRECT-2
var measurementIdsMapping = {
"G-DEBUGEMALL": ["G-REDIRECT-1", "G-REDIRECT-2"]
};
Post Start
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 happens.
<script>
(function() {
// David Vallejo 2024
// Only defined measurementId will be identified and a clone hit will be sent to the defined
// Secondary measurementIds
var measurementIdsMapping = {
"G-DEBUGEMALL": ["G-REDIRECT-1", "G-REDIRECT-2"]
};
// We should not run this twice, if the sendBeacon has been already modified, abort
if (window.fetch && window.fetch.toString().indexOf('native code') !== -1) {
var _window = window,
originalFetch = _window.fetch;
window.fetch = function() {
var resource = arguments[0];
var options = arguments[1];
try{
if (resource && measurementIdsMapping && Object.keys(measurementIdsMapping).length > 0) {
var payload = Object.fromEntries(new URLSearchParams(new URL(resource).search));
if (Object.keys(measurementIdsMapping).includes(payload.tid) && payload.cid) {
measurementIdsMapping[payload.tid].forEach(function(measurementId) {
var beaconBaseUrl = new URL(resource);
beaconBaseUrl.searchParams.set('tid', measurementId);
originalFetch(beaconBaseUrl.toString(), options).catch(function(error) {
console.error('Error in clone hit fetch:', error);
});
});
}
}
}catch(e){}
return originalFetch.apply(this, arguments);
};
}
})();
</script>
<script>
(function() {
// Add your secondary measurement ID(s) here
var measurementIds = ["G-THYNGSTER-2"];
// We should not run this twice, if the fetch has been already modified, abort, jic
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);
}
}
}
)();
</script>
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 ID
s 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 ID
s 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.