#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& OutObject) { const TSharedRef> 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 Object; if (!TestTrue(TEXT("the line parses"), ParseObject(Telemetry::ToJsonLine(Envelope, Event), Object))) { return false; } TestEqual(TEXT("schema_version"), static_cast(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(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(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(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 Object; if (!TestTrue(TEXT("the line parses"), ParseObject(Line, Object))) { return false; } const TSharedPtr* 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 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 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(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