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:
Rainer Leit
2026-09-16 20:22:20 +03:00
co-authored by Claude Fable 5.1
parent 4f2c55cd2a
commit 3e52d26fb7
25 changed files with 881 additions and 23 deletions
@@ -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
+154
View File
@@ -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