Arturo Cuya

Join the weekly newsletter

Not a subscriber?

Every week I'll send you cutting edge advice and actionable tips about modern Roku development. State-of-the-art knowledge that you won't find anywhere else — For free.

June 7, 2023 | 8 min read

Setup Google Analytics 4 (GA4) for your Roku app

By Arturo Cuya

@arturo__cuya

If you’re reading this close to the publication date, you have less than a month to migrate to Google Analytics 4 (GA4). With that in mind, let’s go straight to the point!

Before you start, make sure to create a new GA4 property if you don’t have it, and setup a new stream of type web. This is the most suitable for tracking data from a Roku device. Take note of your stream Measurement ID (starts with a G- prefix).

TL;DR: If you’re already running late for the migration, check the sample app on Github.

Getting started

To collect GA4 events in your Roku app, you should rely on the open source project vixtech/roku-google-analytics-4. Grab the code in the components/ folder and paste it into your project’s components folder.

I usually prefer to put it into a components/lib folder so that I can setup my linter and formatter to ignore all the files from that folder.

Something that you might want to do is prepare the m.global scope so that it can hold the GA4 task node. That way you’ll be able to invoke it to send events from anywhere in your application.

' main.bs
sub main()
    ' ...

    m.global = screen.getGlobalNode()

    ' The 3rd parameter sets the `alwaysNotify` parameter to false.
    m.global.addField("ga4", "node", false)

    ' ...
end sub

You can do this immediately after grabbing the reference for m.global in main.bs. What you can’t do after that is populate the field. You need to do so at least after calling show() on your main screen. Otherwise your app will halt.

m.global = screen.getGlobalNode()
' The 3rd parameter sets the `alwaysNotify` parameter to false.
m.global.addField("ga4", "node", false)

screen.CreateScene("MainScene")
screen.show()

' Only initialize the `ga4` field AFTER your screen has shown.
m.global.setField("ga4", CreateObject("roSGNode", "GoogleAnalytics"))

We now need to call two of the ga4 node functions: initialize() and start().

m.global.ga4.callFunc("initialize", {
    measurementId: "<your-measurement-id>",
    appName: "ga4.arturocuya.com"
})

' Remember that in BrighterScript you can also use the
' `@.` notation to call node functions
m.global.ga4@.start()

The minimal information we should provide to initialize() is your app name (can be anything) and your GA4 web stream Measurement ID.

Testing the water

With this set, let’s run the application to see what happens.

------ Running dev 'setup-ga4-example' main ------

[ANALYTICS] POST https://analytics.google.com/g/collect?sid=1686177954&av=1.0.0&_p=1418464313&_ss=2&tid=<your-measurement-id>&_s=1&seg=0&sr=1920x1080&ul=en_us&v=2&en=page_view&sct=54&cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5&an=ga4.arturocuya.com

[ANALYTICS] POST https://analytics.google.com/g/collect?sid=1686177954&av=1.0.0&_p=1514643476&tid=<your-measurement-id>&_s=2&seg=1&sr=1920x1080&ul=en_us&_et=30000&v=2&en=user_engagement&sct=54&cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5&an=ga4.arturocuya.com

[ANALYTICS] POST https://analytics.google.com/g/collect?sid=1686177954&av=1.0.0&_p=443059806&tid=<your-measurement-id>&_s=3&seg=1&sr=1920x1080&ul=en_us&v=2&epn.time_difference=30000&en=app_time&sct=54&cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5&an=ga4.arturocuya.com

We can see that the analytics library will log into the telnet console every time it sends an event. However, we have not programmed any event to be sent yet. What’s happening is that the library assumes that you will want some events by default. Let’s expand them to see what they’re about.

https://analytics.google.com/g/collect?sid=1686177954
    &av=1.0.0
    &_p=1418464313
    &_ss=2
    &tid=<your-measurement-id>
    &_s=1
    &seg=0
    &sr=1920x1080
    &ul=en_us
    &v=2
    &en=page_view
    &sct=54
    &cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5
    &an=ga4.arturocuya.com

https://analytics.google.com/g/collect?sid=1686177954
    &av=1.0.0
    &_p=1514643476
    &tid=<your-measurement-id>
    &_s=2
    &seg=1
    &sr=1920x1080
    &ul=en_us
    &_et=30000
    &v=2
    &en=user_engagement
    &sct=54
    &cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5
    &an=ga4.arturocuya.com

