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,28 @@
|
||||
// The bs.* console commands. Every one marks the session as cheated through the telemetry subsystem, so a
|
||||
// playtest file that had a cheat in it can be filtered out later. Each step adds its own commands here.
|
||||
|
||||
#include "Core/TelemetrySubsystem.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/World.h"
|
||||
#include "HAL/IConsoleManager.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
UTelemetrySubsystem* TelemetryFor(UWorld* World)
|
||||
{
|
||||
const UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr;
|
||||
return GameInstance ? GameInstance->GetSubsystem<UTelemetrySubsystem>() : nullptr;
|
||||
}
|
||||
|
||||
// bs.TelemetryTest: proves the seam end to end from the console. Emits cheat_used and taints the session.
|
||||
FAutoConsoleCommandWithWorld CmdTelemetryTest(
|
||||
TEXT("bs.TelemetryTest"),
|
||||
TEXT("Emits a cheat_used telemetry event and marks the session as cheated."),
|
||||
FConsoleCommandWithWorldDelegate::CreateLambda([](UWorld* World)
|
||||
{
|
||||
if (UTelemetrySubsystem* Telemetry = TelemetryFor(World))
|
||||
{
|
||||
Telemetry->MarkCheatUsed(TEXT("bs.TelemetryTest"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "Core/SaltyGameMode.h"
|
||||
#include "Core/SaltyGameState.h"
|
||||
#include "Salty.h"
|
||||
|
||||
ASaltyGameMode::ASaltyGameMode()
|
||||
{
|
||||
GameStateClass = ASaltyGameState::StaticClass();
|
||||
}
|
||||
|
||||
void ASaltyGameMode::InitGameState()
|
||||
{
|
||||
Super::InitGameState();
|
||||
|
||||
ASaltyGameState* SaltyGameState = GetGameState<ASaltyGameState>();
|
||||
if (!SaltyGameState)
|
||||
{
|
||||
// A Blueprint child that overrode GameStateClass with something else. Loud, because every telemetry
|
||||
// file of this session would then be sessionless.
|
||||
UE_LOG(LogSalty, Error, TEXT("%s: GameStateClass is not an ASaltyGameState; no session id will be minted"), *GetName());
|
||||
return;
|
||||
}
|
||||
SaltyGameState->SetSessionId(FGuid::NewGuid());
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/GameModeBase.h"
|
||||
#include "SaltyGameMode.generated.h"
|
||||
|
||||
// The server's game mode. Exists only on the server, which makes it the one place a session is minted. Step 3
|
||||
// adds login and spawning; until then the template's Blueprint game mode derives from it through
|
||||
// ATemplateGameMode and inherits the session.
|
||||
UCLASS()
|
||||
class SALTY_API ASaltyGameMode : public AGameModeBase
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
ASaltyGameMode();
|
||||
|
||||
virtual void InitGameState() override;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "Core/SaltyGameState.h"
|
||||
#include "Core/TelemetrySubsystem.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/World.h"
|
||||
#include "Net/UnrealNetwork.h"
|
||||
|
||||
void ASaltyGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
|
||||
{
|
||||
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
|
||||
DOREPLIFETIME(ASaltyGameState, SessionId);
|
||||
}
|
||||
|
||||
void ASaltyGameState::SetSessionId(const FGuid& InSessionId)
|
||||
{
|
||||
if (!HasAuthority())
|
||||
{
|
||||
return;
|
||||
}
|
||||
SessionId = InSessionId;
|
||||
AdoptSessionId();
|
||||
}
|
||||
|
||||
void ASaltyGameState::OnRep_SessionId()
|
||||
{
|
||||
AdoptSessionId();
|
||||
}
|
||||
|
||||
void ASaltyGameState::AdoptSessionId()
|
||||
{
|
||||
if (const UGameInstance* GameInstance = GetGameInstance())
|
||||
{
|
||||
if (UTelemetrySubsystem* Telemetry = GameInstance->GetSubsystem<UTelemetrySubsystem>())
|
||||
{
|
||||
Telemetry->BeginSession(SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/GameStateBase.h"
|
||||
#include "SaltyGameState.generated.h"
|
||||
|
||||
// Replicated session facts. Step 2 carries the session id the server minted so every client's telemetry file
|
||||
// belongs to the same session (Docs/Spec/Networking.md, Telemetry over the wire).
|
||||
UCLASS()
|
||||
class SALTY_API ASaltyGameState : public AGameStateBase
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
|
||||
// Server only. Writes the id and starts the server's own telemetry session.
|
||||
void SetSessionId(const FGuid& InSessionId);
|
||||
|
||||
FGuid GetSessionId() const { return SessionId; }
|
||||
|
||||
protected:
|
||||
UFUNCTION()
|
||||
void OnRep_SessionId();
|
||||
|
||||
private:
|
||||
UPROPERTY(ReplicatedUsing = OnRep_SessionId)
|
||||
FGuid SessionId;
|
||||
|
||||
void AdoptSessionId();
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/GameInstanceSubsystem.h"
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
#include "Telemetry/TelemetrySink.h"
|
||||
#include "TelemetrySubsystem.generated.h"
|
||||
|
||||
// The one seam gameplay emits through. Owns the sink, stamps the envelope, exposes Emit. Gameplay code resolves it
|
||||
// from its game instance once and keeps the pointer; there is no static helper and there will not be one
|
||||
// (Docs/Spec/Telemetry.md). Every peer has its own subsystem and its own file; nothing is forwarded.
|
||||
UCLASS()
|
||||
class SALTY_API UTelemetrySubsystem : public UGameInstanceSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
||||
virtual void Deinitialize() override;
|
||||
|
||||
// Stamps the envelope and hands the event to the sink. Name comes from TelemetryEvents, never a literal.
|
||||
void Emit(FName Name, TSharedPtr<FJsonObject> Payload = nullptr);
|
||||
|
||||
// The server mints the id in ASaltyGameMode; a client adopts the id replicated through ASaltyGameState.
|
||||
void BeginSession(FGuid InSessionId);
|
||||
|
||||
// Log in the editor, Jsonl with -telemetry or telemetry.File 1, Null otherwise. Public so a test can substitute.
|
||||
void SetSink(TUniquePtr<ITelemetrySink> InSink);
|
||||
|
||||
// Called by every bs.* console command. Sticky for the session: tainted sessions are filterable, not deleted.
|
||||
void MarkCheatUsed(FName Command = NAME_None);
|
||||
|
||||
bool HasSession() const { return SessionId.IsValid(); }
|
||||
FGuid GetSessionId() const { return SessionId; }
|
||||
bool WereCheatsUsed() const { return bCheatsUsed; }
|
||||
|
||||
private:
|
||||
FTelemetryEnvelope StampEnvelope() const;
|
||||
TUniquePtr<ITelemetrySink> MakeDefaultSink() const;
|
||||
|
||||
TUniquePtr<ITelemetrySink> Sink;
|
||||
FGuid SessionId;
|
||||
bool bCheatsUsed = false;
|
||||
FString BuildString;
|
||||
};
|
||||
@@ -27,7 +27,11 @@ public class Salty : ModuleRules
|
||||
"Slate"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { });
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { "Json" });
|
||||
|
||||
// The telemetry envelope's build string carries the git hash. Read here at build time; a new commit
|
||||
// changes the definition and rebuilds this module, which is the price of never lying about the build.
|
||||
PrivateDefinitions.Add("SALTY_GIT_HASH=\"" + ReadGitHash() + "\"");
|
||||
|
||||
PublicIncludePaths.AddRange(new string[] {
|
||||
"Salty",
|
||||
@@ -55,4 +59,32 @@ public class Salty : ModuleRules
|
||||
|
||||
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
|
||||
}
|
||||
|
||||
private string ReadGitHash()
|
||||
{
|
||||
try
|
||||
{
|
||||
var StartInfo = new System.Diagnostics.ProcessStartInfo("git", "rev-parse --short HEAD")
|
||||
{
|
||||
WorkingDirectory = ModuleDirectory,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using (var Process = System.Diagnostics.Process.Start(StartInfo))
|
||||
{
|
||||
string Output = Process.StandardOutput.ReadToEnd().Trim();
|
||||
Process.WaitForExit();
|
||||
if (Process.ExitCode == 0 && Output.Length > 0)
|
||||
{
|
||||
return Output;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
}
|
||||
return "nogit";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/GameModeBase.h"
|
||||
#include "Core/SaltyGameMode.h"
|
||||
#include "TemplateGameMode.generated.h"
|
||||
|
||||
/**
|
||||
* Simple GameMode for a third person game
|
||||
* Simple GameMode for a third person game. Derives from ASaltyGameMode so BP_ThirdPersonGameMode, the placeholder
|
||||
* until step 3, mints a telemetry session like the real one will.
|
||||
*/
|
||||
UCLASS(abstract)
|
||||
class ATemplateGameMode : public AGameModeBase
|
||||
class ATemplateGameMode : public ASaltyGameMode
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user