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
@@ -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