// Copyright Epic Games, Inc. All Rights Reserved. #include "AnalyticsSessionSummaryManager.h" #include "AnalyticsPropertyStore.h" #include "IAnalyticsSessionSummarySender.h" #include "GenericPlatform/GenericPlatformProcess.h" #include "HAL/CriticalSection.h" #include "HAL/FileManager.h" #include "HAL/PlatformProcess.h" #include "HAL/PlatformTime.h" #include "Misc/App.h" #include "Misc/Optional.h" #include "Misc/Paths.h" #include "Internationalization/Regex.h" DEFINE_LOG_CATEGORY_STATIC(LogAnalyticsSessionSummary, Verbose, All); namespace AnalyticsManagerProperties { // Those values are added by the manager for internal usage. They are stripped before sending the summary. static const FString InternalPropertyPrefix = TEXT("Internal."); static const TAnalyticsProperty InternalSessionUserId = TEXT("Internal.UserId"); static const TAnalyticsProperty InternalSessionAppId = TEXT("Internal.AppId"); static const TAnalyticsProperty InternalSessionAppVersion = TEXT("Internal.AppVersion"); static const TAnalyticsProperty InternalSessionId = TEXT("Internal.SessionId"); static const TAnalyticsProperty InternalWasProcessed = TEXT("Internal.WasProcessed"); // Those values are implicitely added by the manager into the report because the analytics backend expecpts them. static const TAnalyticsProperty SessionId = TEXT("SessionId"); static const TAnalyticsProperty ShutdownType = TEXT("ShutdownType"); static const TAnalyticsProperty DelayedSend = TEXT("DelayedSend"); static const TAnalyticsProperty SentFrom = TEXT("SentFrom"); static const TAnalyticsProperty MissingDataFrom = TEXT("MissingDataFrom"); // List of reserved property key. static TSet ReservedKeys = { AnalyticsManagerProperties::ShutdownType.Key, AnalyticsManagerProperties::DelayedSend.Key, AnalyticsManagerProperties::SentFrom.Key, AnalyticsManagerProperties::MissingDataFrom.Key }; bool IsReserved(const FString& Key) { return ReservedKeys.Contains(Key); } } // namespace AnalyticsManagerProperties namespace AnalyticsSessionUtils { static const FTimespan OrphanSessionCheckPeriod = FTimespan::FromMinutes(5); static const FTimespan ProcessGroupDeathGracePeriod = FTimespan::FromHours(1); static const FString OrphanSessionsOwnerCriticalSectionName = TEXT("UE_AnalyticsSessionSummaryManager_OrphanOwner"); static const FString OrphanSessionsOwnerFilename = TEXT("8E1D46DBC38F4A789939D781E1B91520"); // A randomly generated GUID. FString GetDefaultSavedDir() { // NOTE: This needs to be a folder common to Epic applications (Editor, CRC, etc.), ideally agnostics from the engine versions to enable picking orphans from // older versions AND being constant between build configurations (like Editor Developement and CRC Shipping). FPaths functions are sensitive to the // build configuration and will put files in different places. return FPaths::Combine(FPlatformProcess::UserSettingsDir(), *FApp::GetEpicProductIdentifier(), TEXT("Common"), TEXT("Analytics")); } FRegexPattern GetAnalyticsPropertyFilenamePattern() { // Filename format is like "ProcessGroupGUID_PID_PID_StoreCount_ProcessName", for example 3E1D7ADBC38F4A789939D781E1B91520_12345_232513_0_Editor" return FRegexPattern(TEXT(R"((^[0-9A-Za-z]+)_([0-9]+)_([0-9]+)(_[0-9]+)?_(.*))")); // Need help with regex? Try https://regex101.com/ } /** Try to acquire the rigth to process and send sessions for orphan groups. */ bool TryAcquiringOrphanGroupsOwnership(const FString& SessionRootPath, const FString& PrincipalProcessName, uint32 CurrentProcessId) { // NOTE: The usage of FPlatformMisc::SetStoredValue()/GetStoredValue() was considered, but those APIs are not thread safe on Mac/Linux // and concurrent processes can easily stomp the changes made by another one. The implementation below prevent races between threads and processes. FAnalyticsPropertyStore OwnerStore; FString OrphanOwnerFilePathname = SessionRootPath / AnalyticsSessionUtils::OrphanSessionsOwnerFilename; bool bAcquiredOwnership = false; // Try to lock. Do not wait if the lock is already taken. That's not a big deal if we delay processing left-over sessions. FSystemWideCriticalSection SysWideLock(AnalyticsSessionUtils::OrphanSessionsOwnerCriticalSectionName, FTimespan::Zero()); if (SysWideLock.IsValid()) { if (IFileManager::Get().FileExists(*OrphanOwnerFilePathname)) { if (OwnerStore.Load(OrphanOwnerFilePathname)) { IAnalyticsPropertyStore::EStatusCode Status = OwnerStore.Set(PrincipalProcessName, CurrentProcessId, [](const uint32* Actual, const int32& ProposedValue) { return Actual == nullptr || *Actual == 0 || !FPlatformProcess::IsApplicationRunning(*Actual); }); bAcquiredOwnership = (Status == IAnalyticsPropertyStore::EStatusCode::Success); } else // If the file exists but fails to load while this process as the system wide lock, that's likely because its content is invalid/corrupted. { IFileManager::Get().Delete(*OrphanOwnerFilePathname); } } else if (OwnerStore.Create(OrphanOwnerFilePathname, /*Capacity*/1 * 1024)) { bAcquiredOwnership = OwnerStore.Set(PrincipalProcessName, CurrentProcessId) == IAnalyticsPropertyStore::EStatusCode::Success; } if (bAcquiredOwnership) { OwnerStore.Flush(); // Ensure to persist the storage to disk. } } return bAcquiredOwnership; } /** Release the ownership to send sessions for orphan groups. */ void ReleaseOrphanGroupsOwnership(const FString& SessionRootPath, const FString& PrincipalProcessName, uint32 CurrentProcessId) { FString OrphanOwnerFilePathname = SessionRootPath / AnalyticsSessionUtils::OrphanSessionsOwnerFilename; if (IFileManager::Get().FileExists(*OrphanOwnerFilePathname)) { // Try to lock. Do not wait if the lock is already taken. FSystemWideCriticalSection SysWideLock(AnalyticsSessionUtils::OrphanSessionsOwnerCriticalSectionName, FTimespan::Zero()); if (SysWideLock.IsValid()) { // Open the file. FAnalyticsPropertyStore OwnerStore; if (OwnerStore.Load(OrphanOwnerFilePathname)) { // Reset to zero for this principal process. IAnalyticsPropertyStore::EStatusCode Status = OwnerStore.Set(PrincipalProcessName, static_cast(0), [CurrentProcessId](const uint32* Actual, const uint32& Proposed) { return Actual == nullptr || *Actual == CurrentProcessId; }); if (Status == IAnalyticsPropertyStore::EStatusCode::Success) { OwnerStore.Flush(); } } } } } } // namespace AnalyticsSessionUtils const TCHAR* LexToString(EAnalyticsSessionShutdownType ShutdownTypeCode) { switch (ShutdownTypeCode) { case EAnalyticsSessionShutdownType::Shutdown: return TEXT("Shutdown"); case EAnalyticsSessionShutdownType::Crashed: return TEXT("Crashed"); case EAnalyticsSessionShutdownType::Terminated: return TEXT("Terminated"); case EAnalyticsSessionShutdownType::Debugged: return TEXT("Debugger"); case EAnalyticsSessionShutdownType::Abnormal: return TEXT("AbnormalShutdown"); case EAnalyticsSessionShutdownType::Unknown: default: return TEXT("Unknown"); } } const TAnalyticsProperty FAnalyticsSessionSummaryManager::ShutdownTypeCodeProperty = TEXT("ShutdownTypeCode"); const TAnalyticsProperty FAnalyticsSessionSummaryManager::IsUserLoggingOutProperty = TEXT("IsUserLoggingOut"); FAnalyticsSessionSummaryManager::FAnalyticsSessionSummaryManager(const FString& InProcessName, const FString& InProcessGroupId,const FString& InUserId, const FString& InAppId, const FString& InAppVersion, const FString& InSessionId, const FString& InSavedDir) : FAnalyticsSessionSummaryManager(InProcessName, InProcessGroupId, FPlatformProcess::GetCurrentProcessId(), FPlatformProcess::GetCurrentProcessId(), InUserId, InAppId, InAppVersion, InSessionId, InSavedDir) { } FAnalyticsSessionSummaryManager::FAnalyticsSessionSummaryManager(const FString& InProcessName, const FString& InProcessGroupId, uint32 InPrincipalProcessId, const FString& InSavedDir) : FAnalyticsSessionSummaryManager(InProcessName, InProcessGroupId, FPlatformProcess::GetCurrentProcessId(), InPrincipalProcessId, FString(), FString(), FString(), FString(), InSavedDir) { } FAnalyticsSessionSummaryManager::FAnalyticsSessionSummaryManager(const FString& InProcessName, const FString& InProcessGroupId, uint32 InCurrentProcessId, uint32 InPrincipalProcessId, const FString& InUserId, const FString& InAppId, const FString& InAppVersion, const FString& InSessionId, const FString& InSavedDir) : ProcessName(InProcessName) , ProcessGroupId(InProcessGroupId) , UserId(InUserId) , AppId(InAppId) , AppVersion(InAppVersion) , SessionId(InSessionId) , SessionRootPath(InSavedDir) , CurrentProcessId(InCurrentProcessId) , PrincipalProcessId(InPrincipalProcessId) , StoreCounter(0) , NextOrphanSessionCheckTimeSecs(FPlatformTime::Seconds()) , bOrphanGroupOwner(false) , bIsPrincipal(InCurrentProcessId == InPrincipalProcessId) { if (SessionRootPath.IsEmpty() || !IFileManager::Get().DirectoryExists(*SessionRootPath)) { SessionRootPath = AnalyticsSessionUtils::GetDefaultSavedDir(); // Fallback on the default. } // Creates a sub-folder to store the session files. IFileManager::Get().MakeDirectory(*SessionRootPath, /*Tree*/true); // Let principal process deals with its orphans. The principal process is expected to knows its application settings while a subsidiary process may not have any context. if (IsPrincipalProcess()) { bOrphanGroupOwner = AnalyticsSessionUtils::TryAcquiringOrphanGroupsOwnership(SessionRootPath, ProcessName, CurrentProcessId); } } FAnalyticsSessionSummaryManager::~FAnalyticsSessionSummaryManager() { if (bOrphanGroupOwner) { AnalyticsSessionUtils::ReleaseOrphanGroupsOwnership(SessionRootPath, ProcessName, CurrentProcessId); } } TSharedPtr FAnalyticsSessionSummaryManager::MakeStore(uint32 InitialCapacity) { // NOTE: The ProcessGroupId (a GUID) + PrincipalProcessId is expected to provide a unique key that should not collide with existing files. FString PropertyStorePathname = SessionRootPath / FString::Printf(TEXT("%s_%d_%d_%d_%s"), *ProcessGroupId, PrincipalProcessId, CurrentProcessId, StoreCounter, *ProcessName); TSharedPtr PropertyStore = MakeShared(); if (PropertyStore && PropertyStore->Create(PropertyStorePathname, InitialCapacity)) { // If this is the principal process for which the session summary is created. if (IsPrincipalProcess() && StoreCounter == 0) { // Add some internal key required to enable a subsidiary process to sent the summary on the behalf of this process. AnalyticsManagerProperties::InternalSessionUserId.Set(PropertyStore.Get(), UserId); AnalyticsManagerProperties::InternalSessionAppId.Set(PropertyStore.Get(), AppId); AnalyticsManagerProperties::InternalSessionAppVersion.Set(PropertyStore.Get(), AppVersion); AnalyticsManagerProperties::InternalSessionId.Set(PropertyStore.Get(), SessionId); } ++StoreCounter; } else { PropertyStore.Reset(); // Ensure to return a null pointer if the store failed to create (not enough disk space for example) } WeakPropertyStores.Add(PropertyStore); PruneTrackedPropertyStores(); return StaticCastSharedPtr(PropertyStore); } void FAnalyticsSessionSummaryManager::SetSender(TSharedPtr Sender) { SummarySender = Sender; } void FAnalyticsSessionSummaryManager::SetUserId(const FString& InUserId) { UserId = InUserId; PruneTrackedPropertyStores(); for (TWeakPtr& WeakPropertyStore : WeakPropertyStores) { if (TSharedPtr PropertyStore = WeakPropertyStore.Pin()) { AnalyticsManagerProperties::InternalSessionUserId.Set(PropertyStore.Get(), UserId); PropertyStore->Flush(); } } } void FAnalyticsSessionSummaryManager::Shutdown(bool bDiscard) { if (SummarySender) { // Check if this process has something to summarize and send for this instance process group. TMap PropertyFiles = GetSessionFiles(); if (const FProcessGroup* ProcessGroup = PropertyFiles.Find(ProcessGroupId)) { if (bDiscard) { CleanupFiles(ProcessGroup->PropertyFiles, /*bOnSuccess*/false); } else { ProcessSummary(ProcessGroupId, *ProcessGroup); } } } else if (bDiscard) { TMap PropertyFiles = GetSessionFiles(); if (const FProcessGroup* ProcessGroup = PropertyFiles.Find(ProcessGroupId)) { CleanupFiles(ProcessGroup->PropertyFiles, /*bOnSuccess*/false); } } StoreCounter = 0; } void FAnalyticsSessionSummaryManager::Tick() { if (!SummarySender || !IsOrphanGroupsOwner()) { return; // Don't process orphans if the result cannot be sent. } if (FPlatformTime::Seconds() > NextOrphanSessionCheckTimeSecs) { NextOrphanSessionCheckTimeSecs = FPlatformTime::Seconds() + AnalyticsSessionUtils::OrphanSessionCheckPeriod.GetTotalSeconds(); // Check if this process has something to summarize and send.. TMap PropertyFiles = GetSessionFiles(); for (const TPair& Pair : PropertyFiles) { if (Pair.Value.PrincipalProcessId == PrincipalProcessId) { continue; // Skip this process session summary. It should not be sent until the last process in the group died. } // Picked up an orphan session, decide what to do with it. ProcessSummary(/*ProcessGroupId*/Pair.Key, /*ProcessGroup*/Pair.Value); } } } bool FAnalyticsSessionSummaryManager::IsPrincipalProcess() const { return bIsPrincipal; } void FAnalyticsSessionSummaryManager::PruneTrackedPropertyStores() { for (int32 index = WeakPropertyStores.Num() - 1; index >= 0; --index) { if (!WeakPropertyStores[index].IsValid()) { WeakPropertyStores.RemoveAtSwap(index); } } } TMap FAnalyticsSessionSummaryManager::GetSessionFiles() const { TMap ProcessFiles; // Visit the files in the session directory. FRegexPattern FilenamePattern = AnalyticsSessionUtils::GetAnalyticsPropertyFilenamePattern(); IFileManager::Get().IterateDirectory(*SessionRootPath, [this, &ProcessFiles, &FilenamePattern](const TCHAR* Pathname, bool bIsDirectory) -> bool { if (!bIsDirectory) { // The property filename is "ProcessGroupGUID_PrincipalProcessId_WriterProcessId_StoreCount_WriterProcessName". The ProcessGroupGUID is expected to have EGuidFormats::Digits -> "00000A00C0000F000000000000000000" FRegexMatcher Matcher(FilenamePattern, FPaths::GetCleanFilename(Pathname)); if (Matcher.FindNext()) { FString FileProcessGroupId = Matcher.GetCaptureGroup(1); if (FileProcessGroupId != ProcessGroupId && !IsOrphanGroupsOwner()) { return true; // This file is from another process group and this manager is not allowed to process it, skip to the next file. } FPropertyFileInfo PropertyFileInfo; PropertyFileInfo.ProcessId = static_cast(FCString::Atoi64(*Matcher.GetCaptureGroup(3))); PropertyFileInfo.ProcessName = Matcher.GetCaptureGroup(5); PropertyFileInfo.Pathname = Pathname; uint32 FilePrincipalProcessId = static_cast(FCString::Atoi64(*Matcher.GetCaptureGroup(2))); FProcessGroup& ProcessGroup = ProcessFiles.FindOrAdd(FileProcessGroupId); ProcessGroup.PrincipalProcessId = FilePrincipalProcessId; if (FilePrincipalProcessId == PropertyFileInfo.ProcessId) // That file was produced by a principal process. { ProcessGroup.PrincipalProcessName = PropertyFileInfo.ProcessName; } ProcessGroup.PropertyFiles.Add(MoveTemp(PropertyFileInfo)); } } return true; // Continue to the next file. }); // Can this manager process orphan groups? if (IsOrphanGroupsOwner()) { check(IsPrincipalProcess()); for (TMap::TIterator It = ProcessFiles.CreateIterator(); It; ++It) { // Discard groups that are not from this applications. if (!It.Value().PrincipalProcessName.IsEmpty() && It.Value().PrincipalProcessName != ProcessName) { It.RemoveCurrent(); } // Discard any other groups that are still running. else if (It.Value().PrincipalProcessId != PrincipalProcessId) { for (const FPropertyFileInfo& PropertyFileInfo : It.Value().PropertyFiles) { if (FPlatformProcess::IsApplicationRunning(PropertyFileInfo.ProcessId)) // One of the process in that group is still running { It.RemoveCurrent(); break; } } } } } return ProcessFiles; } void FAnalyticsSessionSummaryManager::ProcessSummary(const FString& InProcessGroupId, const FProcessGroup& InProcessGroup) { // Check if this process is the last alive in the group, so that it can process the summary for (const FPropertyFileInfo& PropertyFileInfo : InProcessGroup.PropertyFiles) { if (PropertyFileInfo.ProcessId != CurrentProcessId) { if (FPlatformProcess::IsApplicationRunning(PropertyFileInfo.ProcessId)) { return; // A process in the group is running, leave this group alone. } } } // Trying to process a summary for another group than this manager group? if (InProcessGroupId != ProcessGroupId) { checkf(IsOrphanGroupsOwner(), TEXT("Should not process summary of other process group if this wasn't allowed in first place.")); bool bExpired = false; // The files discovered tell that processes in the group are dead, but it is unclear if another process in that group is about to create a files or not (because // of race conditions between processes). for (const FPropertyFileInfo& PropertyFileInfo : InProcessGroup.PropertyFiles) { // Time since the last file modification. double FileAgeSecs = IFileManager::Get().GetFileAgeSeconds(*PropertyFileInfo.Pathname); // Before declaring this group 'dead' leave a reasonable grace period to clear 99% of possible race conditions between processes. if (FileAgeSecs > 0 && FileAgeSecs <= AnalyticsSessionUtils::ProcessGroupDeathGracePeriod.GetTotalSeconds()) { return; // Wait more before declaring this group dead and processing it. } // If the principal file is expired, expire the entire group. if (InProcessGroup.PrincipalProcessId == PropertyFileInfo.ProcessId && FileAgeSecs > GetSessionExpirationAge().GetTotalSeconds()) { bExpired = true; break; } } // This group is expired or doesn't have the properties from the principal process (incomplete session). if (bExpired || InProcessGroup.PrincipalProcessName.IsEmpty()) { // The session data is partial or expired. CleanupFiles(InProcessGroup.PropertyFiles, /*bOnSuccess*/false); return; } } // Aggregate the principal and subsidiary process summaries. TArray InternalProperties; TArray SummaryProperties; if (!AggregateSummaries(InProcessGroupId, InProcessGroup, SummaryProperties, InternalProperties)) { CleanupFiles(InProcessGroup.PropertyFiles, /*bOnSuccess*/false); return; } // Was the group aggregated above the group of this principal process? if (IsPrincipalProcess() && InProcessGroupId == ProcessGroupId) { // Clean up before sending, so that we don't send twice. (In case of crash while sending, we will not know if the summary was sent or not). CleanupFiles(InProcessGroup.PropertyFiles, /*bOnSuccess*/true); SummarySender->SendSessionSummary(UserId, AppId, AppVersion, SessionId, SummaryProperties); } // Send the session summary on behalf of the principal process that created it. else { // The principal process saves those internal properties to let a subsidiary or another principal processes submit the summary on its behalf. const FAnalyticsEventAttribute* UserIdProp = InternalProperties.FindByPredicate([](const FAnalyticsEventAttribute& Candidate) { return Candidate.GetName() == AnalyticsManagerProperties::InternalSessionUserId.Key; }); const FAnalyticsEventAttribute* AppIdProp = InternalProperties.FindByPredicate([](const FAnalyticsEventAttribute& Candidate) { return Candidate.GetName() == AnalyticsManagerProperties::InternalSessionAppId.Key; }); const FAnalyticsEventAttribute* AppVersionProp = InternalProperties.FindByPredicate([](const FAnalyticsEventAttribute& Candidate) { return Candidate.GetName() == AnalyticsManagerProperties::InternalSessionAppVersion.Key; }); const FAnalyticsEventAttribute* SessionIdProp = InternalProperties.FindByPredicate([](const FAnalyticsEventAttribute& Candidate) { return Candidate.GetName() == AnalyticsManagerProperties::InternalSessionId.Key; }); if (UserIdProp && AppIdProp && AppVersionProp && SessionIdProp) { CleanupFiles(InProcessGroup.PropertyFiles, /*bOnSuccess*/true); SummarySender->SendSessionSummary(UserIdProp->GetValue(), AppIdProp->GetValue(), AppVersionProp->GetValue(), SessionIdProp->GetValue(), SummaryProperties); } else { CleanupFiles(InProcessGroup.PropertyFiles, /*bOnSuccess*/false); } } } bool FAnalyticsSessionSummaryManager::AggregateSummaries(const FString& InProcessGroupId, const FProcessGroup& ProcessGroup, TArray& OutSummaryProperties, TArray& OutInternalProperties) { FAnalyticsPropertyStore PropertyStore; TSet SummaryKeys; auto RenameKey = [](const FString& KeyToRename, const FString& FromProcess, const TSet& ExistingKeys, const TCHAR* Reason) { FString RenamedKey; int32 Index = 1; do { RenamedKey = FString::Printf(TEXT("%s_%d"), *KeyToRename, Index++); } while (ExistingKeys.Contains(RenamedKey)); UE_LOG(LogAnalyticsSessionSummary, Warning, TEXT("Renamed key '%s' from process '%s' as '%s'. Reason: %s"), *KeyToRename, *FromProcess, *RenamedKey, Reason); return RenamedKey; }; TArray MissingDataFromProcesses; // Merge the properties collected by the principal and its subsidiary processes. for (const FPropertyFileInfo& PropertyFileInfo : ProcessGroup.PropertyFiles) { if (PropertyStore.Load(PropertyFileInfo.Pathname)) { if (PropertyStore.Contains(AnalyticsManagerProperties::InternalWasProcessed.Key)) { return false; // We don't know if the manager failed to delete before of after the session was sent. Ignore this group. } int32 ShutdownTypeCode; if (PropertyStore.Get(ShutdownTypeCodeProperty.Key, ShutdownTypeCode) == IAnalyticsPropertyStore::EStatusCode::Success) { // Don't add 'ShutdownType' more than once. If 'ShutdownTypeCode' key used to derive the 'ShutdownType' is duplicated, the code will emit a warning and rename the second instance. if (!SummaryKeys.Contains(AnalyticsManagerProperties::ShutdownType.Key)) { // Convert 'ShutdownTypeCode' into its string representation and emit it as 'ShutdownType' as known by the analytics backend. OutSummaryProperties.Emplace(AnalyticsManagerProperties::ShutdownType.Key, LexToString(static_cast(ShutdownTypeCode))); SummaryKeys.Add(AnalyticsManagerProperties::ShutdownType.Key); } } PropertyStore.VisitAll([&PropertyFileInfo, &OutSummaryProperties, &OutInternalProperties, &RenameKey, &SummaryKeys](FAnalyticsEventAttribute&& Attr) { if (Attr.GetName().StartsWith(AnalyticsManagerProperties::InternalPropertyPrefix)) { SummaryKeys.Add(Attr.GetName()); OutInternalProperties.Emplace(Attr); } else if (AnalyticsManagerProperties::IsReserved(Attr.GetName())) { FString RenamedKey = RenameKey(Attr.GetName(), PropertyFileInfo.ProcessName, SummaryKeys, TEXT("Key name is reserved.")); SummaryKeys.Add(RenamedKey); OutSummaryProperties.Emplace(RenamedKey, Attr.GetValue()); } else if (!SummaryKeys.Contains(Attr.GetName())) { SummaryKeys.Add(Attr.GetName()); OutSummaryProperties.Emplace(Attr); } else // Deal with key collisions. Keys are supposed to be unique. Duplication should be easy to spot during developement. { FString RenamedKey = RenameKey(Attr.GetName(), PropertyFileInfo.ProcessName, SummaryKeys, TEXT("Key collision between processes of a group.")); SummaryKeys.Add(Attr.GetName()); OutSummaryProperties.Emplace(RenamedKey, Attr.GetValue()); } }); // Mark this file as processed to avoid sending the same session twice. AnalyticsManagerProperties::InternalWasProcessed.Set(&PropertyStore, true); PropertyStore.Flush(); } else if (PropertyFileInfo.ProcessId == ProcessGroup.PrincipalProcessId) { // Without the principal process data, too much data is missing, abort. return false; } else { // Subsidiary process data will be missing. Not ideal, but this should only be complementary info for the principal process, flag it and continue. MissingDataFromProcesses.Add(PropertyFileInfo.ProcessName); } } // The manager saves this key in the store it creates. if (const FAnalyticsEventAttribute* InternalSessionId = OutInternalProperties.FindByPredicate([](const FAnalyticsEventAttribute& Candidate) { return Candidate.GetName() == AnalyticsManagerProperties::InternalSessionId.Key; })) { OutSummaryProperties.Emplace(AnalyticsManagerProperties::SessionId.Key, InternalSessionId->GetValue()); } if (MissingDataFromProcesses.Num()) { // Add the subsidiary process tag name(s) for which the data file couldn't be loaded (file was locked, corrupted, ...) OutSummaryProperties.Emplace(AnalyticsManagerProperties::MissingDataFrom.Key, FString::Join(MissingDataFromProcesses, TEXT(","))); } // The summary is sent delayed when it is processed from another group. OutSummaryProperties.Emplace(AnalyticsManagerProperties::DelayedSend.Key, InProcessGroupId != ProcessGroupId); // This process is about to send the report, record the process name in the summary. OutSummaryProperties.Emplace(AnalyticsManagerProperties::SentFrom.Key, ProcessName); return true; } bool FAnalyticsSessionSummaryManager::CleanupFiles(const TArray& PropertyFiles, bool bOnSuccess) { // TODO as future work: Compute how many times we clean up on failure. Keep track of it and periodically send an analytic event about it. bool bAllDeleted = true; for (const FPropertyFileInfo& PropertyFileInfo : PropertyFiles) { bAllDeleted &= IFileManager::Get().Delete(*PropertyFileInfo.Pathname, /*MustExist*/false, /*EvenReadOnly*/true, /*Quiet*/true); } return bAllDeleted; } void FAnalyticsSessionSummaryManager::CleanupExpiredFiles(const FString& SavedDir) { FString SessionRootPath = SavedDir.IsEmpty() ? AnalyticsSessionUtils::GetDefaultSavedDir() : SavedDir; // The manager stores its files under its own folder. if (!IFileManager::Get().DirectoryExists(*SessionRootPath)) { return; } // Visit the files in the session directory. FRegexPattern FilenamePattern = AnalyticsSessionUtils::GetAnalyticsPropertyFilenamePattern(); IFileManager::Get().IterateDirectory(*SessionRootPath, [&FilenamePattern](const TCHAR* Pathname, bool bIsDirectory) -> bool { if (bIsDirectory) { return true; // Continue. } FRegexMatcher Matcher(FilenamePattern, FPaths::GetCleanFilename(Pathname)); if (Matcher.FindNext()) { // Time since the last file modification. double FileAgeSecs = IFileManager::Get().GetFileAgeSeconds(Pathname); // Add up an extra day to the normal expiration delay so that we never clean up files that are on the edge. if (FileAgeSecs > GetSessionExpirationAge().GetTotalSeconds() + FTimespan::FromDays(1.0).GetTotalSeconds()) { IFileManager::Get().Delete(Pathname); } } return true; }); }