I’ve released a new library named “Return Of The CustomTask” which as the name it self suggest brings back the Universal Analytics Custom Task functionality to Google Analytics 4.
It’s an Open Source library under the Apache 2.0 license, that uses Fetch Interceptors to mimic the behavior on the old friend the customTask
. The idea of having the change to modify the current GA4 Payload before it gets sent to Google Analytics Servers.
The library consist on a single file that accepts a list of callbacks ( customTasks ) that will be applied to our request.
These tasks will be applied sequentially, meaning you can easily apply more than one action, such as checking for PII and removing duplicate purchase events.
At the same time, I took some time to find all the possible custom tasks by searching on Google, and I’ve already replicated and made them available for everyone. In many cases I’ve even make them even better than the originals 🙂
I must advise that this is a very technical approach to getting things done, so use it at your own risk. If you’re not a developer, consider seeking help rather than just trying to copy and paste. There’re out there so many great Analytics Engineers and Programmers ( including myself ) that will be able to help on having things setup in the best and more safe way.
Note: In the coming days, I will be writing specific posts for each of the tasks to ensure that their usage is clear for everyone. In any case, each task folder on GitHub has a README with the basic details to help set things up.
First Step: Grab the GA4CustomTask code
After building the library you’ll find all the code within the dist/ folder
. The code is provided in minified format and non-minified way. Since you’re not likely going to need to change anything here, i would select the dist/GA4CustomTask.js code ( use the minified code better ). Now they only thing we need to do is adding it into a Custom Html tag
on Google Tag Manager o in other TMS or your page source.
Important Note: This code needs to be run BEFORE GA4 loads, my advise is using the Initialization Trigger or using a Setup Tag on the GA4 Config Tag. We should need to change anything at this point so just copy paste the code. ( Since you won’t need to change anything here, just use the minified code: https://raw.githubusercontent.com/analytics-debugger/Return-Of-The-Custom-Task/refs/heads/main/dist/GA4CustomTask.min.js )
If you using this library without GTM or using another other TMS the logic should be the same, fire it before GTAG Code.
<script>
// dist/GACustomTask.js
// Use the linkj on the top link for updated code
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.GA4CustomTask = factory());
})(this, (function () { 'use strict';
// Check if the URL belongs to GA4
function isGA4Hit(url) {
try {
var urlObj = new URL(url);
var params = new URLSearchParams(urlObj.search);
var tid = params.get('tid');
var cid = params.get('cid');
var v = params.get('v');
return !!tid && tid.startsWith('G-') && !!cid && v === '2';
}
catch (e) {
console.error('Error parsing URL:', e);
return false;
}
}
var interceptors = [];
// Interceptor function to handle fetch requests and responses
function interceptor(fetch, args) {
var reversedInterceptors = interceptors.reduce(function (array, interceptor) { return [interceptor].concat(array); }, []);
var promise = Promise.resolve(args);
// Apply request interceptors (resolve to FetchArgs)
reversedInterceptors.forEach(function (_a) {
var request = _a.request, requestError = _a.requestError;
if (request || requestError) {
promise = promise.then(function (args) { return (request ? request.apply(void 0, args) : args); }, requestError);
}
});
// Proceed with the original fetch call (resolve to Response)
var responsePromise = promise.then(function (args) { return fetch(args[0], args[1]); });
// Apply response interceptors (resolve to Response)
reversedInterceptors.forEach(function (_a) {
var response = _a.response, responseError = _a.responseError;
if (response || responseError) {
responsePromise = responsePromise.then(response, responseError);
}
});
return responsePromise;
}
var GA4CustomTask = function (settings) {
if (!settings)
return;
interceptors.push({
request: function (resource, options) {
if (options === void 0) { options = {}; }
try {
if (typeof resource === 'string' && isGA4Hit(resource)) {
var url = new URL(resource);
var RequestModel_1 = {
endpoint: url.origin + url.pathname,
sharedPayload: null,
events: [],
};
var payloadArray = Array.from(new URLSearchParams(url.search).entries());
if (!options.body) {
RequestModel_1.sharedPayload = Object.fromEntries(payloadArray.slice(0, payloadArray.findIndex(function (_a) {
var key = _a[0];
return key === 'en';
})));
RequestModel_1.events = [
Object.fromEntries(payloadArray.slice(payloadArray.findIndex(function (_a) {
var key = _a[0];
return key === 'en';
})))
];
}
else {
RequestModel_1.sharedPayload = Object.fromEntries(payloadArray);
RequestModel_1.events = options.body
.split('\r\n')
.map(function (e) { return Object.fromEntries(new URLSearchParams(e).entries()); });
}
var payload = Object.fromEntries(new URLSearchParams(url.search));
if (settings.allowedMeasurementIds &&
Array.isArray(settings.allowedMeasurementIds) &&
!settings.allowedMeasurementIds.includes(payload['tid'])) {
return [resource, options];
}
if (Array.isArray(settings.tasks)) {
settings.tasks.forEach(function (callback) {
if (typeof callback === 'function') {
RequestModel_1 = callback.call({ originalFetch: GA4CustomTask.originalFetch }, RequestModel_1);
}
else {
console.warn('Callback is not a function:', callback);
}
});
}
var reBuildResource = function (model) {
var resourceString = new URLSearchParams(model.sharedPayload || {}).toString();
var bodyString = model.events.map(function (e) { return new URLSearchParams(e).toString(); }).join('\r\n');
return {
endpoint: model.endpoint,
resource: resourceString,
body: bodyString,
};
};
var newResource = reBuildResource(RequestModel_1);
if (options.body) {
resource = "".concat(newResource.endpoint, "?").concat(newResource.resource);
options.body = newResource.body;
}
else {
resource = "".concat(newResource.endpoint, "?").concat(newResource.resource, "&").concat(newResource.body);
}
}
}
catch (e) {
console.error('Error in fetch interceptor:', e);
}
return [resource, options];
},
response: function (response) {
return response;
},
responseError: function (error) {
return Promise.reject(error);
},
});
// Ensure fetch is available in the environment
window.fetch = (function (fetch) {
return function (resource, options) {
var fetchArgs = [resource, options];
return interceptor(fetch, fetchArgs);
};
})(window.fetch);
return {
clear: function () {
interceptors = [];
},
};
};
// Add original fetch for TypeScript type safety
GA4CustomTask.originalFetch = window.fetch;
return GA4CustomTask;
}));
</script>
We are on the right path, now we’ll have a new class GA4CustomTask
what we can instantiate, for attaching the intercepts to the Fetch API
<script>
{{ CODE FROM GA4CustomTask.min.js }}
var logRequestsToConsoleTask = () => {...}
var task1= () => {...}
var task2= () => {...}
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
logRequestsToConsoleTask,
task1,
task2
]
});
</script>
We need to focus on the highlighted lines. This tool operates over the Fetch function, but typically we only want to intercept GA4 hits. Don’t worry the tool already detects these hits internally in order to intercept just the requests we need. However, what happens if we’re using two Measurement IDs on our site? On line 3, we can specify which Measurement ID
the Custom Task should apply to.
Then we can define the tasks that will be applied to our payload. On GA4CustomTask
is possible to run some chained tasks, and they will sequentially applied. ( The customTask receives the requestModel and returns it back after the task has finished working with it )
Custom Tasks List
I went ahead an migrated all customTasks I found on internet to this new library. You can find the list of them an the source code at the repository as packages on the folder /tasks
Task Name | Description | |
---|---|---|
#1 logRequestsToConsoleTask | Logs all requests to the console, for debugging pourposes | |
#2 mapClientIdTask | Grabs the clientId (&cid) and attaches the value to the specified parameter | |
#3 mapPayloadSizeTask | Attaches the current payload size to the specified parameter | |
#4 preventDuplicateTransactionsTask | Prevents Duplicate Purchases/transaations keeping a list of transactions on the cookies/localStorage | |
#5 snowPlowStreamingTask | Sends a copy of the payload to your SnowPlow Collector | |
#6 sendToSecondaryMeasurementId | Sends a copy of the payload to a secondary account | |
#7 piiScrubberTask | Loops all data in the payload redacting the PII Data | |
#8 privacySweepTask | Cleans Up all non “Analytics” related parameters/ids |
logRequestsToConsoleTask
This tasks prints the current requestModel
to the console. Useful for debugging pourposes. It doesn’t take any parameters
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
logRequestsToConsoleTask
]
});
mapClientIdTask
This task reads the clientId
value a passed it back to all the events on the request , or to the first event if the scoped defined is ‘user’
It accepts 2 parameters, the name to be used for the event parameter / user property and the scope. If the scope is not specified it will be set as ‘event‘
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => mapClientIdTask(requestModel, 'client_id', 'event'),
]
});
mapPayloadSizeTask
This task will calculate the total payload size on the current hit, and map is an event parameter ( number ).
It takes the parameter name as a parameter.
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => mapPayloadSize(requestModel, 'payload_size'),
]
});
preventDuplicateTransactionsTask
This task will intercept all hits containing at least 1 purchase event on the payload. If the current ep.transaction_id
parameter value was already used on the current browser, that specific event will be removed from the request.
This task relies on Cookies and the LocalStorage for keeping the transactions history. and internally keeps for state management system synched, meaning that if the user removed it’s cookies but not the localStorage the data will be replicated back to the cookie ( and same if they remove the localStorage )
It takes the cookie name as an optional value, or default to __ad_trans_dedup by default
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
preventDuplicateTransactions
]
});
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => preventDuplicateTransactions(requestModel, '__transaction_cookie'),
]
});
snowPlowStreamingTask
This task takes the GA4 Payload and sends a copy to the defined snowplow collector endpoint. Since SnowPlow expects one event per request this task generates an individual request for each event on the payload ( keeping the sharedParameter intact )
You can pass the endpoint Hostname as a parameter.
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => snowPlowStreaming(requestModel, endpointHostname),
]
});
sendToSecondaryMeasurementId
What to say about this one, a classic. It will replicate out request to a secondary Measurement Ids
, but this time, It takes 2 extra parameters: a list of whitelisted events and a list of blacklisted one ( this one will take effect it whitelist is not passed or it’s empty )
// This will relay ALL the events
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => sendToSecondaryMeasurementIdTask(requestModel, ["G-SECONDID","G-ANOTHER"], [], []),
]
});
// This will relay only the add_to_cart and purchase events
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => sendToSecondaryMeasurementIdTask(requestModel, ["G-SECONDID","G-ANOTHER"], ["add_to_cart","purchase"], []),
]
});
// This will relay all events but purchase events
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(requestModel) => sendToSecondaryMeasurementIdTask(requestModel, ["G-SECONDID","G-ANOTHER"], [], ["purchase"]),
]
});
privacySweepTask
This task strips out all the parameter that are not related to Analytics, in case we are wrroried about our privacy and the data going to Google. Useful if we are tracking an intranet or some sensitive environment and we want to have some extra privacy added.
You can find the list of current parameters on the repository
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
privacySweepTask
]
});
EventBouncerTask
Lastly (for now), we have our Bounce Task. We can define a list of events that we want to allow through our implementation, preventing all those pesky vendors and script kiddies from pushing events to the gtag()
function to mess with our data.
But not only that, we can define which parameters we want to allow (WhiteListedEventParameters
), which will strip out any parameter that is not listed from the current event.
The function takes a Schema definition object to work
var GA4CustomTaskInstance = new GA4CustomTask({
allowedMeasurementIds: ["G-DEBUGEMALL"],
tasks: [
(request) => eventBouncerTask(requestModel, {
"sharedEventParameters": ["page_type"],
"events": {
"page_view": {
"wlep": []
},
"add_to_cart": {
"wlep": []
}
}
}),
]
});
In the next days we’ll writing a specific post for each task with more specific details about how to use each of the tasks:)
Enjoy.