Blog

  • How to track AMP / GA4 Pages using Google Tag Manager

    Hi, tomorrow 1st of July is the day when Universal Analytics is being sunsetted for all free account users, 360 accounts will enjoy of an extra year for dealing with the full migration .

    On 26th June, Google luckily announced the official Google Analytics 4 support for AMP pages. Which makes me really happy since in some time from now I won’t need to worried to keep my solution and CDN servers online, (sorry it feels like a lot of responsability to keep something for almost 1B unique users and +3B monthly hits, and also there will be some more money in my pocket )

    I’ve been following the coments on social networks and forums, and it seems there’s a lot of people that were waiting for some AMP/GTM support. But as now 24h to the deadline, there’s no a template for GTM AMP containers.

    I worked on this the past summer, but I didn’t publish it since not having a template on GTM wasn’t making much sense, in any case, I feel this may end help some people (or not…). But I’m showing you how you can do the GA4 tracking on AMP using GTM.

    First of all we need to modify our main <amp-analytics> tag to include the type="googleanalytics" , this is needed for having access to the session information variables.

    <amp-analytics config="https://www.googletagmanager.com/amp.json?id=GTM-THYNGSTER&gtm.url=SOURCE_URL" data-credentials="include" type="googleanalytics">
    </amp-analytics>


    Oki doki, we have the first step, now, current GTM containers for AMP doesn’t have the predefined variables to build a proper Google Analytics 4 hit, for example it’s missing the session_id, session_count, client_hits, and some other details. And here’s were we’re going to make use of the current AMP variables to accordingly calculate this information so we can later or using it in our hits. For this we need to add some vars to our Google Tag Manager configuration, I took care of building everything you, so you just need to copy and paste. ( just change the GTM container ID )

    <amp-analytics config="https://www.googletagmanager.com/amp.json?id=GTM-THYNGSTER&gtm.url=SOURCE_URL" data-credentials="include" type="googleanalytics">
        <script type="application/json">
            {
                "vars": {
                "sid": "$CALC(SESSION_TIMESTAMP, 1000, divide, true)",
    	        "sct": "SESSION_COUNT",
            	"seg": "$IF($EQUALS(SESSION_ENGAGED, true),1,0)",
    	        "_et": "$CALC(INCREMENTAL_ENGAGED_TIME,1000, multiply)",
    	        "gcs": "$IF($EQUALS(${GOOGLE_CONSENT_ENABLED},TRUE),G10$IF($EQUALS(CONSENT_STATE,sufficient),1,0),)",
            	"uaa": "${uach(architecture)}",
    	        "uab": "${uach(bitness)}",
            	"uafvl": "${uach(fullVersionList)}",
    	        "uamb": "$IF($EQUALS($DEFAULT(${uach(mobile)}, EMPTY), EMPTY),,$IF($EQUALS(${uach(mobile)}, false),0,1))",
            	"uam": "${uach(model)}",
    	        "uap": "${uach(platform)}",
            	"uapv": "${uach(platformVersion)}",
    	        "uaw": "$IF($EQUALS($DEFAULT(${uach(wow64)}, EMPTY), EMPTY),,$IF($EQUALS(${uach(wow64)}, false),0,1))",
                "is_first_visit": "$IF($EQUALS($CALC(SESSION_COUNT, $CALC($CALC(${timestamp}, 1000, divide, true),$CALC(SESSION_TIMESTAMP, 1000, divide, true), subtract), add),1), _fv, __nfv)",
                "is_session_start": "$IF($EQUALS($CALC($CALC(${timestamp}, 1000, divide, true),$CALC(SESSION_TIMESTAMP, 1000, divide, true), subtract),0), _ss, __nss)"
                }
            }
        </script>
    </amp-analytics>


    How are we going for now?, easy, isn’t it?. At this point, we have all the necessary data points to properly build a Google Analytics 4 hit payload.

    Now we just need to create some Custom Image tags in GTM for our page_views and events . Just to keep making your life easy, I’m attaching the full main payload you should be using in ALL of our hits. Yuu need to take this and then add your custom details based on the data you want to sent to GA4.

    This is the core custom payload you need to use:

    https://region1.google-analytics.com/g/collect?v=2&tid=G-THYNGSTER&ds=GTM-AMP&_p=${pageViewId}&cid=${clientId}&ul=${browserLanguage}&sr=${screenWidth}x${screenHeight}&_s=${requestCount}&sid=${sid}&sct=${sct}&seg=${seg}&dl=${sourceUrl}&dr=${documentReferrer}&dt=${title}&uua=${uaa}&uab=${uab}&uua=${uaa}&uafvl=${uafvl}&uamb=${uamb}&uam=${uam}&uap=${uap}&uapv=${uapv}&uaw=${uaw}&${is_session_start}=1&${is_first_visit}=1

    Now you only need to add the data related to the event, if you just want to fire a page_view, add the folloowing to the end

    &en=page_view
    or for a custom event name
    &en=outgoing_link

    If you want to add event parameters you need it this way:

    &ep.page_type=homepage // if it's an string
    &epn.page_load_time=122 // if it's a number, note the epN

    If you need to add user properties, it will work this way

    &up.user_name=thyngster // if it's an string
    &upn.life_time_value=234 // if it's a number, note the epN

    Just in case someone got lost, the page_view hit looks like this:

    https://region1.google-analytics.com/g/collect?v=2&tid=G-THYNGSTER&ds=GTM-AMP&_p=${pageViewId}&cid=${clientId}&ul=${browserLanguage}&sr=${screenWidth}x${screenHeight}&_s=${requestCount}&sid=${sid}&sct=${sct}&seg=${seg}&dl=${sourceUrl}&dr=${documentReferrer}&dt=${title}&uua=${uaa}&uab=${uab}&uua=${uaa}&uafvl=${uafvl}&uamb=${uamb}&uam=${uam}&uap=${uap}&uapv=${uapv}&uaw=${uaw}&${is_session_start}=1&${is_first_visit}=1&en=page_view

    At this point i still advice you to update your setup to GTAG tag type on AMP, since it’s not clear at this point if GTM for AMP will be supported in the future, hopefully there will be some announements about this at some point.

    Hope this helps someone in the last minute.

  • Analytics Debugger 2.0.0 Chrome Extension Release

    It took almost 9 months, but it’s finally here. The new Analytics Debugger Extension (Formerly GTM/GA Debugger) hit the 2.0.0 milestone.

    It’s been a full rewrite of the code, mainly to make it fully compliance with the new MV3, but at the same time it’s internal functionality has been improved. Now all the components are loaded asyncronouly, making the extension to use less resources and working fast, and it also make much more easier to add new features.

    The UI has been kept faithful to the original one, but there’re a lot of slight differences that make the reports more clean :). The report that has got the most changes ir the Google Analytics 4 one.

    While debugging your Google Analytics 4 hits/events, you will be able to:

    See the hit event batches
    The server-side generated events (session_start, first_visit, etc )
    On the main report you’ll see the curretn event engagement time, if it’s counting as a conversion, if it has ecommerce.

    • See the events batches grouped but the holding hit request
    • The server-side generated events (session_start, first_visit, etc )
    • On the main report you’ll see the curretn event engagement time, if it’s counting as a conversion, if it has ecommerce.
    • The current used endpoint
    • If it’s a sent to a SGTM server
    • The SGTM response and header ( pixels, and cookies set server side )
    • All the data is presented on a friendly way ( not only the parameters keys )
    • Still you can see the raw payload details
    • The current hit consent mode
    • The session Id and Sessions coount
    • If Google Signals is being used.
    • You can filter the events by the Measurement ID or the event name
    • Event parameters and User properties

    And some much more new features coming in the future stay tuned.

    Real Time Notifications

    The new version, is able to show real-time news/notifications about the current viewed vendor. This will alow me to notify users about service outages or breaking changes on the tools. Of course all this is not automated and I’ll need to be up to date with the news in order to have them showing up on time.

    Amplitude Support

    Another big news, it now supports Amplitude. It support seeing the current hit batches , as usual in a really friendly manner. Clicking the project ID will show up the current tracker/project configuration

    Matomo/Piwik Support

    I also added support for Matomo/Piwik. This report will allow you to see the hits coming in real time, it support the pings, link cliks, purchases. And you can even parse the current hits payload to exactly now what does each payload key means.

    IU Updates

    I really took some time to make the tool configurable, just because not everyone has the same needs. Now you can define which tools to show in your debugging session, and in which order you want the tabs to be shown on the reports:

    The internalization has finally arrived, the extension is now available in English , Spanish and Japanese (kind). Which some more languages coming in the future.

    You can choose if the hit payloads are shown in the way they are send or if you want the payload key sorted out alphabetically

    You can choose the current deep level you want the object to be opened, some people just want the first level to be opened by default, other want to see a deeper level

    Finally you may like to see more data in your screen, or you may use a 4K monitor that makes all very small, you can know setup the zoom level to make your debugging sessions more confortable.

    Support / Bug Reports / Features Proposals

    I’ve opened a Github repository, to track the bugs, you can find it here:

    https://github.com/analytics-debugger/analytics-debugger-browser-extension/issues/new?assignees=thyngster&labels=bug&template=bug_report.md&title=

    Final thoughts

    Please have in mind I rewrite it from scratch, so while I feel it’s more accurate that the previous version, some people may hit bugs on some sites. Just reach me so I can work on them.

    Some more vendors are coming in the next months, just to mention some of them

    • Adobe Analytics
    • Adobe Launch
    • Adobe Target
    • Tealium IQ
    • Yandex Metrika
    • Chartbeat

    And some more debugging features are also coming that will make some of the most complicated debugging tasks a breeze.

  • [Release] iOS/Android Debugger 1.0.0

    It’s been half a year since I released my Android Debugger during the Super Week Punchcard Prize contest. Today it’s a big day for the family, and most logic next step on this travel, I’m adding support for iOS devices.

    This tool is offered “as is” and for free. While you won’t have to pay for it, you’ll be getting some nag screens to invite you to support the development, either grabbing me a coffee or even better becoming a monthly sponsor (๐Ÿ’œ).

    DOWNLOAD iOS ANDROID DEBUGGER 1.0.0

    Android

    The Android version supports debugging your Firebase ( GA4 ), Universal Analytics ( GA3 ) and Google Tag Manager ( basic data ) implementations out of the box for any app on your device. There’s no need for modifying the manifest file, there’s no need to install any certificates, it just works.

    It supports USB and Wireless debugging sessions ( meaning that you won’t need to mess around with the USB Drivers or anything else )

    iOS

    The iOS version works in a totally different way and it uses a proxy to intercept the requests being sent by the device.

    It currently supports debugging features for Firebase( GA4 ), Universal Analytics ( GA3 ) and Adobe Analytics ( sorry this is not currently available for Android )


    Features

    Firebase ( GA4 )

    You’ll be able to see the current events being sent by your app in a very nicely and intuitive way. It event reports the current batches, if hits contains e-commerce details, if the current event is a conversion, the engagment times, all this data is available within the main report.

    Click on an event will give you all the details about the current hit payload, including event the internal data used by firebase in addition to the event name, event parameters and user properties.

    iOS/Android platforms has been synched and the data is reported with the name key names, making even easier to do a cross-platform audits.

    Lastly you’ll be able to copy the events details to the clipboard within a single click.

    Universal Analytics ( GA3 )

    I know, this is legacy stuff, and we’ll should have moved already to Firebase, but that’s not always the case, so we’re keeping this for these who still need to keep an eye on their Universal Analytics implementations.

    This report will show the hits coming from the device to any Universal Analytics Properties in a friendly way, allowing you to copy these hits to the clipboard.

    Google Tag Manager ( only Android )

    This report will show you the current containers, events, and variables being evaluated on your device, since this report relies on the Google Play Services logging service it only works for Android devices.

    While it’s not a full report, it may give you an overall idea of what is happening on Google Tag Manager on your device.



    Adobe Analytics ( only for iOS )

    Since I was not able to offer GTM support, and I wanted to play around with some other tools, I’ve added support for debugging Adobe Analytics implementations.

    This will reports the current Adobe Analytics hits being sent by your devices and will show all the detail in a friendly format. It will report the current event/page name, the current account, and the endpoint being used, along with full payload details.

    As in the other tools, you’ll be able to copy the hits information to your clipboard.

    Splash Screen

    I noticed that I was using a background image for the splash screen and since I was not able to find the author or if it had any copyright. I’ve asked my friend @kroniksan to make some AI generated backgrounds for me, and they look awesome.

    If you are curious about them, you can check more AI generated deviations on his DeviantArt profile: https://www.deviantart.com/kroniksan

    Download

    https://www.analytics-debugger.com/tools/ios-android-debugger

  • [TIP] Slim Events Tracking Library for Web Analysts

    Back in time, I remember relying on jQuery for all the custom event tracking on my client websites, it was really great since it was covering the support for IE8, IE9. Since jQuery dropped the support for the older browser versions, the point of using it for the clicks / interactions tracking has lost most of the sense for me.

    While jQuery is still a really good library it includes a ton of features that us the Web Analysts don’t really need, since a 99% of the times we’ll just be using the CSS selectors engine from jQuery. Meaning that we are loading a lot of JS code that we won’t be using. And you may be right that maybe the site is already using it and that it won’t have any effect of the page loading, but with all the new tecnlogies, React, Vue, Angular, SolidJS, etc, the usage and insterest has been dropping a lot.

    Latest jQuery version weights around 90KB, what about if I tell you that you could mostly replate it by a 300bytes-ish snippet that will cover most of your needs when talking about interactions tracking.

    I’ve been using it for years in some clients without no major issues, it relies on the narive AddEventListener from the browser and takes care of the Event Delegation . If you’re coming from jQuery this equals to using the $.on .

    Here it goes the code:

    var _slimedEventListener = function(element, event, selector, handler, target) {
        element.addEventListener(event, function(evt) {
            for (event = element,
            target = evt.target; target != event; )
                target.matches(selector) ? handler.call(event = target, evt, target) : target = target.parentNode
        })
    };

    The usage would be like:

    _slimedEventListener(document.body, 'mousedown', 'a', function(evt, matched) {
        console.log("clicked element href", matched.getAttribute('href'))
        console.log("clicked element data attributes", matched.dataset)
        console.log("clicked element text", matched.innerText)
    });

    You can grab the current clicked element and it’s data, you could use matched.parentNode , matched.closest, to get and navigate the parent elements or even using the matched.querySelector and matched.querySelectorAll to find some other element withing the returned element.

    As you can see we replace the need of using an external 90KB library dependency, with a 300bytes snippet that will make use of the native browsers functionality. It may take some time to get used to how to grab some elements details ( innerText, dataset, href, getAttribute, hasClass ) instead of the jQuery funciontions names, but I really think that this will boost your code quality and make it more future proof ( sorry, to many clients dropping jQuery at their ends over the last years and breaking everything at my side : ) )

    Hope this helps someone.

  • Performance Timing tracking with Google Analytics 4

    I’m really obsessed with performance, while I may not be the best on that task I’m a real tryhard on that topic, at least up to where my knowledge allows me. And this said, I really miss the Site Speed Report on Google Analytics 4.

    This is why I replicated the current metrics and logic from Universal Analytics, and I sharing it with you on this post. If you end implementing this tracking you’ll have the following metrics avaiable in for your use, for example in data studio.

    The current provided code, will even allow you to set a siteSpeedSampleRate as Universal Analytics.

    Code Snippet

    <script>
    (function() {
    
        var siteSpeedSampleRate = 100;
        var gaCookiename = '_ga';
        var dataLayerName = 'dataLayer';
    
        // No need to edit anything after this line
        var shouldItBeTracked = function(siteSpeedSampleRate) {
            // If we don't pass a sample rate, default value is 1
            if (!siteSpeedSampleRate)
                siteSpeedSampleRate = 1;
            // Generate a hashId from a String
            var hashId = function(a) {
                var b = 1, c;
                if (a)
                    for (b = 0,
                    c = a.length - 1; 0 <= c; c--) {
                        var d = a.charCodeAt(c);
                        b = (b << 6 & 268435455) + d + (d << 14);
                        d = b & 266338304;
                        b = 0 != d ? b ^ d >> 21 : b
                    }
                return b
            }
            var clientId = ('; ' + document.cookie).split('; '+gaCookiename+'=').pop().split(';').shift().split(/GA1\.[0-9]\./)[1];
            if(!clientId) return !1;
            // If, for any reason the sample speed rate is higher than 100, let's keep it to a 100 max value
            var b = Math.min(siteSpeedSampleRate, 100);        
            return hashId(clientId) % 100 >= b ? !1 : !0
        }
    
        if (shouldItBeTracked(siteSpeedSampleRate)) {
            var pt = window.performance || window.webkitPerformance;
            pt = pt && pt.timing;
            if (!pt)
                return;
            if (pt.navigationStart === 0 || pt.loadEventStart === 0)
                return;
            var timingData = {
                "page_load_time": pt.loadEventStart - pt.navigationStart,
                "page_download_time": pt.responseEnd - pt.responseStart,
                "dns_time": pt.domainLookupEnd - pt.domainLookupStart,
                "redirect_response_time": pt.fetchStart - pt.navigationStart,
                "server_response_time": pt.responseStart - pt.requestStart,
                "tcp_connect_time": pt.connectEnd - pt.connectStart,
                "dom_interactive_time": pt.domInteractive - pt.navigationStart,
                "content_load_time": pt.domContentLoadedEventStart - pt.navigationStart
            };
            // Sanity Checks if any value is negative abort
            if (Object.values(timingData).filter(function(e) {
                if (e < 0)
                    return e;
            }).length > 0)
                return;
            window[dataLayerName] && window[dataLayerName].push({
                "event": "performance_timing",
                "timing": timingData
            })
        }
    }
    )() 
    </script>   

    Setting up GTM

    The first thing we need to do it adding the following code snippet above in a Custom HTML tag in Google Tag Manager, that fired on the Window Load event.

    Outcome

    The code we added above will kindly push all the performance timing data in a nicely formated dataLayer push, that we later use to pass the data to any tag/vendor we want. In our case we’ll pushing the data to a GA4 event tag.

    {
    	event: 'performance_timing',
    	timing: {
    		page_load_time: 131,
    		page_download_time: 0,
    		dns_time: 0,
    		redirect_response_time: 1,
    		server_response_time: 34,
    		tcp_connect_time: 0,
    		dom_interactive_time: 63,
    		content_load_time: 63
    	}
    }

    Setting GA4 Event Tag

    Now that we have all the data being push to the dataLayer, we’ll need to create a new GA4 event Tag and maps all the data accordingly, please refer to the following screenshot for all the details.

    Custom Metrics definition in Google Analytics 4

    As you already know, sending a parameter within an event doesn’t mean anything until we map it to a dimension within our property.

    For this we need to go to the Custom Definitions > Custom Metrics, and add all this new parameters with the event scope and Milliseconds as the Unit of Measurement.

    How to view the data

    Now that we started collecting the data, we have some different ways to view it, we could use the GA4 reports ( which will be limiting the reporting possibilities a lot )

    Using the Google Analytics Exploration

    Using Data Studio

    This is my prefered option, you can create AVG metrics in an easy way, AVG metrics in Seconds to match the reports on Universal Analytics.

    For example, I tried to quickly replicate the Universal Site Speed reports using Data Studio without almost no time.

    Using Big Query

    Lastly, if you’re one of the brave analysts around the world, the good news is that the possibilities are now almost enless, is just up to you to play around with this and paint it anywhere you want.

    As a simple example, let’s find out how many and which pages took more than 1 second to load.

    SELECT * ,
       FROM `thyngster.*********.events_20220712`
       WHERE event_name = "performance_timing" AND 
       (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'page_load_time') > 1000

    Metrics List

    Here it goes the list of metrics you can configure and use to replicate the old Universal Analytics reports:

    • Site Speed Events Count
    • Page Load Time (ms) and Avg. Page Load Time (sec)
    • Domain Lookup Time (ms) and Avg. Domain Lookup Time (sec)
    • Page Download Time (ms) and Avg. Page Download Time (sec)
    • Redirection Time (ms) and Avg. Redirection Time (sec)
    • Server Connection Time (ms) and Avg. Server Connection Time (sec)
    • Server Response Time (ms) and Avg. Server Response Time (sec)
    • Document Interactive Time (ms) and Avg. Document Interactive Time
    • Document Content Loaded Time (ms) Avg. Document Content Loaded Time (sec)

    (Extra) How siteSpeedSample Works

    I must confess I’ve never looked at this and I found pretty curious about how Universal Analytics was doing the Site Speed Sampling. I was expecting having it doing a sampling based on hit, but currently the sampling is done on users ( devices ).

    This means that rather than getting details about a 5% of the pageviews, you’re getting the details about the 5% of your visitors instead.

    Universal Analytics has been relying on the clientId value to determine if the current user should be sending the timing details.

  • How to track AMP Pages with Google Analytics 4

    One of the most notorious misses on Google Analytics 4. Is the lack of AMP (Accelerated Mobile Pages) Pages tracking support. While this may not be an issue for many sites, there’re some website types that really need this support (like media sites or magazines).

    That’s why I decided to investigate the possibilities of <amp-analytics> Component and APIs in order to try to build an AMP Native Tracking without needing to draw on some tricky methods like using the infamous iframes.

    I’ve been testing everything I could and everything seems to be working fine. I’m open to receiving feedback from people that may end up trying this solution, which may not end up being perfect, but still is more than what we actually have.

    The good news is that we’ve got all the needed pieces of information available to perform a fully working tracking with Google Analytics 4, including the session tracking and the needed switched to have the, first_visit, session_start, user_engagment events generated, and as an unexpected extra we’ll be able to set event parameters and user properties within our events

    If you feel it you could buy me some coffees to support my work, this time I’m even hosting a copy of the file myself to ease the work (which depending on the traffic may lead to some costs for me.

    Issues / Errors Reporting

    Please report any issues/improvements on the project page on GITHUB


    What features are supported

    While I may have missed something, I tried to cover all the basics for the tracking and event at some point going beyond it as you’ll notice in the next lines.

    The current version does enable the page_view tracking (will fire on the page load if not specified in another way) along with any other event you may want to send.

    This is a list of all supported features:

    • Sessions Tracking
    • Session Engagements (&seg)
    • Sessions Count (&sct)
    • First Visits Tracking (&_fv)
    • Session Starts Tracking (&_ss)
    • AMP Cross-Domain
    • User Properties (number and string)
    • Event Parameters (number and string)
    • Engagement Time Tracking (&_et)
    • Screen Resolution
    • User’s Browser Language
    • Document Title
    • Document URL
    • Document Referrer
    • Unique Pageview Id (&_p)

    Setting up the tracking

    Tracking Snippet Component

    Using the Session Data in AMP is forbidden when using Remote Configurations, this is why we are using the googleanalytics as a base, which will at the same time help in using and managing the reading/writing of the _ga cookie.

    The only thing we need to do is copy the following code in our AMP pages in order to have our Google Analytics 4 tracking in place

    <amp-analytics type="googleanalytics" config="https://amp.analytics-debugger.com/ga4.json" data-credentials="include">
    <script type="application/json">
    {
        "vars": {
                    "GA4_MEASUREMENT_ID": "G-THYNGSTER",
                    "GA4_ENDPOINT_HOSTNAME": "www.google-analytics.com",
                    "DEFAULT_PAGEVIEW_ENABLED": true,    
                    "GOOGLE_CONSENT_ENABLED": false,
                    "WEBVITALS_TRACKING": false,
                    "PERFORMANCE_TIMING_TRACKING": false,
                    "SEND_DOUBLECLICK_BEACON": false
        }
    }
    </script>
    </amp-analytics>

    As you may have noticed there’re many configuration switches, we’ll later explain them all in a deeper way, but for now, the only one you should care about is the “GA4_MEASUREMENT_ID” one. You need to add your MEASUREMENT_ID in there.

    You can grab that value from Admin > Properties > Data Streams > Web and then selecting your Stream

    As a small sneak-peak, this is the meaning for all the configuration switches.

    Feature NameDescription
    GA4_MEASUREMENT_IDYour Measurement ID, G-XXXXXXXX
    GA4_ENDPOINT_HOSTNAMEOverride the default endpoint domain. In case you want to send the hits to your own server or a Server Side GTM Instance.
    GOOGLE_CONSENT_ENABLEDa &gcs parameter will be added to the payloads with the current Consent Status
    WEBVITALS_TRACKINGIf you enable this a webvitals event will fire 5 seconds after the page is visible
    PERFORMANCE_TIMING_TRACKINGWhatever you want to push a performance_timing event including the current page load performance timings
    DEFAULT_PAGEVIEW_ENABLEDIf enabled a page_view event will fire on the page load
    SEND_DOUBLECLICK_BEACONSend a DC Hit
    Configuration Settings

    Important Note

    I’ve put an online copy of the configuration file for those who can’t host themselves, if you can get some collaboration, I suggest you to download the ga4.json from GitHub and hosting it yourself.

    config="https://yourdomain.com/ga4.json"

    Server Side GA4 Tracking (SGTM)

    If you prefer it you can have AMP send the hits to a Server Side GTM instance. for doing this set the current

    GA4_ENDPOINT_HOSTNAME: "sgtm.thyngster.com"

    If you prefer sending a copy of the hits to some internal database or any other tool, the logic is pretty straightforward so I don’t think it needs any explanation just, set your domain there and be sure that you enable an endpoint on the following path “/g/collect

    Consent Compliance

    This Google Analytics 4 tracking solution for AMP pages, supports the integration with the consent module from AMP and also with Google Consent Mode ( allowing you to attach the consent details to the hits )

    Keep reading this section if you are interested in having a Consent compliance setup in your AMP Pages.
    Google Consent Activation

    You can have the hits to hold the information about the current consent status. This will make the tracking compatible with the Google Consent Features. If you turn on this feature the current consent status will be reported within the current event hit, allowing Google Analytics to be more GDRP Compliant (#sigh) and at the same time allowing you to make use of the consent mode modeling when it became available in the future.

    To activate this you need to set the โ€œENABLE_CONSENT_TRACKINGโ€ switch to true, and then a a &gcs parameter will be added to all the hits, containing the actual consent status for the browsing user.

    It will hold 2 different values

    ValueMeaning
    G100Analytics Consent Non-Granted
    G101Analytics Consent Granted

    In case you want to block the tracking unless the user has implicitly given his consent you can make GA4 not to fire any hits in your AMP pages. This can be easily achieved by adding the following attribute to our amp-analytics block,

    data-block-on-consent

    Now our main snippet will looks closely to this:

    <amp-analytics
        type="googleanalytics"
        config="https://amp.analytics-debugger.com/ga4.json"
       data-credentials="include"
       data-block-on-consent
    >

    Two things will happen when you add this, first one if that if the current user didnโ€™t give his explicit consent to be tracked, no hits will be sent to Google Analytics and when the user accepts the consent, AMP will fire hits that were blocked on a first instance.

    Please note, that this functionality relies on the <amp-consent> component, you need to load the right library and also setup the consent modal in the way you want. A simple example would be something like:

    <script async custom-element="amp-consent" src="https://cdn.ampproject.org/v0/amp-consent-0.1.js"></script>
    <script async custom-element="amp-geo" src="https://cdn.ampproject.org/v0/amp-geo-0.1.js"></script>
    <amp-consent layout="nodisplay" id="consent-element">
      <script type="application/json">
        {
          "consentInstanceId": "my-consent",
          "consentRequired": "remote",
          "checkConsentHref": "https://example.com/api/check-consent",
          "promptUI": "consent-ui",
          "onUpdateHref": "https://example.com/update-consent"
        }
      </script>
      <div id="consent-ui">
        <button on="tap:consent-element.accept">Accept</button>
        <button on="tap:consent-element.reject">Reject</button>
        <button on="tap:consent-element.dismiss">Dismiss</button>
      </div>
    </amp-consent>

    Events Tracking

    Pageviews Tracking

    By default a page_view event that will fire on the page load unless you set the DEFAULT_PAGEVIEW_ENABLED to false. There may be a case where you want to personalize the page_view event name, or maybe you need to add some custom parameters to it.

    If thatโ€™s your case, set the default page view to false, and then add a new trigger to fire a page_view event are your own into the init config:

    "custom_pageview": {
        "enabled": true,
        "on": "visible"
        "request": "ga4Event",
        "vars": {
            "ga4_event_name": "my_customized_page_view"
        },
        "extraUrlParams": {
            "event__str_real_url": "https://www.charlesfarina.com/?origin=thyngster",
            "event__str_param_2": "meh"
        }
    }

    Custom Events

    Remember in GA4everything is an event“, well, I tried to set up a configuration logic that allows you to track many user interactions using the currently provided functionality in AMP API.

    This is cool because we’re going even be able to pass User Properties and Event Parameters to our events

    AMP Events Types

    By default no other events than the โ€œpage_viewโ€ are tracked. But you can use any of the AMP event types to track your users interactions.

    There are some more that are not currently properly documented on the main docs, so we’re not covering them. You can read the original docs here:

    clickWhen there’s a click on an element
    hiddenWhen the page becomes hidden
    ini-loadWhen the initial contents of an AMP element or AMP document have been loaded.
    render-startWhen the rendering of an embedded component has started (ie : ads iframes)
    scrollWhen under certain conditions when the page is scrolled
    timerWhen on a regular time interval
    video-*When thereโ€™s a video interaction
    visibleWhen an element becomes visible
    blurWhen a specified element is no longer in focus
    changeWhen a specified element undergoes a state change
    user-errorWhen an error occurs that is attributable to the author of the page or to software that is used in publishing the page

    Event: click

    "triggers": {
      "mailtos": {
        "on": "click",
        "selector": "a[href^='mailto:']",
        "request": "ga4Event",
        "vars": {
          "ga4_event_name": "outgoing_click"
        },
        "extraUrlParams": {
             "event__str_outgoing_click_type": "mailto"
        }       
      }
    }

    This is the main one that we’ll be using, it triggers when an element is clicked, and we’ll use a CSS selector to define the conditional firing.


    Another typical example would be tracking the external links, we could achieve this with the following trigger:
    "triggers": {
      "mailtos": {
        "on": "outgoing",
        "selector": "a[href]:not(:where([href^='#'],[href^='/']:not([href^='//']), [href='thyngster.com'], [href='analytics-debugger.com'])",
        "request": "ga4Event",
        "vars": {
          "ga4_event_name": "outgoing_click"
        },
        "extraUrlParams": {
             "event__str_outgoing_click_type": "link"
        }       
      }
    }

    Event: hidden

    We can set a trigger when the current page is hidden ( ie: minimized, or the browser’s tab is switched )
    If we include the visibilitySpec , we can define some rules, for example firing it only if it has been hiding for 3 seconds, see the example below

    "triggers": {
      "pageHidden": {
        "on": "hidden",
        "request": "ga4Event",
        "vars": {
          "ga4_event_name": "page_is_hidden"
        },
        "visibilitySpec": {
          "selector": "body",
          "visiblePercentageMin": 20,
          "totalTimeMin": 3000
        }
      }
    }

    I won’t dig more on this, you can check all the visibilitySpec options on the following URL: https://github.com/ampproject/amphtml/blob/main/extensions/amp-analytics/amp-analytics.md

    Event: ini-load

    We can have AMP triggering an event when an AMP Element initial content has been loaded, this is done using a CSS Selector, if the selector is not specified this event will be attached to the current document.

    "triggers": {
     "pageLoaded": {
        "on": "ini-load",
        "request": "ga4Event",
        "vars": {
            "ga4_event_name": "page_is_loaded"
         }   
      } 
    }

    Event: render-start

    AMP will trigger this event when an element that embeds other documents in iframes for example the Ads Elements

    "adsLoaded": {
        "on": "render-start",
        "request": "ga4Event",
        "vars": {
            "ga4_event_name": "ads_loaded"
        },
        "selector": "#ads"
      }
    }

    Event: scroll

    When a page is scrolled AMP will trigger the scroll event. This trigger provides special vars that indicate the boundaries that triggered a request to be sent. In order to filter which scroll events we want to fire we’ll use the scrollSpec object.

    We can use the ${verticalScrollBoundary} variable to grab the current scrolling boundary. Here it goes a simple example that will trigger an event when the user scrolls to a 25, 50, 75, 90 of the current page.

    "scrollTracking": {
        "on": "scroll",
        "request": "ga4Event",
        "vars": {
            "ga4_event_name": "scroll"
        },
        "extraUrlParams": {
            "event__str_percent_scrolled": "${verticalScrollBoundary}%"
        },
        "scrollSpec": {
            "verticalBoundaries": [25, 50, 75, 90],
            "horizontalBoundaries": [],
            "useInitialPageSize": false
        }
    }

    Event: timer

    As the name suggests this will allow us to send and event based on a regular time interval to GA4. We can also use  timerSpec to control when this will fire.

    Please note it’s important to know that the timer will keep triggering until maxTimerLength has been reached. Another thing that you need to have in mind is that we can use startSpec to define then this trigger should fire. For example we may want to send a ping event each minute the page is active, but we want to step in if the document is hidden, we could do the following

    "triggers": {
      "pingEvents": {
        "on": "timer",
        "request": "ga4Event",
        "vars": {
            "ga4_event_name": "ping"
        },
        "timerSpec": {
          "interval": 60,
          "startSpec": {
            "on": "visible",
            "selector": ":root"
          },
          "stopSpec": {
            "on": "hidden",
            "selector": ":root"
          }
        },
      }
    }

    Refer to the AMP docs for full details about how to use the timer events.

    Event: video-*

    I feel this is the most complicated trigger available on AMP. It will allow us to track the video interactions happening on our site in Google Analytics 4.

    Multiple video providers are supported by AMP Analytics, these are defined in the following table:

    Video ProviderSupport level
    <amp-video>Full support
    <amp-3q-player>Partial support
    <amp-brid-player>Partial support
    <amp-brightcove>Full support
    <amp-dailymotion>Partial support
    <amp-ima-video>Partial support
    <amp-nexxtv-player>Partial support
    <amp-ooyala-player>Partial support
    <amp-youtube>Partial support
    <amp-viqeo-player>Full support
    Source: https://github.com/ampproject/amphtml/blob/main/extensions/amp-analytics/amp-video-analytics.md

    Partial support means that not all the variables may be available:

    VarTypeDescription
    autoplayBooleanIndicates whether the video began as an autoplay video.
    currentTimeNumberSpecifies the current playback time (in seconds) at the time of trigger.
    durationNumberSpecifies the total duration of the video (in seconds).
    heightNumberSpecifies the height of the video (in px).
    idStringSpecifies the ID of the video element.
    playedTotalNumberSpecifies the total amount of time the user has watched the video.
    stateStringIndicates the state, which can be one โ€œplaying_autoโ€, โ€œplaying_manualโ€, or โ€œpausedโ€.
    widthNumberSpecifies the width of video (in px).
    playedRangesJsonStringRepresents segments of time the user has watched the video (in JSON format). For example, [[1, 10], [5, 20]]
    Source: https://github.com/ampproject/amphtml/blob/main/extensions/amp-analytics/amp-video-analytics.md

    For now, we know which video players are supported and which data we will be able to use in our events, know it’s time to know which triggers/interactions we’ll be able to track, these are:

    Trigger NameDescription
    video-playVideo stats to play
    video-pauseVideo is paused
    video-endedVideo Completes (reach end of playback)
    video-sessionTriggers when a “video session” has ended. a video session starts when a video is played and finishes when the video is paused, ends, or became invisible
    video-seconds-playedThis will trigger each time the defined amount of time is played ( ie: every 10 seconds watched )
    video-percentage-playedSame as above we can define which % of the progress we want to trigger this
    video-ad-startVideo Ad Starts
    video-ad-endVideo Ads Ends

    As you can see, the possibilities are almost endless, so we won’t be adding examples for all of them, you can find some good examples on AMP documentation. In any case, let’s see one example.

    Let’s say that our AMP page has a YouTube Video embedded:

    <amp-youtube
        id="Take off - LGA-YYZ . DL4942 - CRJ-900"
        class="video"
        width="480"
        height="270"    
        data-videoid="Nx-JZ2-kEKU">
    </amp-youtube>

    #TIP Using the element “id” with the video name will allow us to use it for the video_title parameter for our event

    So, for tracking the video plays, we would add the following trigger:

    "triggers": {
        "videoPlayEvent": {
            "on": "video-play",
            "request": "ga4Event",
            "vars": {
                "ga4_event_name": "video_played"
            },
            "selector": ".video",
            "videoSpec": {},
            "extraUrlParams": {
                "event__str_video_title": "${id}",
                "event__num_video_duration": "${duration}"
            }
        }
    }

    Event: visible

    Using this event trigger we’ll be able to fire an event when the current element(s) defined with our CSS selector are visible within the current browser viewport.

    This trigger firing can be fine-tuned using the visibilitySpec , to define the amount of millisecond the element has to be on the screen, or what % of the element needs to be visible to trigger the event.

    Refer to the AMP official docs for all the configuration options ๐Ÿ™‚

    "triggers": {
        "recomendationsViewed": {
            "on": "visible",
            "request": "ga4Event",
            "vars": {
                "ga4_event_name": "recomendations_block_viewed"
            },
            "selector": "#adobeTargetRecos",
            "visibilitySpec": {
                "waitFor": "ini-load",
                "reportWhen": "documentExit",
                "visiblePercentageMin": 20,
                "totalTimeMin": 500,
                "continuousTimeMin": 200
            }
        }
      }
    }

    In-Build Auto-Generated Events

    There is some data provided by AMP that is available and that can provide some cool events, in this first release I’ve added two events to track our page loading speed.

    WebVitals

    You can enable web vitals tracking events, which we expect to be the best ones on AMP ( don’t we’? )

    To enable this event just set the โ€œENABLE_WEBVITALS_TRACKINGโ€ to true in the main snippet settings, and that will make the tool launch an automatic event (web_vitals) 5 seconds after the page load, with the following available parameters:

    ParameterDescriptionExample Value
    epn.first_contentful_paintFirst Contentful Paint170.199951171875
    epn.first_viewport_readyFirst Viewport Ready164.59999990463257
    epn.make_body_visibleMake Body Visible163.40000009536743
    epn.largest_contenful_paintLargest Contentful Paint170.299072265625
    epn.cumulative_layout_shiftCumulative Layout Shift0.012389976353126643

    Performance Timing

    This is the same data that we are used to seeing in the Site Speed Reports in Universal Analytics, which includes the time (in ms) to the DomReady event or the time it took to do the DNS Resolution Time.

    To enable this event just set the PERFORMANCE_TIMING_TRACKING to true. Then on the page load, a performance_timing event will be fired containing the following parameters.

    epn.page_load_timeAmount of time (in seconds) it took that page to load
    epn.domain_lookup_timeThe average time (in seconds) spent in DNS lookup for this page
    epn.tcp_connect_timeProvides the time it took for HTTP connection to be set up. The duration includes connection handshake time and SOCKS authentication. The value is in milliseconds.
    epn.redirect_timeTime taken to complete all the redirects before the request for the current page is made (in ms)
    epn.server_response_timeTotal time taken by the server to start sending the response after it starts receiving the request (in ms)
    epn.page_download_timeProvides the time taken to load the whole page. The value is calculated from the time unload event handler on previous page ends to the time load event for the current page is fired. If there is no previous page, the duration starts from the time the user agent is ready to fetch the document using an HTTP request (in ms)
    epn.content_download_timeProvides the time the page takes to fire the DOMContentLoaded event from the time the previous page is unloaded (in ms)
    epn.dom_interactive_timeProvides the time the page to become interactive from the time the previous page is unloaded (in ms)

    User Properties

    To attach User Properties to our hits, we need to use the extraUrlParams key. We also need to have in mind that a User Property in GA4 can either be a number or a string and while the GTAG/GTM does take care of accordingly casting the values, on AMP we need to define the type.

    The way we should define the parameters is this:

    "user__str_user_id": "123456",    
    "user__num_lifetime_value": "147.34"

    As you can see is easy, the first part defines the current scope (which will be event for the Event Parameters in the next section ).

    If we add the User Properties to our main snippet, these will be added to all the subsequent events fired within the current page load. In the next example, we are setting 3 User Parameters in our init code snippet that will be persisted across all the events pushed during the current page load.,

    {
            "vars": {
                "GA4_MEASUREMENT_ID": "G-THYNGSTER",
                "ENABLE_CONSENT_TRACKING": false,
                "ENABLE_WEBVITALS_TRACKING": true
            },
            "extraUrlParams": {
                "user__str_user_id": "123456",
                "user__str_logged_in": "yes",
                "user__num_lifetime_value": "147.34"
            },
        }

    On the other site, we can attach some User Parameters ONLY to the current event, this is done by adding the extraUlrParameters to the current trigger.

            "triggers": {
                "demoClickEvent": {
                    "on": "click",
                    "request": "ga4Event",
                    "selector": "#upgradeMembership",
                    "vars": {
                        "ga4_event_name": "member_ship_upgraded",
                    },
                    "extraUrlParams": {
                        "user__str_last_membership": "premium"
                    }
                }
            }

    Event Parameters

    Same way as the User Properties above, we have two different ways of setting an Event Parameter, to all the current page events or just to the current event, this will be done, again, on the main init snippet, or adding it within the current trigger extraUrlParams.

    And of course, remember that they need to define the type, string, or number.

    The way we should define the parameter is the same as we did in the User Properties.

    "event__str_user_id": "123456",    
    "event__num_lifetime_value": "147.34"
  • Universal Analytics Migration Library – Custom task

    When Google Announced Google Analytics 4, I started to work on this library, with the main aim to be used as a customTask.

    Now the current deadline is 1 year-ish for all free Universal Analytics users and 3 extra months ( thanks? ) for paid users. In my very humble opinion, this is very short notice for such a big work. In my experience, a big implementation will take at least 6 months on average, for defining everything, write up the specs, have it team implement it, the GTM ( or any other TMS ) setup and then doing the QAing a dozen times until finally all the data is coming as expected and with the proper values.

    The current help Google is offering in this situation is allowing us to turn on a switch on the interface that will autogenerate some GA4 events and pageviews from the Universal Analytics hits. If you wonder how they’re doing this, they’re adding a listener for the window.ga('send') calls.

    At a first look, it may look like good help, but I really feel that’s all against how Google Analytics 4 is supposed to be implemented. I really see a lot of issues coming from this:

    • No data model will be implemented on GA4, if you have messy events tracking setup, you’re new data on GA4 will be at least as messy as your events were on Universal Analytics
    • It won’t pass the custom dimensions/metrics/content grouping, this is likely going to be insufficient for anyone having at least 1 dimension.
    • Timing, Exceptions, Legacy Transaction Events, and Social hits, won’t be migrated.

    Another issue with the automatic migration tool Google offers is that an Event Action seems to be converted to an Event Name. That’s a terrible thing, most of the clients I worked on, will end up having thousands of different events. ( I wonder if this is the reason for having a “non-written” no limit for the events coming from the website ).

    In any case, if you’re migrating your setup to Google Analytics 4, there's a no better chance to fix whatever is not right with your setup. One of the most important implementation steps in GA4 is the data model definition, that’s gonna define your current data quality and it’s gonna limit you in the future ( event parameter limits, user properties limits, etc ). Don’t take the lazy path and regret it later.

    Universal Analytics Payload Parser

    Now, the main post point, I’ve released a Universal Analytics Payload Parser library. This library is able of taking a Univeral Analytics Hit and converting them into some properly modeled dataLayer pushes.

    This library can be used as a customTask in Universal Analytics and it will push the hit contained data into a Google Tag Manager (dataLayer.push), Tealium IQ (utag.link) or Adobe Launch (_satellite.track) pushes.

    Instead of having someone send some predefined data into your GA4 property, this tool will push the data to your TMS so you can send the data to GA4 in the format, you want or pass the data to ANY other tool you want if at some point you want to migrate to any other analytics solution ( Snowplow, Matomo, Amplitude, Adobe Analytics, etc ), or even to any internal data solution you have built in your company.

    Features

    This library accepts a Universal Analytics Payload and parses it to return a formated and standardized object that can be used to pass the current info to any Tag Management System. At the time of writing this post, the library supports the output for Google Tag Manager, Tealium IQ, or Adobe Launch, adding a callback of support for sending the data to any external source should be pretty straightforward.

    This library will take all the UA hit types, and will create an object with all data contained on that hit.

    1. It generates individual pushes for each UA hit.
    2. It’ll attach all event-related implicit parameters ( like the page title or page URL for the pageview event, or the event category, action, label for the events ). Check the table below for more details.
    3. It will also map all the custom dimensions, metrics, and content groupings related to the current hit, and will allow you to define friendly names for them ( it: have the event to hold “client_id” rather than “cd1”
    4. This library will also extract/parse all the e-commerce data from the payloads and generate a standalone Ecommerce Event with the data, also including the custom dimensions/metrics for the products.

    Supported Hit Types / Parameters

    Next, you can find the list of supported hit types and all the data that will be shipped with the generated object push.

    Hit TypeStatusDefault Parameters
    pageviewSupportedpage_title,page_location, page_path
    eventSupportedcategory, action, label, value, nonInt
    timingSupportedtiming_category, timing_value, timing_time, timing_label
    socialSupportedsocial_action, social_network, social_target
    exceptionSupportedexception_description, exception_is_fatal
    transactionSupportedtransaction_id, transaction_affiliation, transaction_revenue, transaction_shipping, transaction_tax, currency
    itemSupportedtransaction_id, item_id, item_name, item_price, item_quantity, item_variation. currency

    Ecommerce

    As we mentioned before the Universal Analytics Payload Parser the library will also take care of parsing the Enhanced Ecommerce data contained within the hits and will generate the following standalone Ecommerce Pushes.

    EventParameters
    view_item_list[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, index,item_list_name]
    select_item[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, index, item_list_name]
    view_item[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price]
    add_to_cart[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity]
    view_cart[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity]
    being_checkout[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity]
    remove_from_cart[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity]
    purchase[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity], transaction_id, value, tax, shipping, affiliation, coupon
    refund[item_id, item_name, quantity, item_brand, item_variant, item_category, item_category2, item_category3, item_category4, item_category5, price, quantity], transaction_id
    view_promotion[promotion_id, promotion_name, creative_name, creative_slot]
    select_promotion[promotion_id, promotion_name, creative_name, creative_slot]

    Setup / Configuration

    The library expects a configuration variable, like this one.

    var parserConfig = {
        tms: 'log',
        eventsName: 'ga_event',
        ecommerceEventsEnabled: true,
        skipTransportEvent: false,
        mapping: {
            keys: {
                cd1: 'client_id',
                cd3: 'logged_in',
                cd7: 'is_in_stock',
                cd19: 'referrer',
                cd20: 'full_url',
                // Metrics
                cm201: 'votes',
                // content Grouping
                cg1: 'content_group_1',
                cg3: 'page_type'
            },
            events: {
                'checkout': {
                    '1' : 'view_cart',
                    '2' : 'begin_checkout'
                }            
            }
        }
    }
    Option
    tmsgtm, tealiumiq, launch, log
    eventsNameThe event name to be used for the events.
    ecommerceEventsEnabledWhether or not to parse the e-commerce data and push it
    skipTransportEventIf we set this to true, the current hit/event will be skipped and only the EEC event will be generated
    mapping.keysOur dimensions, metrics, and content_grouping definition, this will convert the โ€œcd1โ€ payload key into something more readable like โ€œclient_idโ€ in the pushes.
    mapping.events.checkoutGA4 not longer accepts checkout_steps, this will let you define which current Checkout Steps should be considered the new โ€œbegin_checkoutโ€ and โ€œview_cartโ€ events

    Examples

    pageview hit ( no dimensions )

    v=1&_v=j96&a=609235607&t=pageview&_s=1&dl=https%3A%2F%2Fwww.thyngster.com%2F&ul=es-es&de=UTF-8&dt=Web%20Analyst%20and%20Sr.%20Implementation%20Consultant%20-%20David%20Vallejo&sd=24-bit&sr=1920×1080&vp=432×1009&je=0&fl=Service%20Provider%7CNetwork%20Domain&_u=QACAAAABAAAAAC~&jid=2095545038&gjid=1011316425&cid=353185870.1650295583&tid=UA-286304-9&_gid=1709590183.1650826121&_r=1&gtm=2wg4k09LNT&cd1=353185870.1650295583&z=637815381

    Pageview Hit Payload

    Generated Object Push

    {
        "event": "page_view",
        "eventData": {
            "page_title": "Web Analyst and Sr. Implementation Consultant - David Vallejo",
            "page_location": "https://www.thyngster.com/"
        }
    }

    pageview hit ( with dimensions, metrics, content_groupings )

    v=1&_v=j96&a=609235607&t=pageview&_s=1&dl=https%3A%2F%2Fwww.thyngster.com%2F&ul=es-es&de=UTF-8&dt=Web%20Analyst%20and%20Sr.%20Implementation%20Consultant%20-%20David%20Vallejo&sd=24-bit&sr=1920×1080&vp=432×1009&je=0&&cd1=123123123123.2321231232132&cd2=homepage&cg1=homepage&cm3=1&fl=Service%20Provider%7CNetwork%20Domain&_u=QACAAAABAAAAAC~&jid=2095545038&gjid=1011316425&cid=353185870.1650295583&tid=UA-286304-9&_gid=1709590183.1650826121&_r=1&gtm=2wg4k09LNT&cd1=353185870.1650295583&z=637815381

    Generated Object Push

    {
        "event": "page_view",
        "eventData": {
            "client_id": "353185870.1650295583",
            "dimension_2": "homepage",
            "content_group_1": "homepage",
            "metric_3": "1",
            "page_title": "Web Analyst and Sr. Implementation Consultant - David Vallejo",
            "page_location": "https://www.thyngster.com/"
        }
    }

    event hit ( with dimensions, metrics, content_groupings and add_to_cart e-commerce details )

    v=1&_v=j96&a=136223288&t=event&cu=USD&_s=5&dl=https%3A%2F%2Fenhancedecommerce.appspot.com%2Fitem%2Fb55da&ul=es-es&de=UTF-8&dt=Product%20View&sd=24-bit&sr=1920×1080&vp=683×992&je=0&ec=ecommerce&ea=add_to_cart&ev=16&_u=SCCAAUALAAAAAC~&jid=1374636135&gjid=489822857&cid=625473581.1650821355&tid=UA-41425441-17&_gid=335165655.1650821355&_r=1&gtm=2ou4k0&pa=add&pr1id=b55da&pr1nm=Flexigen%20T-Shirt&pr1pr=16.00&pr1qt=1&pr1br=Flexigen&pr1ca=T-Shirts&pr1va=red&z=494832007

    Generated Object Pushes

    {
        "event": "ga_event",
        "eventData": {
            "category": "ecommerce",
            "action": "add_to_cart",
            "value": "16",
            "nonInt": true
        }
    }
    {
        "event": "add_to_cart",
        "items": [
            {
                "item_id": "b55da",
                "item_name": "Flexigen T-Shirt",
                "price": "16.00",
                "quantity": "1",
                "item_brand": "Flexigen",
                "item_category": "T-Shirts",
                "item_variant": "red"
            }
        ]
    }

    event – including purchase info

    v=1&_v=j96&a=1751633849&t=event&cu=USD&_s=13&dl=https%3A%2F%2Fenhancedecommerce.appspot.com%2Fcheckout&ul=es-es&de=UTF-8&dt=Checkout&sd=24-bit&sr=1920×1080&vp=967×1009&je=0&ec=ecommerce&ea=purchase&ev=115&_u=SCCAAUALAAAAAC~&jid=&gjid=&cid=625473581.1650821355&tid=UA-41425441-17&_gid=335165655.1650821355&gtm=2ou4k0&pa=purchase&pr1id=b55da&pr1nm=Flexigen%20T-Shirt&pr1pr=16.00&pr1qt=3&pr1br=Flexigen&pr1ca=T-Shirts&pr1va=red&pr2id=8835a&pr2nm=Isoternia%20T-Shirt&pr2pr=57.00&pr2qt=1&pr2br=Isoternia&pr2ca=T-Shirts&pr2va=red&pr2ps=1&tr=115&tt=5.00&ts=5.00&z=1549501619

    Generated Object Pushes

    {
        "event": "purchase",
        "items": [
            {
                "item_id": "b55da",
                "item_name": "Flexigen T-Shirt",
                "price": "16.00",
                "quantity": "3",
                "item_brand": "Flexigen",
                "item_category": "T-Shirts",
                "item_variant": "red"
            },
            {
                "item_id": "8835a",
                "item_name": "Isoternia T-Shirt",
                "price": "57.00",
                "quantity": "1",
                "item_brand": "Isoternia",
                "item_category": "T-Shirts",
                "item_variant": "red",
                "index": "1"
            }
        ],
        "value": "115",
        "tax": "5.00",
        "shipping": "5.00"
    }

    hit – impressions

    v=1&_v=j96&a=1653659752&t=event&ni=1&_s=2&dl=https%3A%2F%2Fenhancedecommerce.appspot.com%2F&dr=https%3A%2F%2Fwww.google.com%2F&ul=es-es&de=UTF-8&dt=Home&sd=24-bit&sr=2560×1440&vp=570×1321&je=0&ec=engagement&ea=view_item_list&_u=SCCAAUAL~&jid=&gjid=&cid=625473581.1650821355&tid=UA-41425441-17&_gid=335165655.1650821355&gtm=2ou4k0&il1nm=homepage&il1pi1id=9bdd2&il1pi1nm=Compton%20T-Shirt&il1pi1pr=44.00&il1pi1br=Compton&il1pi1ca=T-Shirts&il1pi2id=f6be8&il1pi2nm=Comverges%20T-Shirt&il1pi2pr=33.00&il1pi2br=Comverges&il1pi2ca=T-Shirts&il1pi2ps=1&il1pi3id=b55da&il1pi3nm=Flexigen%20T-Shirt&il1pi3pr=16.00&il1pi3br=Flexigen&il1pi3ca=T-Shirts&il1pi3ps=2&il1pi4id=bc823&il1pi4nm=Fuelworks%20T-Shirt&il1pi4pr=92.00&il1pi4br=Fuelworks&il1pi4ca=T-Shirts&il1pi4ps=3&il1pi5id=035f0&il1pi5nm=Futuris%20T-Shirt&il1pi5pr=55.00&il1pi5br=Futuris&il1pi5ca=T-Shirts&il1pi5ps=4&il1pi6id=8835a&il1pi6nm=Isoternia%20T-Shirt&il1pi6pr=57.00&il1pi6br=Isoternia&il1pi6ca=T-Shirts&il1pi6ps=5&il1pi7id=57b9d&il1pi7nm=Kiosk%20T-Shirt&il1pi7pr=55.00&il1pi7br=Kiosk&il1pi7ca=T-Shirts&il1pi7ps=6&il1pi8id=dc646&il1pi8nm=Lunchpod%20T-Shirt&il1pi8pr=90.00&il1pi8br=Lunchpod&il1pi8ca=T-Shirts&il1pi8ps=7&il1pi9id=7w9e0&il1pi9nm=Masons%20T-Shirt&il1pi9pr=31.00&il1pi9br=Masons&il1pi9ca=T-Shirts&il1pi9ps=8&il1pi10id=239b5&il1pi10nm=Pigzart%20T-Shirt&il1pi10pr=82.00&il1pi10br=Pigzart&il1pi10ca=T-Shirts&il1pi10ps=9&il1pi11id=6d9b0&il1pi11nm=Poyo%20T-Shirt&il1pi11pr=62.00&il1pi11br=Poyo&il1pi11ca=T-Shirts&il1pi11ps=10&il1pi12id=6c3b0&il1pi12nm=Zappix%20T-Shirt&il1pi12pr=99.00&il1pi12br=Zappix&il1pi12ca=T-Shirts&il1pi12ps=11&il2nm=shirts%20you%20may%20like&il2pi1id=6c3b0&il2pi1nm=Zappix%20T-Shirt&il2pi1pr=99.00&il2pi1br=Zappix&il2pi1ca=T-Shirts&il2pi2id=8835a&il2pi2nm=Isoternia%20T-Shirt&il2pi2pr=57.00&il2pi2br=Isoternia&il2pi2ca=T-Shirts&il2pi2ps=1&il2pi3id=035f0&il2pi3nm=Futuris%20T-Shirt&il2pi3pr=55.00&il2pi3br=Futuris&il2pi3ca=T-Shirts&il2pi3ps=2&il2pi4id=239b5&il2pi4nm=Pigzart%20T-Shirt&il2pi4pr=82.00&il2pi4br=Pigzart&il2pi4ca=T-Shirts&il2pi4ps=3&z=819126443

    Generated Object Pushes

    {
        "event": "ga_event",
        "eventData": {
            "category": "engagement",
            "action": "view_item_list",
            "nonInt": false
        }
    }
    {
        "event": "view_item_list",
        "items": [
            {
                "item_list_name": "shirts you may like",
                "item_id": "6c3b0",
                "item_name": "Zappix T-Shirt",
                "price": "99.00",
                "item_brand": "Zappix",
                "item_category": "T-Shirts"
            },
            {
                "item_list_name": "shirts you may like",
                "item_id": "8835a",
                "item_name": "Isoternia T-Shirt",
                "price": "57.00",
                "item_brand": "Isoternia",
                "item_category": "T-Shirts",
                "index": "1"
            },
            {
                "item_list_name": "shirts you may like",
                "item_id": "035f0",
                "item_name": "Futuris T-Shirt",
                "price": "55.00",
                "item_brand": "Futuris",
                "item_category": "T-Shirts",
                "index": "2"
            },
            {
                "item_list_name": "shirts you may like",
                "item_id": "239b5",
                "item_name": "Pigzart T-Shirt",
                "price": "82.00",
                "item_brand": "Pigzart",
                "item_category": "T-Shirts",
                "index": "3"
            },
            {
                "item_list_name": "homepage",
                "item_id": "035f0",
                "item_name": "Futuris T-Shirt",
                "price": "55.00",
                "item_brand": "Futuris",
                "item_category": "T-Shirts",
                "index": "4"
            },
            {
                "item_list_name": "homepage",
                "item_id": "8835a",
                "item_name": "Isoternia T-Shirt",
                "price": "57.00",
                "item_brand": "Isoternia",
                "item_category": "T-Shirts",
                "index": "5"
            },
            {
                "item_list_name": "homepage",
                "item_id": "57b9d",
                "item_name": "Kiosk T-Shirt",
                "price": "55.00",
                "item_brand": "Kiosk",
                "item_category": "T-Shirts",
                "index": "6"
            },
            {
                "item_list_name": "homepage",
                "item_id": "dc646",
                "item_name": "Lunchpod T-Shirt",
                "price": "90.00",
                "item_brand": "Lunchpod",
                "item_category": "T-Shirts",
                "index": "7"
            },
            {
                "item_list_name": "homepage",
                "item_id": "7w9e0",
                "item_name": "Masons T-Shirt",
                "price": "31.00",
                "item_brand": "Masons",
                "item_category": "T-Shirts",
                "index": "8"
            },
            {
                "item_list_name": "homepage",
                "item_id": "239b5",
                "item_name": "Pigzart T-Shirt",
                "price": "82.00",
                "item_brand": "Pigzart",
                "item_category": "T-Shirts",
                "index": "9"
            },
            {
                "item_list_name": "homepage",
                "item_id": "6d9b0",
                "item_name": "Poyo T-Shirt",
                "price": "62.00",
                "item_brand": "Poyo",
                "item_category": "T-Shirts",
                "index": "10"
            },
            {
                "item_list_name": "homepage",
                "item_id": "6c3b0",
                "item_name": "Zappix T-Shirt",
                "price": "99.00",
                "item_brand": "Zappix",
                "item_category": "T-Shirts",
                "index": "11"
            }
        ]
    }

    CustomTask Setup

    As usual, we will need to set up a customTask variable for our tags. I’m attaching an example for Google Tag Manager

    function() {
        return function(customTaskModel) {
            var originalSendHitTask = customTaskModel.get('sendHitTask');
            customTaskModel.set('sendHitTask', function(model) {
                try {
                    var parserConfig = {
                        tms: 'gtm',
                        eventsName: 'ga_event',
                        ecommerceEventsEnabled: true,
                        skipTransportEvent: false,
                        mapping: {
                            keys: {
                                cd1: 'client_id',
                                cd3: 'logged_in',
                                cd7: 'is_in_stock',
                                cd19: 'referrer',
                                cd20: 'full_url',
                                // Metrics
                                cm201: 'votes',
                                // content Grouping
                                cg1: 'content_group_1',
                                cg3: 'page_type'
                            },
                            events: {
                                'checkout': {
                                    '1': 'view_cart',
                                    '2': 'begin_checkout'
                                }
                            }
                        }
                    };
      
                   [[ADD CODE FROM GITHUB]]
    
                    uaPayloadParser(parserConfig, model.get('hitPayload'));
                    originalSendHitTask(model);
                } catch (e) {
                    originalSendHitTask(model);
                }
            });
        }
    }

    In order to have updated content, please replace the [[ADD CODE FROM GITHUB]] with the code from the build/ folder in the GitHub project.

    https://github.com/thyngster/universal-analytics-payload-parser/blob/main/build/uaPayloadParser.min.js

    Recap

    So, that’s all, as you can see rather than sending data to Google Analytics 4, this library allows sending the data to the dataLayer ( or anywhere else ) instead so you can work with the data in any way you want, you could define different event names, you could sanitize the values, you can block/skip the events you don’t need.

    The best point is that you’ll have full control over how the data ends in your Google Analytics 4 account, and this will work even if you’re using GTAG, or even if you have a hardcoded implementation, just adding the custom task in your main snippet should allow you to have the data anywhere else.

    GITHUB PROJECT LINK: https://github.com/thyngster/universal-analytics-payload-parser



  • Google Tag Manager: Google Analytics 4 [GA4] Events Setup with a single Tag

    If you’re using Google Tag Manager for setting up Google Analytics 4 tracking you may have noticed the need of creating a separate tag for each event we want to track. While this gives a really nice and easy-to-understand photo of the current used events, it may add a ton of work for maintaining them, for example, if at some point we end up having hundreds of them.

    In Universal Analytics many implementations were using a single event tag for tracking all their events, using a unique dataLayer event , and then mapping the event category, event action, event label and event value.

    This cannot be done in Google Analytics 4, because we have an open specter of parameter / dimensions names which we have to individually map to each of the events we configure.

    In this post, we’ll be learning today how to do all our events tracking using just a single GA4 Event Tag, while keeping all the features and functionality we’d have if we were setting up individual event tags.

    Defining our dataLayer pushes

    The first thing we need to do is to define how are we going to push the data to our dataLayer, and this is the way we’ll need to do it:

    window.dataLayer.push({
       'event': 'my_action_name',
       'event_params': {
            'param1': 'value1',
            'param2': 'value2'
       },
       'user_properties': {
            'prop1': 'value1',
            'prop2': 'value2'
       }
    })

    At this point you may wonder why we’re grouping our event parameters rather than just pushing them into push like:

    window.dataLayer.push({
       'event': 'my_action_name',
       'param1': 'value1',
       'param2': 'value2'
       'prop1': 'value1',
       'prop2': 'value2'
    })

    There’s a reason for this, Google Tag Manager dataLayer is cumulative, which means that all new pushed data keeps being added to the internal data model. If we push a param1 key it will not only be available for the current event, but it will be present on the subsequent ones. Since we want to use a single tag, this means that this data would be added to the next events.

    In order to deal with this, we’ll be using a v1 dataLayer variable to read the event_params and user_properties. Because this way all the data contained within these keys will be overridden with all the pushes.

    If you are wondering which are the differences between the v1 and v2 dataLayer variables:

    v1Doesn’t support nested notation
    Keys are overwritten rather than merged
    v2Support for nested objects notation ( one.two.three )
    Key are merged

    Let’s see this with an example so we can understand this better. We have these 2 dataLayer pushes, each of them having a different event_parameter attached to them:

    window.dataLayer.push({
       'event': 'outgoing_click',
       'event_params': {
            'clicked_url': 'Simo Blog'
       }
    });
    window.dataLayer.push({
       'event': 'tab',
       'event_params': {
            'tab_id': '1'
       }
    }):

    Then in Google Tag Manager we’d be defining two variables. let’s name them {{dl - clicked_url}} and {{dl - tab_id}} . Let’s see how they would look depending on how we read them

    {{dl - clicked_url}}{{dl - tab_id}}
    First Pushv1: Simo Blog
    v2: Simo Blog
    v1: undefined
    v2: undefined
    Second Pushv1: undefined
    v2: Simo Blog
    v1: 1
    v2: 1

    If you check the table above if we were using the v2 version, the second event would end attaching the clicked_url value to the event, which is something that we don’t want.

    It’s true that if you’re creating individual tags for each event, it’s gonna be hard this would be affecting you all (but it’s not impossible), in any case, let’s learn how to prevent this situation, and also let’s set up GTM to handle all of our GA4 events within a single tag.

    Google Tag Manager Setup

    The core Event Tag

    First thing first, we need a new GA4 Event tag in our container. For now, the only thing we’ll configure here is the Event Name to be the in-build {{Event}} variable which will read the actual dataLayer push event value and pass it back to our GA4 tag event name.

    For now, that’s all we’ll be doing, before mapping up the event parameters and user properties , we will learn how would need to set up the variables to relay on the v1 version.

    The variables

    This may be a bit confusing for some people, but as we explained before the need to use a version 1 parameter, but we also mentioned this dataLayer variable type does not support nested values. The workaround for this is using a variable that grabs the main key ( user_properties, event_params ) and then having some Custom JS variables returning the values we need.

    Let’s start it, create these two variables and remember to set them as Version 1

    The next step is creating a variable for each of the values we want to have access to, in our case, we’ll have 2 of them.

    As you can see instead of reading them from the dataLayer, we are reading them from the v1 variable created. This will cause the event_params to be reset each time, and the values will be undefined for the parameters NOT being added to the current push. This functionally is like all the events/user data will be “reset” on each event push.

    Mapping the variables

    Now that we have our GA4 Event Tag and also our dataLayer variables, it’s time to map these last ones to the first one.

    Variables Mapping

    In your personal case, you’ll need to map as many parameters/variables as different values you’ve defined across all your events.

    Setting up the triggers

    Wait for a second, you said that we’ll be keeping all the features as doing the tracking with individual tags, with the setup we won’t be able to check which events are configured or we won’t be able to pause and specific event.

    That’s where our trigger comes into scene, we’ll define a firing trigger that will trigger on .* the events, but only if the current event name is within an events lookup table.

    Create a lookup table type variable in Google Tag Manager, on this table we’ll define an output of 1 for all the events name we want to enable ( ie: that will fire our GA4 event tag ), and then a default value of 0

    As you may have already guessed this variable will be used as a condition in our trigger this way:

    Recap

    All this may look more convoluted than it really is, but it leads to some really interesting benefits.

    • We only have to set up one single tag
    • We still have the lookup table to control and review which events are defined and firing

    Even if we end up going with the path of adding dozens or hundreds of event tags, the issue of having the dataLayer adding some parameters to some event that it should just because it was pushed before may be present on your setup. So I recommend using the combination of v1 and v2 variables to get rid of this problem.

    At some point, if you have many events you could group them and use an event/trigger/lookup for each group ๐Ÿ™‚

  • 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 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.

  • Google Analytics 4 (GA4) Events Demystified

    At his point, many ( if not all ) have heard Google Analytics is moving to an “events” based tracking model with Google Analytics 4. But, what does it really imply? Do we have to worry about it?. To be honest, it’s not a big ( from the implementation side ) deal since we have been already using “events” all the time, we used to call them hit types. If we look at it from the reporting side it may lead to some “hard times” when trying to use the data, not because it’s better or worse, just because it’s different.

    This post will try to explain Google Analytics 4 Events from the technical perspective, trying to explain how to current event model works, where can the events come from, the limitations, etc.

    I’d say that one of the most important things when working with GA4, is realizing how important is going to be the data model definition we do at the start. Because this is going to condition the future of our implementation and data.

    But don’t worry about this for now. we’ll dig into this across the post ๐Ÿ˜Š.

    How does Google Analytics 4 record the data

    Google Analytics 4 works much similarly to Universal Analytics.

    We’ll be sending hits (network requests) to a specific endpoint ( https://endpoint.url/collect ). This shouldn’t be anything new for anyone, that’s how all analytics tools and pixels work. And this is the way it works for the client-side tracking (gtag.js), server-side tracking ( measurement protocol ), and the app tracking ( Firebase Analytics SDK ).

    Tracking endpoints

    I found there are 5 different endpoints that we could use to send the data to Google Analytics 4, these are:

    • https://www.google-analytics.com/g/collect
    • https://analytics.google.com/g/collect
    • https://custom.domain/g/collect (this will really forward the hits to the first one on this list)
    • https://app-measurement.com
    • https://www.google-analytics.com/mp/collect

    Depending on where we are doing the tracking we’ll be using one of them.

    We could see hits flowing to 4 different endpoints for GA4 + 1 for Firebase

    The first two endpoints are the ones used by the client-side tracking but you may wonder why sometimes we see the hits coming through analytics.google.com, and some other times via the google-analytics.com domain. The reason is that if current GA4 property has “Enable Google signals data collection info” turned on, GA4 will use the *.google.com endpoint ( si Google would be able to use their cookies to identify the users, I guess )

    JavaScript Client Library

    The page tracking is done using a library provided by Google, the same way we used to have analytics.js , ga.js or urchin.js libraries in the past Google Analytics versions.

    The default code snippet will look like this:

    <!-- Global site tag (gtag.js) - Google Analytics -->
    <script async src="https://www.googletagmanager.com/gtag/js?id=G-THYNGSTER"></script>
    <script>
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
    
      gtag('config', 'G-THYNGSTER');
    </script>

    If you have noticed it the snippet loads a JavaScript file from www.googletagmanager.com domain, and this is because all gtag.js snippets are in essence a predefined Google Tag Manager template. It’s not just a plain GTM container, since it does some internal stuff, but it works also based on tags, triggers, and variables.

    Previous tracking libraries were offering a public API to perform all the tracking at our end, ie: it was accepting some methods/calls and converting them to hits, doing the cross-domain tracking allowing us to use Tasks, while at the same time doing some logic for generating the cookies, reading the browser details, and this library was shared across all the users worldwide web.

    This is no longer working this way, now each Data Stream / Measurement ID will have its own snippet and it will load a separate js file. We may look at this as a performance penalty but it’s done this way for a reason.

    Each gtag.js container it’s now built dynamically at Google’s end and contains personalized code for the current property and also holds the settings for the current data Data Stream / Measurement ID. And that’s why the container sizes are different for each container we check. Don’t worry, this is normal and expected. The container size will vary depending on many things, like if we have the Enhanced measurement features we have enabled or the current settings we defined on the admin interface for our property.

    GA4 Containers Sizes

    One thing that has been confusing me since Google Analytics 4 arrived, was thinking that there were lots of things happening on the back that were hardly possible to debug, like the conversions, or the created / modified events.

    And well, that’s not the way it works, almost any setting or feature you enable on the admin it’s going to be translated into code and will be executed on the client-side. This means that when you add a new event on the interface that’s will add some code on the gtag.js container will send an event, and this will make that you “may” end seeing “ghost” events on the browser, don’t waste your time as me trying to see why your implementation was firing duplicated events :). Or for example when we define a conversion event when we configure our internal domains or the ignored referrals.

    While this approach may help some people in doing some common tracking tasks, on the other side it’s preventing to do some advanced implementation because some “loved” features like the “customTasks” are now missing. I’m ok with Google trying to control how things are done, but there will always be sites that will need custom /U personalized implementations, and I really feel that Google should provide some public/documented API methods to easily perform some of the most used common tasks like the cross-domain tracking in Google Analytics 4.

    Let’s see some examples, when you “create a new event” from the Admin Interface, this event won’t be created server-side, what’ is happening is that GA4 will add some code logic to send that hit client-side.

    Google Analytics 4 events creation modal

    Another example would be when you enable the Enhanced Measurement, this will turn on having some code added to your container. Remember that we mentioned that GA4 was in essence a Google Tag Manager container?, if you take a look at the current Measuring categories you’ll notice how they all match the current triggers available on GTM ( clicks tracking, scrolls tracking, youtube tracking )

    Enhanced measurement

    And that’s not all, when we change the session duration or the engagement time, some session_timeout variables will be updated internally (engagementSeconds, sessionMinutes, sessionHours)

    Session Timeout Adjust

    We could keep going on examples, or build a full list, but that’s likely going to get outdates sooner than later. The main idea you need to get from this part of the post is that GTAG is like a “predefined” GTM template and that all the tracking happens on the client’s browser.

    Firebase Analytics SDK

    Apps are usually tracked using the Firebase Analytics SDK . A good starting point would be visiting the following Url: https://firebase.google.com/docs/analytics/get-started?platform=android&hl=en

    The apps hits will use their own endpoint and format, the hits will go to https://app-measurement.com and the current payload will be sent in binary format, which makes it really difficult to debug, event if using Charles, Fiddles, or any other MITM proxy app.

    If you want to debug your Firebase implementation. I recommend you use my Android Debugger for Windows. Once you install the app, you’ll be able to request a free lifetime license.

    Android Debugger Splash Screen

    Google Analytics 4 Measurement Protocol

    Google Analytics finally offers a proper Measurement “Protocol“, which is at the time of writing this post it’s in Beta stage.

    This protocol will use the https://www.google-analytics.com/mp/collect endpoint, and rather than having the developers build the request payloads using some non-intuitive keys, now it accepts a POST request with a JSON string attached to the body using application/json Content-Type:

    fetch('https://www.google-analytics.com/mp/collect?measurement_id=G-THYNGSTER&api_secret=12zneF6DSDFSDFjJPgDAzzQ', {
      method: "POST",
      headers: {
         'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        "client_id": "12345678.87654321",
        "user_id": "RandomUserIdHash",
        "events": [{
          "name": "follow_me_at_twitter",
          "params": {
            "twitter_handle": "@thyng",
            "value": 7.77,
        },{
          "name": "follow_intent",
          "params": {
            "status": "success"
        }]
      })
    });
    KeyType
    client_idstrRequired. 
    user_idstrOptional.
    timestamp_microsintOptional. Hit offset. Up to 3 days ( 2,592e+11 microseconds ) before the current property’s defined timezone.
    user_properties{}Optional.
    non_personalized_adsboolOptional. ( whatever use this event for ads personalization )
    event[][]Required. ( Max 25 Events per request )
    event[].namestrRequired. 
    events[].params{}Optional.

    In any case, there are some things you need to have in mind, you should keep your API Secret not exposed, meaning that this endpoint should not be used client-side, because that would mean that your API Secret would need to be exposed. This endpoint is more likely to be used to track offline interactions, ( like refunds ), or for tracking our transactions server-side.

    At the time of writing this post ( Apr 2022 ), one of the biggest handicaps of this protocol is that it doesn’t support any sessionId parameter, meaning that you won’t be able to stitch the current server-side hits to the client-side session. This should be fixed over the next months,

    In the meanwhile, I’ve published a the GA4 Payload Parameters CheatSheet, which you could use to send some server-side hits in the old-school way ( like we used to do with the first Measurement Protocol for Universal Analytics ) and where you could attach the “&sid” parameter.

    There are of course some other points to have in mind, like that GA4 has some reserved event and parameters names, that you should not be using. We’ll cover this later in the “events” section.

    Events Model / Hit Types

    Let’s start by saying that everything on Google Analytics 4 is an “event“. I’m sure that it’s not the first time you hear that, and it’s totally right, but at the same time if we strictly look to Universal Analytics we were also sending “events“, but then we used to call them “hit types“.

    In a technical meaning, nothing has changed at all. We have networks requests to some endpoints. That is it!. If you want to learn a bit more about how the hits are built or sent from the web tracking library you can take a look at GA4: Google Analytics Measurement Protocol version 2 post to learn a bit more about how it works.

    The main difference on GA4 is that now Google does not offer a fixed tracking data model besides the page_views and the e-commerce. Meaning that the responsibility for building a proper data model falls on us. While working on our definition we need to have in mind that there are some predefined/reserved event and parameters names and that we have some limits we need to have in count (About total events, names, and values lengths).

    Universal Analytics Hit Types Model

    If we take a closer look, since Urchin times we’ve been using “events” for our tracking in Google Analytics. Yep, I’m not joking, we had, we just called them “hit types“.

    Just so you know, we could replicate the current Universal Analytics Data Model in Google Analytics 4 following the next table of events:

    Hit Type / EventParameters
    pageview– Location
    – Path
    – Title
    event– Category
    – Action
    – Label
    – Value
    – Non Interaction
    timing– Category
    – Variable
    – Label
    – Value
    social– Network
    – Action
    – Opt. Target
    exception– Description
    – Fatal
    screenview– Screen Name
    transaction ( Legacy Ecommerce )– Id
    – Affiliation
    – Revenue
    – Tax
    – Shipping
    – Coupon
    item ( Legacy Ecommerce )– Id
    – Name
    – Brand
    – Category
    – Variant
    – Price
    – Quantity

    Even Google offers a setting that will automatically convert all your ga() calls to some predefined events on GA4. From your Data Stream configuration you can enable this feature and all events, timing, and exception events will be converted to GA4 events ( they will add a listener to the ga('sent', 'event|exception|timing') calls for doing this,

    This tool wil map the data in the following way:

    Event NameParameters
    [event_name]This will take the current eventAction
    eventCategory > event_category 
    eventAction > event
    eventLabel > event_label
    eventValue > value
    timing_completetimingCategory > event_category
    timingLabel > event_label
    timingValue > value
    timingVar > name
    exceptionexDescription > description 
    exFatal > fatal 

    Beware because since its converting all Event Actions on “events“, depending on your current de events definition on Universal Analytics you have end up hitting the unique event names limit (500)

    Google Analytics 4 Events

    Event Sources

    The events on Google Analytics 4 can come from 4 different sources. These are:

    • Public Web/App endpoint.
    • Measurement Protocol ( Server Side )
    • Internal self-generated events
    • Admin defined events

    Public Web Endpoint

    The main actual origin for GA4 events we’ve already talked about them. These are the event that is being generated on our site coming from the GTAG.js container ( Check the GA4 Payload Parameters CheatSheet here ).

    Measurement Protocol ( Server Side )

    Another source for our events is the measurement protocol. This works similarly to the public endpoint. but the hits would be sent via server-side and we’ll need to use an API Secret within our requests.

    Internal self-generated Events

    This one can be a bit confusing, GA4 auto-generates some of the events we see in the reports. This means that we see some events in our reports that won’t be seen in our browser.

    This doesn’t mean that they’re being generated randomly or using some server-side logic. Most ( if not all ) of these events are created because a parameter was added to some event.

    Our events payloads may have some extra parameters attached to them sometimes that will make GA4 internally spawn a separate event. As far as I’ve been able to identify this is the list of the internally generated events and what’s the parameter that will trigger them.

    Event NameTrigger
    session_start&_ss
    first_visit&_fv
    user_engagement&seg

    For example, if the current event payload contains a &_ss parameter, a session_start will be generated, if it contains a $_fv then we should be able to see a first_visit events and so on. This list may grow in the future (and it may be missing some events that I’ve not been able to spot yet)

    If we’ve enabled the Enhanced Measurement, we may also see some events in our reports ( this time this event will be visible without the browser requests ), these are:

    Event NameParameters
    clicklink_id
    link_classes
    link_url
    link_domain
    outbound
    file_downloadlink_id
    link_text
    link_url
    file_name
    file_extension
    video_play
    video_pause
    video_seek
    video_buffering
    video_progress
    video_complete
    video_url
    video_title
    video_provider
    video_current_time
    video_duration
    video_percent
    visible
    view_search_resultssearch_term
    scrollpercent_scrolled
    page_viewpage_referrer ( URL and Title are Shared Parameters )


    On the other side, when working with the Firebase Analytics SDK, this one will automatically track a lot of events, without us needing to explicitly define them.

    Here is the current list of autogenerated event names by Firebase:

    ad_activeviewAPP
    ad_clickAPP
    ad_exposureAPP
    ad_impressionAPP
    ad_queryAPP
    adunit_exposureAPP
    app_clear_dataAPP
    app_installAPP
    app_updateAPP
    app_removeAPP
    errorAPP
    first_openAPP
    in_app_purchaseAPP
    notification_dismissAPP
    notification_foregroundAPP
    notification_openAPP
    notification_receiveAPP
    os_updateAPP
    screen_viewAPP
    user_engagementAPP,
    Note: These events will not count towards the unique events name limit

    Admin defined events

    We’ve already talked about these ones, when we create or modify an event within the admin section, these settings will be translated to the client-side tracking.

    This means the following:

    • You may see events being fired on the browser that you didn’t define on Google Tag Manager or GTAG. This is normal, don’t go crazy with it. If you see a duplicate event or a new event that you don’t know where it’s coming from take a look at the Data Stream Settings
    • You may have some unexpected parameters or event names if a “modify” rule is being used.

    Events Limitations

    Google Analytics 4 is full of limitations in many aspects, and it makes it a bit difficult to understand all of them, even more, when the limits keep constantly changing.

    We have limits for event names and values length, same for the event parameters and the user properties. At the same time, we have a limit on how many parameters and properties we can append to each event. And these limits may vary between the free and 360 versions.

    There are also, some exporting limitations (The free version it’s capped to 1M daily hit export to Big Query ) or the data retention settings wherein the free version will top at 14 months while the 360 will allow to hold up to 50 months on data.

    But this is not all the limits we’ll have … we will also have limits for the total conversions, audiences, insights, and funnels we can set. This is not directly related to the events, so if you’re interested you can visit the official Configuration Limits Information.

    Collecting and Names Limitations

    We can attach up to 25 event parameters ( 100 on GA4 360 ) to each event, and we can identify these values in our hits easily these are the ones starting with “^ep(|n).*“. Event Parameters are meant to add some metadata to our events.

    ep.event_origin: gtag

    Each of these parameters should have a name no longer than 40 characters and a value not bigger than 100 characters.

    At the same type, we have the “user properties“, We can attach up to 25 user properties to each hit these are attributes that will describe segments for our users. For example, we could think about recording the current user newsletter sign-up status, or the total purchases made by the current user. We can identify his data in our hits because they will start with “^up(|n).*“,

    up.newsletter_opt_in: yes
    upn.user_total_purchases: 43

    Each of these properties should have a name no longer than 24 characters and a value not bigger than 36 characters.

    Logged itemLimitFree360
    EventsEvent Name 40 chars
    Event parameter Name40 chars
    Event parameter Value100 chars
    Params per event25100
    User propertiesTotal per Property25
    Property Name24 chars
    Property Value36 chars
    User-ID256 characters
    Custom dimensionsEvent Scope50125
    Item Scope10
    User Scope25100
    Custom MetricsEvent Scope50125
    Events Offset3 days
    Full Limits Table

    Event Values Typing

    You may have noticed that some of the parameters start may start with up, ep, upn, epn . This is because an event parameter/user property can be either a string or a number, the good news is that we don’t need to define them since they’re automatically typed by GA4. Just take a look at the logic it’s used to define if a parameter is a string or a number.

    var value = 'something';
    if(typeof(value) === "number" && !isNaN(value)){
        console.log("is a number parameter");   
    }else{
        console.log("is a string parameter");
    }

    SGTM – Google Analytics 4 Hits

    The last thing I want to shout out is that GA4 hits sent via Server Side Google Tag Manager, are able of doing two things that we won’t see on the regular hits.

    First of these is that the hits sent server-side are able to set first-party cookies on the user browser, this is achieved using a Cookie-set header to the request:

    And the last one is that they may contain a response body, this is used to send back some pixels client-side. ie: SGTM builds up a pixel request and gets it back to the browser so it gets sent if for example, it was missing some third party cookie value (where sending it via server-side won’t be making any difference )

    More Questions

    How can I identify a conversion?

    If the current event has a &_c=1 parameter it will be counted as a conversion

    Are there any e-commerce limits?

    Yes, they’re, as far I’ve been able to deduct from the code.

    • A max of 200 items can be sent within a single event, any item above them will be skipped
    • A Max of 10 items scopes parameters, any parameter above this limit will be removed from the item

    It takes some seconds to see my hits

    Google Analytics 4, can delay up to 5 seconds the hits firing. This is because it uses an internal queue in order to batch the event and save some hits requests. At this time there is no way to “force” the queue dispatch, and there’re some situations where the queue is skipped and the events are sent right way. This is for example the first a visitor comes to your site (ie: when there’s no cookie present).

    Why can’t I use any of my parameters on the reports?

    You can send ANY parameters along with your events, but this doesn’t mean that you’ll be able to use them on your exploring reports. This can be confusing because while you’ll see the parameters on the Real-Time reports, you’ll need to set up them as dimensions on the admin in order to be able to use them. If you think about it, it makes sense, the real-time report is just some streaming report where no data is being parsed/processed at all, and we can not expect GA4 to process all the data coming with the events, so it will only process the parameters that we’ve configured. We need to setup then in the Custom Definitions section

    I’ve set-up my dimensions, but they show no data

    I’m not if this is only me, but it drove me crazy sometimes. I’d say that if you add a new event with some parameters and then you directly go to adding in the admin, they won’t show up, but you’ll be able to type the parameter name manually. All times I did this, I was not getting info for that dimension. My advice is to wait some hours before the custom definition and only do it if the dimension is being shown for being selected. ( rather than manually typing it ). If you did it wrong, the only solution that worked for me was archiving the dimension and re-creating it.