https://analytics.google.com/g/collect?sid=1686177954
    &av=1.0.0
    &_p=443059806
    &tid=<your-measurement-id>
    &_s=3
    &seg=1
    &sr=1920x1080
    &ul=en_us
    &v=2
    &epn.time_difference=30000
    &en=app_time
    &sct=54
    &cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5
    &an=ga4.arturocuya.com

From these URLs, we can see that the library is sending the events page_view, user_engagement and app_time. If we look at the library’s source code, we can discover that a page_view event is being logged when we call the start() function, and then the user_engagement and app_time events are being logged every 30s after we created the ga4 node.

Unfortunately, there’s no setting to opt out of these default events. You will need to edit your copy of the source code to remove these logEvent calls.

Sending an event

Let’s setup a simple use case for sending events: We will create a button that saves the number of times it has been pressed. On every press, we will increase this number and send an event button_pressed with the number of times it has been pressed so far.

' MainScene.bs
sub init()
    m.button = m.top.createChild("Button")

    m.button.update({
        text: "Send event button_pressed",
        translation: [100, 100]
    })

    m.button.observeField("buttonSelected", "handleButtonSelected")

    m.button.setFocus(true)

    m.buttonPressedTimes = 0
end sub

sub handleButtonSelected()
    m.buttonPressedTimes += 1
    m.global.ga4@.logEvent("button_pressed", { times: m.buttonPressedTimes })
end sub

Sending an event is very straight forward: call the node function logEvent and pass to it the name and params of the event. That’s it! See the telnet logs to confirm that an event was sent:

------ Running dev 'setup-ga4-example' main ------

[ANALYTICS] POST https://analytics.google.com/g/collect?epn.times=1.0&sid=1686179472&av=1.0.0&_p=387660808&tid=<your-measurement-id>&_s=1&seg=1&sr=1920x1080&ul=en_us&v=2&en=button_pressed&sct=58&cid=69abded1-cd8e-5d2f-b195-d4094dc24bd5&an=ga4.arturocuya.com

If you look at your GA4 dashboard you might or might not see the events there. Remember that it can take up to 48h before they start appearing. What we can see is the Realtime panel, but it shows data for all users, not from our current session.

Enabling debug mode

If you’re familiar with Google Analytics, you’ll know that debug mode allows you to follow a single session, which is helpful during the QA of your application. The library is capable of working in debug mode, we just have to make sure we do it the right way.

First, let’s start cleaning our code a bit. We will externalize our GA4 logic to a different file, so that we can generalize the way we use the library.

' source/utils/ga4.bs
namespace ga4
    ' Initializes and starts the `ga4` global node.
    sub initialize()
        m.global.setField("ga4", CreateObject("roSGNode", "GoogleAnalytics"))
        m.global.ga4@.initialize({
            measurementId: "<your-measurement-id>",
            appName: "ga4.arturocuya.com"
        })
        m.global.ga4@.start()
    end sub

    ' Logs an event to GA4. Returns true if the event was logged.
    function logEvent(eventName as string, params = {} as object) as boolean
        if (m.global.ga4 = invalid)
            ? `[ANALYTICS] Could not send event ${eventName} because global ga4 node is invalid.`
            return false
        end if

        m.global.ga4@.logEvent(eventName, params)

        return true
    end function
end namespace

We now need to refactor our files to use the new ga4 namespace.

' main.bs
sub init()
    ' ...

    screen.show()

    ' From main.bs we don't need to import the file because
    ' everything in `source/` is available here
    ga4.initialize()
end sub
' MainScene.bs
' import the namespace at the beginning of the file
import "pkg:/source/utils/ga4.bs"

sub handleButtonSelected()
    m.buttonPressedTimes += 1
    ga4.logEvent("button_pressed", { times: m.buttonPressedTimes })
end sub

Now that our code is a bit cleaner, let’s configure everything to enable debug mode. I think enabling it or not should be decided from a deeplink. That way you can run the app as usual, sending events to your property, but your QA folks can open the app just once with debug mode enabled through a deeplink.

To capture a deeplink, modify the main() function in main.bs like so:

' main.bs

' Add the `args` parameter.
sub main(args as object)
    ' Before initializing the `ga4` node, parse the `analyticsDebug`
    ' argument from string|invalid to boolean.
    debugModeEnabled = (args.analyticsDebug = "true") ?? false
    ga4.initialize(debugModeEnabled)
