Google Analytics Broken with Switch to GA4

Recently, Google deprecated Universal Analytics and replaced it with Google Analytics 4.
This seems to have broken the juce module as the API key UA-xxxxxxxxx-x has been replaced with a “GA Measurement ID” (I think) e.g. G-xxxxxxxxxx.

Simply swapping that out in the web request however doesn’t work (In this context: https://www.google-analytics.com/batch?v=1&tid=<key>.

Does anyone know if there is a simple migration route or do we need to ditch this and use Firebase with the Google C++ library?

Having a juce-supported migration path would be helpful.

I followed the ‘Analytics for beginners and small businesses  |  Google Analytics  |  Google for Developers’ guide to setup The Google Analytics 4 Measurement Protocol.

Only Step 1: Part 1: Set up a property

Create new Google Analytics 4 Property

Add new Data Stream (Web)

Get your Measurement ID (“apikey”) G-XXXXXXXXXX and generate new ‘secret key’ in Data Stream settings.

Add the Google gtag to your product website (I used my router http ‘Hello world’ HTML page). Google also support website builders etc.

Test and verify Google Analytics is receiving Measurement Protocol events e.g. by CURLing with these examples ส่งเหตุการณ์ Measurement Protocol ไปยัง Google Analytics  |  Google for Developers . According to Google this can take up to 48 hours.

Use this slightly modified AnalyticsCollectionTutorial.h. Plug in your G-XXXXXXXXXX apikey and secret key into corresponding variables. Do something with ‘setUserId’.


#pragma once

enum DemoAnalyticsEventTypes
{
    event,
    sessionStart,
    sessionEnd,
    screenView,
    exception
};

//==============================================================================
class GoogleAnalyticsDestination : public juce::ThreadedAnalyticsDestination
{
  public:
    GoogleAnalyticsDestination()
        : ThreadedAnalyticsDestination("GoogleAnalyticsThread")
    {
        {
            // Choose where to save any unsent events.

            auto appDataDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
                                  .getChildFile(juce::JUCEApplication::getInstance()->getApplicationName()); // [1]

            if (!appDataDir.exists())
                appDataDir.createDirectory(); // [2]

            savedEventsFile = appDataDir.getChildFile("analytics_events.xml"); // [3]
        }

        {
            // It's often a good idea to construct any analytics service API keys
            // at runtime, so they're not searchable in the binary distribution of
            // your application (but we've not done this here). You should replace
            // the following key with your own to get this example application
            // fully working.

            apiKey = "G-XXXXXXXXXXXX";
            secret = "XXXXXXXXXXXXXXXXXXXXX";
        }

        startAnalyticsThread(initialPeriodMs); // [4]
    }

    ~GoogleAnalyticsDestination() override
    {
        // Here we sleep so that our background thread has a chance to send the
        // last lot of batched events. Be careful - if your app takes too long to
        // shut down then some operating systems will kill it forcibly!
        juce::Thread::sleep(initialPeriodMs); // [5]

        stopAnalyticsThread(1000); // [6]
    }

    int getMaximumBatchSize() override
    {
        return 20;
    }

    bool logBatchedEvents(const juce::Array<AnalyticsEvent>& events) override
    {
        juce::StringArray postData;

        for (auto& event : events)
        {
            juce::DynamicObject::Ptr eventData = new juce::DynamicObject();

            eventData->setProperty("client_id", event.userID);

            juce::DynamicObject::Ptr eventDetails = new juce::DynamicObject();

            if (event.eventType == DemoAnalyticsEventTypes::event)
            {
                if (event.name == "startup")
                {
                    eventDetails->setProperty("name", "app_started");
                }
                else if (event.name == "shutdown")
                {
                    eventDetails->setProperty("name", "app_stopped");
                }
                else if (event.name == "button_press")
                {
                    eventDetails->setProperty("name", "button_press");
                    juce::DynamicObject::Ptr params = new juce::DynamicObject();
                    params->setProperty("id", event.parameters["id"]);
                    eventDetails->setProperty("params", juce::var(params.get()));
                }
                else if (event.name == "crash")
                {
                    eventDetails->setProperty("name", "app_crash");
                }
                else
                {
                    jassertfalse;
                    continue;
                }
            }
            else
            {
                jassertfalse;
                continue;
            }

            juce::Array<juce::var> eventsArray;
            eventsArray.add(juce::var(eventDetails.get()));

            eventData->setProperty("events", eventsArray);

            juce::String jsonString = juce::JSON::toString(juce::var(eventData));
            postData.add(jsonString);
        }

        juce::String baseURL = "https://www.google-analytics.com/mp/collect?";
        auto url = juce::URL(baseURL)
                       .withParameter("measurement_id", apiKey)
                       .withParameter("api_secret", secret)
                       .withPOSTData(postData.joinIntoString("\r\n"));

        {
            const juce::ScopedLock lock(webStreamCreation);

            if (shouldExit)
                return false;

            webStream.reset(new juce::WebInputStream(url, false));
        }

        auto success = webStream->connect(nullptr);

        // Do an exponential backoff if we failed to connect.
        if (success)
            periodMs = initialPeriodMs;
        else
            periodMs *= 2;

        setBatchPeriod(periodMs);

        return success;
    }

    void stopLoggingEvents() override
    {
        const juce::ScopedLock lock(webStreamCreation); // [1]

        shouldExit = true; // [2]

        if (webStream.get() != nullptr) // [3]
            webStream->cancel();
    }

  private:
    void saveUnloggedEvents(const std::deque<AnalyticsEvent>& eventsToSave) override
    {
        // Save unsent events to disk. Here we use XML as a serialisation format, but
        // you can use anything else as long as the restoreUnloggedEvents method can
        // restore events from disk. If you're saving very large numbers of events then
        // a binary format may be more suitable if it is faster - remember that this
        // method is called on app shutdown so it needs to complete quickly!

        juce::XmlDocument previouslySavedEvents(savedEventsFile);
        std::unique_ptr<juce::XmlElement> xml(previouslySavedEvents.getDocumentElement()); // [1]

        if (xml.get() == nullptr || xml->getTagName() != "events") // [2]
            xml.reset(new juce::XmlElement("events"));

        for (auto& event : eventsToSave)
        {
            auto* xmlEvent = new juce::XmlElement("google_analytics_event"); // [3]
            xmlEvent->setAttribute("name", event.name);
            xmlEvent->setAttribute("type", event.eventType);
            xmlEvent->setAttribute("timestamp", (int)event.timestamp);
            xmlEvent->setAttribute("user_id", event.userID);

            auto* parameters = new juce::XmlElement("parameters"); // [4]

            for (auto& key : event.parameters.getAllKeys())
                parameters->setAttribute(key, event.parameters[key]);

            xmlEvent->addChildElement(parameters);

            auto* userProperties = new juce::XmlElement("user_properties"); // [5]

            for (auto& key : event.userProperties.getAllKeys())
                userProperties->setAttribute(key, event.userProperties[key]);

            xmlEvent->addChildElement(userProperties);

            xml->addChildElement(xmlEvent); // [6]
        }

        xml->writeTo(savedEventsFile); // [7]
    }

    void restoreUnloggedEvents(std::deque<AnalyticsEvent>& restoredEventQueue) override
    {
        juce::XmlDocument savedEvents(savedEventsFile);
        std::unique_ptr<juce::XmlElement> xml(savedEvents.getDocumentElement()); // [1]

        if (xml.get() == nullptr || xml->getTagName() != "events") // [2]
            return;

        auto numEvents = xml->getNumChildElements();

        for (auto iEvent = 0; iEvent < numEvents; ++iEvent)
        {
            auto* xmlEvent = xml->getChildElement(iEvent); // [3]

            juce::StringPairArray parameters;
            auto* xmlParameters = xmlEvent->getChildByName("parameters"); // [4]
            auto numParameters = xmlParameters->getNumAttributes();

            for (auto iParam = 0; iParam < numParameters; ++iParam)
                parameters.set(xmlParameters->getAttributeName(iParam), xmlParameters->getAttributeValue(iParam));

            juce::StringPairArray userProperties;
            auto* xmlUserProperties = xmlEvent->getChildByName("user_properties"); // [5]
            auto numUserProperties = xmlUserProperties->getNumAttributes();

            for (auto iProp = 0; iProp < numUserProperties; ++iProp)
                userProperties.set(
                    xmlUserProperties->getAttributeName(iProp), xmlUserProperties->getAttributeValue(iProp)
                );

            restoredEventQueue.push_back(
                {xmlEvent->getStringAttribute("name"), // [6]
                 xmlEvent->getIntAttribute("type"),
                 static_cast<juce::uint32>(xmlEvent->getIntAttribute("timestamp")),
                 parameters,
                 xmlEvent->getStringAttribute("user_id"),
                 userProperties}
            );
        }

        savedEventsFile.deleteFile(); // [7]
    }

    const int initialPeriodMs = 1000;
    int periodMs = initialPeriodMs;

    juce::CriticalSection webStreamCreation;
    bool shouldExit = false;
    std::unique_ptr<juce::WebInputStream> webStream;

    juce::String apiKey;
    juce::String secret;

    juce::File savedEventsFile;
};

//==============================================================================
class MainContentComponent : public juce::Component
{
  public:
    //==============================================================================
    MainContentComponent()
    {
        // Add an analytics identifier for the user. Make sure you don't accidentally
        // collect identifiable information if you haven't asked for permission!
        juce::Analytics::getInstance()->setUserId("123456.7654321"); // [1]

        // Add any other constant user information.
        juce::StringPairArray userData;
        userData.set("group", "beta");
        juce::Analytics::getInstance()->setUserProperties(userData); // [2]

        // Add any analytics destinations we want to use to the Analytics singleton.
        juce::Analytics::getInstance()->addDestination(new GoogleAnalyticsDestination()); // [3]

        // The event type here should probably be DemoAnalyticsEventTypes::sessionStart
        // in a more advanced app.
        juce::Analytics::getInstance()->logEvent("startup", {}, DemoAnalyticsEventTypes::event); // [4]

        crashButton.onClick = [this] { sendCrash(); };

        addAndMakeVisible(eventButton);
        addAndMakeVisible(crashButton);

        setSize(300, 200);

        juce::StringPairArray logButtonPressParameters;
        logButtonPressParameters.set("id", "a");
        logEventButtonPress.reset(new juce::ButtonTracker(eventButton, "button_press", logButtonPressParameters)
        ); // [2]
    }

    ~MainContentComponent() override
    {
        // The event type here should probably be DemoAnalyticsEventTypes::sessionEnd
        // in a more advanced app.
        juce::Analytics::getInstance()->logEvent("shutdown", {}, DemoAnalyticsEventTypes::event); // [5]
    }

    void paint(juce::Graphics& g) override
    {
        g.fillAll(getLookAndFeel().findColour(juce::ResizableWindow::backgroundColourId));
    }

    void resized() override
    {
        eventButton.centreWithSize(100, 40);
        eventButton.setBounds(eventButton.getBounds().translated(0, 25));
        crashButton.setBounds(eventButton.getBounds().translated(0, -50));
    }

  private:
    //==============================================================================
    void sendCrash()
    {
        // In a more advanced application you would probably use a different event
        // type here.
        juce::Analytics::getInstance()->logEvent("crash", {}, DemoAnalyticsEventTypes::event);
        juce::Analytics::getInstance()->getDestinations().clear();
        juce::JUCEApplication::getInstance()->quit();
    }

    juce::TextButton eventButton{"Press me!"}, crashButton{"Simulate crash!"};
    std::unique_ptr<juce::ButtonTracker> logEventButtonPress; // [1]

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MainContentComponent)
};


1 Like

Thanks for the detailed response. I’ll be sure to take a closer look at this when I get time to circle back round to it.

@ak5k ive been trying to implement your instructions to no avail, even just trying to run as the demo in the juce examples, are there any further details to how you setup the gtag side in case i missed something there? I followed the links you provided but still see no hits in reporting

it took some time for google to ‘wake up’ and start displaying data. Anyways, I’m getting events registered with that, but it took longer than I expected.

Hey! Any luck with that? I’m having the same issue

@facundogadola i actually gave up and switched to using mixpanel, i got that working well and happy with it, i plan to make my project’s source code available soon and can report here when thats ready but here is similar idea:

2 Likes

Thanks @dylanmach1 ! I was actually planning to use Google Analytics for this. Maybe @ak5k has some ideas or insights on how to get it working?

We just got Claude to convert our GAU code to GA4.
There was a bit of faff setting up the web end and getting a few things like timing to work but was fairly painless.