From 04439d9dec5d37445ca74e3ae3b7a2eaac2b2cfe Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Mon, 8 May 2023 11:40:20 +0200 Subject: [PATCH] Upgrade Editor analytics from deprecated `Universal Analytics` to the latest `GA4` --- Source/Editor/Analytics/EditorAnalytics.cpp | 277 +++++++++----------- Source/Editor/Analytics/EditorAnalytics.h | 21 +- Source/Editor/Editor.Build.cs | 2 +- Source/Engine/Utilities/TextWriter.h | 10 +- 4 files changed, 132 insertions(+), 178 deletions(-) diff --git a/Source/Editor/Analytics/EditorAnalytics.cpp b/Source/Editor/Analytics/EditorAnalytics.cpp index bd96436d7..c0cb8cd39 100644 --- a/Source/Editor/Analytics/EditorAnalytics.cpp +++ b/Source/Editor/Analytics/EditorAnalytics.cpp @@ -6,6 +6,7 @@ #include "Editor/Cooker/GameCooker.h" #include "Engine/Threading/Threading.h" #include "Engine/Platform/FileSystem.h" +#include "Engine/Platform/MemoryStats.h" #include "Engine/Core/Log.h" #include "Engine/Core/Math/Vector2.h" #include "Engine/Core/Types/DateTime.h" @@ -14,78 +15,85 @@ #include "Engine/Engine/Globals.h" #include "Engine/Graphics/GPUDevice.h" #include "Engine/Utilities/StringConverter.h" +#include "Engine/Utilities/TextWriter.h" #include "Engine/ShadowsOfMordor/Builder.h" +#include "Engine/Profiler/ProfilerCPU.h" #include "FlaxEngine.Gen.h" -#include +#include -#define FLAX_EDITOR_GOOGLE_ID "UA-88357703-3" +// Docs: +// https://developers.google.com/analytics/devguides/collection/ga4 +// https://developers.google.com/analytics/devguides/collection/protocol/ga4 -// Helper doc: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters +// [GA4] Flax Editor +#define GA_MEASUREMENT_ID "G-2SNY6RW6VX" +#define GA_API_SECRET "wFlau4khTPGFRnx-AIZ1zg" +#define GA_DEBUG 0 +#if GA_DEBUG +#define GA_URL "https://www.google-analytics.com/debug/mp/collect" +#else +#define GA_URL "https://www.google-analytics.com/mp/collect" +#endif -namespace EditorAnalyticsImpl +namespace { - UATracker Tracker = nullptr; - + StringAnsi Url; StringAnsi ClientId; - StringAnsi ProjectName; - StringAnsi ScreenResolution; - StringAnsi UserLanguage; - StringAnsi GPU; DateTime SessionStartTime; - CriticalSection Locker; bool IsSessionActive = false; + TextWriterANSI JsonBuffer; + curl_slist* CurlHttpHeadersList = nullptr; +} + +size_t curl_null_data_handler(char* ptr, size_t size, size_t nmemb, void* userdata) +{ + return nmemb * size; } void RegisterGameCookingStart(GameCooker::EventType type) { - auto& data = *GameCooker::GetCurrentData(); - auto platform = ToString(data.Platform); if (type == GameCooker::EventType::BuildStarted) { - EditorAnalytics::SendEvent("Actions", "GameCooker.Start", platform); - } - else if (type == GameCooker::EventType::BuildFailed) - { - EditorAnalytics::SendEvent("Actions", "GameCooker.Failed", platform); - } - else if (type == GameCooker::EventType::BuildDone) - { - EditorAnalytics::SendEvent("Actions", "GameCooker.End", platform); + auto& data = *GameCooker::GetCurrentData(); + StringAnsi name = "Build " + StringAnsi(ToString(data.Platform)); + const Pair params[1] = { { "GameCooker", name.Get() } }; + EditorAnalytics::SendEvent("Actions", ToSpan(params, ARRAY_COUNT(params))); } } void RegisterLightmapsBuildingStart() { - EditorAnalytics::SendEvent("Actions", "ShadowsOfMordor.Build", "ShadowsOfMordor.Build"); + const Pair params[1] = { { "ShadowsOfMordor", "Build" }, }; + EditorAnalytics::SendEvent("Actions", ToSpan(params, ARRAY_COUNT(params))); } void RegisterError(LogType type, const StringView& msg) { if (type == LogType::Error && false) { - String value = msg.ToString(); + StringAnsi value(msg); const int32 MaxLength = 300; if (msg.Length() > MaxLength) value = value.Substring(0, MaxLength); value.Replace('\n', ' '); value.Replace('\r', ' '); - EditorAnalytics::SendEvent("Errors", "Log.Error", value); + const Pair params[1] = { { "Error", value.Get() }, }; + EditorAnalytics::SendEvent("Errors", ToSpan(params, ARRAY_COUNT(params))); } else if (type == LogType::Fatal) { - String value = msg.ToString(); + StringAnsi value(msg); const int32 MaxLength = 300; if (msg.Length() > MaxLength) value = value.Substring(0, MaxLength); value.Replace('\n', ' '); value.Replace('\r', ' '); - EditorAnalytics::SendEvent("Errors", "Log.Fatal", value); + const Pair params[1] = { { "Fatal", value.Get() }, }; + EditorAnalytics::SendEvent("Errors", ToSpan(params, ARRAY_COUNT(params))); } } -using namespace EditorAnalyticsImpl; - class EditorAnalyticsService : public EngineService { public: @@ -102,176 +110,141 @@ EditorAnalyticsService EditorAnalyticsServiceInstance; bool EditorAnalytics::IsSessionActive() { - return EditorAnalyticsImpl::IsSessionActive; + return ::IsSessionActive; } void EditorAnalytics::StartSession() { ScopeLock lock(Locker); - if (EditorAnalyticsImpl::IsSessionActive) + if (::IsSessionActive) return; + PROFILE_CPU(); // Prepare client metadata - if (ClientId.IsEmpty()) - { - ClientId = Platform::GetUniqueDeviceId().ToString(Guid::FormatType::N).ToStringAnsi(); - } - if (ScreenResolution.IsEmpty()) - { - const auto desktopSize = Platform::GetDesktopSize(); - ScreenResolution = StringAnsi(StringUtils::ToString((int32)desktopSize.X)) + "x" + StringAnsi(StringUtils::ToString((int32)desktopSize.Y)); - } - if (UserLanguage.IsEmpty()) - { - UserLanguage = Platform::GetUserLocaleName().ToStringAnsi(); - } - if (GPU.IsEmpty()) - { - const auto gpu = GPUDevice::Instance; - if (gpu && gpu->GetState() == GPUDevice::DeviceState::Ready) - GPU = StringAsANSI<>(gpu->GetAdapter()->GetDescription().GetText()).Get(); - } - if (ProjectName.IsEmpty()) - { - ProjectName = Editor::Project->Name.ToStringAnsi(); - } + ClientId = Platform::GetUniqueDeviceId().ToString(Guid::FormatType::N).ToStringAnsi(); + StringAnsi ProjectName = Editor::Project->Name.ToStringAnsi(); + const auto desktopSize = Platform::GetDesktopSize(); + StringAnsi ScreenResolution = StringAnsi::Format("{0}x{1}", (int32)desktopSize.X, (int32)desktopSize.Y); + const auto memoryStats = Platform::GetMemoryStats(); + StringAnsi Memory = StringAnsi::Format("{0} GB", (int32)(memoryStats.TotalPhysicalMemory / 1024 / 1024 / 1000)); + StringAnsi UserLocale = Platform::GetUserLocaleName().ToStringAnsi(); + StringAnsi GPU; + if (GPUDevice::Instance && GPUDevice::Instance->GetState() == GPUDevice::DeviceState::Ready) + GPU = StringAsANSI<>(GPUDevice::Instance->GetAdapter()->GetDescription().GetText()).Get(); SessionStartTime = DateTime::Now(); - - // Initialize the analytics tracker - Tracker = createTracker(FLAX_EDITOR_GOOGLE_ID, ClientId.Get(), nullptr); - Tracker->user_agent = "Flax Editor"; - - // Store these options permanently (for the lifetime of the tracker) - setTrackerOption(Tracker, UA_OPTION_QUEUE, 1); - UASettings GlobalSettings = - { - { - { UA_DOCUMENT_PATH, 0, "Flax Editor" }, - { UA_DOCUMENT_TITLE, 0, "Flax Editor" }, + StringAnsiView EngineVersion = FLAXENGINE_VERSION_TEXT; #if PLATFORM_WINDOWS - { UA_USER_AGENT, 0, "Windows " FLAXENGINE_VERSION_TEXT }, + StringAnsiView PlatformName = "Windows"; #elif PLATFORM_LINUX - { UA_USER_AGENT, 0, "Linux " FLAXENGINE_VERSION_TEXT }, + StringAnsiView PlatformName = "Linux"; #elif PLATFORM_MAC - { UA_USER_AGENT, 0, "Mac " FLAXENGINE_VERSION_TEXT }, + StringAnsiView PlatformName = "Mac"; #else #error "Unknown platform" #endif - { UA_ANONYMIZE_IP, 0, "0" }, - { UA_APP_ID, 0, "Flax Editor " FLAXENGINE_VERSION_TEXT }, - { UA_APP_INSTALLER_ID, 0, "Flax Editor" }, - { UA_APP_NAME, 0, "Flax Editor" }, - { UA_APP_VERSION, 0, FLAXENGINE_VERSION_TEXT }, - { UA_SCREEN_NAME, 0, "Flax Editor " FLAXENGINE_VERSION_TEXT }, - { UA_SCREEN_RESOLUTION, 0, ScreenResolution.Get() }, - { UA_USER_LANGUAGE, 0, UserLanguage.Get() }, - } - }; - setParameters(Tracker, &GlobalSettings); - // Send the initial session event - UAOptions sessionViewOptions = + // Initialize HTTP + Url = StringAnsi::Format("{0}?measurement_id={1}&api_secret={2}", GA_URL, GA_MEASUREMENT_ID, GA_API_SECRET); + curl_global_init(CURL_GLOBAL_ALL); + CurlHttpHeadersList = curl_slist_append(nullptr, "Content-Type: application/json"); + ::IsSessionActive = true; + + // Start session { - { - { UA_EVENT_CATEGORY, 0, "Session" }, - { UA_EVENT_ACTION, 0, "Start Editor" }, - { UA_EVENT_LABEL, 0, "Start Editor" }, - { UA_SESSION_CONTROL, 0, "start" }, - { UA_DOCUMENT_TITLE, 0, ProjectName.Get() }, - } - }; - sendTracking(Tracker, UA_SCREENVIEW, &sessionViewOptions); + const Pair params[1] = { { "Project", ProjectName.Get() }, }; + SendEvent("Session", ToSpan(params, ARRAY_COUNT(params))); + } - EditorAnalyticsImpl::IsSessionActive = true; + // Report telemetry stats +#define SEND_TELEMETRY(name, value) \ + if (value.HasChars()) \ + { \ + const Pair params[1] = { { name, value.Get() } }; \ + SendEvent("Telemetry", ToSpan(params, ARRAY_COUNT(params))); \ + } + SEND_TELEMETRY("Platform", PlatformName); + SEND_TELEMETRY("GPU", GPU); + SEND_TELEMETRY("Memory", Memory); + SEND_TELEMETRY("Locale", UserLocale); + SEND_TELEMETRY("Screen", ScreenResolution); + SEND_TELEMETRY("Version", EngineVersion); +#undef SEND_TELEMETRY // Bind events GameCooker::OnEvent.Bind(); ShadowsOfMordor::Builder::Instance()->OnBuildStarted.Bind(); Log::Logger::OnError.Bind(); - - // Report GPU model - if (GPU.HasChars()) - { - SendEvent("Telemetry", "GPU.Model", GPU.Get()); - } } void EditorAnalytics::EndSession() { ScopeLock lock(Locker); - if (!EditorAnalyticsImpl::IsSessionActive) + if (!::IsSessionActive) return; + PROFILE_CPU(); // Unbind events GameCooker::OnEvent.Unbind(); ShadowsOfMordor::Builder::Instance()->OnBuildStarted.Unbind(); Log::Logger::OnError.Unbind(); - StringAnsi sessionLength = StringAnsi::Format("{0}", (int32)(DateTime::Now() - SessionStartTime).GetTotalSeconds()); - - // Send the end session event - UAOptions sessionEventOptions = + // End session { + StringAnsi sessionLength = StringAnsi::Format("{0}", (int32)(DateTime::Now() - SessionStartTime).GetTotalSeconds()); + const Pair params[1] = { - { UA_EVENT_CATEGORY, 0, "Session" }, - { UA_EVENT_ACTION, 0, "Session Length" }, - { UA_EVENT_LABEL, 0, "Session Length" }, - { UA_EVENT_VALUE, 0, sessionLength.Get() }, - { UA_CUSTOM_DIMENSION, 1, "Session Length" }, - { UA_CUSTOM_METRIC, 1, sessionLength.Get() }, - } - }; - sendTracking(Tracker, UA_EVENT, &sessionEventOptions); - - // Send the end session event - UAOptions sessionViewOptions = - { - { - { UA_EVENT_CATEGORY, 0, "Session" }, - { UA_EVENT_ACTION, 0, "End Editor" }, - { UA_EVENT_LABEL, 0, "End Editor" }, - { UA_EVENT_VALUE, 0, sessionLength.Get() }, - { UA_SESSION_CONTROL, 0, "end" }, - } - }; - sendTracking(Tracker, UA_SCREENVIEW, &sessionViewOptions); + { "Duration", sessionLength.Get() }, + }; + SendEvent("Session", ToSpan(params, ARRAY_COUNT(params))); + } // Cleanup - removeTracker(Tracker); - Tracker = nullptr; - - EditorAnalyticsImpl::IsSessionActive = false; + curl_slist_free_all(CurlHttpHeadersList); + CurlHttpHeadersList = nullptr; + curl_global_cleanup(); + ::IsSessionActive = false; } -void EditorAnalytics::SendEvent(const char* category, const char* name, const char* label) +void EditorAnalytics::SendEvent(const char* name, Span> parameters) { ScopeLock lock(Locker); - if (!EditorAnalyticsImpl::IsSessionActive) + if (!::IsSessionActive) return; + PROFILE_CPU(); - UAOptions opts = + // Create Json request contents + JsonBuffer.Clear(); + JsonBuffer.Write("{ \"client_id\": \""); + JsonBuffer.Write(ClientId); + JsonBuffer.Write("\", \"events\": [ { \"name\": \""); + JsonBuffer.Write(name); + JsonBuffer.Write("\", \"params\": {"); + for (int32 i = 0; i < parameters.Length(); i++) { - { - { UA_EVENT_CATEGORY, 0, (char*)category }, - { UA_EVENT_ACTION, 0, (char*)name }, - { UA_EVENT_LABEL, 0, (char*)label }, - } - }; - sendTracking(Tracker, UA_EVENT, &opts); -} + if (i != 0) + JsonBuffer.Write(","); + const auto& e = parameters[i]; + JsonBuffer.Write("\""); + JsonBuffer.Write(e.First); + JsonBuffer.Write("\":\""); + JsonBuffer.Write(e.Second); + JsonBuffer.Write("\""); + } + JsonBuffer.Write("}}]}"); + const StringAnsiView json((const char*)JsonBuffer.GetBuffer()->GetHandle(), (int32)JsonBuffer.GetBuffer()->GetPosition()); -void EditorAnalytics::SendEvent(const char* category, const char* name, const StringView& label) -{ - SendEvent(category, name, label.Get()); -} - -void EditorAnalytics::SendEvent(const char* category, const char* name, const Char* label) -{ - ScopeLock lock(Locker); - if (!EditorAnalyticsImpl::IsSessionActive) - return; - const StringAsANSI<> labelAnsi(label); - SendEvent(category, name, labelAnsi.Get()); + // Send HTTP request + CURL* curl = curl_easy_init(); + curl_easy_setopt(curl, CURLOPT_POST, 1); + curl_easy_setopt(curl, CURLOPT_URL, Url.Get()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, CurlHttpHeadersList); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json.Get()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, json.Length()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Flax Editor"); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, curl); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_null_data_handler); + CURLcode res = curl_easy_perform(curl); + curl_easy_cleanup(curl); } bool EditorAnalyticsService::Init() diff --git a/Source/Editor/Analytics/EditorAnalytics.h b/Source/Editor/Analytics/EditorAnalytics.h index ac693aa13..2df4bbcc9 100644 --- a/Source/Editor/Analytics/EditorAnalytics.h +++ b/Source/Editor/Analytics/EditorAnalytics.h @@ -28,24 +28,7 @@ public: /// /// Sends the custom event. /// - /// The event category name. /// The event name. - /// The event label. - static void SendEvent(const char* category, const char* name, const char* label = nullptr); - - /// - /// Sends the custom event. - /// - /// The event category name. - /// The event name. - /// The event label. - static void SendEvent(const char* category, const char* name, const StringView& label); - - /// - /// Sends the custom event. - /// - /// The event category name. - /// The event name. - /// The event label. - static void SendEvent(const char* category, const char* name, const Char* label); + /// The event parameters (key and value pairs). + static void SendEvent(const char* name, Span> parameters); }; diff --git a/Source/Editor/Editor.Build.cs b/Source/Editor/Editor.Build.cs index a3f7a39e6..64578570c 100644 --- a/Source/Editor/Editor.Build.cs +++ b/Source/Editor/Editor.Build.cs @@ -43,7 +43,7 @@ public class Editor : EditorModule options.PublicDependencies.Add("Engine"); options.PrivateDependencies.Add("pugixml"); - options.PrivateDependencies.Add("UniversalAnalytics"); + options.PrivateDependencies.Add("curl"); options.PrivateDependencies.Add("ContentImporters"); options.PrivateDependencies.Add("ContentExporters"); options.PrivateDependencies.Add("ShadowsOfMordor"); diff --git a/Source/Engine/Utilities/TextWriter.h b/Source/Engine/Utilities/TextWriter.h index 2825e7c6d..397c6cd9e 100644 --- a/Source/Engine/Utilities/TextWriter.h +++ b/Source/Engine/Utilities/TextWriter.h @@ -22,7 +22,7 @@ public: /// Init with default capacity /// /// Initial capacity in bytes - TextWriter(uint32 capacity) + TextWriter(uint32 capacity = 1024) : _buffer(capacity) { } @@ -36,19 +36,17 @@ public: public: /// - /// Gets writer private buffer + /// Gets writer private buffer. /// - /// Buffer FORCE_INLINE MemoryWriteStream* GetBuffer() { return &_buffer; } /// - /// Gets writer private buffer + /// Gets writer private buffer. /// - /// Buffer - const FORCE_INLINE MemoryWriteStream* GetBuffer() const + FORCE_INLINE const MemoryWriteStream* GetBuffer() const { return &_buffer; }