end sub

We will also modify the initialize() function to support the new parameter.

' Initializes and starts the `ga4` global node.
sub initialize(debugModeEnabled as boolean)
    m.global.setField("ga4", CreateObject("roSGNode", "GoogleAnalytics"))
    m.global.ga4@.initialize({
        measurementId: "<your-measurement-id>",
        appName: "ga4.arturocuya.com",
        customArgs: {
            debug_mode: debugModeEnabled
        }
    })
    m.global.ga4@.start()
end sub

Notice the customArgs parameter that has been added in the ga4@.initialize() call. Whatever we put in that associative array will also be included as URL query params in every event.

Using the Deep Linking Tester, launch your application with ?analyticsDebug=true and start sending events. During this session you will be able to view the events in real time on the DebugView page of your GA4 dashboard.

Sending UTM parameters

Another common use case is to send to GA4 the UTM parameters that were used to open your application. These are also included in the args of main.bs::main(), usually present when the app is open through an ad panel.

The usual five UTM parameters are:

  • utm_source: Identifies which site sent the traffic.

  • utm_medium: Identifies what type of link was used, such as cost per click or email.

  • utm_campaign: Identifies a specific product promotion or strategic campaign.

  • utm_term: Identifies search terms.

  • utm_content: Identifies what specifically was clicked to bring the user to the site, such as a banner ad or a text link. It is often used for A/B testing and content-targeted ads.

An example of these as query parameters for a regular website would be something like:

?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=running+shoes&utm_content=logolink

The first problem we will encounter is that GA4 uses its own format for UTM query parameters:

  • utm_source is cs

  • utm_medium is cm

  • utm_campaign is cn

  • utm_term is ck

With that in mind, let’s refactor our app to include the UTM params in the library’s customArgs, as we just did for debug_mode.

namespace ga4
    ' Initializes and starts the `ga4` global node.
    sub initialize(launchArgs as object)
        m.global.setField("ga4", CreateObject("roSGNode", "GoogleAnalytics"))

        debugModeEnabled = (launchArgs.analyticsDebug = "true") ?? false
        customArgs = {
            debug_mode: debugModeEnabled
        }

        ' You might want to change the keys here if your business
        ' configures ads with different utm params.
        keyMap = {
            utm_source: "cs",
            utm_medium: "cm",
            utm_campaign: "cn",
            utm_term: "ck"
        }

        utmParams = ga4.parseUtmParams(keyMap, launchArgs)

        if (utmParams <> invalid)
            customArgs.append(utmParams)
        end if

        m.global.ga4@.initialize({
            measurementId: "<your-measurement-id>",
            appName: "ga4.arturocuya.com",
            customArgs: customArgs
        })
        m.global.ga4@.start()
    end sub

    ' Parses the UTM params to the format GA4 accepts.
    ' If any of the keys in the provided keyMap is not present,
    ' the function will return `invalid`.
    function parseUtmParams(keyMap as object, utmParams as object) as dynamic
        parsed = {}
        
        for each item in keyMap.items()
            if (utmParams[item.key] = invalid)
                return invalid
            end if

            parsed[item.value] = utmParams[item.key]
        end for

        return parsed
    end function

    ' ...
end namespace

We have replaced the argument of initialize() to receive the complete launch arguments from main.bs::main(args). Then we can parse the campaign data by providing a keymap with the equivalents between your ads’ UTM params and the ones GA4 accepts, with the helper function parseUtmParams().

Remember to send the whole args object to ga4.initialize() in main.bs

That’s it! Now you know how to setup GA4 in your Roku application, use it in debug mode and send your UTM campaign data. Enjoy!

See the final code in the Github repository


Some other ways I can help you:

1. Course: A Modern Introduction to Roku Development (Coming April 2024)

For absolute beginners, this course is meant to set you in the right path to make use of modern tools and best practices from day one. Subscribe to the newsletter to get notified when it's available.

2. Open Source tools to boost your productivity:

Use these tools to 10x your workflows. Free and open source forever.

  • create-roku-app: A CLI tool to quickly scaffold Roku apps using the best practices.
  • SceneGraph Flex: A flexbox component for Roku Scenegraph applications.
  • Roku Animate: Define complex Roku Scenegraph animations in one line of code.