280 lines
9.3 KiB
C++
280 lines
9.3 KiB
C++
// Copyright (c) 2012-2024 Wojciech Figat. All rights reserved.
|
|
|
|
#include "EditorAnalytics.h"
|
|
#include "Editor/Editor.h"
|
|
#include "Editor/ProjectInfo.h"
|
|
#include "Editor/Cooker/GameCooker.h"
|
|
#include "Engine/Threading/Task.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"
|
|
#include "Engine/Core/Types/TimeSpan.h"
|
|
#include "Engine/Engine/EngineService.h"
|
|
#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 <ThirdParty/UniversalAnalytics/http.h>
|
|
|
|
// Docs:
|
|
// https://developers.google.com/analytics/devguides/collection/ga4
|
|
// https://developers.google.com/analytics/devguides/collection/protocol/ga4
|
|
|
|
// [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
|
|
{
|
|
StringAnsi Url;
|
|
StringAnsi ClientId;
|
|
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)
|
|
{
|
|
if (type == GameCooker::EventType::BuildStarted)
|
|
{
|
|
auto& data = *GameCooker::GetCurrentData();
|
|
StringAnsi name = "Build " + StringAnsi(ToString(data.Platform));
|
|
const Pair<const char*, const char*> params[1] = { { "GameCooker", name.Get() } };
|
|
EditorAnalytics::SendEvent("Actions", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
}
|
|
|
|
void RegisterLightmapsBuildingStart()
|
|
{
|
|
const Pair<const char*, const char*> params[1] = { { "ShadowsOfMordor", "Build" }, };
|
|
EditorAnalytics::SendEvent("Actions", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
|
|
void RegisterError(LogType type, const StringView& msg)
|
|
{
|
|
if (type == LogType::Error && false)
|
|
{
|
|
StringAnsi value(msg);
|
|
const int32 MaxLength = 300;
|
|
if (msg.Length() > MaxLength)
|
|
value = value.Substring(0, MaxLength);
|
|
value.Replace('\n', ' ');
|
|
value.Replace('\r', ' ');
|
|
const Pair<const char*, const char*> params[1] = { { "Error", value.Get() }, };
|
|
EditorAnalytics::SendEvent("Errors", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
else if (type == LogType::Fatal)
|
|
{
|
|
StringAnsi value(msg);
|
|
const int32 MaxLength = 300;
|
|
if (msg.Length() > MaxLength)
|
|
value = value.Substring(0, MaxLength);
|
|
value.Replace('\n', ' ');
|
|
value.Replace('\r', ' ');
|
|
const Pair<const char*, const char*> params[1] = { { "Fatal", value.Get() }, };
|
|
EditorAnalytics::SendEvent("Errors", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
}
|
|
|
|
class EditorAnalyticsService : public EngineService
|
|
{
|
|
public:
|
|
EditorAnalyticsService()
|
|
: EngineService(TEXT("Editor Analytics"))
|
|
{
|
|
}
|
|
|
|
bool Init() override;
|
|
void Dispose() override;
|
|
};
|
|
|
|
EditorAnalyticsService EditorAnalyticsServiceInstance;
|
|
|
|
bool EditorAnalytics::IsSessionActive()
|
|
{
|
|
return ::IsSessionActive;
|
|
}
|
|
|
|
void EditorAnalytics::StartSession()
|
|
{
|
|
ScopeLock lock(Locker);
|
|
if (::IsSessionActive)
|
|
return;
|
|
PROFILE_CPU();
|
|
|
|
// Prepare client metadata
|
|
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();
|
|
StringAnsiView EngineVersion = FLAXENGINE_VERSION_TEXT;
|
|
#if PLATFORM_WINDOWS
|
|
StringAnsiView PlatformName = "Windows";
|
|
#elif PLATFORM_LINUX
|
|
StringAnsiView PlatformName = "Linux";
|
|
#elif PLATFORM_MAC
|
|
StringAnsiView PlatformName = "Mac";
|
|
#else
|
|
#error "Unknown platform"
|
|
#endif
|
|
|
|
// 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
|
|
{
|
|
const Pair<const char*, const char*> params[1] = { { "Project", ProjectName.Get() }, };
|
|
SendEvent("Session", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
|
|
// Report telemetry stats
|
|
#define SEND_TELEMETRY(name, value) \
|
|
if (value.HasChars()) \
|
|
{ \
|
|
const Pair<const char*, const char*> 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<RegisterGameCookingStart>();
|
|
ShadowsOfMordor::Builder::Instance()->OnBuildStarted.Bind<RegisterLightmapsBuildingStart>();
|
|
Log::Logger::OnError.Bind<RegisterError>();
|
|
}
|
|
|
|
void EditorAnalytics::EndSession()
|
|
{
|
|
ScopeLock lock(Locker);
|
|
if (!::IsSessionActive)
|
|
return;
|
|
PROFILE_CPU();
|
|
|
|
// Unbind events
|
|
GameCooker::OnEvent.Unbind<RegisterGameCookingStart>();
|
|
ShadowsOfMordor::Builder::Instance()->OnBuildStarted.Unbind<RegisterLightmapsBuildingStart>();
|
|
Log::Logger::OnError.Unbind<RegisterError>();
|
|
|
|
// End session
|
|
{
|
|
StringAnsi sessionLength = StringAnsi::Format("{}", (int32)(DateTime::Now() - SessionStartTime).GetTotalSeconds());
|
|
const Pair<const char*, const char*> params[1] =
|
|
{
|
|
{ "Duration", sessionLength.Get() },
|
|
};
|
|
SendEvent("Session", ToSpan(params, ARRAY_COUNT(params)));
|
|
}
|
|
|
|
// Cleanup
|
|
curl_slist_free_all(CurlHttpHeadersList);
|
|
CurlHttpHeadersList = nullptr;
|
|
curl_global_cleanup();
|
|
::IsSessionActive = false;
|
|
}
|
|
|
|
void EditorAnalytics::SendEvent(const char* name, Span<Pair<const char*, const char*>> parameters)
|
|
{
|
|
ScopeLock lock(Locker);
|
|
if (!::IsSessionActive)
|
|
return;
|
|
PROFILE_CPU();
|
|
|
|
// 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++)
|
|
{
|
|
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());
|
|
|
|
// 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()
|
|
{
|
|
#if COMPILE_WITH_DEV_ENV
|
|
// Disable analytics from the dev (internal) builds
|
|
LOG(Info, "Editor analytics service is disabled in dev builds.");
|
|
return false;
|
|
#endif
|
|
|
|
// If file '<editor_install_root>/noTracking' exists then do not use analytics (per engine instance)
|
|
// If file '%appdata/Flax/noTracking' exists then do not use analytics (globally)
|
|
String appDataPath;
|
|
FileSystem::GetSpecialFolderPath(SpecialFolder::AppData, appDataPath);
|
|
if (FileSystem::FileExists(Globals::StartupFolder / TEXT("noTracking")) ||
|
|
FileSystem::FileExists(appDataPath / TEXT("Flax/noTracking")))
|
|
{
|
|
LOG(Info, "Editor analytics service is disabled.");
|
|
return false;
|
|
}
|
|
|
|
LOG(Info, "Editor analytics service is enabled. Curl version: {0}", TEXT(LIBCURL_VERSION));
|
|
Task::StartNew(EditorAnalytics::StartSession);
|
|
|
|
return false;
|
|
}
|
|
|
|
void EditorAnalyticsService::Dispose()
|
|
{
|
|
EditorAnalytics::EndSession();
|
|
}
|