Files
Rainer LeitandClaude Fable 5.1 3e52d26fb7 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>
2026-09-16 20:22:20 +03:00

138 lines
3.8 KiB
C++

#include "Core/TelemetrySubsystem.h"
#include "Salty.h"
#include "SaltyCore.h"
#include "Telemetry/TelemetryEvents.h"
#include "Telemetry/TelemetrySink.h"
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "GameFramework/GameStateBase.h"
#include "HAL/IConsoleManager.h"
#include "Misc/App.h"
#include "Misc/CommandLine.h"
#include "Misc/Paths.h"
#ifndef SALTY_GIT_HASH
#define SALTY_GIT_HASH "nogit"
#endif
namespace
{
// telemetry.File 1 installs the file sink on the next game instance (set it in the editor console before
// Play In Editor to get one file per PIE instance); -telemetry on the command line does the same.
TAutoConsoleVariable<int32> CVarTelemetryFile(
TEXT("telemetry.File"), 0,
TEXT("1: write telemetry to Saved/Telemetry/session_*.jsonl. Read when a game instance starts."),
ECVF_Default);
}
void UTelemetrySubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
BuildString = FString::Printf(TEXT("%s+%s"), FApp::GetBuildVersion(), TEXT(SALTY_GIT_HASH));
Sink = MakeDefaultSink();
Emit(TelemetryEvents::AppStarted, FTelemetryPayload().Set(TEXT("engine"), FEngineVersion::Current().ToString()));
}
void UTelemetrySubsystem::Deinitialize()
{
if (HasSession())
{
Emit(TelemetryEvents::SessionEnded);
}
if (Sink)
{
Sink->Flush();
Sink.Reset();
}
Super::Deinitialize();
}
TUniquePtr<ITelemetrySink> UTelemetrySubsystem::MakeDefaultSink() const
{
const bool bFileRequested = FParse::Param(FCommandLine::Get(), TEXT("telemetry")) || CVarTelemetryFile.GetValueOnGameThread() != 0;
if (bFileRequested)
{
// One file per peer; the instance id keeps two PIE instances started in the same second apart.
const FString FileName = FString::Printf(TEXT("session_%s_%s.jsonl"),
*FDateTime::UtcNow().ToString(TEXT("%Y%m%d_%H%M%S")),
*FGuid::NewGuid().ToString(EGuidFormats::Digits).Left(8));
const FString Path = FPaths::ProjectSavedDir() / TEXT("Telemetry") / FileName;
UE_LOG(LogTelemetry, Log, TEXT("Telemetry file sink: %s"), *Path);
return MakeUnique<FJsonlTelemetrySink>(Path);
}
#if WITH_EDITOR
if (GIsEditor)
{
return MakeUnique<FLogTelemetrySink>();
}
#endif
return MakeUnique<FNullTelemetrySink>();
}
void UTelemetrySubsystem::SetSink(TUniquePtr<ITelemetrySink> InSink)
{
if (Sink)
{
Sink->Flush();
}
Sink = MoveTemp(InSink);
if (!Sink)
{
Sink = MakeUnique<FNullTelemetrySink>();
}
}
void UTelemetrySubsystem::Emit(FName Name, TSharedPtr<FJsonObject> Payload)
{
if (!Sink)
{
return;
}
FTelemetryEvent Event;
Event.Name = Name;
Event.Payload = MoveTemp(Payload);
Sink->Emit(StampEnvelope(), Event);
}
void UTelemetrySubsystem::BeginSession(FGuid InSessionId)
{
if (!InSessionId.IsValid() || InSessionId == SessionId)
{
return;
}
SessionId = InSessionId;
Emit(TelemetryEvents::SessionStarted);
}
void UTelemetrySubsystem::MarkCheatUsed(FName Command)
{
bCheatsUsed = true;
Emit(TelemetryEvents::CheatUsed, FTelemetryPayload().Set(TEXT("command"), Command));
}
FTelemetryEnvelope UTelemetrySubsystem::StampEnvelope() const
{
FTelemetryEnvelope Envelope;
Envelope.EventId = FGuid::NewGuid();
Envelope.TimestampUtc = FDateTime::UtcNow();
Envelope.SessionId = SessionId;
Envelope.Build = BuildString;
Envelope.bCheatsUsed = bCheatsUsed;
// PlayerId stays empty until step 3 mints one on the player state (Docs/Spec/Networking.md, Identity).
const UGameInstance* GameInstance = GetGameInstance();
const UWorld* World = GameInstance ? GameInstance->GetWorld() : nullptr;
if (World)
{
Envelope.GameTime = World->GetTimeSeconds();
Envelope.bIsServer = World->GetNetMode() != NM_Client; // standalone is its own authority
Envelope.Map = UWorld::RemovePIEPrefix(World->GetMapName());
if (const AGameStateBase* GameState = World->GetGameState())
{
Envelope.PartySize = GameState->PlayerArray.Num();
}
}
return Envelope;
}