Step 2: the telemetry seam
ITelemetrySink with the null, log and JSON Lines sinks, FTelemetryEvent and the envelope, TelemetryEvents, UTelemetrySubsystem on the game instance, ASaltyGameMode minting the session id into the replicated ASaltyGameState, bs.TelemetryTest, the git hash in the build string (D-42) and four Salty.Core.Telemetry tests replacing the placeholder. Proved standalone and with a headless server plus client sharing one session id. Also enables the engine's Editor, AutomationTest, GameplayTags, ConfigSettings and LiveCoding MCP toolsets (D-43) so the editor's MCP server exposes more than the skills toolset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4f2c55cd2a
commit
3e52d26fb7
@@ -13,7 +13,8 @@ public class SaltyCore : ModuleRules
|
||||
"CoreUObject",
|
||||
"Engine", // UPrimaryDataAsset, FGameplayTag
|
||||
"GameplayTags",
|
||||
"GameplayAbilities" // FGameplayAttribute in the damage maths
|
||||
"GameplayAbilities", // FGameplayAttribute in the damage maths
|
||||
"Json" // the telemetry payload and the JSON Lines sink
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { });
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
IMPLEMENT_MODULE(FDefaultModuleImpl, SaltyCore);
|
||||
|
||||
DEFINE_LOG_CATEGORY(LogSaltyCore);
|
||||
DEFINE_LOG_CATEGORY(LogTelemetry);
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
#include "Logging/LogMacros.h"
|
||||
|
||||
SALTYCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogSaltyCore, Log, All);
|
||||
// Every event the log sink emits, one JSON line each (Docs/Spec/Telemetry.md).
|
||||
SALTYCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogTelemetry, Log, All);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
#include "Policies/CondensedJsonPrintPolicy.h"
|
||||
|
||||
FString Telemetry::ToJsonLine(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
|
||||
{
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetNumberField(TEXT("schema_version"), Envelope.SchemaVersion);
|
||||
Root->SetStringField(TEXT("event_id"), Envelope.EventId.ToString(EGuidFormats::DigitsWithHyphensLower));
|
||||
Root->SetStringField(TEXT("timestamp_utc"), Envelope.TimestampUtc.ToIso8601());
|
||||
Root->SetNumberField(TEXT("game_time"), Envelope.GameTime);
|
||||
Root->SetStringField(TEXT("session_id"), Envelope.SessionId.ToString(EGuidFormats::DigitsWithHyphensLower));
|
||||
Root->SetStringField(TEXT("player_id"), Envelope.PlayerId);
|
||||
Root->SetBoolField(TEXT("is_server"), Envelope.bIsServer);
|
||||
Root->SetNumberField(TEXT("party_size"), Envelope.PartySize);
|
||||
Root->SetStringField(TEXT("build"), Envelope.Build);
|
||||
Root->SetStringField(TEXT("map"), Envelope.Map);
|
||||
Root->SetBoolField(TEXT("cheats_used"), Envelope.bCheatsUsed);
|
||||
Root->SetStringField(TEXT("name"), Event.Name.ToString());
|
||||
Root->SetObjectField(TEXT("payload"), Event.Payload.IsValid() ? Event.Payload : MakeShared<FJsonObject>());
|
||||
|
||||
FString Line;
|
||||
TSharedRef<TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>> Writer =
|
||||
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Line);
|
||||
FJsonSerializer::Serialize(Root, Writer);
|
||||
return Line;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
|
||||
// One emission. Call sites fill Name (from TelemetryEvents) and Payload; the subsystem stamps the envelope.
|
||||
struct SALTYCORE_API FTelemetryEvent
|
||||
{
|
||||
FName Name;
|
||||
TSharedPtr<FJsonObject> Payload;
|
||||
};
|
||||
|
||||
// Stamped by the subsystem onto every event. Serialised with snake_case keys; see Telemetry::ToJsonLine.
|
||||
struct SALTYCORE_API FTelemetryEnvelope
|
||||
{
|
||||
int32 SchemaVersion = 1;
|
||||
FGuid EventId; // unique per emission, for de-duplication
|
||||
FDateTime TimestampUtc;
|
||||
float GameTime = 0.f; // seconds since the world began; zero when there is no world yet
|
||||
FGuid SessionId; // minted by the server, adopted by clients on join; invalid before a session
|
||||
FString PlayerId; // hashed server-minted id; empty on the server
|
||||
bool bIsServer = false; // authoritative events versus observed ones
|
||||
int32 PartySize = 0;
|
||||
FString Build; // FApp::GetBuildVersion() plus the git hash
|
||||
FString Map;
|
||||
bool bCheatsUsed = false; // sticky true after the first cheat this session
|
||||
};
|
||||
|
||||
// A small fluent builder for payloads, so a call site reads as one expression:
|
||||
// Telemetry->Emit(TelemetryEvents::CheatUsed, FTelemetryPayload().Set(TEXT("command"), Command));
|
||||
class SALTYCORE_API FTelemetryPayload
|
||||
{
|
||||
public:
|
||||
FTelemetryPayload() : Object(MakeShared<FJsonObject>()) {}
|
||||
|
||||
FTelemetryPayload& Set(const FString& Key, const FString& Value) { Object->SetStringField(Key, Value); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, const TCHAR* Value) { Object->SetStringField(Key, Value); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, FName Value) { Object->SetStringField(Key, Value.ToString()); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, const FGuid& Value) { Object->SetStringField(Key, Value.ToString(EGuidFormats::DigitsWithHyphensLower)); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, double Value) { Object->SetNumberField(Key, Value); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, float Value) { Object->SetNumberField(Key, Value); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, int32 Value) { Object->SetNumberField(Key, Value); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, int64 Value) { Object->SetNumberField(Key, static_cast<double>(Value)); return *this; }
|
||||
FTelemetryPayload& Set(const FString& Key, bool Value) { Object->SetBoolField(Key, Value); return *this; }
|
||||
|
||||
TSharedPtr<FJsonObject> Build() const { return Object; }
|
||||
operator TSharedPtr<FJsonObject>() const { return Object; }
|
||||
|
||||
private:
|
||||
TSharedPtr<FJsonObject> Object;
|
||||
};
|
||||
|
||||
namespace Telemetry
|
||||
{
|
||||
// The one serialisation of an event: the envelope's fields as snake_case keys, then "name" and "payload"
|
||||
// (an empty object when the event has none), condensed to a single line with no trailing newline. Pure, so
|
||||
// the sinks share it and the tests can check it without a sink.
|
||||
SALTYCORE_API FString ToJsonLine(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
// The event names. Every call site passes one of these to UTelemetrySubsystem::Emit; a literal at a call site is a
|
||||
// typo waiting for the analysis to find it. A name is added here, to the catalogue in Docs/Spec/Telemetry.md and to
|
||||
// the feature spec's Telemetry section in the same change as its first emit call, so the three never disagree.
|
||||
namespace TelemetryEvents
|
||||
{
|
||||
// Session (step 2)
|
||||
inline const FName AppStarted(TEXT("app_started"));
|
||||
inline const FName SessionStarted(TEXT("session_started"));
|
||||
inline const FName SessionEnded(TEXT("session_ended"));
|
||||
inline const FName CheatUsed(TEXT("cheat_used"));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "Telemetry/TelemetrySink.h"
|
||||
#include "SaltyCore.h"
|
||||
#include "HAL/PlatformFileManager.h"
|
||||
#include "HAL/RunnableThread.h"
|
||||
#include "HAL/Event.h"
|
||||
#include "GenericPlatform/GenericPlatformFile.h"
|
||||
#include "Misc/CoreDelegates.h"
|
||||
#include "Misc/Paths.h"
|
||||
|
||||
void FLogTelemetrySink::Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
|
||||
{
|
||||
UE_LOG(LogTelemetry, Log, TEXT("%s"), *Telemetry::ToJsonLine(Envelope, Event));
|
||||
}
|
||||
|
||||
FJsonlTelemetrySink::FJsonlTelemetrySink(const FString& InFilePath, double InDrainIntervalSeconds)
|
||||
: FilePath(InFilePath)
|
||||
, DrainIntervalSeconds(FMath::Max(InDrainIntervalSeconds, 0.01))
|
||||
{
|
||||
WakeEvent = FPlatformProcess::GetSynchEventFromPool(false);
|
||||
DrainedEvent = FPlatformProcess::GetSynchEventFromPool(false);
|
||||
Thread = FRunnableThread::Create(this, TEXT("TelemetryJsonlSink"), 0, TPri_BelowNormal);
|
||||
|
||||
OnExitHandle = FCoreDelegates::OnExit.AddRaw(this, &FJsonlTelemetrySink::Flush);
|
||||
OnSystemErrorHandle = FCoreDelegates::OnHandleSystemError.AddRaw(this, &FJsonlTelemetrySink::Flush);
|
||||
}
|
||||
|
||||
FJsonlTelemetrySink::~FJsonlTelemetrySink()
|
||||
{
|
||||
FCoreDelegates::OnExit.Remove(OnExitHandle);
|
||||
FCoreDelegates::OnHandleSystemError.Remove(OnSystemErrorHandle);
|
||||
|
||||
bStopping = true;
|
||||
WakeEvent->Trigger();
|
||||
if (Thread)
|
||||
{
|
||||
Thread->WaitForCompletion(); // Run drains once more on its way out
|
||||
delete Thread;
|
||||
Thread = nullptr;
|
||||
}
|
||||
Drain(); // anything queued after the thread's last pass
|
||||
File.Reset();
|
||||
|
||||
FPlatformProcess::ReturnSynchEventToPool(WakeEvent);
|
||||
FPlatformProcess::ReturnSynchEventToPool(DrainedEvent);
|
||||
}
|
||||
|
||||
void FJsonlTelemetrySink::Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
|
||||
{
|
||||
// The whole cost on the caller's thread: one string and one enqueue.
|
||||
Queue.Enqueue(Telemetry::ToJsonLine(Envelope, Event));
|
||||
}
|
||||
|
||||
void FJsonlTelemetrySink::Flush()
|
||||
{
|
||||
if (!Thread || bStopping)
|
||||
{
|
||||
Drain();
|
||||
return;
|
||||
}
|
||||
|
||||
const uint64 Ticket = ++FlushesRequested;
|
||||
WakeEvent->Trigger();
|
||||
// Bounded wait: a flush from the crash handler must not hang the process on a wedged disk.
|
||||
const double Deadline = FPlatformTime::Seconds() + 5.0;
|
||||
while (FlushesCompleted.Load() < Ticket && FPlatformTime::Seconds() < Deadline)
|
||||
{
|
||||
DrainedEvent->Wait(50);
|
||||
}
|
||||
}
|
||||
|
||||
uint32 FJsonlTelemetrySink::Run()
|
||||
{
|
||||
const uint32 IntervalMs = static_cast<uint32>(FMath::Clamp(DrainIntervalSeconds * 1000.0, 10.0, 3600.0 * 1000.0));
|
||||
while (!bStopping)
|
||||
{
|
||||
WakeEvent->Wait(IntervalMs);
|
||||
const uint64 Ticket = FlushesRequested.Load();
|
||||
Drain();
|
||||
FlushesCompleted.Store(Ticket);
|
||||
DrainedEvent->Trigger();
|
||||
}
|
||||
Drain();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void FJsonlTelemetrySink::Stop()
|
||||
{
|
||||
bStopping = true;
|
||||
WakeEvent->Trigger();
|
||||
}
|
||||
|
||||
void FJsonlTelemetrySink::Drain()
|
||||
{
|
||||
// Single consumer: the worker while it runs, the destructor after it has joined. Never both.
|
||||
FString Line;
|
||||
bool bWroteAnything = false;
|
||||
while (Queue.Dequeue(Line))
|
||||
{
|
||||
if (!File)
|
||||
{
|
||||
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
|
||||
PlatformFile.CreateDirectoryTree(*FPaths::GetPath(FilePath));
|
||||
File.Reset(PlatformFile.OpenWrite(*FilePath, /*bAppend*/ true, /*bAllowRead*/ true));
|
||||
if (!File)
|
||||
{
|
||||
UE_LOG(LogTelemetry, Warning, TEXT("Cannot open telemetry file %s; dropping events"), *FilePath);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Line.AppendChar(TEXT('\n'));
|
||||
const FTCHARToUTF8 Utf8(*Line);
|
||||
File->Write(reinterpret_cast<const uint8*>(Utf8.Get()), Utf8.Length());
|
||||
bWroteAnything = true;
|
||||
}
|
||||
if (File && bWroteAnything)
|
||||
{
|
||||
File->Flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Containers/Queue.h"
|
||||
#include "HAL/Runnable.h"
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
|
||||
class FRunnableThread;
|
||||
class FEvent;
|
||||
class IFileHandle;
|
||||
|
||||
// Where events go. The emitter never knows. Three implementations on day one; a fourth adapts an analytics
|
||||
// provider when there is one (Docs/Spec/Telemetry.md, Q2).
|
||||
class SALTYCORE_API ITelemetrySink
|
||||
{
|
||||
public:
|
||||
virtual ~ITelemetrySink() = default;
|
||||
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
|
||||
virtual void Flush() {}
|
||||
};
|
||||
|
||||
// Does nothing. The shipping default until there is somewhere to send anything.
|
||||
class SALTYCORE_API FNullTelemetrySink : public ITelemetrySink
|
||||
{
|
||||
public:
|
||||
virtual void Emit(const FTelemetryEnvelope&, const FTelemetryEvent&) override {}
|
||||
};
|
||||
|
||||
// One line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
|
||||
class SALTYCORE_API FLogTelemetrySink : public ITelemetrySink
|
||||
{
|
||||
public:
|
||||
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) override;
|
||||
};
|
||||
|
||||
// Appends one line per event to a JSON Lines file. Emit only builds the line and queues it; a worker thread
|
||||
// drains the queue to disk every DrainIntervalSeconds and on Flush, so the calling thread never touches the
|
||||
// file. Flushed on quit and from the unhandled-exception handler, so a crash loses at most one interval.
|
||||
class SALTYCORE_API FJsonlTelemetrySink : public ITelemetrySink, private FRunnable
|
||||
{
|
||||
public:
|
||||
explicit FJsonlTelemetrySink(const FString& InFilePath, double DrainIntervalSeconds = 2.0);
|
||||
virtual ~FJsonlTelemetrySink() override;
|
||||
|
||||
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) override;
|
||||
virtual void Flush() override;
|
||||
|
||||
const FString& GetFilePath() const { return FilePath; }
|
||||
|
||||
private:
|
||||
// FRunnable
|
||||
virtual uint32 Run() override;
|
||||
virtual void Stop() override;
|
||||
|
||||
void Drain();
|
||||
|
||||
FString FilePath;
|
||||
double DrainIntervalSeconds;
|
||||
TQueue<FString, EQueueMode::Mpsc> Queue;
|
||||
TUniquePtr<IFileHandle> File;
|
||||
FEvent* WakeEvent = nullptr;
|
||||
FEvent* DrainedEvent = nullptr;
|
||||
FRunnableThread* Thread = nullptr;
|
||||
TAtomic<bool> bStopping{ false };
|
||||
TAtomic<uint64> FlushesRequested{ 0 };
|
||||
TAtomic<uint64> FlushesCompleted{ 0 };
|
||||
FDelegateHandle OnExitHandle;
|
||||
FDelegateHandle OnSystemErrorHandle;
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
// Proves the test pipeline itself: Scripts/run-tests.sh finds the Salty.Core filter, runs it headless, and reports.
|
||||
// Replaced by real rule tests from step 2 onwards; it may be deleted once another Salty.Core.* test exists.
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSaltyCorePlaceholderTest, "Salty.Core.Placeholder.Compiles",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FSaltyCorePlaceholderTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
TestTrue(TEXT("The core module compiles and its tests run"), true);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
#include "Telemetry/TelemetryEvents.h"
|
||||
#include "Telemetry/TelemetrySink.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
namespace
|
||||
{
|
||||
FTelemetryEnvelope MakeEnvelope()
|
||||
{
|
||||
FTelemetryEnvelope Envelope;
|
||||
Envelope.EventId = FGuid::NewGuid();
|
||||
Envelope.TimestampUtc = FDateTime::UtcNow();
|
||||
Envelope.GameTime = 12.5f;
|
||||
Envelope.SessionId = FGuid::NewGuid();
|
||||
Envelope.PlayerId = TEXT("abc123");
|
||||
Envelope.bIsServer = true;
|
||||
Envelope.PartySize = 2;
|
||||
Envelope.Build = TEXT("test+deadbeef");
|
||||
Envelope.Map = TEXT("L_Gym");
|
||||
Envelope.bCheatsUsed = true;
|
||||
return Envelope;
|
||||
}
|
||||
|
||||
FString TempJsonlPath()
|
||||
{
|
||||
return FPaths::ProjectSavedDir() / TEXT("Automation/Telemetry") / FGuid::NewGuid().ToString(EGuidFormats::Digits) + TEXT(".jsonl");
|
||||
}
|
||||
|
||||
bool ParseObject(const FString& Line, TSharedPtr<FJsonObject>& OutObject)
|
||||
{
|
||||
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Line);
|
||||
return FJsonSerializer::Deserialize(Reader, OutObject) && OutObject.IsValid();
|
||||
}
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTelemetryEnvelopeCarriesEveryFieldTest, "Salty.Core.Telemetry.Envelope.CarriesEveryField",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FTelemetryEnvelopeCarriesEveryFieldTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FTelemetryEnvelope Envelope = MakeEnvelope();
|
||||
FTelemetryEvent Event;
|
||||
Event.Name = TelemetryEvents::SessionStarted;
|
||||
Event.Payload = FTelemetryPayload().Set(TEXT("answer"), 42).Set(TEXT("tag"), TEXT("x"));
|
||||
|
||||
TSharedPtr<FJsonObject> Object;
|
||||
if (!TestTrue(TEXT("the line parses"), ParseObject(Telemetry::ToJsonLine(Envelope, Event), Object)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TestEqual(TEXT("schema_version"), static_cast<int32>(Object->GetNumberField(TEXT("schema_version"))), Envelope.SchemaVersion);
|
||||
TestEqual(TEXT("event_id"), Object->GetStringField(TEXT("event_id")), Envelope.EventId.ToString(EGuidFormats::DigitsWithHyphensLower));
|
||||
TestEqual(TEXT("timestamp_utc"), Object->GetStringField(TEXT("timestamp_utc")), Envelope.TimestampUtc.ToIso8601());
|
||||
TestEqual(TEXT("game_time"), static_cast<float>(Object->GetNumberField(TEXT("game_time"))), Envelope.GameTime);
|
||||
TestEqual(TEXT("session_id"), Object->GetStringField(TEXT("session_id")), Envelope.SessionId.ToString(EGuidFormats::DigitsWithHyphensLower));
|
||||
TestEqual(TEXT("player_id"), Object->GetStringField(TEXT("player_id")), Envelope.PlayerId);
|
||||
TestEqual(TEXT("is_server"), Object->GetBoolField(TEXT("is_server")), Envelope.bIsServer);
|
||||
TestEqual(TEXT("party_size"), static_cast<int32>(Object->GetNumberField(TEXT("party_size"))), Envelope.PartySize);
|
||||
TestEqual(TEXT("build"), Object->GetStringField(TEXT("build")), Envelope.Build);
|
||||
TestEqual(TEXT("map"), Object->GetStringField(TEXT("map")), Envelope.Map);
|
||||
TestEqual(TEXT("cheats_used"), Object->GetBoolField(TEXT("cheats_used")), Envelope.bCheatsUsed);
|
||||
TestEqual(TEXT("name"), Object->GetStringField(TEXT("name")), TelemetryEvents::SessionStarted.ToString());
|
||||
TestEqual(TEXT("payload.answer"), static_cast<int32>(Object->GetObjectField(TEXT("payload"))->GetNumberField(TEXT("answer"))), 42);
|
||||
TestEqual(TEXT("payload.tag"), Object->GetObjectField(TEXT("payload"))->GetStringField(TEXT("tag")), FString(TEXT("x")));
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTelemetryEventNoPayloadTest, "Salty.Core.Telemetry.Event.NoPayloadIsEmptyObject",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FTelemetryEventNoPayloadTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
FTelemetryEvent Event;
|
||||
Event.Name = TelemetryEvents::AppStarted;
|
||||
const FString Line = Telemetry::ToJsonLine(MakeEnvelope(), Event);
|
||||
|
||||
TSharedPtr<FJsonObject> Object;
|
||||
if (!TestTrue(TEXT("the line parses"), ParseObject(Line, Object)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const TSharedPtr<FJsonObject>* Payload = nullptr;
|
||||
TestTrue(TEXT("payload is an object"), Object->TryGetObjectField(TEXT("payload"), Payload));
|
||||
TestTrue(TEXT("payload is empty"), Payload && (*Payload)->Values.Num() == 0);
|
||||
TestTrue(TEXT("the line is a single line"), !Line.Contains(TEXT("\n")));
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTelemetryJsonlOneLinePerEventTest, "Salty.Core.Telemetry.Jsonl.OneValidLinePerEvent",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FTelemetryJsonlOneLinePerEventTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FString Path = TempJsonlPath();
|
||||
constexpr int32 Count = 25;
|
||||
{
|
||||
FJsonlTelemetrySink Sink(Path, /*DrainIntervalSeconds*/ 3600.0);
|
||||
for (int32 Index = 0; Index < Count; ++Index)
|
||||
{
|
||||
FTelemetryEvent Event;
|
||||
Event.Name = TelemetryEvents::CheatUsed;
|
||||
Event.Payload = FTelemetryPayload().Set(TEXT("index"), Index);
|
||||
Sink.Emit(MakeEnvelope(), Event);
|
||||
}
|
||||
Sink.Flush();
|
||||
}
|
||||
|
||||
TArray<FString> Lines;
|
||||
TestTrue(TEXT("the file exists after Flush"), FFileHelper::LoadFileToStringArray(Lines, *Path));
|
||||
TestEqual(TEXT("one line per event"), Lines.Num(), Count);
|
||||
for (int32 Index = 0; Index < Lines.Num(); ++Index)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Object;
|
||||
if (!TestTrue(FString::Printf(TEXT("line %d parses"), Index), ParseObject(Lines[Index], Object)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TestEqual(FString::Printf(TEXT("line %d is in order"), Index), static_cast<int32>(Object->GetObjectField(TEXT("payload"))->GetNumberField(TEXT("index"))), Index);
|
||||
}
|
||||
IFileManager::Get().Delete(*Path);
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTelemetryJsonlEmitDoesNotTouchFileTest, "Salty.Core.Telemetry.Jsonl.EmitDoesNotTouchTheFile",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FTelemetryJsonlEmitDoesNotTouchFileTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
// The rule is "never blocks the caller longer than building the object". The observable form of that rule:
|
||||
// Emit does no file work at all; only the worker's drain (here deferred by the interval) or Flush writes.
|
||||
const FString Path = TempJsonlPath();
|
||||
{
|
||||
FJsonlTelemetrySink Sink(Path, /*DrainIntervalSeconds*/ 3600.0);
|
||||
FTelemetryEvent Event;
|
||||
Event.Name = TelemetryEvents::AppStarted;
|
||||
Sink.Emit(MakeEnvelope(), Event);
|
||||
TestFalse(TEXT("no file after Emit alone"), IFileManager::Get().FileExists(*Path));
|
||||
Sink.Flush();
|
||||
TestTrue(TEXT("the file exists after Flush"), IFileManager::Get().FileExists(*Path));
|
||||
}
|
||||
IFileManager::Get().Delete(*Path);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user